From 684b61755c89a4b050168fd55c6d3c6e9bebdbc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=93=D0=BB=D0=B0=D0=B7=D1=83?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2?= Date: Mon, 16 Feb 2026 11:38:26 +0300 Subject: [PATCH 1/3] feat: implement Whisper worker for isolated audio transcription --- src/index.js | 547 +++++++++++----------- src/utils/localai.js | 900 ++++++++++++++++++++++++------------- src/utils/whisperWorker.js | 150 +++++++ 3 files changed, 1027 insertions(+), 570 deletions(-) create mode 100644 src/utils/whisperWorker.js diff --git a/src/index.js b/src/index.js index 0bfed4a..e301bb5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,299 +1,336 @@ -if (require('electron-squirrel-startup')) { - process.exit(0); +if (require("electron-squirrel-startup")) { + process.exit(0); } -const { app, BrowserWindow, shell, ipcMain } = require('electron'); -const { createWindow, updateGlobalShortcuts } = require('./utils/window'); -const { setupGeminiIpcHandlers, stopMacOSAudioCapture, sendToRenderer } = require('./utils/gemini'); -const storage = require('./storage'); +// ── Global crash handlers to prevent silent process termination ── +process.on("uncaughtException", (error) => { + console.error("[FATAL] Uncaught exception:", error); + try { + const { sendToRenderer } = require("./utils/gemini"); + sendToRenderer( + "update-status", + "Fatal error: " + (error?.message || "unknown"), + ); + } catch (_) { + // sendToRenderer may not be available yet + } +}); + +process.on("unhandledRejection", (reason) => { + console.error("[FATAL] Unhandled promise rejection:", reason); + try { + const { sendToRenderer } = require("./utils/gemini"); + sendToRenderer( + "update-status", + "Unhandled error: " + + (reason instanceof Error ? reason.message : String(reason)), + ); + } catch (_) { + // sendToRenderer may not be available yet + } +}); + +const { app, BrowserWindow, shell, ipcMain } = require("electron"); +const { createWindow, updateGlobalShortcuts } = require("./utils/window"); +const { + setupGeminiIpcHandlers, + stopMacOSAudioCapture, + sendToRenderer, +} = require("./utils/gemini"); +const storage = require("./storage"); const geminiSessionRef = { current: null }; let mainWindow = null; function createMainWindow() { - mainWindow = createWindow(sendToRenderer, geminiSessionRef); - return mainWindow; + mainWindow = createWindow(sendToRenderer, geminiSessionRef); + return mainWindow; } app.whenReady().then(async () => { - // Initialize storage (checks version, resets if needed) - storage.initializeStorage(); + // Initialize storage (checks version, resets if needed) + storage.initializeStorage(); - // Trigger screen recording permission prompt on macOS if not already granted - if (process.platform === 'darwin') { - const { desktopCapturer } = require('electron'); - desktopCapturer.getSources({ types: ['screen'] }).catch(() => {}); - } + // Trigger screen recording permission prompt on macOS if not already granted + if (process.platform === "darwin") { + const { desktopCapturer } = require("electron"); + desktopCapturer.getSources({ types: ["screen"] }).catch(() => {}); + } + createMainWindow(); + setupGeminiIpcHandlers(geminiSessionRef); + setupStorageIpcHandlers(); + setupGeneralIpcHandlers(); +}); + +app.on("window-all-closed", () => { + stopMacOSAudioCapture(); + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("before-quit", () => { + stopMacOSAudioCapture(); +}); + +app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { createMainWindow(); - setupGeminiIpcHandlers(geminiSessionRef); - setupStorageIpcHandlers(); - setupGeneralIpcHandlers(); -}); - -app.on('window-all-closed', () => { - stopMacOSAudioCapture(); - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -app.on('before-quit', () => { - stopMacOSAudioCapture(); -}); - -app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createMainWindow(); - } + } }); function setupStorageIpcHandlers() { - // ============ CONFIG ============ - ipcMain.handle('storage:get-config', async () => { - try { - return { success: true, data: storage.getConfig() }; - } catch (error) { - console.error('Error getting config:', error); - return { success: false, error: error.message }; - } - }); + // ============ CONFIG ============ + ipcMain.handle("storage:get-config", async () => { + try { + return { success: true, data: storage.getConfig() }; + } catch (error) { + console.error("Error getting config:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:set-config', async (event, config) => { - try { - storage.setConfig(config); - return { success: true }; - } catch (error) { - console.error('Error setting config:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:set-config", async (event, config) => { + try { + storage.setConfig(config); + return { success: true }; + } catch (error) { + console.error("Error setting config:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:update-config', async (event, key, value) => { - try { - storage.updateConfig(key, value); - return { success: true }; - } catch (error) { - console.error('Error updating config:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:update-config", async (event, key, value) => { + try { + storage.updateConfig(key, value); + return { success: true }; + } catch (error) { + console.error("Error updating config:", error); + return { success: false, error: error.message }; + } + }); - // ============ CREDENTIALS ============ - ipcMain.handle('storage:get-credentials', async () => { - try { - return { success: true, data: storage.getCredentials() }; - } catch (error) { - console.error('Error getting credentials:', error); - return { success: false, error: error.message }; - } - }); + // ============ CREDENTIALS ============ + ipcMain.handle("storage:get-credentials", async () => { + try { + return { success: true, data: storage.getCredentials() }; + } catch (error) { + console.error("Error getting credentials:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:set-credentials', async (event, credentials) => { - try { - storage.setCredentials(credentials); - return { success: true }; - } catch (error) { - console.error('Error setting credentials:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:set-credentials", async (event, credentials) => { + try { + storage.setCredentials(credentials); + return { success: true }; + } catch (error) { + console.error("Error setting credentials:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:get-api-key', async () => { - try { - return { success: true, data: storage.getApiKey() }; - } catch (error) { - console.error('Error getting API key:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:get-api-key", async () => { + try { + return { success: true, data: storage.getApiKey() }; + } catch (error) { + console.error("Error getting API key:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:set-api-key', async (event, apiKey) => { - try { - storage.setApiKey(apiKey); - return { success: true }; - } catch (error) { - console.error('Error setting API key:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:set-api-key", async (event, apiKey) => { + try { + storage.setApiKey(apiKey); + return { success: true }; + } catch (error) { + console.error("Error setting API key:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:get-groq-api-key', async () => { - try { - return { success: true, data: storage.getGroqApiKey() }; - } catch (error) { - console.error('Error getting Groq API key:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:get-groq-api-key", async () => { + try { + return { success: true, data: storage.getGroqApiKey() }; + } catch (error) { + console.error("Error getting Groq API key:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:set-groq-api-key', async (event, groqApiKey) => { - try { - storage.setGroqApiKey(groqApiKey); - return { success: true }; - } catch (error) { - console.error('Error setting Groq API key:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:set-groq-api-key", async (event, groqApiKey) => { + try { + storage.setGroqApiKey(groqApiKey); + return { success: true }; + } catch (error) { + console.error("Error setting Groq API key:", error); + return { success: false, error: error.message }; + } + }); - // ============ PREFERENCES ============ - ipcMain.handle('storage:get-preferences', async () => { - try { - return { success: true, data: storage.getPreferences() }; - } catch (error) { - console.error('Error getting preferences:', error); - return { success: false, error: error.message }; - } - }); + // ============ PREFERENCES ============ + ipcMain.handle("storage:get-preferences", async () => { + try { + return { success: true, data: storage.getPreferences() }; + } catch (error) { + console.error("Error getting preferences:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:set-preferences', async (event, preferences) => { - try { - storage.setPreferences(preferences); - return { success: true }; - } catch (error) { - console.error('Error setting preferences:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:set-preferences", async (event, preferences) => { + try { + storage.setPreferences(preferences); + return { success: true }; + } catch (error) { + console.error("Error setting preferences:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:update-preference', async (event, key, value) => { - try { - storage.updatePreference(key, value); - return { success: true }; - } catch (error) { - console.error('Error updating preference:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:update-preference", async (event, key, value) => { + try { + storage.updatePreference(key, value); + return { success: true }; + } catch (error) { + console.error("Error updating preference:", error); + return { success: false, error: error.message }; + } + }); - // ============ KEYBINDS ============ - ipcMain.handle('storage:get-keybinds', async () => { - try { - return { success: true, data: storage.getKeybinds() }; - } catch (error) { - console.error('Error getting keybinds:', error); - return { success: false, error: error.message }; - } - }); + // ============ KEYBINDS ============ + ipcMain.handle("storage:get-keybinds", async () => { + try { + return { success: true, data: storage.getKeybinds() }; + } catch (error) { + console.error("Error getting keybinds:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:set-keybinds', async (event, keybinds) => { - try { - storage.setKeybinds(keybinds); - return { success: true }; - } catch (error) { - console.error('Error setting keybinds:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:set-keybinds", async (event, keybinds) => { + try { + storage.setKeybinds(keybinds); + return { success: true }; + } catch (error) { + console.error("Error setting keybinds:", error); + return { success: false, error: error.message }; + } + }); - // ============ HISTORY ============ - ipcMain.handle('storage:get-all-sessions', async () => { - try { - return { success: true, data: storage.getAllSessions() }; - } catch (error) { - console.error('Error getting sessions:', error); - return { success: false, error: error.message }; - } - }); + // ============ HISTORY ============ + ipcMain.handle("storage:get-all-sessions", async () => { + try { + return { success: true, data: storage.getAllSessions() }; + } catch (error) { + console.error("Error getting sessions:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:get-session', async (event, sessionId) => { - try { - return { success: true, data: storage.getSession(sessionId) }; - } catch (error) { - console.error('Error getting session:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:get-session", async (event, sessionId) => { + try { + return { success: true, data: storage.getSession(sessionId) }; + } catch (error) { + console.error("Error getting session:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:save-session', async (event, sessionId, data) => { - try { - storage.saveSession(sessionId, data); - return { success: true }; - } catch (error) { - console.error('Error saving session:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:save-session", async (event, sessionId, data) => { + try { + storage.saveSession(sessionId, data); + return { success: true }; + } catch (error) { + console.error("Error saving session:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:delete-session', async (event, sessionId) => { - try { - storage.deleteSession(sessionId); - return { success: true }; - } catch (error) { - console.error('Error deleting session:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:delete-session", async (event, sessionId) => { + try { + storage.deleteSession(sessionId); + return { success: true }; + } catch (error) { + console.error("Error deleting session:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('storage:delete-all-sessions', async () => { - try { - storage.deleteAllSessions(); - return { success: true }; - } catch (error) { - console.error('Error deleting all sessions:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("storage:delete-all-sessions", async () => { + try { + storage.deleteAllSessions(); + return { success: true }; + } catch (error) { + console.error("Error deleting all sessions:", error); + return { success: false, error: error.message }; + } + }); - // ============ LIMITS ============ - ipcMain.handle('storage:get-today-limits', async () => { - try { - return { success: true, data: storage.getTodayLimits() }; - } catch (error) { - console.error('Error getting today limits:', error); - return { success: false, error: error.message }; - } - }); + // ============ LIMITS ============ + ipcMain.handle("storage:get-today-limits", async () => { + try { + return { success: true, data: storage.getTodayLimits() }; + } catch (error) { + console.error("Error getting today limits:", error); + return { success: false, error: error.message }; + } + }); - // ============ CLEAR ALL ============ - ipcMain.handle('storage:clear-all', async () => { - try { - storage.clearAllData(); - return { success: true }; - } catch (error) { - console.error('Error clearing all data:', error); - return { success: false, error: error.message }; - } - }); + // ============ CLEAR ALL ============ + ipcMain.handle("storage:clear-all", async () => { + try { + storage.clearAllData(); + return { success: true }; + } catch (error) { + console.error("Error clearing all data:", error); + return { success: false, error: error.message }; + } + }); } function setupGeneralIpcHandlers() { - ipcMain.handle('get-app-version', async () => { - return app.getVersion(); - }); + ipcMain.handle("get-app-version", async () => { + return app.getVersion(); + }); - ipcMain.handle('quit-application', async event => { - try { - stopMacOSAudioCapture(); - app.quit(); - return { success: true }; - } catch (error) { - console.error('Error quitting application:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("quit-application", async (event) => { + try { + stopMacOSAudioCapture(); + app.quit(); + return { success: true }; + } catch (error) { + console.error("Error quitting application:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.handle('open-external', async (event, url) => { - try { - await shell.openExternal(url); - return { success: true }; - } catch (error) { - console.error('Error opening external URL:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle("open-external", async (event, url) => { + try { + await shell.openExternal(url); + return { success: true }; + } catch (error) { + console.error("Error opening external URL:", error); + return { success: false, error: error.message }; + } + }); - ipcMain.on('update-keybinds', (event, newKeybinds) => { - if (mainWindow) { - // Also save to storage - storage.setKeybinds(newKeybinds); - updateGlobalShortcuts(newKeybinds, mainWindow, sendToRenderer, geminiSessionRef); - } - }); + ipcMain.on("update-keybinds", (event, newKeybinds) => { + if (mainWindow) { + // Also save to storage + storage.setKeybinds(newKeybinds); + updateGlobalShortcuts( + newKeybinds, + mainWindow, + sendToRenderer, + geminiSessionRef, + ); + } + }); - // Debug logging from renderer - ipcMain.on('log-message', (event, msg) => { - console.log(msg); - }); + // Debug logging from renderer + ipcMain.on("log-message", (event, msg) => { + console.log(msg); + }); } diff --git a/src/utils/localai.js b/src/utils/localai.js index cc6583f..800656a 100644 --- a/src/utils/localai.js +++ b/src/utils/localai.js @@ -1,17 +1,27 @@ -const { Ollama } = require('ollama'); -const { getSystemPrompt } = require('./prompts'); -const { sendToRenderer, initializeNewSession, saveConversationTurn } = require('./gemini'); +const { Ollama } = require("ollama"); +const { getSystemPrompt } = require("./prompts"); +const { + sendToRenderer, + initializeNewSession, + saveConversationTurn, +} = require("./gemini"); +const { fork } = require("child_process"); +const path = require("path"); // ── State ── let ollamaClient = null; let ollamaModel = null; -let whisperPipeline = null; +let whisperWorker = null; let isWhisperLoading = false; +let whisperReady = false; let localConversationHistory = []; let currentSystemPrompt = null; let isLocalActive = false; +// Pending transcription callback (one at a time) +let pendingTranscribe = null; + // VAD state let isSpeaking = false; let speechBuffers = []; @@ -20,418 +30,678 @@ let speechFrameCount = 0; // VAD configuration const VAD_MODES = { - NORMAL: { energyThreshold: 0.01, speechFramesRequired: 3, silenceFramesRequired: 30 }, - LOW_BITRATE: { energyThreshold: 0.008, speechFramesRequired: 4, silenceFramesRequired: 35 }, - AGGRESSIVE: { energyThreshold: 0.015, speechFramesRequired: 2, silenceFramesRequired: 20 }, - VERY_AGGRESSIVE: { energyThreshold: 0.02, speechFramesRequired: 2, silenceFramesRequired: 15 }, + NORMAL: { + energyThreshold: 0.01, + speechFramesRequired: 3, + silenceFramesRequired: 30, + }, + LOW_BITRATE: { + energyThreshold: 0.008, + speechFramesRequired: 4, + silenceFramesRequired: 35, + }, + AGGRESSIVE: { + energyThreshold: 0.015, + speechFramesRequired: 2, + silenceFramesRequired: 20, + }, + VERY_AGGRESSIVE: { + energyThreshold: 0.02, + speechFramesRequired: 2, + silenceFramesRequired: 15, + }, }; let vadConfig = VAD_MODES.VERY_AGGRESSIVE; +// Maximum speech buffer size: ~30 seconds at 16kHz, 16-bit mono +const MAX_SPEECH_BUFFER_BYTES = 16000 * 2 * 30; // 960,000 bytes + // Audio resampling buffer let resampleRemainder = Buffer.alloc(0); // ── Audio Resampling (24kHz → 16kHz) ── function resample24kTo16k(inputBuffer) { - // Combine with any leftover samples from previous call - const combined = Buffer.concat([resampleRemainder, inputBuffer]); - const inputSamples = Math.floor(combined.length / 2); // 16-bit = 2 bytes per sample - // Ratio: 16000/24000 = 2/3, so for every 3 input samples we produce 2 output samples - const outputSamples = Math.floor((inputSamples * 2) / 3); - const outputBuffer = Buffer.alloc(outputSamples * 2); + // Combine with any leftover samples from previous call + const combined = Buffer.concat([resampleRemainder, inputBuffer]); + const inputSamples = Math.floor(combined.length / 2); // 16-bit = 2 bytes per sample + // Ratio: 16000/24000 = 2/3, so for every 3 input samples we produce 2 output samples + const outputSamples = Math.floor((inputSamples * 2) / 3); + const outputBuffer = Buffer.alloc(outputSamples * 2); - for (let i = 0; i < outputSamples; i++) { - // Map output sample index to input position - const srcPos = (i * 3) / 2; - const srcIndex = Math.floor(srcPos); - const frac = srcPos - srcIndex; + for (let i = 0; i < outputSamples; i++) { + // Map output sample index to input position + const srcPos = (i * 3) / 2; + const srcIndex = Math.floor(srcPos); + const frac = srcPos - srcIndex; - const s0 = combined.readInt16LE(srcIndex * 2); - const s1 = srcIndex + 1 < inputSamples ? combined.readInt16LE((srcIndex + 1) * 2) : s0; - const interpolated = Math.round(s0 + frac * (s1 - s0)); - outputBuffer.writeInt16LE(Math.max(-32768, Math.min(32767, interpolated)), i * 2); - } + const s0 = combined.readInt16LE(srcIndex * 2); + const s1 = + srcIndex + 1 < inputSamples + ? combined.readInt16LE((srcIndex + 1) * 2) + : s0; + const interpolated = Math.round(s0 + frac * (s1 - s0)); + outputBuffer.writeInt16LE( + Math.max(-32768, Math.min(32767, interpolated)), + i * 2, + ); + } - // Store remainder for next call - const consumedInputSamples = Math.ceil((outputSamples * 3) / 2); - const remainderStart = consumedInputSamples * 2; - resampleRemainder = remainderStart < combined.length ? combined.slice(remainderStart) : Buffer.alloc(0); + // Store remainder for next call + const consumedInputSamples = Math.ceil((outputSamples * 3) / 2); + const remainderStart = consumedInputSamples * 2; + resampleRemainder = + remainderStart < combined.length + ? combined.slice(remainderStart) + : Buffer.alloc(0); - return outputBuffer; + return outputBuffer; } // ── VAD (Voice Activity Detection) ── function calculateRMS(pcm16Buffer) { - const samples = pcm16Buffer.length / 2; - if (samples === 0) return 0; - let sumSquares = 0; - for (let i = 0; i < samples; i++) { - const sample = pcm16Buffer.readInt16LE(i * 2) / 32768; - sumSquares += sample * sample; - } - return Math.sqrt(sumSquares / samples); + const samples = pcm16Buffer.length / 2; + if (samples === 0) return 0; + let sumSquares = 0; + for (let i = 0; i < samples; i++) { + const sample = pcm16Buffer.readInt16LE(i * 2) / 32768; + sumSquares += sample * sample; + } + return Math.sqrt(sumSquares / samples); } function processVAD(pcm16kBuffer) { - const rms = calculateRMS(pcm16kBuffer); - const isVoice = rms > vadConfig.energyThreshold; + const rms = calculateRMS(pcm16kBuffer); + const isVoice = rms > vadConfig.energyThreshold; - if (isVoice) { - speechFrameCount++; - silenceFrameCount = 0; + if (isVoice) { + speechFrameCount++; + silenceFrameCount = 0; - if (!isSpeaking && speechFrameCount >= vadConfig.speechFramesRequired) { - isSpeaking = true; - speechBuffers = []; - console.log('[LocalAI] Speech started (RMS:', rms.toFixed(4), ')'); - sendToRenderer('update-status', 'Listening... (speech detected)'); - } - } else { - silenceFrameCount++; - speechFrameCount = 0; - - if (isSpeaking && silenceFrameCount >= vadConfig.silenceFramesRequired) { - isSpeaking = false; - console.log('[LocalAI] Speech ended, accumulated', speechBuffers.length, 'chunks'); - sendToRenderer('update-status', 'Transcribing...'); - - // Trigger transcription with accumulated audio - const audioData = Buffer.concat(speechBuffers); - speechBuffers = []; - handleSpeechEnd(audioData); - return; - } + if (!isSpeaking && speechFrameCount >= vadConfig.speechFramesRequired) { + isSpeaking = true; + speechBuffers = []; + console.log("[LocalAI] Speech started (RMS:", rms.toFixed(4), ")"); + sendToRenderer("update-status", "Listening... (speech detected)"); } + } else { + silenceFrameCount++; + speechFrameCount = 0; - // Accumulate audio during speech - if (isSpeaking) { - speechBuffers.push(Buffer.from(pcm16kBuffer)); + if (isSpeaking && silenceFrameCount >= vadConfig.silenceFramesRequired) { + isSpeaking = false; + console.log( + "[LocalAI] Speech ended, accumulated", + speechBuffers.length, + "chunks", + ); + sendToRenderer("update-status", "Transcribing..."); + + // Trigger transcription with accumulated audio + const audioData = Buffer.concat(speechBuffers); + speechBuffers = []; + handleSpeechEnd(audioData).catch((err) => { + console.error("[LocalAI] handleSpeechEnd crashed:", err); + sendToRenderer( + "update-status", + "Transcription error: " + (err?.message || "unknown"), + ); + }); + return; } + } + + // Accumulate audio during speech + if (isSpeaking) { + speechBuffers.push(Buffer.from(pcm16kBuffer)); + + // Cap buffer at ~30 seconds to prevent OOM and ONNX tensor overflow + const totalBytes = speechBuffers.reduce((sum, b) => sum + b.length, 0); + if (totalBytes >= MAX_SPEECH_BUFFER_BYTES) { + isSpeaking = false; + console.log( + "[LocalAI] Speech buffer limit reached (" + + totalBytes + + " bytes), forcing transcription", + ); + sendToRenderer("update-status", "Transcribing (max length reached)..."); + const audioData = Buffer.concat(speechBuffers); + speechBuffers = []; + silenceFrameCount = 0; + speechFrameCount = 0; + handleSpeechEnd(audioData).catch((err) => { + console.error("[LocalAI] handleSpeechEnd crashed:", err); + sendToRenderer( + "update-status", + "Transcription error: " + (err?.message || "unknown"), + ); + }); + } + } } -// ── Whisper Transcription ── +// ── Whisper Worker (isolated child process) ── + +function spawnWhisperWorker() { + if (whisperWorker) return; + + const workerPath = path.join(__dirname, "whisperWorker.js"); + console.log("[LocalAI] Spawning Whisper worker:", workerPath); + + whisperWorker = fork(workerPath, [], { + stdio: ["pipe", "pipe", "pipe", "ipc"], + // ELECTRON_RUN_AS_NODE makes the Electron binary behave as plain Node.js, + // which is required for child_process.fork() in packaged Electron apps. + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + }); + + whisperWorker.stdout.on("data", (data) => { + console.log("[WhisperWorker stdout]", data.toString().trim()); + }); + whisperWorker.stderr.on("data", (data) => { + console.error("[WhisperWorker stderr]", data.toString().trim()); + }); + + whisperWorker.on("message", (msg) => { + switch (msg.type) { + case "ready": + console.log("[LocalAI] Whisper worker ready"); + break; + case "load-result": + handleWorkerLoadResult(msg); + break; + case "transcribe-result": + handleWorkerTranscribeResult(msg); + break; + case "status": + sendToRenderer("update-status", msg.message); + break; + } + }); + + whisperWorker.on("exit", (code, signal) => { + console.error( + "[LocalAI] Whisper worker exited — code:", + code, + "signal:", + signal, + ); + whisperWorker = null; + whisperReady = false; + + // Reject any pending transcription + if (pendingTranscribe) { + pendingTranscribe.reject( + new Error( + "Whisper worker crashed (code: " + code + ", signal: " + signal + ")", + ), + ); + pendingTranscribe = null; + } + + // If session is still active, inform the user and respawn + if (isLocalActive) { + sendToRenderer( + "update-status", + "Whisper crashed (signal: " + + (signal || code) + + "). Respawning worker...", + ); + setTimeout(() => { + if (isLocalActive) { + respawnWhisperWorker(); + } + }, 2000); + } + }); + + whisperWorker.on("error", (err) => { + console.error("[LocalAI] Whisper worker error:", err); + whisperWorker = null; + whisperReady = false; + }); +} + +let pendingLoad = null; + +function handleWorkerLoadResult(msg) { + if (msg.success) { + console.log("[LocalAI] Whisper model loaded successfully (in worker)"); + whisperReady = true; + sendToRenderer("whisper-downloading", false); + isWhisperLoading = false; + if (pendingLoad) { + pendingLoad.resolve(true); + pendingLoad = null; + } + } else { + console.error("[LocalAI] Whisper worker failed to load model:", msg.error); + sendToRenderer("whisper-downloading", false); + sendToRenderer( + "update-status", + "Failed to load Whisper model: " + msg.error, + ); + isWhisperLoading = false; + if (pendingLoad) { + pendingLoad.resolve(false); + pendingLoad = null; + } + } +} + +function handleWorkerTranscribeResult(msg) { + if (!pendingTranscribe) return; + if (msg.success) { + console.log("[LocalAI] Transcription:", msg.text); + pendingTranscribe.resolve(msg.text || null); + } else { + console.error("[LocalAI] Worker transcription error:", msg.error); + pendingTranscribe.resolve(null); + } + pendingTranscribe = null; +} + +function respawnWhisperWorker() { + killWhisperWorker(); + spawnWhisperWorker(); + const { app } = require("electron"); + const cacheDir = path.join(app.getPath("userData"), "whisper-models"); + const modelName = + require("../storage").getPreferences().whisperModel || + "Xenova/whisper-small"; + sendToRenderer("whisper-downloading", true); + isWhisperLoading = true; + whisperWorker.send({ type: "load", modelName, cacheDir }); +} + +function killWhisperWorker() { + if (whisperWorker) { + try { + whisperWorker.removeAllListeners(); + whisperWorker.kill(); + } catch (_) { + // Already dead + } + whisperWorker = null; + whisperReady = false; + } +} async function loadWhisperPipeline(modelName) { - if (whisperPipeline) return whisperPipeline; - if (isWhisperLoading) return null; + if (whisperReady) return true; + if (isWhisperLoading) return null; - isWhisperLoading = true; - console.log('[LocalAI] Loading Whisper model:', modelName); - sendToRenderer('whisper-downloading', true); - sendToRenderer('update-status', 'Loading Whisper model (first time may take a while)...'); + isWhisperLoading = true; + console.log("[LocalAI] Loading Whisper model via worker:", modelName); + sendToRenderer("whisper-downloading", true); + sendToRenderer( + "update-status", + "Loading Whisper model (first time may take a while)...", + ); - try { - // Dynamic import for ESM module - const { pipeline, env } = await import('@huggingface/transformers'); - // Cache models outside the asar archive so ONNX runtime can load them - const { app } = require('electron'); - const path = require('path'); - env.cacheDir = path.join(app.getPath('userData'), 'whisper-models'); - whisperPipeline = await pipeline('automatic-speech-recognition', modelName, { - dtype: 'q8', - device: 'auto', - }); - console.log('[LocalAI] Whisper model loaded successfully'); - sendToRenderer('whisper-downloading', false); - isWhisperLoading = false; - return whisperPipeline; - } catch (error) { - console.error('[LocalAI] Failed to load Whisper model:', error); - sendToRenderer('whisper-downloading', false); - sendToRenderer('update-status', 'Failed to load Whisper model: ' + error.message); - isWhisperLoading = false; - return null; - } -} + spawnWhisperWorker(); -function pcm16ToFloat32(pcm16Buffer) { - const samples = pcm16Buffer.length / 2; - const float32 = new Float32Array(samples); - for (let i = 0; i < samples; i++) { - float32[i] = pcm16Buffer.readInt16LE(i * 2) / 32768; - } - return float32; + const { app } = require("electron"); + const cacheDir = path.join(app.getPath("userData"), "whisper-models"); + + return new Promise((resolve) => { + pendingLoad = { resolve }; + whisperWorker.send({ type: "load", modelName, cacheDir }); + }); } async function transcribeAudio(pcm16kBuffer) { - if (!whisperPipeline) { - console.error('[LocalAI] Whisper pipeline not loaded'); - return null; - } + if (!whisperReady || !whisperWorker) { + console.error("[LocalAI] Whisper worker not ready"); + return null; + } + + if (!pcm16kBuffer || pcm16kBuffer.length < 2) { + console.error("[LocalAI] Invalid audio buffer:", pcm16kBuffer?.length); + return null; + } + + console.log( + "[LocalAI] Starting transcription, audio length:", + pcm16kBuffer.length, + "bytes", + ); + + // Send audio to worker as base64 (IPC serialization) + const audioBase64 = pcm16kBuffer.toString("base64"); + + return new Promise((resolve, reject) => { + // Timeout: if worker takes > 60s, assume it's stuck + const timeout = setTimeout(() => { + console.error("[LocalAI] Transcription timed out after 60s"); + if (pendingTranscribe) { + pendingTranscribe = null; + resolve(null); + } + }, 60000); + + pendingTranscribe = { + resolve: (val) => { + clearTimeout(timeout); + resolve(val); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + }, + }; try { - const float32Audio = pcm16ToFloat32(pcm16kBuffer); - - // Whisper expects audio at 16kHz which is what we have - const result = await whisperPipeline(float32Audio, { - sampling_rate: 16000, - language: 'en', - task: 'transcribe', - }); - - const text = result.text?.trim(); - console.log('[LocalAI] Transcription:', text); - return text; - } catch (error) { - console.error('[LocalAI] Transcription error:', error); - return null; + whisperWorker.send({ type: "transcribe", audioBase64 }); + } catch (err) { + clearTimeout(timeout); + pendingTranscribe = null; + console.error("[LocalAI] Failed to send to worker:", err); + resolve(null); } + }); } // ── Speech End Handler ── async function handleSpeechEnd(audioData) { - if (!isLocalActive) return; + if (!isLocalActive) return; - // Minimum audio length check (~0.5 seconds at 16kHz, 16-bit) - if (audioData.length < 16000) { - console.log('[LocalAI] Audio too short, skipping'); - sendToRenderer('update-status', 'Listening...'); - return; - } + // Minimum audio length check (~0.5 seconds at 16kHz, 16-bit) + if (audioData.length < 16000) { + console.log("[LocalAI] Audio too short, skipping"); + sendToRenderer("update-status", "Listening..."); + return; + } + console.log("[LocalAI] Processing audio:", audioData.length, "bytes"); + + try { const transcription = await transcribeAudio(audioData); - if (!transcription || transcription.trim() === '' || transcription.trim().length < 2) { - console.log('[LocalAI] Empty transcription, skipping'); - sendToRenderer('update-status', 'Listening...'); - return; + if ( + !transcription || + transcription.trim() === "" || + transcription.trim().length < 2 + ) { + console.log("[LocalAI] Empty transcription, skipping"); + sendToRenderer("update-status", "Listening..."); + return; } - sendToRenderer('update-status', 'Generating response...'); + sendToRenderer("update-status", "Generating response..."); await sendToOllama(transcription); + } catch (error) { + console.error("[LocalAI] handleSpeechEnd error:", error); + sendToRenderer( + "update-status", + "Error: " + (error?.message || "transcription failed"), + ); + } } // ── Ollama Chat ── async function sendToOllama(transcription) { - if (!ollamaClient || !ollamaModel) { - console.error('[LocalAI] Ollama not configured'); - return; - } + if (!ollamaClient || !ollamaModel) { + console.error("[LocalAI] Ollama not configured"); + return; + } - console.log('[LocalAI] Sending to Ollama:', transcription.substring(0, 100) + '...'); + console.log( + "[LocalAI] Sending to Ollama:", + transcription.substring(0, 100) + "...", + ); - localConversationHistory.push({ - role: 'user', - content: transcription.trim(), + localConversationHistory.push({ + role: "user", + content: transcription.trim(), + }); + + // Keep history manageable + if (localConversationHistory.length > 20) { + localConversationHistory = localConversationHistory.slice(-20); + } + + try { + const messages = [ + { + role: "system", + content: currentSystemPrompt || "You are a helpful assistant.", + }, + ...localConversationHistory, + ]; + + const response = await ollamaClient.chat({ + model: ollamaModel, + messages, + stream: true, }); - // Keep history manageable - if (localConversationHistory.length > 20) { - localConversationHistory = localConversationHistory.slice(-20); + let fullText = ""; + let isFirst = true; + + for await (const part of response) { + const token = part.message?.content || ""; + if (token) { + fullText += token; + sendToRenderer(isFirst ? "new-response" : "update-response", fullText); + isFirst = false; + } } - try { - const messages = [ - { role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' }, - ...localConversationHistory, - ]; + if (fullText.trim()) { + localConversationHistory.push({ + role: "assistant", + content: fullText.trim(), + }); - const response = await ollamaClient.chat({ - model: ollamaModel, - messages, - stream: true, - }); - - let fullText = ''; - let isFirst = true; - - for await (const part of response) { - const token = part.message?.content || ''; - if (token) { - fullText += token; - sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText); - isFirst = false; - } - } - - if (fullText.trim()) { - localConversationHistory.push({ - role: 'assistant', - content: fullText.trim(), - }); - - saveConversationTurn(transcription, fullText); - } - - console.log('[LocalAI] Ollama response completed'); - sendToRenderer('update-status', 'Listening...'); - } catch (error) { - console.error('[LocalAI] Ollama error:', error); - sendToRenderer('update-status', 'Ollama error: ' + error.message); + saveConversationTurn(transcription, fullText); } + + console.log("[LocalAI] Ollama response completed"); + sendToRenderer("update-status", "Listening..."); + } catch (error) { + console.error("[LocalAI] Ollama error:", error); + sendToRenderer("update-status", "Ollama error: " + error.message); + } } // ── Public API ── -async function initializeLocalSession(ollamaHost, model, whisperModel, profile, customPrompt) { - console.log('[LocalAI] Initializing local session:', { ollamaHost, model, whisperModel, profile }); +async function initializeLocalSession( + ollamaHost, + model, + whisperModel, + profile, + customPrompt, +) { + console.log("[LocalAI] Initializing local session:", { + ollamaHost, + model, + whisperModel, + profile, + }); - sendToRenderer('session-initializing', true); + sendToRenderer("session-initializing", true); + try { + // Setup system prompt + currentSystemPrompt = getSystemPrompt(profile, customPrompt, false); + + // Initialize Ollama client + ollamaClient = new Ollama({ host: ollamaHost }); + ollamaModel = model; + + // Test Ollama connection try { - // Setup system prompt - currentSystemPrompt = getSystemPrompt(profile, customPrompt, false); - - // Initialize Ollama client - ollamaClient = new Ollama({ host: ollamaHost }); - ollamaModel = model; - - // Test Ollama connection - try { - await ollamaClient.list(); - console.log('[LocalAI] Ollama connection verified'); - } catch (error) { - console.error('[LocalAI] Cannot connect to Ollama at', ollamaHost, ':', error.message); - sendToRenderer('session-initializing', false); - sendToRenderer('update-status', 'Cannot connect to Ollama at ' + ollamaHost); - return false; - } - - // Load Whisper model - const pipeline = await loadWhisperPipeline(whisperModel); - if (!pipeline) { - sendToRenderer('session-initializing', false); - return false; - } - - // Reset VAD state - isSpeaking = false; - speechBuffers = []; - silenceFrameCount = 0; - speechFrameCount = 0; - resampleRemainder = Buffer.alloc(0); - localConversationHistory = []; - - // Initialize conversation session - initializeNewSession(profile, customPrompt); - - isLocalActive = true; - sendToRenderer('session-initializing', false); - sendToRenderer('update-status', 'Local AI ready - Listening...'); - - console.log('[LocalAI] Session initialized successfully'); - return true; + await ollamaClient.list(); + console.log("[LocalAI] Ollama connection verified"); } catch (error) { - console.error('[LocalAI] Initialization error:', error); - sendToRenderer('session-initializing', false); - sendToRenderer('update-status', 'Local AI error: ' + error.message); - return false; + console.error( + "[LocalAI] Cannot connect to Ollama at", + ollamaHost, + ":", + error.message, + ); + sendToRenderer("session-initializing", false); + sendToRenderer( + "update-status", + "Cannot connect to Ollama at " + ollamaHost, + ); + return false; } -} -function processLocalAudio(monoChunk24k) { - if (!isLocalActive) return; - - // Resample from 24kHz to 16kHz - const pcm16k = resample24kTo16k(monoChunk24k); - if (pcm16k.length > 0) { - processVAD(pcm16k); + // Load Whisper model + const pipeline = await loadWhisperPipeline(whisperModel); + if (!pipeline) { + sendToRenderer("session-initializing", false); + return false; } -} -function closeLocalSession() { - console.log('[LocalAI] Closing local session'); - isLocalActive = false; + // Reset VAD state isSpeaking = false; speechBuffers = []; silenceFrameCount = 0; speechFrameCount = 0; resampleRemainder = Buffer.alloc(0); localConversationHistory = []; - ollamaClient = null; - ollamaModel = null; - currentSystemPrompt = null; - // Note: whisperPipeline is kept loaded to avoid reloading on next session + + // Initialize conversation session + initializeNewSession(profile, customPrompt); + + isLocalActive = true; + sendToRenderer("session-initializing", false); + sendToRenderer("update-status", "Local AI ready - Listening..."); + + console.log("[LocalAI] Session initialized successfully"); + return true; + } catch (error) { + console.error("[LocalAI] Initialization error:", error); + sendToRenderer("session-initializing", false); + sendToRenderer("update-status", "Local AI error: " + error.message); + return false; + } +} + +function processLocalAudio(monoChunk24k) { + if (!isLocalActive) return; + + // Resample from 24kHz to 16kHz + const pcm16k = resample24kTo16k(monoChunk24k); + if (pcm16k.length > 0) { + processVAD(pcm16k); + } +} + +function closeLocalSession() { + console.log("[LocalAI] Closing local session"); + isLocalActive = false; + isSpeaking = false; + speechBuffers = []; + silenceFrameCount = 0; + speechFrameCount = 0; + resampleRemainder = Buffer.alloc(0); + localConversationHistory = []; + ollamaClient = null; + ollamaModel = null; + currentSystemPrompt = null; + // Note: whisperWorker is kept alive to avoid reloading model on next session + // To fully clean up, call killWhisperWorker() } function isLocalSessionActive() { - return isLocalActive; + return isLocalActive; } // ── Send text directly to Ollama (for manual text input) ── async function sendLocalText(text) { - if (!isLocalActive || !ollamaClient) { - return { success: false, error: 'No active local session' }; - } + if (!isLocalActive || !ollamaClient) { + return { success: false, error: "No active local session" }; + } - try { - await sendToOllama(text); - return { success: true }; - } catch (error) { - return { success: false, error: error.message }; - } + try { + await sendToOllama(text); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } } async function sendLocalImage(base64Data, prompt) { - if (!isLocalActive || !ollamaClient) { - return { success: false, error: 'No active local session' }; + if (!isLocalActive || !ollamaClient) { + return { success: false, error: "No active local session" }; + } + + try { + console.log("[LocalAI] Sending image to Ollama"); + sendToRenderer("update-status", "Analyzing image..."); + + const userMessage = { + role: "user", + content: prompt, + images: [base64Data], + }; + + // Store text-only version in history + localConversationHistory.push({ role: "user", content: prompt }); + + if (localConversationHistory.length > 20) { + localConversationHistory = localConversationHistory.slice(-20); } - try { - console.log('[LocalAI] Sending image to Ollama'); - sendToRenderer('update-status', 'Analyzing image...'); + const messages = [ + { + role: "system", + content: currentSystemPrompt || "You are a helpful assistant.", + }, + ...localConversationHistory.slice(0, -1), + userMessage, + ]; - const userMessage = { - role: 'user', - content: prompt, - images: [base64Data], - }; + const response = await ollamaClient.chat({ + model: ollamaModel, + messages, + stream: true, + }); - // Store text-only version in history - localConversationHistory.push({ role: 'user', content: prompt }); + let fullText = ""; + let isFirst = true; - if (localConversationHistory.length > 20) { - localConversationHistory = localConversationHistory.slice(-20); - } - - const messages = [ - { role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' }, - ...localConversationHistory.slice(0, -1), - userMessage, - ]; - - const response = await ollamaClient.chat({ - model: ollamaModel, - messages, - stream: true, - }); - - let fullText = ''; - let isFirst = true; - - for await (const part of response) { - const token = part.message?.content || ''; - if (token) { - fullText += token; - sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText); - isFirst = false; - } - } - - if (fullText.trim()) { - localConversationHistory.push({ role: 'assistant', content: fullText.trim() }); - saveConversationTurn(prompt, fullText); - } - - console.log('[LocalAI] Image response completed'); - sendToRenderer('update-status', 'Listening...'); - return { success: true, text: fullText, model: ollamaModel }; - } catch (error) { - console.error('[LocalAI] Image error:', error); - sendToRenderer('update-status', 'Ollama error: ' + error.message); - return { success: false, error: error.message }; + for await (const part of response) { + const token = part.message?.content || ""; + if (token) { + fullText += token; + sendToRenderer(isFirst ? "new-response" : "update-response", fullText); + isFirst = false; + } } + + if (fullText.trim()) { + localConversationHistory.push({ + role: "assistant", + content: fullText.trim(), + }); + saveConversationTurn(prompt, fullText); + } + + console.log("[LocalAI] Image response completed"); + sendToRenderer("update-status", "Listening..."); + return { success: true, text: fullText, model: ollamaModel }; + } catch (error) { + console.error("[LocalAI] Image error:", error); + sendToRenderer("update-status", "Ollama error: " + error.message); + return { success: false, error: error.message }; + } } module.exports = { - initializeLocalSession, - processLocalAudio, - closeLocalSession, - isLocalSessionActive, - sendLocalText, - sendLocalImage, + initializeLocalSession, + processLocalAudio, + closeLocalSession, + isLocalSessionActive, + sendLocalText, + sendLocalImage, }; diff --git a/src/utils/whisperWorker.js b/src/utils/whisperWorker.js new file mode 100644 index 0000000..c2482e0 --- /dev/null +++ b/src/utils/whisperWorker.js @@ -0,0 +1,150 @@ +/** + * Whisper Worker — runs ONNX Runtime in an isolated child process. + * + * The main Electron process forks this file and communicates via IPC messages. + * If ONNX Runtime crashes (SIGSEGV/SIGABRT inside the native Metal or CPU + * execution provider), only this worker dies — the main process survives and + * can respawn the worker automatically. + * + * Protocol (parent ↔ worker): + * parent → worker: + * { type: 'load', modelName, cacheDir } + * { type: 'transcribe', audioBase64 } // PCM 16-bit 16kHz as base64 + * { type: 'shutdown' } + * + * worker → parent: + * { type: 'load-result', success, error? } + * { type: 'transcribe-result', success, text?, error? } + * { type: 'status', message } + * { type: 'ready' } + */ + +let whisperPipeline = null; + +function pcm16ToFloat32(pcm16Buffer) { + if (!pcm16Buffer || pcm16Buffer.length === 0) { + return new Float32Array(0); + } + const alignedLength = + pcm16Buffer.length % 2 === 0 ? pcm16Buffer.length : pcm16Buffer.length - 1; + const samples = alignedLength / 2; + const float32 = new Float32Array(samples); + for (let i = 0; i < samples; i++) { + float32[i] = pcm16Buffer.readInt16LE(i * 2) / 32768; + } + return float32; +} + +async function loadModel(modelName, cacheDir) { + if (whisperPipeline) { + send({ type: "load-result", success: true }); + return; + } + + try { + send({ + type: "status", + message: "Loading Whisper model (first time may take a while)...", + }); + const { pipeline, env } = await import("@huggingface/transformers"); + env.cacheDir = cacheDir; + whisperPipeline = await pipeline( + "automatic-speech-recognition", + modelName, + { + dtype: "q8", + device: "cpu", + }, + ); + send({ type: "load-result", success: true }); + } catch (error) { + send({ type: "load-result", success: false, error: error.message }); + } +} + +async function transcribe(audioBase64) { + if (!whisperPipeline) { + send({ + type: "transcribe-result", + success: false, + error: "Whisper pipeline not loaded", + }); + return; + } + + try { + const pcm16Buffer = Buffer.from(audioBase64, "base64"); + + if (pcm16Buffer.length < 2) { + send({ + type: "transcribe-result", + success: false, + error: "Audio buffer too small", + }); + return; + } + + // Cap at ~30 seconds (16kHz, 16-bit mono) + const maxBytes = 16000 * 2 * 30; + const audioData = + pcm16Buffer.length > maxBytes + ? pcm16Buffer.slice(0, maxBytes) + : pcm16Buffer; + + const float32Audio = pcm16ToFloat32(audioData); + if (float32Audio.length === 0) { + send({ + type: "transcribe-result", + success: false, + error: "Empty audio after conversion", + }); + return; + } + + const result = await whisperPipeline(float32Audio, { + sampling_rate: 16000, + language: "en", + task: "transcribe", + }); + + const text = result.text?.trim() || ""; + send({ type: "transcribe-result", success: true, text }); + } catch (error) { + send({ + type: "transcribe-result", + success: false, + error: error.message || String(error), + }); + } +} + +function send(msg) { + try { + if (process.send) { + process.send(msg); + } + } catch (_) { + // Parent may have disconnected + } +} + +process.on("message", (msg) => { + switch (msg.type) { + case "load": + loadModel(msg.modelName, msg.cacheDir).catch((err) => { + send({ type: "load-result", success: false, error: err.message }); + }); + break; + case "transcribe": + transcribe(msg.audioBase64).catch((err) => { + send({ type: "transcribe-result", success: false, error: err.message }); + }); + break; + case "shutdown": + process.exit(0); + break; + } +}); + +// Signal readiness to parent +send({ type: "ready" }); -- 2.47.2 From 526bc4e877c5fd830c82ee71df9ae6bff4f3cd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=93=D0=BB=D0=B0=D0=B7=D1=83?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2?= Date: Mon, 16 Feb 2026 17:10:57 +0300 Subject: [PATCH 2/3] feat: enhance Whisper worker integration with system Node.js detection --- forge.config.js | 151 +- package.json | 102 +- src/components/views/MainView.js | 2443 +++++++++++++++++------------- src/storage.js | 708 ++++----- src/utils/localai.js | 158 +- src/utils/nodeDetect.js | 177 +++ src/utils/whisperWorker.js | 190 ++- 7 files changed, 2375 insertions(+), 1554 deletions(-) create mode 100644 src/utils/nodeDetect.js diff --git a/forge.config.js b/forge.config.js index cdf9d62..952a21c 100644 --- a/forge.config.js +++ b/forge.config.js @@ -1,78 +1,83 @@ -const { FusesPlugin } = require('@electron-forge/plugin-fuses'); -const { FuseV1Options, FuseVersion } = require('@electron/fuses'); +const { FusesPlugin } = require("@electron-forge/plugin-fuses"); +const { FuseV1Options, FuseVersion } = require("@electron/fuses"); module.exports = { - packagerConfig: { - asar: { - unpack: '**/{onnxruntime-node,onnxruntime-common,@huggingface/transformers,sharp,@img}/**', - }, - extraResource: ['./src/assets/SystemAudioDump'], - name: 'Mastermind', - icon: 'src/assets/logo', - // use `security find-identity -v -p codesigning` to find your identity - // for macos signing - // also fuck apple - // osxSign: { - // identity: '', - // optionsForFile: (filePath) => { - // return { - // entitlements: 'entitlements.plist', - // }; - // }, - // }, - // notarize if off cuz i ran this for 6 hours and it still didnt finish - // osxNotarize: { - // appleId: 'your apple id', - // appleIdPassword: 'app specific password', - // teamId: 'your team id', - // }, + packagerConfig: { + asar: { + unpack: + "**/{onnxruntime-node,onnxruntime-common,@huggingface/transformers,sharp,@img}/**", }, - rebuildConfig: {}, - makers: [ - { - name: '@electron-forge/maker-squirrel', - config: { - name: 'mastermind', - productName: 'Mastermind', - shortcutName: 'Mastermind', - createDesktopShortcut: true, - createStartMenuShortcut: true, - }, + extraResource: ["./src/assets/SystemAudioDump"], + name: "Mastermind", + icon: "src/assets/logo", + // use `security find-identity -v -p codesigning` to find your identity + // for macos signing + // also fuck apple + // osxSign: { + // identity: '', + // optionsForFile: (filePath) => { + // return { + // entitlements: 'entitlements.plist', + // }; + // }, + // }, + // notarize if off cuz i ran this for 6 hours and it still didnt finish + // osxNotarize: { + // appleId: 'your apple id', + // appleIdPassword: 'app specific password', + // teamId: 'your team id', + // }, + }, + rebuildConfig: { + // Ensure onnxruntime-node is rebuilt against Electron's Node.js headers + // so the native binding matches the ABI used in packaged builds. + onlyModules: ["onnxruntime-node", "sharp"], + }, + makers: [ + { + name: "@electron-forge/maker-squirrel", + config: { + name: "mastermind", + productName: "Mastermind", + shortcutName: "Mastermind", + createDesktopShortcut: true, + createStartMenuShortcut: true, + }, + }, + { + name: "@electron-forge/maker-dmg", + platforms: ["darwin"], + }, + { + name: "@reforged/maker-appimage", + platforms: ["linux"], + config: { + options: { + name: "Mastermind", + productName: "Mastermind", + genericName: "AI Assistant", + description: "AI assistant for interviews and learning", + categories: ["Development", "Education"], + icon: "src/assets/logo.png", }, - { - name: '@electron-forge/maker-dmg', - platforms: ['darwin'], - }, - { - name: '@reforged/maker-appimage', - platforms: ['linux'], - config: { - options: { - name: 'Mastermind', - productName: 'Mastermind', - genericName: 'AI Assistant', - description: 'AI assistant for interviews and learning', - categories: ['Development', 'Education'], - icon: 'src/assets/logo.png' - } - }, - }, - ], - plugins: [ - { - name: '@electron-forge/plugin-auto-unpack-natives', - config: {}, - }, - // Fuses are used to enable/disable various Electron functionality - // at package time, before code signing the application - new FusesPlugin({ - version: FuseVersion.V1, - [FuseV1Options.RunAsNode]: false, - [FuseV1Options.EnableCookieEncryption]: true, - [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, - [FuseV1Options.EnableNodeCliInspectArguments]: false, - [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, - [FuseV1Options.OnlyLoadAppFromAsar]: true, - }), - ], + }, + }, + ], + plugins: [ + { + name: "@electron-forge/plugin-auto-unpack-natives", + config: {}, + }, + // Fuses are used to enable/disable various Electron functionality + // at package time, before code signing the application + new FusesPlugin({ + version: FuseVersion.V1, + [FuseV1Options.RunAsNode]: false, + [FuseV1Options.EnableCookieEncryption]: true, + [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, + [FuseV1Options.EnableNodeCliInspectArguments]: false, + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, + [FuseV1Options.OnlyLoadAppFromAsar]: true, + }), + ], }; diff --git a/package.json b/package.json index b9f5f60..2cc937d 100644 --- a/package.json +++ b/package.json @@ -1,53 +1,55 @@ { - "name": "mastermind", - "productName": "Mastermind", - "version": "0.7.0", - "description": "Mastermind AI assistant", - "main": "src/index.js", - "scripts": { - "start": "electron-forge start", - "package": "electron-forge package", - "make": "electron-forge make", - "publish": "electron-forge publish", - "lint": "echo \"No linting configured\"" - }, - "keywords": [ - "mastermind", - "mastermind ai", - "mastermind ai assistant", - "mastermind ai assistant for interviews", - "mastermind ai assistant for interviews" - ], - "author": { - "name": "ShiftyX1", - "email": "lead@pyserve.org" - }, - "license": "GPL-3.0", - "dependencies": { - "@google/genai": "^1.41.0", - "@huggingface/transformers": "^3.8.1", - "electron-squirrel-startup": "^1.0.1", - "ollama": "^0.6.3", - "openai": "^6.22.0", - "p-retry": "^4.6.2", - "ws": "^8.19.0" - }, - "devDependencies": { - "@electron-forge/cli": "^7.8.1", - "@electron-forge/maker-deb": "^7.8.1", - "@electron-forge/maker-dmg": "^7.8.1", - "@electron-forge/maker-rpm": "^7.8.1", - "@electron-forge/maker-squirrel": "^7.8.1", - "@electron-forge/maker-zip": "^7.8.1", - "@electron-forge/plugin-auto-unpack-natives": "^7.8.1", - "@electron-forge/plugin-fuses": "^7.8.1", - "@electron/fuses": "^1.8.0", - "@reforged/maker-appimage": "^5.0.0", - "electron": "^30.0.5" - }, - "pnpm": { - "overrides": { - "p-retry": "4.6.2" - } + "name": "mastermind", + "productName": "Mastermind", + "version": "0.7.0", + "description": "Mastermind AI assistant", + "main": "src/index.js", + "scripts": { + "start": "electron-forge start", + "package": "electron-forge package", + "make": "electron-forge make", + "publish": "electron-forge publish", + "lint": "echo \"No linting configured\"", + "postinstall": "electron-rebuild -f -w onnxruntime-node" + }, + "keywords": [ + "mastermind", + "mastermind ai", + "mastermind ai assistant", + "mastermind ai assistant for interviews", + "mastermind ai assistant for interviews" + ], + "author": { + "name": "ShiftyX1", + "email": "lead@pyserve.org" + }, + "license": "GPL-3.0", + "dependencies": { + "@google/genai": "^1.41.0", + "@huggingface/transformers": "^3.8.1", + "electron-squirrel-startup": "^1.0.1", + "ollama": "^0.6.3", + "openai": "^6.22.0", + "p-retry": "^4.6.2", + "ws": "^8.19.0" + }, + "devDependencies": { + "@electron/rebuild": "^3.7.1", + "@electron-forge/cli": "^7.8.1", + "@electron-forge/maker-deb": "^7.8.1", + "@electron-forge/maker-dmg": "^7.8.1", + "@electron-forge/maker-rpm": "^7.8.1", + "@electron-forge/maker-squirrel": "^7.8.1", + "@electron-forge/maker-zip": "^7.8.1", + "@electron-forge/plugin-auto-unpack-natives": "^7.8.1", + "@electron-forge/plugin-fuses": "^7.8.1", + "@electron/fuses": "^1.8.0", + "@reforged/maker-appimage": "^5.0.0", + "electron": "^30.0.5" + }, + "pnpm": { + "overrides": { + "p-retry": "4.6.2" } + } } diff --git a/src/components/views/MainView.js b/src/components/views/MainView.js index 3f9b199..16bd44a 100644 --- a/src/components/views/MainView.js +++ b/src/components/views/MainView.js @@ -1,1109 +1,1440 @@ -import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js'; +import { html, css, LitElement } from "../../assets/lit-core-2.7.4.min.js"; export class MainView extends LitElement { - static styles = css` - * { - font-family: var(--font); - cursor: default; - user-select: none; - box-sizing: border-box; + static styles = css` + * { + font-family: var(--font); + cursor: default; + user-select: none; + box-sizing: border-box; + } + + :host { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-xl) var(--space-lg); + } + + .form-wrapper { + width: 100%; + max-width: 420px; + display: flex; + flex-direction: column; + gap: var(--space-md); + } + + .page-title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + margin-bottom: var(--space-xs); + } + + .page-title .mode-suffix { + opacity: 0.5; + } + + .page-subtitle { + font-size: var(--font-size-sm); + color: var(--text-muted); + margin-bottom: var(--space-md); + } + + /* ── Form controls ── */ + + .form-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); + } + + .form-label { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + input, + select, + textarea { + background: var(--bg-elevated); + color: var(--text-primary); + border: 1px solid var(--border); + padding: 10px 12px; + width: 100%; + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-family: var(--font); + transition: + border-color var(--transition), + box-shadow var(--transition); + } + + input:hover:not(:focus), + select:hover:not(:focus), + textarea:hover:not(:focus) { + border-color: var(--text-muted); + } + + input:focus, + select:focus, + textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); + } + + input::placeholder, + textarea::placeholder { + color: var(--text-muted); + } + + input.error { + border-color: var(--danger, #ef4444); + } + + select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23999' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 8px center; + background-repeat: no-repeat; + background-size: 14px; + padding-right: 28px; + } + + textarea { + resize: vertical; + min-height: 80px; + line-height: var(--line-height); + } + + .form-hint { + font-size: var(--font-size-xs); + color: var(--text-muted); + } + + .form-hint a, + .form-hint span.link { + color: var(--accent); + text-decoration: none; + cursor: pointer; + } + + .form-hint span.link:hover { + text-decoration: underline; + } + + .whisper-label-row { + display: flex; + align-items: center; + gap: 6px; + } + + .whisper-spinner { + width: 12px; + height: 12px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: whisper-spin 0.8s linear infinite; + } + + @keyframes whisper-spin { + to { + transform: rotate(360deg); + } + } + + /* ── Start button ── */ + + .start-button { + position: relative; + overflow: hidden; + background: #e8e8e8; + color: #111111; + border: none; + padding: 12px var(--space-md); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + cursor: pointer; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + } + + .start-button canvas.btn-aurora { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + } + + .start-button canvas.btn-dither { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 1; + opacity: 0.1; + mix-blend-mode: overlay; + pointer-events: none; + image-rendering: pixelated; + } + + .start-button .btn-label { + position: relative; + z-index: 2; + display: flex; + align-items: center; + gap: var(--space-sm); + } + + .start-button:hover { + opacity: 0.9; + } + + .start-button.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .start-button.disabled:hover { + opacity: 0.5; + } + + .shortcut-hint { + display: inline-flex; + align-items: center; + gap: 2px; + opacity: 0.5; + font-family: var(--font-mono); + } + + /* ── Divider ── */ + + .divider { + display: flex; + align-items: center; + gap: var(--space-md); + margin: var(--space-sm) 0; + } + + .divider-line { + flex: 1; + height: 1px; + background: var(--border); + } + + .divider-text { + font-size: var(--font-size-xs); + color: var(--text-muted); + text-transform: lowercase; + } + + /* ── Mode switch links ── */ + + .mode-links { + display: flex; + justify-content: center; + gap: var(--space-lg); + } + + .mode-link { + font-size: var(--font-size-sm); + color: var(--text-secondary); + cursor: pointer; + background: none; + border: none; + padding: 0; + transition: color var(--transition); + } + + .mode-link:hover { + color: var(--text-primary); + } + + /* ── Mode option cards ── */ + + .mode-cards { + display: flex; + gap: var(--space-sm); + } + + .mode-card { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--bg-elevated); + cursor: pointer; + transition: + border-color 0.2s, + background 0.2s; + } + + .mode-card:hover { + border-color: var(--text-muted); + background: var(--bg-hover); + } + + .mode-card-title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + } + + .mode-card-desc { + font-size: var(--font-size-xs); + color: var(--text-muted); + line-height: var(--line-height); + } + + /* ── Title row with help ── */ + + .title-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-xs); + } + + .title-row .page-title { + margin-bottom: 0; + } + + .help-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: color 0.2s; + display: flex; + align-items: center; + } + + .help-btn:hover { + color: var(--text-secondary); + } + + .help-btn * { + pointer-events: none; + } + + /* ── Help content ── */ + + .help-content { + display: flex; + flex-direction: column; + gap: var(--space-md); + max-height: 500px; + overflow-y: auto; + } + + .help-section { + display: flex; + flex-direction: column; + gap: 4px; + } + + .help-section-title { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + } + + .help-section-text { + font-size: var(--font-size-xs); + color: var(--text-secondary); + line-height: var(--line-height); + } + + .help-code { + font-family: var(--font-mono); + font-size: 11px; + background: var(--bg-hover); + padding: 6px 8px; + border-radius: var(--radius-sm); + color: var(--text-primary); + display: block; + } + + .help-link { + color: var(--accent); + cursor: pointer; + text-decoration: none; + } + + .help-link:hover { + text-decoration: underline; + } + + .help-models { + display: flex; + flex-direction: column; + gap: 2px; + } + + .help-model { + font-size: var(--font-size-xs); + color: var(--text-secondary); + display: flex; + justify-content: space-between; + } + + .help-model-name { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-primary); + } + + .help-divider { + border: none; + border-top: 1px solid var(--border); + margin: 0; + } + + .help-warn { + font-size: var(--font-size-xs); + color: var(--warning); + line-height: var(--line-height); + } + `; + + static properties = { + onStart: { type: Function }, + onExternalLink: { type: Function }, + selectedProfile: { type: String }, + onProfileChange: { type: Function }, + isInitializing: { type: Boolean }, + whisperDownloading: { type: Boolean }, + // Internal state + _mode: { state: true }, + _token: { state: true }, + _geminiKey: { state: true }, + _groqKey: { state: true }, + _openaiKey: { state: true }, + _openaiCompatibleApiKey: { state: true }, + _openaiCompatibleBaseUrl: { state: true }, + _openaiCompatibleModel: { state: true }, + _availableModels: { state: true }, + _loadingModels: { state: true }, + _manualModelInput: { state: true }, + _responseProvider: { state: true }, + _tokenError: { state: true }, + _keyError: { state: true }, + // Local AI state + _ollamaHost: { state: true }, + _ollamaModel: { state: true }, + _whisperModel: { state: true }, + _customWhisperModel: { state: true }, + _showLocalHelp: { state: true }, + }; + + constructor() { + super(); + this.onStart = () => {}; + this.onExternalLink = () => {}; + this.selectedProfile = "interview"; + this.onProfileChange = () => {}; + this.isInitializing = false; + this.whisperDownloading = false; + + this._mode = "byok"; + this._token = ""; + this._geminiKey = ""; + this._groqKey = ""; + this._openaiKey = ""; + this._openaiCompatibleApiKey = ""; + this._openaiCompatibleBaseUrl = ""; + this._openaiCompatibleModel = ""; + this._availableModels = []; + this._loadingModels = false; + this._manualModelInput = false; + this._responseProvider = "gemini"; + this._tokenError = false; + this._keyError = false; + this._showLocalHelp = false; + this._ollamaHost = "http://127.0.0.1:11434"; + this._ollamaModel = "llama3.1"; + this._whisperModel = "Xenova/whisper-small"; + this._customWhisperModel = ""; + + this._animId = null; + this._time = 0; + this._mouseX = -1; + this._mouseY = -1; + + this.boundKeydownHandler = this._handleKeydown.bind(this); + this._loadFromStorage(); + } + + async _loadFromStorage() { + try { + const [prefs, creds] = await Promise.all([ + cheatingDaddy.storage.getPreferences(), + cheatingDaddy.storage.getCredentials().catch(() => ({})), + ]); + + this._mode = prefs.providerMode || "byok"; + + // Load keys + this._token = ""; + this._geminiKey = + (await cheatingDaddy.storage.getApiKey().catch(() => "")) || ""; + this._groqKey = + (await cheatingDaddy.storage.getGroqApiKey().catch(() => "")) || ""; + this._openaiKey = creds.openaiKey || ""; + + // Load OpenAI-compatible config + const openaiConfig = await cheatingDaddy.storage + .getOpenAICompatibleConfig() + .catch(() => ({})); + this._openaiCompatibleApiKey = openaiConfig.apiKey || ""; + this._openaiCompatibleBaseUrl = openaiConfig.baseUrl || ""; + this._openaiCompatibleModel = openaiConfig.model || ""; + + // Load response provider preference + this._responseProvider = prefs.responseProvider || "gemini"; + + // Load local AI settings + this._ollamaHost = prefs.ollamaHost || "http://127.0.0.1:11434"; + this._ollamaModel = prefs.ollamaModel || "llama3.1"; + this._whisperModel = prefs.whisperModel || "Xenova/whisper-small"; + // If the saved model isn't one of the presets, it's a custom HF model + const presets = [ + "Xenova/whisper-tiny", + "Xenova/whisper-base", + "Xenova/whisper-small", + "Xenova/whisper-medium", + ]; + if (!presets.includes(this._whisperModel)) { + this._customWhisperModel = this._whisperModel; + this._whisperModel = "__custom__"; + } + + this.requestUpdate(); + + // Auto-load models if OpenAI-compatible is selected and URL is set + if ( + this._responseProvider === "openai-compatible" && + this._openaiCompatibleBaseUrl + ) { + this._loadModels(); + } + } catch (e) { + console.error("Error loading MainView storage:", e); + } + } + + connectedCallback() { + super.connectedCallback(); + document.addEventListener("keydown", this.boundKeydownHandler); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener("keydown", this.boundKeydownHandler); + if (this._animId) cancelAnimationFrame(this._animId); + if (this._loadModelsTimeout) clearTimeout(this._loadModelsTimeout); + } + + updated(changedProperties) { + super.updated(changedProperties); + if (changedProperties.has("_mode")) { + // Stop old animation when switching modes + if (this._animId) { + cancelAnimationFrame(this._animId); + this._animId = null; + } + } + } + + _initButtonAurora() { + const btn = this.shadowRoot.querySelector(".start-button"); + const aurora = this.shadowRoot.querySelector("canvas.btn-aurora"); + const dither = this.shadowRoot.querySelector("canvas.btn-dither"); + if (!aurora || !dither || !btn) return; + + // Mouse tracking + this._mouseX = -1; + this._mouseY = -1; + btn.addEventListener("mousemove", (e) => { + const rect = btn.getBoundingClientRect(); + this._mouseX = (e.clientX - rect.left) / rect.width; + this._mouseY = (e.clientY - rect.top) / rect.height; + }); + btn.addEventListener("mouseleave", () => { + this._mouseX = -1; + this._mouseY = -1; + }); + + // Dither + const blockSize = 8; + const cols = Math.ceil(aurora.offsetWidth / blockSize); + const rows = Math.ceil(aurora.offsetHeight / blockSize); + dither.width = cols; + dither.height = rows; + const dCtx = dither.getContext("2d"); + const img = dCtx.createImageData(cols, rows); + for (let i = 0; i < img.data.length; i += 4) { + const v = Math.random() > 0.5 ? 255 : 0; + img.data[i] = v; + img.data[i + 1] = v; + img.data[i + 2] = v; + img.data[i + 3] = 255; + } + dCtx.putImageData(img, 0, 0); + + // Aurora + const ctx = aurora.getContext("2d"); + const scale = 0.4; + aurora.width = Math.floor(aurora.offsetWidth * scale); + aurora.height = Math.floor(aurora.offsetHeight * scale); + + const blobs = [ + { color: [120, 160, 230], x: 0.1, y: 0.3, vx: 0.25, vy: 0.2, phase: 0 }, + { + color: [150, 120, 220], + x: 0.8, + y: 0.5, + vx: -0.2, + vy: 0.25, + phase: 1.5, + }, + { + color: [200, 140, 210], + x: 0.5, + y: 0.6, + vx: 0.18, + vy: -0.22, + phase: 3.0, + }, + { color: [100, 190, 190], x: 0.3, y: 0.7, vx: 0.3, vy: 0.15, phase: 4.5 }, + { + color: [220, 170, 130], + x: 0.7, + y: 0.4, + vx: -0.22, + vy: -0.25, + phase: 6.0, + }, + ]; + + const draw = () => { + this._time += 0.008; + const w = aurora.width; + const h = aurora.height; + const maxDim = Math.max(w, h); + + ctx.fillStyle = "#f0f0f0"; + ctx.fillRect(0, 0, w, h); + + const hovering = this._mouseX >= 0; + + for (const blob of blobs) { + const t = this._time; + const cx = (blob.x + Math.sin(t * blob.vx + blob.phase) * 0.4) * w; + const cy = + (blob.y + Math.cos(t * blob.vy + blob.phase * 0.7) * 0.4) * h; + const r = maxDim * 0.45; + + let boost = 1; + if (hovering) { + const dx = cx / w - this._mouseX; + const dy = cy / h - this._mouseY; + const dist = Math.sqrt(dx * dx + dy * dy); + boost = 1 + 2.5 * Math.max(0, 1 - dist / 0.6); } - :host { - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-xl) var(--space-lg); - } + const a0 = Math.min(1, 0.18 * boost); + const a1 = Math.min(1, 0.08 * boost); + const a2 = Math.min(1, 0.02 * boost); - .form-wrapper { - width: 100%; - max-width: 420px; - display: flex; - flex-direction: column; - gap: var(--space-md); - } + const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); + grad.addColorStop( + 0, + `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a0})`, + ); + grad.addColorStop( + 0.3, + `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a1})`, + ); + grad.addColorStop( + 0.6, + `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a2})`, + ); + grad.addColorStop( + 1, + `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0)`, + ); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + } - .page-title { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-semibold); - color: var(--text-primary); - margin-bottom: var(--space-xs); - } - - .page-title .mode-suffix { - opacity: 0.5; - } - - .page-subtitle { - font-size: var(--font-size-sm); - color: var(--text-muted); - margin-bottom: var(--space-md); - } - - /* ── Form controls ── */ - - .form-group { - display: flex; - flex-direction: column; - gap: var(--space-xs); - } - - .form-label { - font-size: var(--font-size-xs); - font-weight: var(--font-weight-medium); - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - } - - input, select, textarea { - background: var(--bg-elevated); - color: var(--text-primary); - border: 1px solid var(--border); - padding: 10px 12px; - width: 100%; - border-radius: var(--radius-sm); - font-size: var(--font-size-sm); - font-family: var(--font); - transition: border-color var(--transition), box-shadow var(--transition); - } - - input:hover:not(:focus), select:hover:not(:focus), textarea:hover:not(:focus) { - border-color: var(--text-muted); - } - - input:focus, select:focus, textarea:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 1px var(--accent); - } - - input::placeholder, textarea::placeholder { - color: var(--text-muted); - } - - input.error { - border-color: var(--danger, #EF4444); - } - - select { - cursor: pointer; - appearance: none; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23999' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 8px center; - background-repeat: no-repeat; - background-size: 14px; - padding-right: 28px; - } - - textarea { - resize: vertical; - min-height: 80px; - line-height: var(--line-height); - } - - .form-hint { - font-size: var(--font-size-xs); - color: var(--text-muted); - } - - .form-hint a, .form-hint span.link { - color: var(--accent); - text-decoration: none; - cursor: pointer; - } - - .form-hint span.link:hover { - text-decoration: underline; - } - - .whisper-label-row { - display: flex; - align-items: center; - gap: 6px; - } - - .whisper-spinner { - width: 12px; - height: 12px; - border: 2px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: whisper-spin 0.8s linear infinite; - } - - @keyframes whisper-spin { - to { transform: rotate(360deg); } - } - - /* ── Start button ── */ - - .start-button { - position: relative; - overflow: hidden; - background: #e8e8e8; - color: #111111; - border: none; - padding: 12px var(--space-md); - border-radius: var(--radius-sm); - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - cursor: pointer; - width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-sm); - } - - .start-button canvas.btn-aurora { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - z-index: 0; - } - - .start-button canvas.btn-dither { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - z-index: 1; - opacity: 0.1; - mix-blend-mode: overlay; - pointer-events: none; - image-rendering: pixelated; - } - - .start-button .btn-label { - position: relative; - z-index: 2; - display: flex; - align-items: center; - gap: var(--space-sm); - } - - .start-button:hover { - opacity: 0.9; - } - - .start-button.disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .start-button.disabled:hover { - opacity: 0.5; - } - - .shortcut-hint { - display: inline-flex; - align-items: center; - gap: 2px; - opacity: 0.5; - font-family: var(--font-mono); - } - - /* ── Divider ── */ - - .divider { - display: flex; - align-items: center; - gap: var(--space-md); - margin: var(--space-sm) 0; - } - - .divider-line { - flex: 1; - height: 1px; - background: var(--border); - } - - .divider-text { - font-size: var(--font-size-xs); - color: var(--text-muted); - text-transform: lowercase; - } - - /* ── Mode switch links ── */ - - .mode-links { - display: flex; - justify-content: center; - gap: var(--space-lg); - } - - .mode-link { - font-size: var(--font-size-sm); - color: var(--text-secondary); - cursor: pointer; - background: none; - border: none; - padding: 0; - transition: color var(--transition); - } - - .mode-link:hover { - color: var(--text-primary); - } - - /* ── Mode option cards ── */ - - .mode-cards { - display: flex; - gap: var(--space-sm); - } - - .mode-card { - flex: 1; - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; - border-radius: var(--radius-md); - border: 1px solid var(--border); - background: var(--bg-elevated); - cursor: pointer; - transition: border-color 0.2s, background 0.2s; - } - - .mode-card:hover { - border-color: var(--text-muted); - background: var(--bg-hover); - } - - .mode-card-title { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - color: var(--text-primary); - } - - .mode-card-desc { - font-size: var(--font-size-xs); - color: var(--text-muted); - line-height: var(--line-height); - } - - /* ── Title row with help ── */ - - .title-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--space-xs); - } - - .title-row .page-title { - margin-bottom: 0; - } - - .help-btn { - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 4px; - border-radius: var(--radius-sm); - transition: color 0.2s; - display: flex; - align-items: center; - } - - .help-btn:hover { - color: var(--text-secondary); - } - - .help-btn * { - pointer-events: none; - } - - /* ── Help content ── */ - - .help-content { - display: flex; - flex-direction: column; - gap: var(--space-md); - max-height: 500px; - overflow-y: auto; - } - - .help-section { - display: flex; - flex-direction: column; - gap: 4px; - } - - .help-section-title { - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - color: var(--text-primary); - } - - .help-section-text { - font-size: var(--font-size-xs); - color: var(--text-secondary); - line-height: var(--line-height); - } - - .help-code { - font-family: var(--font-mono); - font-size: 11px; - background: var(--bg-hover); - padding: 6px 8px; - border-radius: var(--radius-sm); - color: var(--text-primary); - display: block; - } - - .help-link { - color: var(--accent); - cursor: pointer; - text-decoration: none; - } - - .help-link:hover { - text-decoration: underline; - } - - .help-models { - display: flex; - flex-direction: column; - gap: 2px; - } - - .help-model { - font-size: var(--font-size-xs); - color: var(--text-secondary); - display: flex; - justify-content: space-between; - } - - .help-model-name { - font-family: var(--font-mono); - font-size: 11px; - color: var(--text-primary); - } - - .help-divider { - border: none; - border-top: 1px solid var(--border); - margin: 0; - } - - .help-warn { - font-size: var(--font-size-xs); - color: var(--warning); - line-height: var(--line-height); - } - `; - - static properties = { - onStart: { type: Function }, - onExternalLink: { type: Function }, - selectedProfile: { type: String }, - onProfileChange: { type: Function }, - isInitializing: { type: Boolean }, - whisperDownloading: { type: Boolean }, - // Internal state - _mode: { state: true }, - _token: { state: true }, - _geminiKey: { state: true }, - _groqKey: { state: true }, - _openaiKey: { state: true }, - _openaiCompatibleApiKey: { state: true }, - _openaiCompatibleBaseUrl: { state: true }, - _openaiCompatibleModel: { state: true }, - _availableModels: { state: true }, - _loadingModels: { state: true }, - _manualModelInput: { state: true }, - _responseProvider: { state: true }, - _tokenError: { state: true }, - _keyError: { state: true }, - // Local AI state - _ollamaHost: { state: true }, - _ollamaModel: { state: true }, - _whisperModel: { state: true }, - _showLocalHelp: { state: true }, + this._animId = requestAnimationFrame(draw); }; - constructor() { - super(); - this.onStart = () => {}; - this.onExternalLink = () => {}; - this.selectedProfile = 'interview'; - this.onProfileChange = () => {}; - this.isInitializing = false; - this.whisperDownloading = false; + draw(); + } - this._mode = 'byok'; - this._token = ''; - this._geminiKey = ''; - this._groqKey = ''; - this._openaiKey = ''; - this._openaiCompatibleApiKey = ''; - this._openaiCompatibleBaseUrl = ''; - this._openaiCompatibleModel = ''; - this._availableModels = []; - this._loadingModels = false; - this._manualModelInput = false; - this._responseProvider = 'gemini'; - this._tokenError = false; - this._keyError = false; - this._showLocalHelp = false; - this._ollamaHost = 'http://127.0.0.1:11434'; - this._ollamaModel = 'llama3.1'; - this._whisperModel = 'Xenova/whisper-small'; + _handleKeydown(e) { + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; + if ((isMac ? e.metaKey : e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + this._handleStart(); + } + } - this._animId = null; - this._time = 0; - this._mouseX = -1; - this._mouseY = -1; + // ── Persistence ── - this.boundKeydownHandler = this._handleKeydown.bind(this); - this._loadFromStorage(); + async _saveMode(mode) { + this._mode = mode; + this._keyError = false; + await cheatingDaddy.storage.updatePreference("providerMode", mode); + this.requestUpdate(); + } + + async _saveGeminiKey(val) { + this._geminiKey = val; + this._keyError = false; + await cheatingDaddy.storage.setApiKey(val); + this.requestUpdate(); + } + + async _saveGroqKey(val) { + this._groqKey = val; + await cheatingDaddy.storage.setGroqApiKey(val); + this.requestUpdate(); + } + + async _saveOpenaiKey(val) { + this._openaiKey = val; + try { + const creds = await cheatingDaddy.storage + .getCredentials() + .catch(() => ({})); + await cheatingDaddy.storage.setCredentials({ ...creds, openaiKey: val }); + } catch (e) {} + this.requestUpdate(); + } + + async _saveOpenAICompatibleApiKey(val) { + this._openaiCompatibleApiKey = val; + await cheatingDaddy.storage.setOpenAICompatibleConfig( + val, + this._openaiCompatibleBaseUrl, + this._openaiCompatibleModel, + ); + this.requestUpdate(); + // Auto-load models when both key and URL are set + this._debouncedLoadModels(); + } + + async _saveOpenAICompatibleBaseUrl(val) { + this._openaiCompatibleBaseUrl = val; + await cheatingDaddy.storage.setOpenAICompatibleConfig( + this._openaiCompatibleApiKey, + val, + this._openaiCompatibleModel, + ); + this.requestUpdate(); + // Auto-load models when both key and URL are set + this._debouncedLoadModels(); + } + + async _saveOpenAICompatibleModel(val) { + this._openaiCompatibleModel = val; + await cheatingDaddy.storage.setOpenAICompatibleConfig( + this._openaiCompatibleApiKey, + this._openaiCompatibleBaseUrl, + val, + ); + this.requestUpdate(); + } + + async _saveResponseProvider(val) { + this._responseProvider = val; + await cheatingDaddy.storage.updatePreference("responseProvider", val); + this.requestUpdate(); + + // Auto-load models when switching to openai-compatible + if (val === "openai-compatible" && this._openaiCompatibleBaseUrl) { + this._loadModels(); + } + } + + async _loadModels() { + if ( + this._responseProvider !== "openai-compatible" || + !this._openaiCompatibleBaseUrl + ) { + return; } - async _loadFromStorage() { - try { - const [prefs, creds] = await Promise.all([ - cheatingDaddy.storage.getPreferences(), - cheatingDaddy.storage.getCredentials().catch(() => ({})), - ]); + this._loadingModels = true; + this._availableModels = []; + this.requestUpdate(); - this._mode = prefs.providerMode || 'byok'; + try { + let modelsUrl = this._openaiCompatibleBaseUrl.trim(); + modelsUrl = modelsUrl.replace(/\/$/, ""); + if (!modelsUrl.includes("/models")) { + modelsUrl = modelsUrl.includes("/v1") + ? modelsUrl + "/models" + : modelsUrl + "/v1/models"; + } - // Load keys - this._token = ''; - this._geminiKey = await cheatingDaddy.storage.getApiKey().catch(() => '') || ''; - this._groqKey = await cheatingDaddy.storage.getGroqApiKey().catch(() => '') || ''; - this._openaiKey = creds.openaiKey || ''; - - // Load OpenAI-compatible config - const openaiConfig = await cheatingDaddy.storage.getOpenAICompatibleConfig().catch(() => ({})); - this._openaiCompatibleApiKey = openaiConfig.apiKey || ''; - this._openaiCompatibleBaseUrl = openaiConfig.baseUrl || ''; - this._openaiCompatibleModel = openaiConfig.model || ''; - - // Load response provider preference - this._responseProvider = prefs.responseProvider || 'gemini'; + console.log("Loading models from:", modelsUrl); - // Load local AI settings - this._ollamaHost = prefs.ollamaHost || 'http://127.0.0.1:11434'; - this._ollamaModel = prefs.ollamaModel || 'llama3.1'; - this._whisperModel = prefs.whisperModel || 'Xenova/whisper-small'; + const headers = { + "Content-Type": "application/json", + }; - this.requestUpdate(); - - // Auto-load models if OpenAI-compatible is selected and URL is set - if (this._responseProvider === 'openai-compatible' && this._openaiCompatibleBaseUrl) { - this._loadModels(); - } - } catch (e) { - console.error('Error loading MainView storage:', e); - } + if (this._openaiCompatibleApiKey) { + headers["Authorization"] = `Bearer ${this._openaiCompatibleApiKey}`; + } + + const response = await fetch(modelsUrl, { headers }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + + if (data.data && Array.isArray(data.data)) { + this._availableModels = data.data + .map((m) => m.id || m.model || m.name) + .filter(Boolean); + } else if (Array.isArray(data)) { + this._availableModels = data + .map((m) => m.id || m.model || m.name || m) + .filter(Boolean); + } + + console.log("Loaded models:", this._availableModels.length); + + if ( + this._availableModels.length > 0 && + !this._availableModels.includes(this._openaiCompatibleModel) + ) { + await this._saveOpenAICompatibleModel(this._availableModels[0]); + } + } catch (error) { + console.log("Could not load models:", error.message); + this._availableModels = []; + } finally { + this._loadingModels = false; + this.requestUpdate(); } + } - connectedCallback() { - super.connectedCallback(); - document.addEventListener('keydown', this.boundKeydownHandler); + _debouncedLoadModels() { + if (this._loadModelsTimeout) { + clearTimeout(this._loadModelsTimeout); } + this._loadModelsTimeout = setTimeout(() => { + this._loadModels(); + }, 500); + } - disconnectedCallback() { - super.disconnectedCallback(); - document.removeEventListener('keydown', this.boundKeydownHandler); - if (this._animId) cancelAnimationFrame(this._animId); - if (this._loadModelsTimeout) clearTimeout(this._loadModelsTimeout); + _toggleManualInput() { + this._manualModelInput = !this._manualModelInput; + this.requestUpdate(); + } + + async _saveOllamaHost(val) { + this._ollamaHost = val; + await cheatingDaddy.storage.updatePreference("ollamaHost", val); + this.requestUpdate(); + } + + async _saveOllamaModel(val) { + this._ollamaModel = val; + await cheatingDaddy.storage.updatePreference("ollamaModel", val); + this.requestUpdate(); + } + + async _saveWhisperModel(val) { + this._whisperModel = val; + if (val === "__custom__") { + // Don't save yet — wait for the custom input + this.requestUpdate(); + return; } + this._customWhisperModel = ""; + await cheatingDaddy.storage.updatePreference("whisperModel", val); + this.requestUpdate(); + } - updated(changedProperties) { - super.updated(changedProperties); - if (changedProperties.has('_mode')) { - // Stop old animation when switching modes - if (this._animId) { - cancelAnimationFrame(this._animId); - this._animId = null; - } - } + async _saveCustomWhisperModel(val) { + this._customWhisperModel = val.trim(); + if (this._customWhisperModel) { + await cheatingDaddy.storage.updatePreference( + "whisperModel", + this._customWhisperModel, + ); } + this.requestUpdate(); + } - _initButtonAurora() { - const btn = this.shadowRoot.querySelector('.start-button'); - const aurora = this.shadowRoot.querySelector('canvas.btn-aurora'); - const dither = this.shadowRoot.querySelector('canvas.btn-dither'); - if (!aurora || !dither || !btn) return; + _handleProfileChange(e) { + this.onProfileChange(e.target.value); + } - // Mouse tracking - this._mouseX = -1; - this._mouseY = -1; - btn.addEventListener('mousemove', (e) => { - const rect = btn.getBoundingClientRect(); - this._mouseX = (e.clientX - rect.left) / rect.width; - this._mouseY = (e.clientY - rect.top) / rect.height; - }); - btn.addEventListener('mouseleave', () => { - this._mouseX = -1; - this._mouseY = -1; - }); + // ── Start ── - // Dither - const blockSize = 8; - const cols = Math.ceil(aurora.offsetWidth / blockSize); - const rows = Math.ceil(aurora.offsetHeight / blockSize); - dither.width = cols; - dither.height = rows; - const dCtx = dither.getContext('2d'); - const img = dCtx.createImageData(cols, rows); - for (let i = 0; i < img.data.length; i += 4) { - const v = Math.random() > 0.5 ? 255 : 0; - img.data[i] = v; img.data[i+1] = v; img.data[i+2] = v; img.data[i+3] = 255; - } - dCtx.putImageData(img, 0, 0); + _handleStart() { + if (this.isInitializing) return; - // Aurora - const ctx = aurora.getContext('2d'); - const scale = 0.4; - aurora.width = Math.floor(aurora.offsetWidth * scale); - aurora.height = Math.floor(aurora.offsetHeight * scale); - - const blobs = [ - { color: [120, 160, 230], x: 0.1, y: 0.3, vx: 0.25, vy: 0.2, phase: 0 }, - { color: [150, 120, 220], x: 0.8, y: 0.5, vx: -0.2, vy: 0.25, phase: 1.5 }, - { color: [200, 140, 210], x: 0.5, y: 0.6, vx: 0.18, vy: -0.22, phase: 3.0 }, - { color: [100, 190, 190], x: 0.3, y: 0.7, vx: 0.3, vy: 0.15, phase: 4.5 }, - { color: [220, 170, 130], x: 0.7, y: 0.4, vx: -0.22, vy: -0.25, phase: 6.0 }, - ]; - - const draw = () => { - this._time += 0.008; - const w = aurora.width; - const h = aurora.height; - const maxDim = Math.max(w, h); - - ctx.fillStyle = '#f0f0f0'; - ctx.fillRect(0, 0, w, h); - - const hovering = this._mouseX >= 0; - - for (const blob of blobs) { - const t = this._time; - const cx = (blob.x + Math.sin(t * blob.vx + blob.phase) * 0.4) * w; - const cy = (blob.y + Math.cos(t * blob.vy + blob.phase * 0.7) * 0.4) * h; - const r = maxDim * 0.45; - - let boost = 1; - if (hovering) { - const dx = cx / w - this._mouseX; - const dy = cy / h - this._mouseY; - const dist = Math.sqrt(dx * dx + dy * dy); - boost = 1 + 2.5 * Math.max(0, 1 - dist / 0.6); - } - - const a0 = Math.min(1, 0.18 * boost); - const a1 = Math.min(1, 0.08 * boost); - const a2 = Math.min(1, 0.02 * boost); - - const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); - grad.addColorStop(0, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a0})`); - grad.addColorStop(0.3, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a1})`); - grad.addColorStop(0.6, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a2})`); - grad.addColorStop(1, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0)`); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, w, h); - } - - this._animId = requestAnimationFrame(draw); - }; - - draw(); - } - - _handleKeydown(e) { - const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; - if ((isMac ? e.metaKey : e.ctrlKey) && e.key === 'Enter') { - e.preventDefault(); - this._handleStart(); - } - } - - // ── Persistence ── - - async _saveMode(mode) { - this._mode = mode; - this._keyError = false; - await cheatingDaddy.storage.updatePreference('providerMode', mode); - this.requestUpdate(); - } - - async _saveGeminiKey(val) { - this._geminiKey = val; - this._keyError = false; - await cheatingDaddy.storage.setApiKey(val); - this.requestUpdate(); - } - - async _saveGroqKey(val) { - this._groqKey = val; - await cheatingDaddy.storage.setGroqApiKey(val); - this.requestUpdate(); - } - - async _saveOpenaiKey(val) { - this._openaiKey = val; - try { - const creds = await cheatingDaddy.storage.getCredentials().catch(() => ({})); - await cheatingDaddy.storage.setCredentials({ ...creds, openaiKey: val }); - } catch (e) {} - this.requestUpdate(); - } - - async _saveOpenAICompatibleApiKey(val) { - this._openaiCompatibleApiKey = val; - await cheatingDaddy.storage.setOpenAICompatibleConfig( - val, - this._openaiCompatibleBaseUrl, - this._openaiCompatibleModel - ); - this.requestUpdate(); - // Auto-load models when both key and URL are set - this._debouncedLoadModels(); - } - - async _saveOpenAICompatibleBaseUrl(val) { - this._openaiCompatibleBaseUrl = val; - await cheatingDaddy.storage.setOpenAICompatibleConfig( - this._openaiCompatibleApiKey, - val, - this._openaiCompatibleModel - ); - this.requestUpdate(); - // Auto-load models when both key and URL are set - this._debouncedLoadModels(); - } - - async _saveOpenAICompatibleModel(val) { - this._openaiCompatibleModel = val; - await cheatingDaddy.storage.setOpenAICompatibleConfig( - this._openaiCompatibleApiKey, - this._openaiCompatibleBaseUrl, - val - ); - this.requestUpdate(); - } - - async _saveResponseProvider(val) { - this._responseProvider = val; - await cheatingDaddy.storage.updatePreference('responseProvider', val); - this.requestUpdate(); - - // Auto-load models when switching to openai-compatible - if (val === 'openai-compatible' && this._openaiCompatibleBaseUrl) { - this._loadModels(); - } - } - - async _loadModels() { - if (this._responseProvider !== 'openai-compatible' || !this._openaiCompatibleBaseUrl) { - return; - } - - this._loadingModels = true; - this._availableModels = []; - this.requestUpdate(); - - try { - let modelsUrl = this._openaiCompatibleBaseUrl.trim(); - modelsUrl = modelsUrl.replace(/\/$/, ''); - if (!modelsUrl.includes('/models')) { - modelsUrl = modelsUrl.includes('/v1') ? modelsUrl + '/models' : modelsUrl + '/v1/models'; - } - - console.log('Loading models from:', modelsUrl); - - const headers = { - 'Content-Type': 'application/json' - }; - - if (this._openaiCompatibleApiKey) { - headers['Authorization'] = `Bearer ${this._openaiCompatibleApiKey}`; - } - - const response = await fetch(modelsUrl, { headers }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const data = await response.json(); - - if (data.data && Array.isArray(data.data)) { - this._availableModels = data.data.map(m => m.id || m.model || m.name).filter(Boolean); - } else if (Array.isArray(data)) { - this._availableModels = data.map(m => m.id || m.model || m.name || m).filter(Boolean); - } - - console.log('Loaded models:', this._availableModels.length); - - if (this._availableModels.length > 0 && !this._availableModels.includes(this._openaiCompatibleModel)) { - await this._saveOpenAICompatibleModel(this._availableModels[0]); - } - } catch (error) { - console.log('Could not load models:', error.message); - this._availableModels = []; - } finally { - this._loadingModels = false; - this.requestUpdate(); - } - } - - _debouncedLoadModels() { - if (this._loadModelsTimeout) { - clearTimeout(this._loadModelsTimeout); - } - this._loadModelsTimeout = setTimeout(() => { - this._loadModels(); - }, 500); - } - - _toggleManualInput() { - this._manualModelInput = !this._manualModelInput; - this.requestUpdate(); - } - - async _saveOllamaHost(val) { - this._ollamaHost = val; - await cheatingDaddy.storage.updatePreference('ollamaHost', val); - this.requestUpdate(); - } - - async _saveOllamaModel(val) { - this._ollamaModel = val; - await cheatingDaddy.storage.updatePreference('ollamaModel', val); - this.requestUpdate(); - } - - async _saveWhisperModel(val) { - this._whisperModel = val; - await cheatingDaddy.storage.updatePreference('whisperModel', val); - this.requestUpdate(); - } - - _handleProfileChange(e) { - this.onProfileChange(e.target.value); - } - - // ── Start ── - - _handleStart() { - if (this.isInitializing) return; - - if (this._mode === 'byok') { - if (!this._geminiKey.trim()) { - this._keyError = true; - this.requestUpdate(); - return; - } - } else if (this._mode === 'local') { - // Local mode doesn't need API keys, just Ollama host - if (!this._ollamaHost.trim()) { - return; - } - } - - this.onStart(); - } - - triggerApiKeyError() { + if (this._mode === "byok") { + if (!this._geminiKey.trim()) { this._keyError = true; this.requestUpdate(); - setTimeout(() => { - this._keyError = false; - this.requestUpdate(); - }, 2000); + return; + } + } else if (this._mode === "local") { + // Local mode doesn't need API keys, just Ollama host + if (!this._ollamaHost.trim()) { + return; + } } - // ── Render helpers ── + this.onStart(); + } - _renderStartButton() { - const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + triggerApiKeyError() { + this._keyError = true; + this.requestUpdate(); + setTimeout(() => { + this._keyError = false; + this.requestUpdate(); + }, 2000); + } - const cmdIcon = html``; - const ctrlIcon = html``; - const enterIcon = html``; + // ── Render helpers ── - return html` - - `; - } + _renderStartButton() { + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; - // ── BYOK mode ── + const cmdIcon = html` + + `; + const ctrlIcon = html` + + `; + const enterIcon = html` + + + `; - _renderByokMode() { - return html` + return html` + + `; + } + + // ── BYOK mode ── + + _renderByokMode() { + return html` +
+ + this._saveGeminiKey(e.target.value)} + class=${this._keyError ? "error" : ""} + /> +
+ + this.onExternalLink("https://aistudio.google.com/apikey")} + >Get Gemini key + - Always used for audio transcription +
+
+ +
+ + +
+ Choose which API to use for generating responses +
+
+ + ${this._responseProvider === "groq" + ? html`
- - this._saveGeminiKey(e.target.value)} - class=${this._keyError ? 'error' : ''} - /> -
- this.onExternalLink('https://aistudio.google.com/apikey')}>Get Gemini key - Always used for audio transcription -
-
- -
- - this._saveGroqKey(e.target.value)} + /> +
+ + this.onExternalLink("https://console.groq.com/keys")} + >Get Groq key - - - - -
- Choose which API to use for generating responses -
+
- - ${this._responseProvider === 'groq' ? html` -
- - this._saveGroqKey(e.target.value)} - /> -
- this.onExternalLink('https://console.groq.com/keys')}>Get Groq key -
-
- ` : ''} - - ${this._responseProvider === 'openai-compatible' ? html` -
- -
- this._saveOpenAICompatibleApiKey(e.target.value)} - /> - this._saveOpenAICompatibleBaseUrl(e.target.value)} - /> - ${this._loadingModels ? html` - - ` : this._availableModels.length > 0 && !this._manualModelInput ? html` -
- + this._saveOpenAICompatibleApiKey(e.target.value)} + /> + + this._saveOpenAICompatibleBaseUrl(e.target.value)} + /> + ${this._loadingModels + ? html` + + ` + : this._availableModels.length > 0 && !this._manualModelInput + ? html` +
+ + ${model} + + `, + )} + + +
+ ` + : html` +
+ + this._saveOpenAICompatibleModel(e.target.value)} + /> + ${this._availableModels.length > 0 + ? html` -
- ` : html` -
- this._saveOpenAICompatibleModel(e.target.value)} - /> - ${this._availableModels.length > 0 ? html` - - ` : ''} -
- `} -
-
- ${this._loadingModels ? 'Loading available models...' : - this._availableModels.length > 0 ? `${this._availableModels.length} models available` : - 'Use OpenRouter, DeepSeek, Together AI, or any OpenAI-compatible API'} -
-
- ` : ''} - - ${this._renderStartButton()} - `; - } - - // ── Local AI mode ── - - _renderLocalMode() { - return html` -
- - this._saveOllamaHost(e.target.value)} - /> -
Ollama must be running locally
+ type="button" + @click=${() => this._toggleManualInput()} + style="padding: 8px 12px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; color: var(--text-muted); font-size: var(--font-size-xs);" + title="Select from list" + > + 📋 + + ` + : ""} +
+ `} +
+
+ ${this._loadingModels + ? "Loading available models..." + : this._availableModels.length > 0 + ? `${this._availableModels.length} models available` + : "Use OpenRouter, DeepSeek, Together AI, or any OpenAI-compatible API"} +
+ ` + : ""} + ${this._renderStartButton()} + `; + } -
- - this._saveOllamaModel(e.target.value)} - /> -
Run ollama pull ${this._ollamaModel} first
-
+ // ── Local AI mode ── -
-
- - ${this.whisperDownloading ? html`
` : ''} + _renderLocalMode() { + return html` +
+ + this._saveOllamaHost(e.target.value)} + /> +
Ollama must be running locally
+
+ +
+ + this._saveOllamaModel(e.target.value)} + /> +
+ Run + ollama pull ${this._ollamaModel} + first +
+
+ +
+
+ + ${this.whisperDownloading + ? html`
` + : ""} +
+ + ${this._whisperModel === "__custom__" + ? html` + this._saveCustomWhisperModel(e.target.value)} + @input=${(e) => { + this._customWhisperModel = e.target.value; + }} + style="margin-top: 6px;" + /> +
+ Enter a HuggingFace model ID compatible with + @huggingface/transformers speech-to-text pipeline +
+ ` + : html` +
+ ${this.whisperDownloading + ? "Downloading model..." + : "Downloaded automatically on first use"} +
+ `} +
+ + ${this._renderStartButton()} + `; + } + + // ── Main render ── + + render() { + const helpIcon = html` + + + + + `; + const closeIcon = html` + + `; + + return html` +
+ ${this._mode === "local" + ? html` +
+
+ Mastermind Local AI
- -
${this.whisperDownloading ? 'Downloading model...' : 'Downloaded automatically on first use'}
+ ${this._showLocalHelp ? closeIcon : helpIcon} + +
+ ` + : html` +
+ Mastermind BYOK +
+ `} +
+ ${this._mode === "byok" + ? "Bring your own API keys" + : "Run models locally on your machine"} +
+ + ${this._mode === "byok" ? this._renderByokMode() : ""} + ${this._mode === "local" + ? this._showLocalHelp + ? this._renderLocalHelp() + : this._renderLocalMode() + : ""} + +
+
+
or switch to
+
+
+ + +
+ `; + } + + _renderLocalHelp() { + return html` +
+
+
What is Ollama?
+
+ Ollama lets you run large language models locally on your machine. + Everything stays on your computer — no data leaves your device. +
+
+ +
+
Install Ollama
+
+ Download from + this.onExternalLink("https://ollama.com/download")} + >ollama.com/download + and install it. +
+
+ +
+
Ollama must be running
+
+ Ollama needs to be running before you start a session. If it's not + running, open your terminal and type: +
+ ollama serve +
+ +
+
Pull a model
+
+ Download a model before first use: +
+ ollama pull gemma3:4b +
+ +
+
Recommended models
+
+
+ gemma3:4b4B — fast, multimodal (images + text)
- - ${this._renderStartButton()} - `; - } - - // ── Main render ── - - render() { - const helpIcon = html``; - const closeIcon = html``; - - return html` -
- ${this._mode === 'local' ? html` -
-
Mastermind Local AI
- -
- ` : html` -
- Mastermind BYOK -
- `} -
- ${this._mode === 'byok' ? 'Bring your own API keys' : 'Run models locally on your machine'} -
- - ${this._mode === 'byok' ? this._renderByokMode() : ''} - ${this._mode === 'local' ? (this._showLocalHelp ? this._renderLocalHelp() : this._renderLocalMode()) : ''} - -
-
-
or switch to
-
-
- - +
+ mistral-small8B — solid all-rounder, text only
- `; - } +
+
+ gemma3:4b and above supports images — screenshots will work with + these models. +
+
- _renderLocalHelp() { - return html` -
-
-
What is Ollama?
-
Ollama lets you run large language models locally on your machine. Everything stays on your computer — no data leaves your device.
-
+
+
+ Avoid "thinking" models (e.g. deepseek-r1, qwq). Local inference is + already slower — a thinking model adds extra delay before + responding. +
+
-
-
Install Ollama
-
Download from this.onExternalLink('https://ollama.com/download')}>ollama.com/download and install it.
-
- -
-
Ollama must be running
-
Ollama needs to be running before you start a session. If it's not running, open your terminal and type:
- ollama serve -
- -
-
Pull a model
-
Download a model before first use:
- ollama pull gemma3:4b -
- -
-
Recommended models
-
-
gemma3:4b4B — fast, multimodal (images + text)
-
mistral-small8B — solid all-rounder, text only
-
-
gemma3:4b and above supports images — screenshots will work with these models.
-
- -
-
Avoid "thinking" models (e.g. deepseek-r1, qwq). Local inference is already slower — a thinking model adds extra delay before responding.
-
- -
-
Whisper
-
The Whisper speech-to-text model is downloaded automatically the first time you start a session. This is a one-time download.
-
-
- `; - } +
+
Whisper
+
+ The Whisper speech-to-text model is downloaded automatically the + first time you start a session. This is a one-time download. +
+
+
+ `; + } } -customElements.define('main-view', MainView); +customElements.define("main-view", MainView); diff --git a/src/storage.js b/src/storage.js index d38918c..231457a 100644 --- a/src/storage.js +++ b/src/storage.js @@ -1,554 +1,574 @@ -const fs = require('fs'); -const path = require('path'); -const os = require('os'); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); const CONFIG_VERSION = 1; // Default values const DEFAULT_CONFIG = { - configVersion: CONFIG_VERSION, - onboarded: false, - layout: 'normal' + configVersion: CONFIG_VERSION, + onboarded: false, + layout: "normal", }; const DEFAULT_CREDENTIALS = { - apiKey: '', - groqApiKey: '', - openaiCompatibleApiKey: '', - openaiCompatibleBaseUrl: '', - openaiCompatibleModel: '' + apiKey: "", + groqApiKey: "", + openaiCompatibleApiKey: "", + openaiCompatibleBaseUrl: "", + openaiCompatibleModel: "", }; const DEFAULT_PREFERENCES = { - customPrompt: '', - selectedProfile: 'interview', - selectedLanguage: 'en-US', - selectedScreenshotInterval: '5', - selectedImageQuality: 'medium', - advancedMode: false, - audioMode: 'speaker_only', - fontSize: 'medium', - backgroundTransparency: 0.8, - googleSearchEnabled: false, - responseProvider: 'gemini', - ollamaHost: 'http://127.0.0.1:11434', - ollamaModel: 'llama3.1', - whisperModel: 'Xenova/whisper-small', + customPrompt: "", + selectedProfile: "interview", + selectedLanguage: "en-US", + selectedScreenshotInterval: "5", + selectedImageQuality: "medium", + advancedMode: false, + audioMode: "speaker_only", + fontSize: "medium", + backgroundTransparency: 0.8, + googleSearchEnabled: false, + responseProvider: "gemini", + ollamaHost: "http://127.0.0.1:11434", + ollamaModel: "llama3.1", + whisperModel: "Xenova/whisper-small", + whisperDevice: "", // '' = auto-detect, 'cpu' = native, 'wasm' = compatible }; const DEFAULT_KEYBINDS = null; // null means use system defaults const DEFAULT_LIMITS = { - data: [] // Array of { date: 'YYYY-MM-DD', flash: { count }, flashLite: { count }, groq: { 'qwen3-32b': { chars, limit }, 'gpt-oss-120b': { chars, limit }, 'gpt-oss-20b': { chars, limit } }, gemini: { 'gemma-3-27b-it': { chars } } } + data: [], // Array of { date: 'YYYY-MM-DD', flash: { count }, flashLite: { count }, groq: { 'qwen3-32b': { chars, limit }, 'gpt-oss-120b': { chars, limit }, 'gpt-oss-20b': { chars, limit } }, gemini: { 'gemma-3-27b-it': { chars } } } }; // Get the config directory path based on OS function getConfigDir() { - const platform = os.platform(); - let configDir; + const platform = os.platform(); + let configDir; - if (platform === 'win32') { - configDir = path.join(os.homedir(), 'AppData', 'Roaming', 'cheating-daddy-config'); - } else if (platform === 'darwin') { - configDir = path.join(os.homedir(), 'Library', 'Application Support', 'cheating-daddy-config'); - } else { - configDir = path.join(os.homedir(), '.config', 'cheating-daddy-config'); - } + if (platform === "win32") { + configDir = path.join( + os.homedir(), + "AppData", + "Roaming", + "cheating-daddy-config", + ); + } else if (platform === "darwin") { + configDir = path.join( + os.homedir(), + "Library", + "Application Support", + "cheating-daddy-config", + ); + } else { + configDir = path.join(os.homedir(), ".config", "cheating-daddy-config"); + } - return configDir; + return configDir; } // File paths function getConfigPath() { - return path.join(getConfigDir(), 'config.json'); + return path.join(getConfigDir(), "config.json"); } function getCredentialsPath() { - return path.join(getConfigDir(), 'credentials.json'); + return path.join(getConfigDir(), "credentials.json"); } function getPreferencesPath() { - return path.join(getConfigDir(), 'preferences.json'); + return path.join(getConfigDir(), "preferences.json"); } function getKeybindsPath() { - return path.join(getConfigDir(), 'keybinds.json'); + return path.join(getConfigDir(), "keybinds.json"); } function getLimitsPath() { - return path.join(getConfigDir(), 'limits.json'); + return path.join(getConfigDir(), "limits.json"); } function getHistoryDir() { - return path.join(getConfigDir(), 'history'); + return path.join(getConfigDir(), "history"); } // Helper to read JSON file safely function readJsonFile(filePath, defaultValue) { - try { - if (fs.existsSync(filePath)) { - const data = fs.readFileSync(filePath, 'utf8'); - return JSON.parse(data); - } - } catch (error) { - console.warn(`Error reading ${filePath}:`, error.message); + try { + if (fs.existsSync(filePath)) { + const data = fs.readFileSync(filePath, "utf8"); + return JSON.parse(data); } - return defaultValue; + } catch (error) { + console.warn(`Error reading ${filePath}:`, error.message); + } + return defaultValue; } // Helper to write JSON file safely function writeJsonFile(filePath, data) { - try { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); - return true; - } catch (error) { - console.error(`Error writing ${filePath}:`, error.message); - return false; + try { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); } + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8"); + return true; + } catch (error) { + console.error(`Error writing ${filePath}:`, error.message); + return false; + } } // Check if we need to reset (no configVersion or wrong version) function needsReset() { - const configPath = getConfigPath(); - if (!fs.existsSync(configPath)) { - return true; - } + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) { + return true; + } - try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - return !config.configVersion || config.configVersion !== CONFIG_VERSION; - } catch { - return true; - } + try { + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + return !config.configVersion || config.configVersion !== CONFIG_VERSION; + } catch { + return true; + } } // Wipe and reinitialize the config directory function resetConfigDir() { - const configDir = getConfigDir(); + const configDir = getConfigDir(); - console.log('Resetting config directory...'); + console.log("Resetting config directory..."); - // Remove existing directory if it exists - if (fs.existsSync(configDir)) { - fs.rmSync(configDir, { recursive: true, force: true }); - } + // Remove existing directory if it exists + if (fs.existsSync(configDir)) { + fs.rmSync(configDir, { recursive: true, force: true }); + } - // Create fresh directory structure - fs.mkdirSync(configDir, { recursive: true }); - fs.mkdirSync(getHistoryDir(), { recursive: true }); + // Create fresh directory structure + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(getHistoryDir(), { recursive: true }); - // Initialize with defaults - writeJsonFile(getConfigPath(), DEFAULT_CONFIG); - writeJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS); - writeJsonFile(getPreferencesPath(), DEFAULT_PREFERENCES); + // Initialize with defaults + writeJsonFile(getConfigPath(), DEFAULT_CONFIG); + writeJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS); + writeJsonFile(getPreferencesPath(), DEFAULT_PREFERENCES); - console.log('Config directory initialized with defaults'); + console.log("Config directory initialized with defaults"); } // Initialize storage - call this on app startup function initializeStorage() { - if (needsReset()) { - resetConfigDir(); - } else { - // Ensure history directory exists - const historyDir = getHistoryDir(); - if (!fs.existsSync(historyDir)) { - fs.mkdirSync(historyDir, { recursive: true }); - } + if (needsReset()) { + resetConfigDir(); + } else { + // Ensure history directory exists + const historyDir = getHistoryDir(); + if (!fs.existsSync(historyDir)) { + fs.mkdirSync(historyDir, { recursive: true }); } + } } // ============ CONFIG ============ function getConfig() { - return readJsonFile(getConfigPath(), DEFAULT_CONFIG); + return readJsonFile(getConfigPath(), DEFAULT_CONFIG); } function setConfig(config) { - const current = getConfig(); - const updated = { ...current, ...config, configVersion: CONFIG_VERSION }; - return writeJsonFile(getConfigPath(), updated); + const current = getConfig(); + const updated = { ...current, ...config, configVersion: CONFIG_VERSION }; + return writeJsonFile(getConfigPath(), updated); } function updateConfig(key, value) { - const config = getConfig(); - config[key] = value; - return writeJsonFile(getConfigPath(), config); + const config = getConfig(); + config[key] = value; + return writeJsonFile(getConfigPath(), config); } // ============ CREDENTIALS ============ function getCredentials() { - return readJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS); + return readJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS); } function setCredentials(credentials) { - const current = getCredentials(); - const updated = { ...current, ...credentials }; - return writeJsonFile(getCredentialsPath(), updated); + const current = getCredentials(); + const updated = { ...current, ...credentials }; + return writeJsonFile(getCredentialsPath(), updated); } function getApiKey() { - return getCredentials().apiKey || ''; + return getCredentials().apiKey || ""; } function setApiKey(apiKey) { - return setCredentials({ apiKey }); + return setCredentials({ apiKey }); } function getGroqApiKey() { - return getCredentials().groqApiKey || ''; + return getCredentials().groqApiKey || ""; } function setGroqApiKey(groqApiKey) { - return setCredentials({ groqApiKey }); + return setCredentials({ groqApiKey }); } function getOpenAICompatibleConfig() { - const creds = getCredentials(); - return { - apiKey: creds.openaiCompatibleApiKey || '', - baseUrl: creds.openaiCompatibleBaseUrl || '', - model: creds.openaiCompatibleModel || '' - }; + const creds = getCredentials(); + return { + apiKey: creds.openaiCompatibleApiKey || "", + baseUrl: creds.openaiCompatibleBaseUrl || "", + model: creds.openaiCompatibleModel || "", + }; } function setOpenAICompatibleConfig(apiKey, baseUrl, model) { - return setCredentials({ - openaiCompatibleApiKey: apiKey, - openaiCompatibleBaseUrl: baseUrl, - openaiCompatibleModel: model - }); + return setCredentials({ + openaiCompatibleApiKey: apiKey, + openaiCompatibleBaseUrl: baseUrl, + openaiCompatibleModel: model, + }); } // ============ PREFERENCES ============ function getPreferences() { - const saved = readJsonFile(getPreferencesPath(), {}); - return { ...DEFAULT_PREFERENCES, ...saved }; + const saved = readJsonFile(getPreferencesPath(), {}); + return { ...DEFAULT_PREFERENCES, ...saved }; } function setPreferences(preferences) { - const current = getPreferences(); - const updated = { ...current, ...preferences }; - return writeJsonFile(getPreferencesPath(), updated); + const current = getPreferences(); + const updated = { ...current, ...preferences }; + return writeJsonFile(getPreferencesPath(), updated); } function updatePreference(key, value) { - const preferences = getPreferences(); - preferences[key] = value; - return writeJsonFile(getPreferencesPath(), preferences); + const preferences = getPreferences(); + preferences[key] = value; + return writeJsonFile(getPreferencesPath(), preferences); } // ============ KEYBINDS ============ function getKeybinds() { - return readJsonFile(getKeybindsPath(), DEFAULT_KEYBINDS); + return readJsonFile(getKeybindsPath(), DEFAULT_KEYBINDS); } function setKeybinds(keybinds) { - return writeJsonFile(getKeybindsPath(), keybinds); + return writeJsonFile(getKeybindsPath(), keybinds); } // ============ LIMITS (Rate Limiting) ============ function getLimits() { - return readJsonFile(getLimitsPath(), DEFAULT_LIMITS); + return readJsonFile(getLimitsPath(), DEFAULT_LIMITS); } function setLimits(limits) { - return writeJsonFile(getLimitsPath(), limits); + return writeJsonFile(getLimitsPath(), limits); } function getTodayDateString() { - const now = new Date(); - return now.toISOString().split('T')[0]; // YYYY-MM-DD + const now = new Date(); + return now.toISOString().split("T")[0]; // YYYY-MM-DD } function getTodayLimits() { - const limits = getLimits(); - const today = getTodayDateString(); + const limits = getLimits(); + const today = getTodayDateString(); - // Find today's entry - const todayEntry = limits.data.find(entry => entry.date === today); + // Find today's entry + const todayEntry = limits.data.find((entry) => entry.date === today); - if (todayEntry) { - // ensure new fields exist - if(!todayEntry.groq) { - todayEntry.groq = { - 'qwen3-32b': { chars: 0, limit: 1500000 }, - 'gpt-oss-120b': { chars: 0, limit: 600000 }, - 'gpt-oss-20b': { chars: 0, limit: 600000 }, - 'kimi-k2-instruct': { chars: 0, limit: 600000 } - }; - } - if(!todayEntry.gemini) { - todayEntry.gemini = { - 'gemma-3-27b-it': { chars: 0 } - }; - } - setLimits(limits); - return todayEntry; + if (todayEntry) { + // ensure new fields exist + if (!todayEntry.groq) { + todayEntry.groq = { + "qwen3-32b": { chars: 0, limit: 1500000 }, + "gpt-oss-120b": { chars: 0, limit: 600000 }, + "gpt-oss-20b": { chars: 0, limit: 600000 }, + "kimi-k2-instruct": { chars: 0, limit: 600000 }, + }; + } + if (!todayEntry.gemini) { + todayEntry.gemini = { + "gemma-3-27b-it": { chars: 0 }, + }; } - - // No entry for today - clean old entries and create new one - limits.data = limits.data.filter(entry => entry.date === today); - const newEntry = { - date: today, - flash: { count: 0 }, - flashLite: { count: 0 }, - groq: { - 'qwen3-32b': { chars: 0, limit: 1500000 }, - 'gpt-oss-120b': { chars: 0, limit: 600000 }, - 'gpt-oss-20b': { chars: 0, limit: 600000 }, - 'kimi-k2-instruct': { chars: 0, limit: 600000 } - }, - gemini: { - 'gemma-3-27b-it': { chars: 0 } - } - }; - limits.data.push(newEntry); setLimits(limits); + return todayEntry; + } - return newEntry; + // No entry for today - clean old entries and create new one + limits.data = limits.data.filter((entry) => entry.date === today); + const newEntry = { + date: today, + flash: { count: 0 }, + flashLite: { count: 0 }, + groq: { + "qwen3-32b": { chars: 0, limit: 1500000 }, + "gpt-oss-120b": { chars: 0, limit: 600000 }, + "gpt-oss-20b": { chars: 0, limit: 600000 }, + "kimi-k2-instruct": { chars: 0, limit: 600000 }, + }, + gemini: { + "gemma-3-27b-it": { chars: 0 }, + }, + }; + limits.data.push(newEntry); + setLimits(limits); + + return newEntry; } function incrementLimitCount(model) { - const limits = getLimits(); - const today = getTodayDateString(); + const limits = getLimits(); + const today = getTodayDateString(); - // Find or create today's entry - let todayEntry = limits.data.find(entry => entry.date === today); + // Find or create today's entry + let todayEntry = limits.data.find((entry) => entry.date === today); - if (!todayEntry) { - // Clean old entries and create new one - limits.data = []; - todayEntry = { - date: today, - flash: { count: 0 }, - flashLite: { count: 0 } - }; - limits.data.push(todayEntry); - } else { - // Clean old entries, keep only today - limits.data = limits.data.filter(entry => entry.date === today); - } + if (!todayEntry) { + // Clean old entries and create new one + limits.data = []; + todayEntry = { + date: today, + flash: { count: 0 }, + flashLite: { count: 0 }, + }; + limits.data.push(todayEntry); + } else { + // Clean old entries, keep only today + limits.data = limits.data.filter((entry) => entry.date === today); + } - // Increment the appropriate model count - if (model === 'gemini-2.5-flash') { - todayEntry.flash.count++; - } else if (model === 'gemini-2.5-flash-lite') { - todayEntry.flashLite.count++; - } + // Increment the appropriate model count + if (model === "gemini-2.5-flash") { + todayEntry.flash.count++; + } else if (model === "gemini-2.5-flash-lite") { + todayEntry.flashLite.count++; + } - setLimits(limits); - return todayEntry; + setLimits(limits); + return todayEntry; } function incrementCharUsage(provider, model, charCount) { - getTodayLimits(); + getTodayLimits(); - const limits = getLimits(); - const today = getTodayDateString(); - const todayEntry = limits.data.find(entry => entry.date === today); + const limits = getLimits(); + const today = getTodayDateString(); + const todayEntry = limits.data.find((entry) => entry.date === today); - if(todayEntry[provider] && todayEntry[provider][model]) { - todayEntry[provider][model].chars += charCount; - setLimits(limits); - } + if (todayEntry[provider] && todayEntry[provider][model]) { + todayEntry[provider][model].chars += charCount; + setLimits(limits); + } - return todayEntry; + return todayEntry; } function getAvailableModel() { - const todayLimits = getTodayLimits(); + const todayLimits = getTodayLimits(); - // RPD limits: flash = 20, flash-lite = 20 - // After both exhausted, fall back to flash (for paid API users) - if (todayLimits.flash.count < 20) { - return 'gemini-2.5-flash'; - } else if (todayLimits.flashLite.count < 20) { - return 'gemini-2.5-flash-lite'; - } + // RPD limits: flash = 20, flash-lite = 20 + // After both exhausted, fall back to flash (for paid API users) + if (todayLimits.flash.count < 20) { + return "gemini-2.5-flash"; + } else if (todayLimits.flashLite.count < 20) { + return "gemini-2.5-flash-lite"; + } - return 'gemini-2.5-flash'; // Default to flash for paid API users + return "gemini-2.5-flash"; // Default to flash for paid API users } function getModelForToday() { - const todayEntry = getTodayLimits(); - const groq = todayEntry.groq; + const todayEntry = getTodayLimits(); + const groq = todayEntry.groq; - if (groq['qwen3-32b'].chars < groq['qwen3-32b'].limit) { - return 'qwen/qwen3-32b'; - } - if (groq['gpt-oss-120b'].chars < groq['gpt-oss-120b'].limit) { - return 'openai/gpt-oss-120b'; - } - if (groq['gpt-oss-20b'].chars < groq['gpt-oss-20b'].limit) { - return 'openai/gpt-oss-20b'; - } - if (groq['kimi-k2-instruct'].chars < groq['kimi-k2-instruct'].limit) { - return 'moonshotai/kimi-k2-instruct'; - } + if (groq["qwen3-32b"].chars < groq["qwen3-32b"].limit) { + return "qwen/qwen3-32b"; + } + if (groq["gpt-oss-120b"].chars < groq["gpt-oss-120b"].limit) { + return "openai/gpt-oss-120b"; + } + if (groq["gpt-oss-20b"].chars < groq["gpt-oss-20b"].limit) { + return "openai/gpt-oss-20b"; + } + if (groq["kimi-k2-instruct"].chars < groq["kimi-k2-instruct"].limit) { + return "moonshotai/kimi-k2-instruct"; + } - // All limits exhausted - return null; + // All limits exhausted + return null; } // ============ HISTORY ============ function getSessionPath(sessionId) { - return path.join(getHistoryDir(), `${sessionId}.json`); + return path.join(getHistoryDir(), `${sessionId}.json`); } function saveSession(sessionId, data) { - const sessionPath = getSessionPath(sessionId); + const sessionPath = getSessionPath(sessionId); - // Load existing session to preserve metadata - const existingSession = readJsonFile(sessionPath, null); + // Load existing session to preserve metadata + const existingSession = readJsonFile(sessionPath, null); - const sessionData = { - sessionId, - createdAt: existingSession?.createdAt || parseInt(sessionId), - lastUpdated: Date.now(), - // Profile context - set once when session starts - profile: data.profile || existingSession?.profile || null, - customPrompt: data.customPrompt || existingSession?.customPrompt || null, - // Conversation data - conversationHistory: data.conversationHistory || existingSession?.conversationHistory || [], - screenAnalysisHistory: data.screenAnalysisHistory || existingSession?.screenAnalysisHistory || [] - }; - return writeJsonFile(sessionPath, sessionData); + const sessionData = { + sessionId, + createdAt: existingSession?.createdAt || parseInt(sessionId), + lastUpdated: Date.now(), + // Profile context - set once when session starts + profile: data.profile || existingSession?.profile || null, + customPrompt: data.customPrompt || existingSession?.customPrompt || null, + // Conversation data + conversationHistory: + data.conversationHistory || existingSession?.conversationHistory || [], + screenAnalysisHistory: + data.screenAnalysisHistory || + existingSession?.screenAnalysisHistory || + [], + }; + return writeJsonFile(sessionPath, sessionData); } function getSession(sessionId) { - return readJsonFile(getSessionPath(sessionId), null); + return readJsonFile(getSessionPath(sessionId), null); } function getAllSessions() { - const historyDir = getHistoryDir(); + const historyDir = getHistoryDir(); - try { - if (!fs.existsSync(historyDir)) { - return []; - } - - const files = fs.readdirSync(historyDir) - .filter(f => f.endsWith('.json')) - .sort((a, b) => { - // Sort by timestamp descending (newest first) - const tsA = parseInt(a.replace('.json', '')); - const tsB = parseInt(b.replace('.json', '')); - return tsB - tsA; - }); - - return files.map(file => { - const sessionId = file.replace('.json', ''); - const data = readJsonFile(path.join(historyDir, file), null); - if (data) { - return { - sessionId, - createdAt: data.createdAt, - lastUpdated: data.lastUpdated, - messageCount: data.conversationHistory?.length || 0, - screenAnalysisCount: data.screenAnalysisHistory?.length || 0, - profile: data.profile || null, - customPrompt: data.customPrompt || null - }; - } - return null; - }).filter(Boolean); - } catch (error) { - console.error('Error reading sessions:', error.message); - return []; + try { + if (!fs.existsSync(historyDir)) { + return []; } + + const files = fs + .readdirSync(historyDir) + .filter((f) => f.endsWith(".json")) + .sort((a, b) => { + // Sort by timestamp descending (newest first) + const tsA = parseInt(a.replace(".json", "")); + const tsB = parseInt(b.replace(".json", "")); + return tsB - tsA; + }); + + return files + .map((file) => { + const sessionId = file.replace(".json", ""); + const data = readJsonFile(path.join(historyDir, file), null); + if (data) { + return { + sessionId, + createdAt: data.createdAt, + lastUpdated: data.lastUpdated, + messageCount: data.conversationHistory?.length || 0, + screenAnalysisCount: data.screenAnalysisHistory?.length || 0, + profile: data.profile || null, + customPrompt: data.customPrompt || null, + }; + } + return null; + }) + .filter(Boolean); + } catch (error) { + console.error("Error reading sessions:", error.message); + return []; + } } function deleteSession(sessionId) { - const sessionPath = getSessionPath(sessionId); - try { - if (fs.existsSync(sessionPath)) { - fs.unlinkSync(sessionPath); - return true; - } - } catch (error) { - console.error('Error deleting session:', error.message); + const sessionPath = getSessionPath(sessionId); + try { + if (fs.existsSync(sessionPath)) { + fs.unlinkSync(sessionPath); + return true; } - return false; + } catch (error) { + console.error("Error deleting session:", error.message); + } + return false; } function deleteAllSessions() { - const historyDir = getHistoryDir(); - try { - if (fs.existsSync(historyDir)) { - const files = fs.readdirSync(historyDir).filter(f => f.endsWith('.json')); - files.forEach(file => { - fs.unlinkSync(path.join(historyDir, file)); - }); - } - return true; - } catch (error) { - console.error('Error deleting all sessions:', error.message); - return false; + const historyDir = getHistoryDir(); + try { + if (fs.existsSync(historyDir)) { + const files = fs + .readdirSync(historyDir) + .filter((f) => f.endsWith(".json")); + files.forEach((file) => { + fs.unlinkSync(path.join(historyDir, file)); + }); } + return true; + } catch (error) { + console.error("Error deleting all sessions:", error.message); + return false; + } } // ============ CLEAR ALL DATA ============ function clearAllData() { - resetConfigDir(); - return true; + resetConfigDir(); + return true; } module.exports = { - // Initialization - initializeStorage, - getConfigDir, + // Initialization + initializeStorage, + getConfigDir, - // Config - getConfig, - setConfig, - updateConfig, + // Config + getConfig, + setConfig, + updateConfig, - // Credentials - getCredentials, - setCredentials, - getApiKey, - setApiKey, - getGroqApiKey, - setGroqApiKey, - getOpenAICompatibleConfig, - setOpenAICompatibleConfig, + // Credentials + getCredentials, + setCredentials, + getApiKey, + setApiKey, + getGroqApiKey, + setGroqApiKey, + getOpenAICompatibleConfig, + setOpenAICompatibleConfig, - // Preferences - getPreferences, - setPreferences, - updatePreference, + // Preferences + getPreferences, + setPreferences, + updatePreference, - // Keybinds - getKeybinds, - setKeybinds, + // Keybinds + getKeybinds, + setKeybinds, - // Limits (Rate Limiting) - getLimits, - setLimits, - getTodayLimits, - incrementLimitCount, - getAvailableModel, - incrementCharUsage, - getModelForToday, + // Limits (Rate Limiting) + getLimits, + setLimits, + getTodayLimits, + incrementLimitCount, + getAvailableModel, + incrementCharUsage, + getModelForToday, - // History - saveSession, - getSession, - getAllSessions, - deleteSession, - deleteAllSessions, + // History + saveSession, + getSession, + getAllSessions, + deleteSession, + deleteAllSessions, - // Clear all - clearAllData + // Clear all + clearAllData, }; diff --git a/src/utils/localai.js b/src/utils/localai.js index 800656a..5167ceb 100644 --- a/src/utils/localai.js +++ b/src/utils/localai.js @@ -7,6 +7,7 @@ const { } = require("./gemini"); const { fork } = require("child_process"); const path = require("path"); +const { getSystemNode } = require("./nodeDetect"); // ── State ── @@ -19,6 +20,9 @@ let localConversationHistory = []; let currentSystemPrompt = null; let isLocalActive = false; +// Set when we intentionally kill the worker to suppress crash handling +let whisperShuttingDown = false; + // Pending transcription callback (one at a time) let pendingTranscribe = null; @@ -189,12 +193,54 @@ function spawnWhisperWorker() { const workerPath = path.join(__dirname, "whisperWorker.js"); console.log("[LocalAI] Spawning Whisper worker:", workerPath); - whisperWorker = fork(workerPath, [], { - stdio: ["pipe", "pipe", "pipe", "ipc"], - // ELECTRON_RUN_AS_NODE makes the Electron binary behave as plain Node.js, - // which is required for child_process.fork() in packaged Electron apps. - env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, - }); + // Determine the best way to spawn the worker: + // 1. System Node.js (preferred) — native addons were compiled against this + // ABI, so onnxruntime-node works without SIGTRAP / ABI mismatches. + // 2. Electron utilityProcess (packaged builds) — proper Node.js child + // process API that doesn't require the RunAsNode fuse. + // 3. ELECTRON_RUN_AS_NODE (last resort, dev only) — the old approach that + // only works when the RunAsNode fuse isn't flipped. + + const systemNode = getSystemNode(); + + if (systemNode) { + // Spawn with system Node.js — onnxruntime-node native binary matches ABI + console.log("[LocalAI] Using system Node.js:", systemNode.nodePath); + whisperWorker = fork(workerPath, [], { + stdio: ["pipe", "pipe", "pipe", "ipc"], + execPath: systemNode.nodePath, + env: { + ...process.env, + // Unset ELECTRON_RUN_AS_NODE so the system node doesn't inherit it + ELECTRON_RUN_AS_NODE: undefined, + }, + }); + } else { + // No system Node.js found — try utilityProcess (Electron >= 22) + // utilityProcess.fork() creates a proper child Node.js process without + // needing the RunAsNode fuse. Falls back to ELECTRON_RUN_AS_NODE for + // dev mode where fuses aren't applied. + try { + const { utilityProcess: UP } = require("electron"); + if (UP && typeof UP.fork === "function") { + console.log("[LocalAI] Using Electron utilityProcess"); + const up = UP.fork(workerPath); + // Wrap utilityProcess to look like a ChildProcess for the rest of localai.js + whisperWorker = wrapUtilityProcess(up); + return; + } + } catch (_) { + // utilityProcess not available (older Electron or renderer context) + } + + console.warn( + "[LocalAI] No system Node.js — falling back to ELECTRON_RUN_AS_NODE (WASM backend will be used)", + ); + whisperWorker = fork(workerPath, [], { + stdio: ["pipe", "pipe", "pipe", "ipc"], + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + }); + } whisperWorker.stdout.on("data", (data) => { console.log("[WhisperWorker stdout]", data.toString().trim()); @@ -230,6 +276,12 @@ function spawnWhisperWorker() { whisperWorker = null; whisperReady = false; + // If we intentionally shut down, don't treat as crash + if (whisperShuttingDown) { + whisperShuttingDown = false; + return; + } + // Reject any pending transcription if (pendingTranscribe) { pendingTranscribe.reject( @@ -263,11 +315,52 @@ function spawnWhisperWorker() { }); } +/** + * Wrap Electron's utilityProcess to behave like a ChildProcess (duck-typing) + * so the rest of localai.js can use the same API. + */ +function wrapUtilityProcess(up) { + const EventEmitter = require("events"); + const wrapper = new EventEmitter(); + + // Forward messages + up.on("message", (msg) => wrapper.emit("message", msg)); + + // Map utilityProcess exit to ChildProcess-like exit event + up.on("exit", (code) => wrapper.emit("exit", code, null)); + + // Provide stdout/stderr stubs (utilityProcess pipes to parent console) + const { Readable } = require("stream"); + wrapper.stdout = new Readable({ read() {} }); + wrapper.stderr = new Readable({ read() {} }); + + wrapper.send = (data) => up.postMessage(data); + wrapper.kill = (signal) => up.kill(); + wrapper.removeAllListeners = () => { + up.removeAllListeners(); + EventEmitter.prototype.removeAllListeners.call(wrapper); + }; + + // Setup stdout/stderr forwarding + wrapper.stdout.on("data", (data) => { + console.log("[WhisperWorker stdout]", data.toString().trim()); + }); + wrapper.stderr.on("data", (data) => { + console.error("[WhisperWorker stderr]", data.toString().trim()); + }); + + return wrapper; +} + let pendingLoad = null; function handleWorkerLoadResult(msg) { if (msg.success) { - console.log("[LocalAI] Whisper model loaded successfully (in worker)"); + console.log( + "[LocalAI] Whisper model loaded successfully (in worker, device:", + msg.device || "unknown", + ")", + ); whisperReady = true; sendToRenderer("whisper-downloading", false); isWhisperLoading = false; @@ -312,11 +405,49 @@ function respawnWhisperWorker() { "Xenova/whisper-small"; sendToRenderer("whisper-downloading", true); isWhisperLoading = true; - whisperWorker.send({ type: "load", modelName, cacheDir }); + const device = resolveWhisperDevice(); + whisperWorker.send({ type: "load", modelName, cacheDir, device }); +} + +/** + * Determine which ONNX backend to use for Whisper inference. + * - "cpu" → onnxruntime-node (fast, native — requires matching ABI) + * - "wasm" → onnxruntime-web (slower but universally compatible) + * + * When spawned with system Node.js, native CPU backend is safe. + * Otherwise default to WASM to prevent native crashes. + */ +function resolveWhisperDevice() { + const prefs = require("../storage").getPreferences(); + if (prefs.whisperDevice) return prefs.whisperDevice; + // Auto-detect: if we're running with system Node.js, native is safe + const systemNode = getSystemNode(); + return systemNode ? "cpu" : "wasm"; +} + +/** + * Map the app's BCP-47 language tag (e.g. "en-US", "ru-RU") to the + * ISO 639-1 code that Whisper expects (e.g. "en", "ru"). + * Returns "auto" when the user selected auto-detect, which tells the + * worker to let Whisper detect the language itself. + */ +function resolveWhisperLanguage() { + const prefs = require("../storage").getPreferences(); + const lang = prefs.selectedLanguage || "en-US"; + if (lang === "auto") return "auto"; + // BCP-47: primary subtag is the ISO 639 code + // Handle special case: "cmn-CN" → "zh" (Mandarin Chinese → Whisper uses "zh") + const primary = lang.split("-")[0].toLowerCase(); + const WHISPER_LANG_MAP = { + cmn: "zh", + yue: "zh", + }; + return WHISPER_LANG_MAP[primary] || primary; } function killWhisperWorker() { if (whisperWorker) { + whisperShuttingDown = true; try { whisperWorker.removeAllListeners(); whisperWorker.kill(); @@ -345,9 +476,12 @@ async function loadWhisperPipeline(modelName) { const { app } = require("electron"); const cacheDir = path.join(app.getPath("userData"), "whisper-models"); + const device = resolveWhisperDevice(); + console.log("[LocalAI] Whisper device:", device); + return new Promise((resolve) => { pendingLoad = { resolve }; - whisperWorker.send({ type: "load", modelName, cacheDir }); + whisperWorker.send({ type: "load", modelName, cacheDir, device }); }); } @@ -393,7 +527,11 @@ async function transcribeAudio(pcm16kBuffer) { }; try { - whisperWorker.send({ type: "transcribe", audioBase64 }); + whisperWorker.send({ + type: "transcribe", + audioBase64, + language: resolveWhisperLanguage(), + }); } catch (err) { clearTimeout(timeout); pendingTranscribe = null; diff --git a/src/utils/nodeDetect.js b/src/utils/nodeDetect.js new file mode 100644 index 0000000..7d58fb2 --- /dev/null +++ b/src/utils/nodeDetect.js @@ -0,0 +1,177 @@ +/** + * nodeDetect.js — Locate the system Node.js binary. + * + * When spawning child processes that rely on native addons compiled against the + * system Node.js ABI (e.g. onnxruntime-node), we must NOT run them inside + * Electron's embedded Node.js runtime — the ABI mismatch causes SIGTRAP / + * SIGSEGV crashes. This module finds the real system `node` binary so we can + * pass it as `execPath` to `child_process.fork()`. + * + * Falls back to `null` when no system Node.js is found, letting the caller + * decide on an alternative strategy (e.g. WASM backend). + */ + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +/** Well-known Node.js install locations per platform. */ +const KNOWN_PATHS = { + darwin: [ + "/usr/local/bin/node", + "/opt/homebrew/bin/node", // Apple Silicon Homebrew + path.join(os.homedir(), ".nvm/versions/node"), // nvm — needs glob + path.join(os.homedir(), ".volta/bin/node"), // Volta + path.join(os.homedir(), ".fnm/aliases/default/bin/node"), // fnm + path.join(os.homedir(), ".mise/shims/node"), // mise (rtx) + path.join(os.homedir(), ".asdf/shims/node"), // asdf + ], + linux: [ + "/usr/bin/node", + "/usr/local/bin/node", + path.join(os.homedir(), ".nvm/versions/node"), + path.join(os.homedir(), ".volta/bin/node"), + path.join(os.homedir(), ".fnm/aliases/default/bin/node"), + path.join(os.homedir(), ".mise/shims/node"), + path.join(os.homedir(), ".asdf/shims/node"), + ], + win32: [ + "C:\\Program Files\\nodejs\\node.exe", + "C:\\Program Files (x86)\\nodejs\\node.exe", + path.join(os.homedir(), "AppData", "Roaming", "nvm", "current", "node.exe"), + path.join(os.homedir(), ".volta", "bin", "node.exe"), + ], +}; + +/** + * Find the latest nvm-installed Node.js binary on macOS / Linux. + * Returns the path to the `node` binary or null. + */ +function findNvmNode() { + const nvmDir = path.join(os.homedir(), ".nvm", "versions", "node"); + try { + if (!fs.existsSync(nvmDir)) return null; + const versions = fs.readdirSync(nvmDir).filter((d) => d.startsWith("v")); + if (versions.length === 0) return null; + // Sort semver descending (rough but sufficient) + versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + const nodeBin = path.join(nvmDir, versions[0], "bin", "node"); + if (fs.existsSync(nodeBin)) return nodeBin; + } catch (_) { + // Ignore + } + return null; +} + +/** + * Attempt to resolve `node` via the system PATH using `which` (Unix) or + * `where` (Windows). Returns the path string or null. + */ +function whichNode() { + try { + const cmd = process.platform === "win32" ? "where node" : "which node"; + const result = execSync(cmd, { + encoding: "utf8", + timeout: 5000, + env: { + ...process.env, + // Ensure common manager shim dirs are on PATH + PATH: [ + process.env.PATH || "", + "/usr/local/bin", + "/opt/homebrew/bin", + path.join(os.homedir(), ".volta", "bin"), + path.join(os.homedir(), ".fnm", "aliases", "default", "bin"), + path.join(os.homedir(), ".mise", "shims"), + path.join(os.homedir(), ".asdf", "shims"), + ].join(process.platform === "win32" ? ";" : ":"), + }, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + // `where` on Windows may return multiple lines — take the first + const first = result.split(/\r?\n/)[0].trim(); + if (first && fs.existsSync(first)) return first; + } catch (_) { + // Command failed + } + return null; +} + +/** + * Check whether a given path is a real Node.js binary (not the Electron binary + * pretending to be Node via ELECTRON_RUN_AS_NODE). + */ +function isRealNode(nodePath) { + if (!nodePath) return false; + try { + const out = execSync( + `"${nodePath}" -e "process.stdout.write(String(!process.versions.electron))"`, + { + encoding: "utf8", + timeout: 5000, + env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined }, + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); + return out === "true"; + } catch (_) { + return false; + } +} + +/** + * Find the system Node.js binary. + * + * @returns {{ nodePath: string } | null} The absolute path to system `node`, + * or null if none found. The caller should fall back to WASM when null. + */ +function findSystemNode() { + // 1. Try `which node` / `where node` first (respects user's PATH / shims) + const fromPath = whichNode(); + if (fromPath && isRealNode(fromPath)) { + return { nodePath: fromPath }; + } + + // 2. Try nvm (has multiple version dirs) + const fromNvm = findNvmNode(); + if (fromNvm && isRealNode(fromNvm)) { + return { nodePath: fromNvm }; + } + + // 3. Walk the well-known paths for the current platform + const platform = process.platform; + const candidates = KNOWN_PATHS[platform] || KNOWN_PATHS.linux; + for (const candidate of candidates) { + // Skip the nvm root — already handled above + if (candidate.includes(".nvm/versions/node")) continue; + if (fs.existsSync(candidate) && isRealNode(candidate)) { + return { nodePath: candidate }; + } + } + + return null; +} + +/** Cache so we only search once per process lifetime. */ +let _cached = undefined; + +/** + * Cached version of `findSystemNode()`. + * @returns {{ nodePath: string } | null} + */ +function getSystemNode() { + if (_cached === undefined) { + _cached = findSystemNode(); + if (_cached) { + console.log("[nodeDetect] Found system Node.js:", _cached.nodePath); + } else { + console.warn( + "[nodeDetect] No system Node.js found — will fall back to WASM backend", + ); + } + } + return _cached; +} + +module.exports = { findSystemNode, getSystemNode, isRealNode }; diff --git a/src/utils/whisperWorker.js b/src/utils/whisperWorker.js index c2482e0..e5444eb 100644 --- a/src/utils/whisperWorker.js +++ b/src/utils/whisperWorker.js @@ -8,18 +8,48 @@ * * Protocol (parent ↔ worker): * parent → worker: - * { type: 'load', modelName, cacheDir } - * { type: 'transcribe', audioBase64 } // PCM 16-bit 16kHz as base64 + * { type: 'load', modelName, cacheDir, device? } + * { type: 'transcribe', audioBase64, language? } // PCM 16-bit 16kHz as base64 * { type: 'shutdown' } * * worker → parent: - * { type: 'load-result', success, error? } + * { type: 'load-result', success, error?, device? } * { type: 'transcribe-result', success, text?, error? } * { type: 'status', message } * { type: 'ready' } */ +// ── Crash handlers — report fatal errors before the process dies ── + +process.on("uncaughtException", (err) => { + try { + send({ + type: "status", + message: `[Worker] Uncaught exception: ${err.message || err}`, + }); + console.error("[WhisperWorker] Uncaught exception:", err); + } catch (_) { + // Cannot communicate with parent anymore + } + process.exit(1); +}); + +process.on("unhandledRejection", (reason) => { + try { + send({ + type: "status", + message: `[Worker] Unhandled rejection: ${reason?.message || reason}`, + }); + console.error("[WhisperWorker] Unhandled rejection:", reason); + } catch (_) { + // Cannot communicate with parent anymore + } + // Don't exit — let it be caught by the pipeline's own handlers +}); + let whisperPipeline = null; +/** Which ONNX backend is actually active: "cpu" | "wasm" */ +let activeDevice = null; function pcm16ToFloat32(pcm16Buffer) { if (!pcm16Buffer || pcm16Buffer.length === 0) { @@ -35,9 +65,18 @@ function pcm16ToFloat32(pcm16Buffer) { return float32; } -async function loadModel(modelName, cacheDir) { +/** + * Load the Whisper model. + * + * @param {string} modelName HuggingFace model id, e.g. "Xenova/whisper-small" + * @param {string} cacheDir Directory for cached model files + * @param {string} [device] "cpu" (onnxruntime-node) or "wasm" (onnxruntime-web). + * When "cpu" is requested we try native first and fall + * back to "wasm" on failure (ABI mismatch, etc.). + */ +async function loadModel(modelName, cacheDir, device = "cpu") { if (whisperPipeline) { - send({ type: "load-result", success: true }); + send({ type: "load-result", success: true, device: activeDevice }); return; } @@ -46,23 +85,112 @@ async function loadModel(modelName, cacheDir) { type: "status", message: "Loading Whisper model (first time may take a while)...", }); + + // Validate / create cache directory + const fs = require("fs"); + const path = require("path"); + if (cacheDir) { + try { + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + console.log("[WhisperWorker] Created cache directory:", cacheDir); + } + } catch (mkdirErr) { + console.warn( + "[WhisperWorker] Cannot create cache dir:", + mkdirErr.message, + ); + } + + // Check for corrupted partial downloads — if an onnx file exists but + // is suspiciously small (< 1 KB), delete it so the library re-downloads. + try { + const modelDir = path.join(cacheDir, modelName.replace("/", path.sep)); + if (fs.existsSync(modelDir)) { + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if ( + entry.name.endsWith(".onnx") && + fs.statSync(full).size < 1024 + ) { + console.warn( + "[WhisperWorker] Removing likely-corrupt file:", + full, + ); + fs.unlinkSync(full); + } + } + }; + walk(modelDir); + } + } catch (cleanErr) { + console.warn("[WhisperWorker] Cache cleanup error:", cleanErr.message); + } + } + const { pipeline, env } = await import("@huggingface/transformers"); env.cacheDir = cacheDir; - whisperPipeline = await pipeline( - "automatic-speech-recognition", - modelName, - { - dtype: "q8", - device: "cpu", - }, - ); - send({ type: "load-result", success: true }); + + // Attempt to load with the requested device + const devicesToTry = device === "wasm" ? ["wasm"] : ["cpu", "wasm"]; + + let lastError = null; + + for (const dev of devicesToTry) { + try { + send({ + type: "status", + message: `Loading Whisper (${dev} backend)...`, + }); + console.log( + `[WhisperWorker] Trying device: ${dev}, model: ${modelName}`, + ); + + whisperPipeline = await pipeline( + "automatic-speech-recognition", + modelName, + { + dtype: "q8", + device: dev, + }, + ); + + activeDevice = dev; + console.log( + `[WhisperWorker] Model loaded successfully (device: ${dev})`, + ); + send({ type: "load-result", success: true, device: dev }); + return; + } catch (err) { + lastError = err; + console.error( + `[WhisperWorker] Failed to load with device "${dev}":`, + err.message || err, + ); + + if (dev === "cpu" && devicesToTry.includes("wasm")) { + send({ + type: "status", + message: `Native CPU backend failed (${err.message}). Trying WASM fallback...`, + }); + } + + // Reset pipeline state before retry + whisperPipeline = null; + } + } + + // All devices failed + throw lastError || new Error("All ONNX backends failed"); } catch (error) { send({ type: "load-result", success: false, error: error.message }); } } -async function transcribe(audioBase64) { +async function transcribe(audioBase64, language) { if (!whisperPipeline) { send({ type: "transcribe-result", @@ -101,11 +229,16 @@ async function transcribe(audioBase64) { return; } - const result = await whisperPipeline(float32Audio, { + // Build pipeline options with the requested language + const pipelineOpts = { sampling_rate: 16000, - language: "en", task: "transcribe", - }); + }; + if (language && language !== "auto") { + pipelineOpts.language = language; + } + + const result = await whisperPipeline(float32Audio, pipelineOpts); const text = result.text?.trim() || ""; send({ type: "transcribe-result", success: true, text }); @@ -131,17 +264,32 @@ function send(msg) { process.on("message", (msg) => { switch (msg.type) { case "load": - loadModel(msg.modelName, msg.cacheDir).catch((err) => { + loadModel(msg.modelName, msg.cacheDir, msg.device).catch((err) => { send({ type: "load-result", success: false, error: err.message }); }); break; case "transcribe": - transcribe(msg.audioBase64).catch((err) => { + transcribe(msg.audioBase64, msg.language).catch((err) => { send({ type: "transcribe-result", success: false, error: err.message }); }); break; case "shutdown": - process.exit(0); + // Dispose the ONNX session gracefully before exiting to avoid + // native cleanup race conditions (SIGABRT on mutex destroy). + (async () => { + if (whisperPipeline) { + try { + if (typeof whisperPipeline.dispose === "function") { + await whisperPipeline.dispose(); + } + } catch (_) { + // Best-effort cleanup + } + whisperPipeline = null; + } + // Small delay to let native threads wind down + setTimeout(() => process.exit(0), 200); + })(); break; } }); -- 2.47.2 From 0d56e06724674ec209593454ce84abbb76b6350e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=93=D0=BB=D0=B0=D0=B7=D1=83?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2?= Date: Mon, 16 Feb 2026 19:55:39 +0300 Subject: [PATCH 3/3] feat: add whisper progress tracking and UI updates for download status --- src/components/app/CheatingDaddyApp.js | 8 +++ src/components/views/MainView.js | 96 ++++++++++++++++++++++++++ src/utils/localai.js | 9 +++ src/utils/whisperWorker.js | 34 +++++++++ 4 files changed, 147 insertions(+) diff --git a/src/components/app/CheatingDaddyApp.js b/src/components/app/CheatingDaddyApp.js index 24df6db..6fecf2d 100644 --- a/src/components/app/CheatingDaddyApp.js +++ b/src/components/app/CheatingDaddyApp.js @@ -382,6 +382,7 @@ export class CheatingDaddyApp extends LitElement { _storageLoaded: { state: true }, _updateAvailable: { state: true }, _whisperDownloading: { state: true }, + _whisperProgress: { state: true }, }; constructor() { @@ -407,6 +408,7 @@ export class CheatingDaddyApp extends LitElement { this._timerInterval = null; this._updateAvailable = false; this._whisperDownloading = false; + this._whisperProgress = null; this._localVersion = ""; this._loadFromStorage(); @@ -485,6 +487,10 @@ export class CheatingDaddyApp extends LitElement { ); ipcRenderer.on("whisper-downloading", (_, downloading) => { this._whisperDownloading = downloading; + if (!downloading) this._whisperProgress = null; + }); + ipcRenderer.on("whisper-progress", (_, progress) => { + this._whisperProgress = progress; }); } } @@ -500,6 +506,7 @@ export class CheatingDaddyApp extends LitElement { ipcRenderer.removeAllListeners("click-through-toggled"); ipcRenderer.removeAllListeners("reconnect-failed"); ipcRenderer.removeAllListeners("whisper-downloading"); + ipcRenderer.removeAllListeners("whisper-progress"); } } @@ -778,6 +785,7 @@ export class CheatingDaddyApp extends LitElement { .onStart=${() => this.handleStart()} .onExternalLink=${(url) => this.handleExternalLinkClick(url)} .whisperDownloading=${this._whisperDownloading} + .whisperProgress=${this._whisperProgress} > `; diff --git a/src/components/views/MainView.js b/src/components/views/MainView.js index 16bd44a..ac82a35 100644 --- a/src/components/views/MainView.js +++ b/src/components/views/MainView.js @@ -151,6 +151,63 @@ export class MainView extends LitElement { } } + /* ── Whisper download progress ── */ + + .whisper-progress-container { + margin-top: 8px; + padding: 8px 10px; + background: var(--bg-elevated, rgba(255, 255, 255, 0.05)); + border-radius: var(--radius-sm, 6px); + border: 1px solid var(--border, rgba(255, 255, 255, 0.1)); + } + + .whisper-progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; + font-size: 11px; + color: var(--text-secondary, #999); + } + + .whisper-progress-file { + font-family: var(--font-mono, monospace); + font-size: 10px; + color: var(--text-secondary, #999); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; + } + + .whisper-progress-pct { + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--accent, #6cb4ee); + } + + .whisper-progress-track { + height: 4px; + background: var(--border, rgba(255, 255, 255, 0.1)); + border-radius: 2px; + overflow: hidden; + } + + .whisper-progress-bar { + height: 100%; + background: var(--accent, #6cb4ee); + border-radius: 2px; + transition: width 0.3s ease; + min-width: 0; + } + + .whisper-progress-size { + margin-top: 4px; + font-size: 10px; + color: var(--text-tertiary, #666); + text-align: right; + } + /* ── Start button ── */ .start-button { @@ -422,6 +479,7 @@ export class MainView extends LitElement { onProfileChange: { type: Function }, isInitializing: { type: Boolean }, whisperDownloading: { type: Boolean }, + whisperProgress: { type: Object }, // Internal state _mode: { state: true }, _token: { state: true }, @@ -453,6 +511,7 @@ export class MainView extends LitElement { this.onProfileChange = () => {}; this.isInitializing = false; this.whisperDownloading = false; + this.whisperProgress = null; this._mode = "byok"; this._token = ""; @@ -893,6 +952,40 @@ export class MainView extends LitElement { this.requestUpdate(); } + _formatBytes(bytes) { + if (!bytes || bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(i > 1 ? 1 : 0) + " " + units[i]; + } + + _renderWhisperProgress() { + const p = this.whisperProgress; + if (!p) return ""; + + const pct = Math.round(p.progress || 0); + const fileName = p.file ? p.file.split("/").pop() : ""; + + return html` +
+
+ ${fileName || "Preparing..."} + ${pct}% +
+
+
+
+ ${p.total + ? html`
+ ${this._formatBytes(p.loaded)} / ${this._formatBytes(p.total)} +
` + : ""} +
+ `; + } + _handleProfileChange(e) { this.onProfileChange(e.target.value); } @@ -1261,6 +1354,9 @@ export class MainView extends LitElement { : "Downloaded automatically on first use"}
`} + ${this.whisperDownloading && this.whisperProgress + ? this._renderWhisperProgress() + : ""}
${this._renderStartButton()} diff --git a/src/utils/localai.js b/src/utils/localai.js index 5167ceb..7f99244 100644 --- a/src/utils/localai.js +++ b/src/utils/localai.js @@ -263,6 +263,15 @@ function spawnWhisperWorker() { case "status": sendToRenderer("update-status", msg.message); break; + case "progress": + sendToRenderer("whisper-progress", { + file: msg.file, + progress: msg.progress, + loaded: msg.loaded, + total: msg.total, + status: msg.status, + }); + break; } }); diff --git a/src/utils/whisperWorker.js b/src/utils/whisperWorker.js index e5444eb..98aafbe 100644 --- a/src/utils/whisperWorker.js +++ b/src/utils/whisperWorker.js @@ -155,6 +155,40 @@ async function loadModel(modelName, cacheDir, device = "cpu") { { dtype: "q8", device: dev, + progress_callback: (progress) => { + // progress: { status, name?, file?, progress?, loaded?, total? } + if ( + progress.status === "download" || + progress.status === "progress" + ) { + send({ + type: "progress", + file: progress.file || progress.name || "", + progress: progress.progress ?? 0, + loaded: progress.loaded ?? 0, + total: progress.total ?? 0, + status: progress.status, + }); + } else if (progress.status === "done") { + send({ + type: "progress", + file: progress.file || progress.name || "", + progress: 100, + loaded: progress.total ?? 0, + total: progress.total ?? 0, + status: "done", + }); + } else if (progress.status === "initiate") { + send({ + type: "progress", + file: progress.file || progress.name || "", + progress: 0, + loaded: 0, + total: 0, + status: "initiate", + }); + } + }, }, ); -- 2.47.2