GROQ_API_KEY. TTS uses the Orpheus English model via Groq's API.
+ diff --git a/chat.html b/chat.html index 47edcaff..6785873b 100644 --- a/chat.html +++ b/chat.html @@ -732,6 +732,11 @@ let listeningTimer = null; let speechAvailable = false; + // Renderer-based audio capture (getUserMedia in chat window) + let rendererAudioStream = null; + let rendererAudioContext = null; + let rendererAudioProcessor = null; + // Chat persistence and deduplication const CHAT_HISTORY_KEY = 'opencluely_chat_history_v1'; let chatHistory = []; @@ -798,12 +803,15 @@ } async function initSpeechAvailability() { + console.log('[Chat] initSpeechAvailability() called'); try { if (window.electronAPI && window.electronAPI.getSpeechAvailability) { speechAvailable = await window.electronAPI.getSpeechAvailability(); + console.log('[Chat] Speech availability:', speechAvailable); applyMicVisibility(); } } catch (e) { + console.error('[Chat] Failed to get speech availability:', e); speechAvailable = false; applyMicVisibility(); } @@ -817,12 +825,58 @@ }); } + // TTS: speak assistant responses aloud + let ttsEnabled = true; + + // Load TTS enabled state from settings + if (whysperAPI && typeof whysperAPI.getSettings === 'function') { + whysperAPI.getSettings().then(settings => { + if (settings && settings.ttsEnabled !== undefined) { + ttsEnabled = settings.ttsEnabled; + } + }).catch(() => {}); + } + + function maybeSpeakResponse(text) { + if (!ttsEnabled || !text || !whysperAPI || typeof whysperAPI.synthesizeSpeech !== 'function') return; + whysperAPI.synthesizeSpeech(text).catch(() => {}); + } + + // Listen for TTS events to update UI + if (whysperAPI && whysperAPI.onTtsStarted) { + whysperAPI.onTtsStarted(() => { + const ttsIndicator = document.getElementById('ttsIndicator'); + if (ttsIndicator) ttsIndicator.style.display = 'inline-block'; + }); + } + if (whysperAPI && whysperAPI.onTtsCompleted) { + whysperAPI.onTtsCompleted(() => { + const ttsIndicator = document.getElementById('ttsIndicator'); + if (ttsIndicator) ttsIndicator.style.display = 'none'; + }); + } + if (whysperAPI && whysperAPI.onTtsError) { + whysperAPI.onTtsError((event, data) => { + const ttsIndicator = document.getElementById('ttsIndicator'); + if (ttsIndicator) ttsIndicator.style.display = 'none'; + console.warn('TTS error:', data?.error); + }); + } + if (whysperAPI && whysperAPI.receive) { + whysperAPI.receive('tts-settings-changed', (data) => { + if (data && data.ttsEnabled !== undefined) { + ttsEnabled = data.ttsEnabled; + } + }); + } + // Also capture generic LLM responses (from OCR/screenshot flows) if (whysperAPI && whysperAPI.onLlmResponse) { whysperAPI.onLlmResponse((event, data) => { try { hideThinkingIndicator(); } catch (_) {} if (data && data.response) { renderAssistantResponse(data.response); + maybeSpeakResponse(data.response); } }); } @@ -1173,7 +1227,7 @@ // Listen for speech errors whysperAPI.onSpeechError((event, data) => { if (data && data.error) { - addMessage(`Error in recognizing speech`, 'error'); + addMessage(`Speech error: ${data.error}`, 'error'); handleRecordingStopped(); } }); @@ -1196,6 +1250,60 @@ if (data && data.response) { try { hideThinkingIndicator(); } catch (_) {} renderAssistantResponse(data.response); + maybeSpeakResponse(data.response); + } + }); + + // Renderer-based audio capture (getUserMedia in chat window) + whysperAPI.onStartRendererCapture(() => { + console.log('[Chat] Starting renderer audio capture'); + if (rendererAudioStream) { + console.log('[Chat] Renderer capture already active'); + return; + } + navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => { + console.log('[Chat] getUserMedia succeeded'); + rendererAudioStream = stream; + + const audioCtx = new AudioContext(); + rendererAudioContext = audioCtx; + const source = audioCtx.createMediaStreamSource(stream); + const processor = audioCtx.createScriptProcessor(4096, 1, 1); + rendererAudioProcessor = processor; + + source.connect(processor); + processor.onaudioprocess = (e) => { + const input = e.inputBuffer.getChannelData(0); + const int16 = new Int16Array(input.length); + for (let i = 0; i < input.length; i++) { + const s = Math.max(-1, Math.min(1, input[i])); + int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; + } + if (whysperAPI && whysperAPI.sendAudioChunk) { + whysperAPI.sendAudioChunk(int16.buffer); + } + }; + }).catch((err) => { + console.error('[Chat] getUserMedia failed:', err.message); + if (whysperAPI && whysperAPI.sendAudioCaptureError) { + whysperAPI.sendAudioCaptureError(err.message); + } + }); + }); + + whysperAPI.onStopRendererCapture(() => { + console.log('[Chat] Stopping renderer audio capture'); + if (rendererAudioProcessor) { + try { rendererAudioProcessor.disconnect(); } catch (_) {} + rendererAudioProcessor = null; + } + if (rendererAudioContext) { + try { rendererAudioContext.close(); } catch (_) {} + rendererAudioContext = null; + } + if (rendererAudioStream) { + rendererAudioStream.getTracks().forEach(t => t.stop()); + rendererAudioStream = null; } }); } @@ -1207,6 +1315,7 @@ if (micButton) { micButton.addEventListener('click', async () => { + console.log('[Chat] Mic button clicked, isInteractive:', isInteractive, 'speechAvailable:', speechAvailable, 'isRecording:', isRecording); if (!isInteractive) { addMessage('Window is in non-interactive mode. Press Alt+A to enable interaction.', 'error'); return; @@ -1218,16 +1327,21 @@ try { if (isRecording) { + console.log('[Chat] Stopping speech recognition'); if (whysperAPI) { const result = await whysperAPI.stopSpeechRecognition(); + console.log('[Chat] Stop result:', result); } } else { + console.log('[Chat] Starting speech recognition'); if (whysperAPI) { const result = await whysperAPI.startSpeechRecognition(); + console.log('[Chat] Start result:', result); } } } catch (error) { - addMessage("Error in recognizing speech", 'error'); + console.error('[Chat] Speech recognition error:', error); + addMessage(`Speech error: ${error.message || error}`, 'error'); } }); } diff --git a/env.example b/env.example index f5fd56a3..75561255 100644 --- a/env.example +++ b/env.example @@ -1,16 +1,32 @@ -# Google Gemini API Configuration +# LLM Provider Configuration +# Choose your AI backend: gemini, openrouter, or groq +LLM_PROVIDER=gemini + +# Google Gemini API Configuration (when LLM_PROVIDER=gemini) # Get your API key from: https://makersuite.google.com/app/apikey GEMINI_API_KEY=your_gemini_api_key_here -# Speech Recognition Configuration -# Choose one provider: azure or whisper +# OpenRouter API Configuration (when LLM_PROVIDER=openrouter) +# Get your API key from: https://openrouter.ai/keys +OPENROUTER_API_KEY= +# Model to use — "openrouter/free" auto-selects free vision-capable models +OPENROUTER_MODEL=openrouter/free + +# Groq API Configuration (when LLM_PROVIDER=groq or SPEECH_PROVIDER=groq) +# Get your API key from: https://console.groq.com/keys +GROQ_API_KEY= +# LLM model — "llama-3.3-70b-versatile" is fast and free +GROQ_MODEL=llama-3.3-70b-versatile + +# Speech / STT Configuration +# Choose one provider: whisper, azure, or groq SPEECH_PROVIDER=whisper -# Optional: Azure Speech Services Configuration +# Azure Speech Services Configuration (when SPEECH_PROVIDER=azure) AZURE_SPEECH_KEY=your_azure_speech_key_here AZURE_SPEECH_REGION=your_azure_region_here -# Optional: Local OpenAI Whisper Configuration +# Local OpenAI Whisper Configuration (when SPEECH_PROVIDER=whisper) # Requires a local Whisper CLI installation, for example: # pip install openai-whisper # brew install ffmpeg sox @@ -20,3 +36,13 @@ WHISPER_MODEL_DIR=.whisper-models WHISPER_MODEL=turbo WHISPER_LANGUAGE=en WHISPER_SEGMENT_MS=4000 + +# Groq STT Configuration (when SPEECH_PROVIDER=groq) +# Uses Whisper Large V3 via Groq's API — requires GROQ_API_KEY above +GROQ_STT_MODEL=whisper-large-v3-turbo + +# Groq TTS Configuration (Text-to-Speech via Orpheus) +# Uses Orpheus TTS — requires GROQ_API_KEY above +GROQ_TTS_MODEL=orpheus-tts-0.1-ayane +GROQ_TTS_VOICE=tara +GROQ_TTS_SPEED=1.0 diff --git a/llm-response.html b/llm-response.html index 40c2669c..b340c3ee 100644 --- a/llm-response.html +++ b/llm-response.html @@ -462,7 +462,7 @@ // Global variables let currentLayout = 'split'; let hasCode = false; - let currentSkill = 'dsa'; + let currentSkill = 'general'; let isInteractive = false; let scrollableElements = []; let initialized = false; diff --git a/main.js b/main.js index a96510d6..c9cb3166 100644 --- a/main.js +++ b/main.js @@ -1,8 +1,15 @@ -require("dotenv").config(); - const path = require("path"); const { app, BrowserWindow, globalShortcut, session, ipcMain } = require("electron"); +// .env lives in app.getPath("userData") (~/Library/Application Support/OpenCluely/) +// so settings survive re-launches when the app is installed in /Applications. +// For development, .env in process.cwd() is also loaded but userData takes priority +// so saved settings persist across restarts. +const userDataPath = app.getPath("userData"); +const userDataEnv = path.join(userDataPath, ".env"); +require("dotenv").config({ path: userDataEnv }); +require("dotenv").config(); + // ── Linux GPU process crash workaround ── // On many Linux setups (Wayland, X11 without GPU drivers, Docker, headless, // or systems with broken Mesa/NVIDIA stacks), Chromium's GPU process crashes @@ -40,6 +47,7 @@ const FirstRunManager = require("./src/core/first-run"); // Screen capture (image-based) const captureService = require("./src/services/capture.service"); const speechService = require("./src/services/speech.service"); +const ttsService = require("./src/services/tts.service"); const llmService = require("./src/services/llm.service"); // Managers @@ -50,19 +58,21 @@ class ApplicationController { constructor() { this.isReady = false; this.starting = false; - this.activeSkill = "dsa"; + this.activeSkill = "general"; // Default to C++ so language is enforced from first run this.codingLanguage = "cpp"; this.speechAvailable = false; + // .env lives in userData so settings survive re-launches from /Applications + this.envPath = path.join(app.getPath("userData"), ".env"); + // First-run onboarding: detects missing .env / API key and triggers // a settings-window prompt on first launch so users don't have to // dig through docs to figure out they need a Gemini API key. this.firstRunManager = new FirstRunManager({ logger: logger, - // Sentinel lives in userData so it survives cwd changes - // (the app may be launched from any directory). sentinelPath: path.join(app.getPath("userData"), ".opencluely-firstrun-completed"), + envPath: this.envPath, }); // Lazily-initialised in getWhisperInstaller() so tests can mock // the constructor without polluting main-process startup. @@ -258,13 +268,65 @@ class ApplicationController { setupPermissions() { session.defaultSession.setPermissionRequestHandler( (webContents, permission, callback) => { - const allowedPermissions = ["microphone", "camera", "display-capture"]; + console.log('[Main] Session permission request:', permission); + const allowedPermissions = ["microphone", "camera", "display-capture", "media"]; const granted = allowedPermissions.includes(permission); logger.debug("Permission request", { permission, granted }); callback(granted); } ); + + session.defaultSession.setPermissionCheckHandler(() => true); + } + + async _ensureMicrophonePermission() { + console.log('[Main] _ensureMicrophonePermission() called'); + // On macOS 14+ the first microphone hardware access via a spawned + // subprocess (sox/rec) can crash the app if permission hasn't been + // granted yet. Use the programmatic API to trigger the dialog safely. + if (process.platform !== "darwin") { + console.log('[Main] Not macOS, skipping mic permission check'); + return; + } + + const { systemPreferences } = require("electron"); + if (!systemPreferences.getMediaAccessStatus) { + console.log('[Main] No getMediaAccessStatus API, skipping'); + return; + } + + const status = systemPreferences.getMediaAccessStatus("microphone"); + logger.debug("Microphone permission status", { status }); + console.log('[Main] Microphone permission status:', status); + + if (status === "granted") { + console.log('[Main] Mic permission already granted'); + return; + } + + if (status === "denied") { + console.log('[Main] Mic permission denied'); + throw new Error( + "Microphone permission denied. " + + "Go to System Settings → Privacy & Security → Microphone, " + + "find OpenCluely in the list and check the box, then restart the app." + ); + } + + // status === "not-determined" — first time; ask safely via the API + if (status === "not-determined" && systemPreferences.askForMediaAccess) { + console.log('[Main] Asking for mic permission...'); + const granted = await systemPreferences.askForMediaAccess("microphone"); + console.log('[Main] askForMediaAccess result:', granted); + if (!granted) { + throw new Error( + "Microphone permission was not granted. " + + "Go to System Settings → Privacy & Security → Microphone " + + "to enable access." + ); + } + } } setupGlobalShortcuts() { @@ -329,7 +391,7 @@ class ApplicationController { text: text.substring(0, 100) }); } - }, 500); + }, 50); }); speechService.on("interim-transcription", (text) => { @@ -379,8 +441,25 @@ class ApplicationController { return speechService.isAvailable ? speechService.isAvailable() : false; }); - ipcMain.handle("start-speech-recognition", () => { - speechService.startRecording(); + ipcMain.handle("start-speech-recognition", async () => { + console.log('[Main] start-speech-recognition invoked'); + try { + await this._ensureMicrophonePermission(); + console.log('[Main] Mic permission OK, calling speechService.startRecording()'); + speechService.startRecording(); + console.log('[Main] speechService.startRecording() returned'); + } catch (e) { + console.error('[Main] start-speech-recognition failed:', e.message); + logger.warn("Microphone permission check failed", { error: e.message }); + windowManager.broadcastToAllWindows("speech-status", { + status: e.message, + available: false, + }); + windowManager.broadcastToAllWindows("speech-error", { + error: e.message, + available: false, + }); + } return speechService.getStatus(); }); @@ -390,14 +469,70 @@ class ApplicationController { }); // Also handle direct send events for fallback - ipcMain.on("start-speech-recognition", () => { - speechService.startRecording(); + ipcMain.on("start-speech-recognition", async () => { + try { + await this._ensureMicrophonePermission(); + speechService.startRecording(); + } catch (e) { + logger.warn("Microphone permission check failed", { error: e.message }); + } }); ipcMain.on("stop-speech-recognition", () => { speechService.stopRecording(); }); + // Audio capture IPC from renderer-based getUserMedia + let chunkCount = 0; + ipcMain.on("audio-chunk", (_event, buffer) => { + chunkCount++; + if (chunkCount % 50 === 1) { + console.log('[Main] Received audio-chunk #', chunkCount, 'size:', buffer?.length || 0); + } + speechService._handleRendererAudioChunk(buffer); + }); + + ipcMain.on("audio-capture-error", (_event, msg) => { + logger.error("Renderer audio capture error", { error: msg }); + speechService.emit("error", `Microphone error: ${msg}`); + speechService.stopRecording(); + }); + + ipcMain.on("audio-capture-ready", (event) => { + const win = event.sender.getOwnerBrowserWindow(); + if (win && !win.isDestroyed()) { + win.hide(); + } + }); + + // TTS (Text-to-Speech) IPC + ipcMain.handle("synthesize-speech", async (_event, text, options) => { + try { + await ttsService.synthesizeSpeech(text, options); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }); + + ipcMain.handle("stop-tts-playback", () => { + ttsService.stopPlayback(); + return { success: true }; + }); + + // Broadcast TTS events to all windows + ttsService.on("tts-started", () => { + windowManager.broadcastToAllWindows("tts-started"); + }); + + ttsService.on("tts-completed", () => { + windowManager.broadcastToAllWindows("tts-completed"); + }); + + ttsService.on("tts-error", (error) => { + windowManager.broadcastToAllWindows("tts-error", { error }); + }); + ipcMain.on("chat-window-ready", () => { // Send a test message to confirm communication setTimeout(() => { @@ -532,7 +667,7 @@ class ApplicationController { text: text.substring(0, 100) }); } - }, 500); + }, 50); return { success: true }; }); @@ -642,9 +777,10 @@ class ApplicationController { try { this.firstRunManager.markCompleted(); this.isFirstRun = false; - // Reinitialize speech service with the latest persisted settings - // so the mic button reflects the provider/command set during onboarding. + // Reinitialize services with the latest persisted settings + // so the mic button and AI calls reflect onboarding choices. speechService.initializeClient(); + llmService.initializeClient(); this.speechAvailable = speechService.isAvailable ? speechService.isAvailable() : false; @@ -840,7 +976,7 @@ class ApplicationController { }); } - toggleSpeechRecognition() { + async toggleSpeechRecognition() { const isAvailable = typeof speechService.isAvailable === 'function' ? speechService.isAvailable() : !!speechService.getStatus?.().isInitialized; if (!isAvailable) { logger.warn("Speech recognition unavailable; toggle ignored"); @@ -861,11 +997,18 @@ class ApplicationController { } } else { try { + await this._ensureMicrophonePermission(); speechService.startRecording(); windowManager.showChatWindow(); logger.info("Speech recognition started via global shortcut"); } catch (error) { logger.error("Error starting speech recognition:", error); + try { + windowManager.broadcastToAllWindows("speech-status", { + status: error.message, + available: false, + }); + } catch (_) {} } } } @@ -926,7 +1069,9 @@ class ApplicationController { navigateSkill(direction) { const availableSkills = [ - "dsa", + "general", + "coding", + "meeting", ]; const currentIndex = availableSkills.indexOf(this.activeSkill); @@ -984,7 +1129,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 = ['coding']; const needsProgrammingLanguage = skillsRequiringProgrammingLanguage.includes(this.activeSkill); const llmResult = await llmService.processImageWithSkill( @@ -1037,7 +1182,7 @@ class ApplicationController { sessionManager.addUserInput(text, 'llm_input'); // Check if current skill needs programming language context - const skillsRequiringProgrammingLanguage = ['dsa']; + const skillsRequiringProgrammingLanguage = ['coding']; const needsProgrammingLanguage = skillsRequiringProgrammingLanguage.includes(this.activeSkill); const llmResult = await llmService.processTextWithSkill( @@ -1116,7 +1261,7 @@ class ApplicationController { }); // Check if current skill needs programming language context - const skillsRequiringProgrammingLanguage = ['dsa']; + const skillsRequiringProgrammingLanguage = ['coding']; const needsProgrammingLanguage = skillsRequiringProgrammingLanguage.includes(this.activeSkill); const llmResult = await llmService.processTranscriptionWithIntelligentResponse( @@ -1318,11 +1463,17 @@ class ApplicationController { // distinguish "unset" from "stale value from a previous load". return { codingLanguage: this.codingLanguage || "cpp", - activeSkill: this.activeSkill || "dsa", + activeSkill: this.activeSkill || "general", appIcon: this.appIcon || "terminal", selectedIcon: this.appIcon || "terminal", windowGap: windowManager.windowGap, + llmProvider: process.env.LLM_PROVIDER || "gemini", + openrouterKey: process.env.OPENROUTER_API_KEY || "", + openrouterModel: process.env.OPENROUTER_MODEL || "openrouter/free", + groqKey: process.env.GROQ_API_KEY || "", + groqModel: process.env.GROQ_MODEL || "llama-3.3-70b-versatile", + speechProvider: speechService.provider || "whisper", azureKey: process.env.AZURE_SPEECH_KEY || "", azureRegion: process.env.AZURE_SPEECH_REGION || "", @@ -1330,6 +1481,11 @@ class ApplicationController { whisperModel: process.env.WHISPER_MODEL || "turbo", whisperLanguage: process.env.WHISPER_LANGUAGE || "en", whisperSegmentMs: process.env.WHISPER_SEGMENT_MS || "4000", + groqSttModel: process.env.GROQ_STT_MODEL || "whisper-large-v3-turbo", + ttsEnabled: true, + ttsVoice: process.env.GROQ_TTS_VOICE || "tara", + ttsModel: process.env.GROQ_TTS_MODEL || "orpheus-tts-0.1-ayane", + ttsSpeed: process.env.GROQ_TTS_SPEED || "1.0", geminiKey: process.env.GEMINI_API_KEY || "", azureConfigured: !!process.env.AZURE_SPEECH_KEY && !!process.env.AZURE_SPEECH_REGION, @@ -1369,7 +1525,7 @@ class ApplicationController { // Writing to .env ensures they survive app restarts and are picked // up the next time the app boots. const envUpdates = {}; - if (settings.speechProvider === "azure" || settings.speechProvider === "whisper") { + if (settings.speechProvider === "azure" || settings.speechProvider === "whisper" || settings.speechProvider === "groq") { envUpdates.SPEECH_PROVIDER = settings.speechProvider; } if (settings.azureKey !== undefined) { @@ -1390,23 +1546,59 @@ class ApplicationController { if (settings.whisperSegmentMs !== undefined) { envUpdates.WHISPER_SEGMENT_MS = String(settings.whisperSegmentMs); } + if (settings.groqSttModel !== undefined) { + envUpdates.GROQ_STT_MODEL = settings.groqSttModel; + } + if (settings.ttsVoice !== undefined) { + envUpdates.GROQ_TTS_VOICE = settings.ttsVoice; + } + if (settings.ttsModel !== undefined) { + envUpdates.GROQ_TTS_MODEL = settings.ttsModel; + } + if (settings.ttsSpeed !== undefined) { + envUpdates.GROQ_TTS_SPEED = String(settings.ttsSpeed); + } + if (settings.groqKey !== undefined) { + envUpdates.GROQ_API_KEY = settings.groqKey; + } + if (settings.llmProvider !== undefined) { + envUpdates.LLM_PROVIDER = settings.llmProvider; + } + if (settings.openrouterKey !== undefined) { + envUpdates.OPENROUTER_API_KEY = settings.openrouterKey; + } + if (settings.openrouterModel !== undefined) { + envUpdates.OPENROUTER_MODEL = settings.openrouterModel; + } + if (settings.groqKey !== undefined) { + envUpdates.GROQ_API_KEY = settings.groqKey; + } + if (settings.groqModel !== undefined) { + envUpdates.GROQ_MODEL = settings.groqModel; + } if (settings.geminiKey !== undefined) { envUpdates.GEMINI_API_KEY = settings.geminiKey; } const persistedKeys = this.persistEnvUpdates(envUpdates); - // If the Gemini key was just saved, reinitialize the LLM service - // so the new client picks up the key. Without this, the test- - // connection button in the onboarding wizard fails with - // "Service not initialized" because the client was first created - // at app startup, before any key was set. - if (settings.geminiKey !== undefined && envUpdates.GEMINI_API_KEY !== undefined) { + // Reinitialize LLM service when provider, API key, or model changes. + // Without this the test-connection button and all AI calls would + // use stale credentials or the wrong provider. + const llmProviderChanged = settings.llmProvider !== undefined && + (process.env.LLM_PROVIDER || "gemini") !== settings.llmProvider; + const llmKeyChanged = settings.geminiKey !== undefined || settings.openrouterKey !== undefined || settings.groqKey !== undefined; + const llmModelChanged = settings.openrouterModel !== undefined || settings.groqModel !== undefined; + if (llmProviderChanged || llmKeyChanged || llmModelChanged) { try { llmService.initializeClient(); - logger.info("LLM service reinitialized after Gemini key update"); + logger.info("LLM service reinitialized after settings change", { + providerChanged: llmProviderChanged, + keyChanged: llmKeyChanged, + modelChanged: llmModelChanged + }); } catch (e) { - logger.warn("Failed to reinitialize LLM service after Gemini key update", { + logger.warn("Failed to reinitialize LLM service", { error: e.message }); } @@ -1421,7 +1613,9 @@ class ApplicationController { const providerChanged = settings.speechProvider && speechService.provider !== settings.speechProvider; const whisperCommandChanged = settings.whisperCommand !== undefined && (process.env.WHISPER_COMMAND || '') !== String(settings.whisperCommand || ''); - if (providerChanged || whisperCommandChanged) { + const groqSttModelChanged = settings.groqSttModel !== undefined && + (process.env.GROQ_STT_MODEL || '') !== String(settings.groqSttModel || ''); + if (providerChanged || whisperCommandChanged || groqSttModelChanged) { try { speechService.initializeClient(); this.speechAvailable = speechService.isAvailable @@ -1439,6 +1633,7 @@ class ApplicationController { logger.info('Speech service reinitialized after settings change', { providerChanged, whisperCommandChanged, + groqSttModelChanged, speechAvailable: this.speechAvailable, }); } catch (e) { @@ -1448,8 +1643,38 @@ class ApplicationController { } } + // Reinitialize TTS service when Groq key or TTS settings change + const groqKeyChanged = settings.groqKey !== undefined; + const ttsSettingsChanged = settings.ttsEnabled !== undefined || + settings.ttsVoice !== undefined || + settings.ttsModel !== undefined || + settings.ttsSpeed !== undefined; + if (groqKeyChanged || ttsSettingsChanged) { + try { + ttsService.updateSettings({ + groqKey: process.env.GROQ_API_KEY, + ttsEnabled: settings.ttsEnabled, + ttsVoice: settings.ttsVoice, + ttsModel: settings.ttsModel, + ttsSpeed: settings.ttsSpeed + }); + logger.info('TTS service reinitialized after settings change'); + // Broadcast TTS settings to chat windows + windowManager.broadcastToAllWindows('tts-settings-changed', { + ttsEnabled: settings.ttsEnabled !== false + }); + } catch (e) { + logger.warn("Failed to reinitialize TTS service", { error: e.message }); + } + } + + const redacted = { ...settings }; + if (redacted.geminiKey) redacted.geminiKey = redacted.geminiKey.substring(0, 4) + "…"; + if (redacted.openrouterKey) redacted.openrouterKey = redacted.openrouterKey.substring(0, 4) + "…"; + if (redacted.groqKey) redacted.groqKey = redacted.groqKey.substring(0, 4) + "…"; + if (redacted.azureKey) redacted.azureKey = redacted.azureKey.substring(0, 4) + "…"; logger.info("Settings saved successfully", { - ...settings, + ...redacted, persistedEnvKeys: persistedKeys }); return { success: true, persistedEnvKeys: persistedKeys }; @@ -1481,7 +1706,13 @@ class ApplicationController { const fs = require("fs"); const path = require("path"); - const envPath = path.join(process.cwd(), ".env"); + const envPath = this.envPath; + + // Ensure the parent directory exists (userData is usually pre-created, + // but guard against edge cases like a sandboxed fresh install). + try { + fs.mkdirSync(path.dirname(envPath), { recursive: true }); + } catch (_) { /* best effort */ } let existing = ""; try { diff --git a/onboarding.html b/onboarding.html index 7e2db50d..388e68dd 100644 --- a/onboarding.html +++ b/onboarding.html @@ -72,6 +72,10 @@ flex-shrink: 0; position: relative; z-index: 1; + -webkit-app-region: drag; + } + .header > * { + -webkit-app-region: no-drag; } .brand { display: flex; @@ -713,6 +717,8 @@
+ + @@ -740,37 +746,156 @@- OpenCluely uses Google's Gemini to generate answers. You'll need a free API key. + Pick the AI backend OpenCluely will use to generate answers. You can change this later in Settings.
-openrouter/free router auto-selects the best available model.
+ .env — never sent anywhere except Google.
+ GROQ_API_KEY for transcription via Groq's API. No local Whisper CLI required.
+ .env so they persist across restarts.
GROQ_API_KEY. TTS uses the Orpheus English model via Groq's API.
+