Compare commits

..

No commits in common. "2ebde60dcd72332b648a58bf480b0d3e6e3610f8" and "1b7496800605ef5d086256466d925bfe502dcd40" have entirely different histories.

9 changed files with 2100 additions and 3525 deletions

View File

@ -1,15 +1,14 @@
const { FusesPlugin } = require("@electron-forge/plugin-fuses"); const { FusesPlugin } = require('@electron-forge/plugin-fuses');
const { FuseV1Options, FuseVersion } = require("@electron/fuses"); const { FuseV1Options, FuseVersion } = require('@electron/fuses');
module.exports = { module.exports = {
packagerConfig: { packagerConfig: {
asar: { asar: {
unpack: unpack: '**/{onnxruntime-node,onnxruntime-common,@huggingface/transformers,sharp,@img}/**',
"**/{onnxruntime-node,onnxruntime-common,@huggingface/transformers,sharp,@img}/**",
}, },
extraResource: ["./src/assets/SystemAudioDump"], extraResource: ['./src/assets/SystemAudioDump'],
name: "Mastermind", name: 'Mastermind',
icon: "src/assets/logo", icon: 'src/assets/logo',
// use `security find-identity -v -p codesigning` to find your identity // use `security find-identity -v -p codesigning` to find your identity
// for macos signing // for macos signing
// also fuck apple // also fuck apple
@ -28,44 +27,40 @@ module.exports = {
// teamId: 'your team id', // teamId: 'your team id',
// }, // },
}, },
rebuildConfig: { 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: [ makers: [
{ {
name: "@electron-forge/maker-squirrel", name: '@electron-forge/maker-squirrel',
config: { config: {
name: "mastermind", name: 'mastermind',
productName: "Mastermind", productName: 'Mastermind',
shortcutName: "Mastermind", shortcutName: 'Mastermind',
createDesktopShortcut: true, createDesktopShortcut: true,
createStartMenuShortcut: true, createStartMenuShortcut: true,
}, },
}, },
{ {
name: "@electron-forge/maker-dmg", name: '@electron-forge/maker-dmg',
platforms: ["darwin"], platforms: ['darwin'],
}, },
{ {
name: "@reforged/maker-appimage", name: '@reforged/maker-appimage',
platforms: ["linux"], platforms: ['linux'],
config: { config: {
options: { options: {
name: "Mastermind", name: 'Mastermind',
productName: "Mastermind", productName: 'Mastermind',
genericName: "AI Assistant", genericName: 'AI Assistant',
description: "AI assistant for interviews and learning", description: 'AI assistant for interviews and learning',
categories: ["Development", "Education"], categories: ['Development', 'Education'],
icon: "src/assets/logo.png", icon: 'src/assets/logo.png'
}, }
}, },
}, },
], ],
plugins: [ plugins: [
{ {
name: "@electron-forge/plugin-auto-unpack-natives", name: '@electron-forge/plugin-auto-unpack-natives',
config: {}, config: {},
}, },
// Fuses are used to enable/disable various Electron functionality // Fuses are used to enable/disable various Electron functionality

View File

@ -9,8 +9,7 @@
"package": "electron-forge package", "package": "electron-forge package",
"make": "electron-forge make", "make": "electron-forge make",
"publish": "electron-forge publish", "publish": "electron-forge publish",
"lint": "echo \"No linting configured\"", "lint": "echo \"No linting configured\""
"postinstall": "electron-rebuild -f -w onnxruntime-node"
}, },
"keywords": [ "keywords": [
"mastermind", "mastermind",
@ -34,7 +33,6 @@
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^3.7.1",
"@electron-forge/cli": "^7.8.1", "@electron-forge/cli": "^7.8.1",
"@electron-forge/maker-deb": "^7.8.1", "@electron-forge/maker-deb": "^7.8.1",
"@electron-forge/maker-dmg": "^7.8.1", "@electron-forge/maker-dmg": "^7.8.1",

View File

@ -382,7 +382,6 @@ export class CheatingDaddyApp extends LitElement {
_storageLoaded: { state: true }, _storageLoaded: { state: true },
_updateAvailable: { state: true }, _updateAvailable: { state: true },
_whisperDownloading: { state: true }, _whisperDownloading: { state: true },
_whisperProgress: { state: true },
}; };
constructor() { constructor() {
@ -408,7 +407,6 @@ export class CheatingDaddyApp extends LitElement {
this._timerInterval = null; this._timerInterval = null;
this._updateAvailable = false; this._updateAvailable = false;
this._whisperDownloading = false; this._whisperDownloading = false;
this._whisperProgress = null;
this._localVersion = ""; this._localVersion = "";
this._loadFromStorage(); this._loadFromStorage();
@ -487,10 +485,6 @@ export class CheatingDaddyApp extends LitElement {
); );
ipcRenderer.on("whisper-downloading", (_, downloading) => { ipcRenderer.on("whisper-downloading", (_, downloading) => {
this._whisperDownloading = downloading; this._whisperDownloading = downloading;
if (!downloading) this._whisperProgress = null;
});
ipcRenderer.on("whisper-progress", (_, progress) => {
this._whisperProgress = progress;
}); });
} }
} }
@ -506,7 +500,6 @@ export class CheatingDaddyApp extends LitElement {
ipcRenderer.removeAllListeners("click-through-toggled"); ipcRenderer.removeAllListeners("click-through-toggled");
ipcRenderer.removeAllListeners("reconnect-failed"); ipcRenderer.removeAllListeners("reconnect-failed");
ipcRenderer.removeAllListeners("whisper-downloading"); ipcRenderer.removeAllListeners("whisper-downloading");
ipcRenderer.removeAllListeners("whisper-progress");
} }
} }
@ -785,7 +778,6 @@ export class CheatingDaddyApp extends LitElement {
.onStart=${() => this.handleStart()} .onStart=${() => this.handleStart()}
.onExternalLink=${(url) => this.handleExternalLinkClick(url)} .onExternalLink=${(url) => this.handleExternalLinkClick(url)}
.whisperDownloading=${this._whisperDownloading} .whisperDownloading=${this._whisperDownloading}
.whisperProgress=${this._whisperProgress}
></main-view> ></main-view>
`; `;

File diff suppressed because it is too large Load Diff

View File

@ -1,43 +1,11 @@
if (require("electron-squirrel-startup")) { if (require('electron-squirrel-startup')) {
process.exit(0); process.exit(0);
} }
// ── Global crash handlers to prevent silent process termination ── const { app, BrowserWindow, shell, ipcMain } = require('electron');
process.on("uncaughtException", (error) => { const { createWindow, updateGlobalShortcuts } = require('./utils/window');
console.error("[FATAL] Uncaught exception:", error); const { setupGeminiIpcHandlers, stopMacOSAudioCapture, sendToRenderer } = require('./utils/gemini');
try { const storage = require('./storage');
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 }; const geminiSessionRef = { current: null };
let mainWindow = null; let mainWindow = null;
@ -52,9 +20,9 @@ app.whenReady().then(async () => {
storage.initializeStorage(); storage.initializeStorage();
// Trigger screen recording permission prompt on macOS if not already granted // Trigger screen recording permission prompt on macOS if not already granted
if (process.platform === "darwin") { if (process.platform === 'darwin') {
const { desktopCapturer } = require("electron"); const { desktopCapturer } = require('electron');
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {}); desktopCapturer.getSources({ types: ['screen'] }).catch(() => {});
} }
createMainWindow(); createMainWindow();
@ -63,18 +31,18 @@ app.whenReady().then(async () => {
setupGeneralIpcHandlers(); setupGeneralIpcHandlers();
}); });
app.on("window-all-closed", () => { app.on('window-all-closed', () => {
stopMacOSAudioCapture(); stopMacOSAudioCapture();
if (process.platform !== "darwin") { if (process.platform !== 'darwin') {
app.quit(); app.quit();
} }
}); });
app.on("before-quit", () => { app.on('before-quit', () => {
stopMacOSAudioCapture(); stopMacOSAudioCapture();
}); });
app.on("activate", () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow(); createMainWindow();
} }
@ -82,255 +50,250 @@ app.on("activate", () => {
function setupStorageIpcHandlers() { function setupStorageIpcHandlers() {
// ============ CONFIG ============ // ============ CONFIG ============
ipcMain.handle("storage:get-config", async () => { ipcMain.handle('storage:get-config', async () => {
try { try {
return { success: true, data: storage.getConfig() }; return { success: true, data: storage.getConfig() };
} catch (error) { } catch (error) {
console.error("Error getting config:", error); console.error('Error getting config:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:set-config", async (event, config) => { ipcMain.handle('storage:set-config', async (event, config) => {
try { try {
storage.setConfig(config); storage.setConfig(config);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error setting config:", error); console.error('Error setting config:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:update-config", async (event, key, value) => { ipcMain.handle('storage:update-config', async (event, key, value) => {
try { try {
storage.updateConfig(key, value); storage.updateConfig(key, value);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error updating config:", error); console.error('Error updating config:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ CREDENTIALS ============ // ============ CREDENTIALS ============
ipcMain.handle("storage:get-credentials", async () => { ipcMain.handle('storage:get-credentials', async () => {
try { try {
return { success: true, data: storage.getCredentials() }; return { success: true, data: storage.getCredentials() };
} catch (error) { } catch (error) {
console.error("Error getting credentials:", error); console.error('Error getting credentials:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:set-credentials", async (event, credentials) => { ipcMain.handle('storage:set-credentials', async (event, credentials) => {
try { try {
storage.setCredentials(credentials); storage.setCredentials(credentials);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error setting credentials:", error); console.error('Error setting credentials:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:get-api-key", async () => { ipcMain.handle('storage:get-api-key', async () => {
try { try {
return { success: true, data: storage.getApiKey() }; return { success: true, data: storage.getApiKey() };
} catch (error) { } catch (error) {
console.error("Error getting API key:", error); console.error('Error getting API key:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:set-api-key", async (event, apiKey) => { ipcMain.handle('storage:set-api-key', async (event, apiKey) => {
try { try {
storage.setApiKey(apiKey); storage.setApiKey(apiKey);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error setting API key:", error); console.error('Error setting API key:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:get-groq-api-key", async () => { ipcMain.handle('storage:get-groq-api-key', async () => {
try { try {
return { success: true, data: storage.getGroqApiKey() }; return { success: true, data: storage.getGroqApiKey() };
} catch (error) { } catch (error) {
console.error("Error getting Groq API key:", error); console.error('Error getting Groq API key:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:set-groq-api-key", async (event, groqApiKey) => { ipcMain.handle('storage:set-groq-api-key', async (event, groqApiKey) => {
try { try {
storage.setGroqApiKey(groqApiKey); storage.setGroqApiKey(groqApiKey);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error setting Groq API key:", error); console.error('Error setting Groq API key:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ PREFERENCES ============ // ============ PREFERENCES ============
ipcMain.handle("storage:get-preferences", async () => { ipcMain.handle('storage:get-preferences', async () => {
try { try {
return { success: true, data: storage.getPreferences() }; return { success: true, data: storage.getPreferences() };
} catch (error) { } catch (error) {
console.error("Error getting preferences:", error); console.error('Error getting preferences:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:set-preferences", async (event, preferences) => { ipcMain.handle('storage:set-preferences', async (event, preferences) => {
try { try {
storage.setPreferences(preferences); storage.setPreferences(preferences);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error setting preferences:", error); console.error('Error setting preferences:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:update-preference", async (event, key, value) => { ipcMain.handle('storage:update-preference', async (event, key, value) => {
try { try {
storage.updatePreference(key, value); storage.updatePreference(key, value);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error updating preference:", error); console.error('Error updating preference:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ KEYBINDS ============ // ============ KEYBINDS ============
ipcMain.handle("storage:get-keybinds", async () => { ipcMain.handle('storage:get-keybinds', async () => {
try { try {
return { success: true, data: storage.getKeybinds() }; return { success: true, data: storage.getKeybinds() };
} catch (error) { } catch (error) {
console.error("Error getting keybinds:", error); console.error('Error getting keybinds:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:set-keybinds", async (event, keybinds) => { ipcMain.handle('storage:set-keybinds', async (event, keybinds) => {
try { try {
storage.setKeybinds(keybinds); storage.setKeybinds(keybinds);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error setting keybinds:", error); console.error('Error setting keybinds:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ HISTORY ============ // ============ HISTORY ============
ipcMain.handle("storage:get-all-sessions", async () => { ipcMain.handle('storage:get-all-sessions', async () => {
try { try {
return { success: true, data: storage.getAllSessions() }; return { success: true, data: storage.getAllSessions() };
} catch (error) { } catch (error) {
console.error("Error getting sessions:", error); console.error('Error getting sessions:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:get-session", async (event, sessionId) => { ipcMain.handle('storage:get-session', async (event, sessionId) => {
try { try {
return { success: true, data: storage.getSession(sessionId) }; return { success: true, data: storage.getSession(sessionId) };
} catch (error) { } catch (error) {
console.error("Error getting session:", error); console.error('Error getting session:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:save-session", async (event, sessionId, data) => { ipcMain.handle('storage:save-session', async (event, sessionId, data) => {
try { try {
storage.saveSession(sessionId, data); storage.saveSession(sessionId, data);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error saving session:", error); console.error('Error saving session:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:delete-session", async (event, sessionId) => { ipcMain.handle('storage:delete-session', async (event, sessionId) => {
try { try {
storage.deleteSession(sessionId); storage.deleteSession(sessionId);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error deleting session:", error); console.error('Error deleting session:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("storage:delete-all-sessions", async () => { ipcMain.handle('storage:delete-all-sessions', async () => {
try { try {
storage.deleteAllSessions(); storage.deleteAllSessions();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error deleting all sessions:", error); console.error('Error deleting all sessions:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ LIMITS ============ // ============ LIMITS ============
ipcMain.handle("storage:get-today-limits", async () => { ipcMain.handle('storage:get-today-limits', async () => {
try { try {
return { success: true, data: storage.getTodayLimits() }; return { success: true, data: storage.getTodayLimits() };
} catch (error) { } catch (error) {
console.error("Error getting today limits:", error); console.error('Error getting today limits:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ CLEAR ALL ============ // ============ CLEAR ALL ============
ipcMain.handle("storage:clear-all", async () => { ipcMain.handle('storage:clear-all', async () => {
try { try {
storage.clearAllData(); storage.clearAllData();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error clearing all data:", error); console.error('Error clearing all data:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
} }
function setupGeneralIpcHandlers() { function setupGeneralIpcHandlers() {
ipcMain.handle("get-app-version", async () => { ipcMain.handle('get-app-version', async () => {
return app.getVersion(); return app.getVersion();
}); });
ipcMain.handle("quit-application", async (event) => { ipcMain.handle('quit-application', async event => {
try { try {
stopMacOSAudioCapture(); stopMacOSAudioCapture();
app.quit(); app.quit();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error quitting application:", error); console.error('Error quitting application:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("open-external", async (event, url) => { ipcMain.handle('open-external', async (event, url) => {
try { try {
await shell.openExternal(url); await shell.openExternal(url);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error opening external URL:", error); console.error('Error opening external URL:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.on("update-keybinds", (event, newKeybinds) => { ipcMain.on('update-keybinds', (event, newKeybinds) => {
if (mainWindow) { if (mainWindow) {
// Also save to storage // Also save to storage
storage.setKeybinds(newKeybinds); storage.setKeybinds(newKeybinds);
updateGlobalShortcuts( updateGlobalShortcuts(newKeybinds, mainWindow, sendToRenderer, geminiSessionRef);
newKeybinds,
mainWindow,
sendToRenderer,
geminiSessionRef,
);
} }
}); });
// Debug logging from renderer // Debug logging from renderer
ipcMain.on("log-message", (event, msg) => { ipcMain.on('log-message', (event, msg) => {
console.log(msg); console.log(msg);
}); });
} }

View File

@ -1,6 +1,6 @@
const fs = require("fs"); const fs = require('fs');
const path = require("path"); const path = require('path');
const os = require("os"); const os = require('os');
const CONFIG_VERSION = 1; const CONFIG_VERSION = 1;
@ -8,39 +8,38 @@ const CONFIG_VERSION = 1;
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
configVersion: CONFIG_VERSION, configVersion: CONFIG_VERSION,
onboarded: false, onboarded: false,
layout: "normal", layout: 'normal'
}; };
const DEFAULT_CREDENTIALS = { const DEFAULT_CREDENTIALS = {
apiKey: "", apiKey: '',
groqApiKey: "", groqApiKey: '',
openaiCompatibleApiKey: "", openaiCompatibleApiKey: '',
openaiCompatibleBaseUrl: "", openaiCompatibleBaseUrl: '',
openaiCompatibleModel: "", openaiCompatibleModel: ''
}; };
const DEFAULT_PREFERENCES = { const DEFAULT_PREFERENCES = {
customPrompt: "", customPrompt: '',
selectedProfile: "interview", selectedProfile: 'interview',
selectedLanguage: "en-US", selectedLanguage: 'en-US',
selectedScreenshotInterval: "5", selectedScreenshotInterval: '5',
selectedImageQuality: "medium", selectedImageQuality: 'medium',
advancedMode: false, advancedMode: false,
audioMode: "speaker_only", audioMode: 'speaker_only',
fontSize: "medium", fontSize: 'medium',
backgroundTransparency: 0.8, backgroundTransparency: 0.8,
googleSearchEnabled: false, googleSearchEnabled: false,
responseProvider: "gemini", responseProvider: 'gemini',
ollamaHost: "http://127.0.0.1:11434", ollamaHost: 'http://127.0.0.1:11434',
ollamaModel: "llama3.1", ollamaModel: 'llama3.1',
whisperModel: "Xenova/whisper-small", whisperModel: 'Xenova/whisper-small',
whisperDevice: "", // '' = auto-detect, 'cpu' = native, 'wasm' = compatible
}; };
const DEFAULT_KEYBINDS = null; // null means use system defaults const DEFAULT_KEYBINDS = null; // null means use system defaults
const DEFAULT_LIMITS = { 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 // Get the config directory path based on OS
@ -48,22 +47,12 @@ function getConfigDir() {
const platform = os.platform(); const platform = os.platform();
let configDir; let configDir;
if (platform === "win32") { if (platform === 'win32') {
configDir = path.join( configDir = path.join(os.homedir(), 'AppData', 'Roaming', 'cheating-daddy-config');
os.homedir(), } else if (platform === 'darwin') {
"AppData", configDir = path.join(os.homedir(), 'Library', 'Application Support', 'cheating-daddy-config');
"Roaming",
"cheating-daddy-config",
);
} else if (platform === "darwin") {
configDir = path.join(
os.homedir(),
"Library",
"Application Support",
"cheating-daddy-config",
);
} else { } else {
configDir = path.join(os.homedir(), ".config", "cheating-daddy-config"); configDir = path.join(os.homedir(), '.config', 'cheating-daddy-config');
} }
return configDir; return configDir;
@ -71,34 +60,34 @@ function getConfigDir() {
// File paths // File paths
function getConfigPath() { function getConfigPath() {
return path.join(getConfigDir(), "config.json"); return path.join(getConfigDir(), 'config.json');
} }
function getCredentialsPath() { function getCredentialsPath() {
return path.join(getConfigDir(), "credentials.json"); return path.join(getConfigDir(), 'credentials.json');
} }
function getPreferencesPath() { function getPreferencesPath() {
return path.join(getConfigDir(), "preferences.json"); return path.join(getConfigDir(), 'preferences.json');
} }
function getKeybindsPath() { function getKeybindsPath() {
return path.join(getConfigDir(), "keybinds.json"); return path.join(getConfigDir(), 'keybinds.json');
} }
function getLimitsPath() { function getLimitsPath() {
return path.join(getConfigDir(), "limits.json"); return path.join(getConfigDir(), 'limits.json');
} }
function getHistoryDir() { function getHistoryDir() {
return path.join(getConfigDir(), "history"); return path.join(getConfigDir(), 'history');
} }
// Helper to read JSON file safely // Helper to read JSON file safely
function readJsonFile(filePath, defaultValue) { function readJsonFile(filePath, defaultValue) {
try { try {
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath, "utf8"); const data = fs.readFileSync(filePath, 'utf8');
return JSON.parse(data); return JSON.parse(data);
} }
} catch (error) { } catch (error) {
@ -114,7 +103,7 @@ function writeJsonFile(filePath, data) {
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
} }
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8"); fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
return true; return true;
} catch (error) { } catch (error) {
console.error(`Error writing ${filePath}:`, error.message); console.error(`Error writing ${filePath}:`, error.message);
@ -130,7 +119,7 @@ function needsReset() {
} }
try { try {
const config = JSON.parse(fs.readFileSync(configPath, "utf8")); const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return !config.configVersion || config.configVersion !== CONFIG_VERSION; return !config.configVersion || config.configVersion !== CONFIG_VERSION;
} catch { } catch {
return true; return true;
@ -141,7 +130,7 @@ function needsReset() {
function resetConfigDir() { function resetConfigDir() {
const configDir = getConfigDir(); const configDir = getConfigDir();
console.log("Resetting config directory..."); console.log('Resetting config directory...');
// Remove existing directory if it exists // Remove existing directory if it exists
if (fs.existsSync(configDir)) { if (fs.existsSync(configDir)) {
@ -157,7 +146,7 @@ function resetConfigDir() {
writeJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS); writeJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS);
writeJsonFile(getPreferencesPath(), DEFAULT_PREFERENCES); 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 // Initialize storage - call this on app startup
@ -204,7 +193,7 @@ function setCredentials(credentials) {
} }
function getApiKey() { function getApiKey() {
return getCredentials().apiKey || ""; return getCredentials().apiKey || '';
} }
function setApiKey(apiKey) { function setApiKey(apiKey) {
@ -212,7 +201,7 @@ function setApiKey(apiKey) {
} }
function getGroqApiKey() { function getGroqApiKey() {
return getCredentials().groqApiKey || ""; return getCredentials().groqApiKey || '';
} }
function setGroqApiKey(groqApiKey) { function setGroqApiKey(groqApiKey) {
@ -222,9 +211,9 @@ function setGroqApiKey(groqApiKey) {
function getOpenAICompatibleConfig() { function getOpenAICompatibleConfig() {
const creds = getCredentials(); const creds = getCredentials();
return { return {
apiKey: creds.openaiCompatibleApiKey || "", apiKey: creds.openaiCompatibleApiKey || '',
baseUrl: creds.openaiCompatibleBaseUrl || "", baseUrl: creds.openaiCompatibleBaseUrl || '',
model: creds.openaiCompatibleModel || "", model: creds.openaiCompatibleModel || ''
}; };
} }
@ -232,7 +221,7 @@ function setOpenAICompatibleConfig(apiKey, baseUrl, model) {
return setCredentials({ return setCredentials({
openaiCompatibleApiKey: apiKey, openaiCompatibleApiKey: apiKey,
openaiCompatibleBaseUrl: baseUrl, openaiCompatibleBaseUrl: baseUrl,
openaiCompatibleModel: model, openaiCompatibleModel: model
}); });
} }
@ -277,7 +266,7 @@ function setLimits(limits) {
function getTodayDateString() { function getTodayDateString() {
const now = new Date(); const now = new Date();
return now.toISOString().split("T")[0]; // YYYY-MM-DD return now.toISOString().split('T')[0]; // YYYY-MM-DD
} }
function getTodayLimits() { function getTodayLimits() {
@ -285,21 +274,21 @@ function getTodayLimits() {
const today = getTodayDateString(); const today = getTodayDateString();
// Find today's entry // Find today's entry
const todayEntry = limits.data.find((entry) => entry.date === today); const todayEntry = limits.data.find(entry => entry.date === today);
if (todayEntry) { if (todayEntry) {
// ensure new fields exist // ensure new fields exist
if (!todayEntry.groq) { if(!todayEntry.groq) {
todayEntry.groq = { todayEntry.groq = {
"qwen3-32b": { chars: 0, limit: 1500000 }, 'qwen3-32b': { chars: 0, limit: 1500000 },
"gpt-oss-120b": { chars: 0, limit: 600000 }, 'gpt-oss-120b': { chars: 0, limit: 600000 },
"gpt-oss-20b": { chars: 0, limit: 600000 }, 'gpt-oss-20b': { chars: 0, limit: 600000 },
"kimi-k2-instruct": { chars: 0, limit: 600000 }, 'kimi-k2-instruct': { chars: 0, limit: 600000 }
}; };
} }
if (!todayEntry.gemini) { if(!todayEntry.gemini) {
todayEntry.gemini = { todayEntry.gemini = {
"gemma-3-27b-it": { chars: 0 }, 'gemma-3-27b-it': { chars: 0 }
}; };
} }
setLimits(limits); setLimits(limits);
@ -307,20 +296,20 @@ function getTodayLimits() {
} }
// No entry for today - clean old entries and create new one // No entry for today - clean old entries and create new one
limits.data = limits.data.filter((entry) => entry.date === today); limits.data = limits.data.filter(entry => entry.date === today);
const newEntry = { const newEntry = {
date: today, date: today,
flash: { count: 0 }, flash: { count: 0 },
flashLite: { count: 0 }, flashLite: { count: 0 },
groq: { groq: {
"qwen3-32b": { chars: 0, limit: 1500000 }, 'qwen3-32b': { chars: 0, limit: 1500000 },
"gpt-oss-120b": { chars: 0, limit: 600000 }, 'gpt-oss-120b': { chars: 0, limit: 600000 },
"gpt-oss-20b": { chars: 0, limit: 600000 }, 'gpt-oss-20b': { chars: 0, limit: 600000 },
"kimi-k2-instruct": { chars: 0, limit: 600000 }, 'kimi-k2-instruct': { chars: 0, limit: 600000 }
}, },
gemini: { gemini: {
"gemma-3-27b-it": { chars: 0 }, 'gemma-3-27b-it': { chars: 0 }
}, }
}; };
limits.data.push(newEntry); limits.data.push(newEntry);
setLimits(limits); setLimits(limits);
@ -333,7 +322,7 @@ function incrementLimitCount(model) {
const today = getTodayDateString(); const today = getTodayDateString();
// Find or create today's entry // Find or create today's entry
let todayEntry = limits.data.find((entry) => entry.date === today); let todayEntry = limits.data.find(entry => entry.date === today);
if (!todayEntry) { if (!todayEntry) {
// Clean old entries and create new one // Clean old entries and create new one
@ -341,18 +330,18 @@ function incrementLimitCount(model) {
todayEntry = { todayEntry = {
date: today, date: today,
flash: { count: 0 }, flash: { count: 0 },
flashLite: { count: 0 }, flashLite: { count: 0 }
}; };
limits.data.push(todayEntry); limits.data.push(todayEntry);
} else { } else {
// Clean old entries, keep only today // Clean old entries, keep only today
limits.data = limits.data.filter((entry) => entry.date === today); limits.data = limits.data.filter(entry => entry.date === today);
} }
// Increment the appropriate model count // Increment the appropriate model count
if (model === "gemini-2.5-flash") { if (model === 'gemini-2.5-flash') {
todayEntry.flash.count++; todayEntry.flash.count++;
} else if (model === "gemini-2.5-flash-lite") { } else if (model === 'gemini-2.5-flash-lite') {
todayEntry.flashLite.count++; todayEntry.flashLite.count++;
} }
@ -365,9 +354,9 @@ function incrementCharUsage(provider, model, charCount) {
const limits = getLimits(); const limits = getLimits();
const today = getTodayDateString(); const today = getTodayDateString();
const todayEntry = limits.data.find((entry) => entry.date === today); const todayEntry = limits.data.find(entry => entry.date === today);
if (todayEntry[provider] && todayEntry[provider][model]) { if(todayEntry[provider] && todayEntry[provider][model]) {
todayEntry[provider][model].chars += charCount; todayEntry[provider][model].chars += charCount;
setLimits(limits); setLimits(limits);
} }
@ -381,29 +370,29 @@ function getAvailableModel() {
// RPD limits: flash = 20, flash-lite = 20 // RPD limits: flash = 20, flash-lite = 20
// After both exhausted, fall back to flash (for paid API users) // After both exhausted, fall back to flash (for paid API users)
if (todayLimits.flash.count < 20) { if (todayLimits.flash.count < 20) {
return "gemini-2.5-flash"; return 'gemini-2.5-flash';
} else if (todayLimits.flashLite.count < 20) { } else if (todayLimits.flashLite.count < 20) {
return "gemini-2.5-flash-lite"; 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() { function getModelForToday() {
const todayEntry = getTodayLimits(); const todayEntry = getTodayLimits();
const groq = todayEntry.groq; const groq = todayEntry.groq;
if (groq["qwen3-32b"].chars < groq["qwen3-32b"].limit) { if (groq['qwen3-32b'].chars < groq['qwen3-32b'].limit) {
return "qwen/qwen3-32b"; return 'qwen/qwen3-32b';
} }
if (groq["gpt-oss-120b"].chars < groq["gpt-oss-120b"].limit) { if (groq['gpt-oss-120b'].chars < groq['gpt-oss-120b'].limit) {
return "openai/gpt-oss-120b"; return 'openai/gpt-oss-120b';
} }
if (groq["gpt-oss-20b"].chars < groq["gpt-oss-20b"].limit) { if (groq['gpt-oss-20b'].chars < groq['gpt-oss-20b'].limit) {
return "openai/gpt-oss-20b"; return 'openai/gpt-oss-20b';
} }
if (groq["kimi-k2-instruct"].chars < groq["kimi-k2-instruct"].limit) { if (groq['kimi-k2-instruct'].chars < groq['kimi-k2-instruct'].limit) {
return "moonshotai/kimi-k2-instruct"; return 'moonshotai/kimi-k2-instruct';
} }
// All limits exhausted // All limits exhausted
@ -430,12 +419,8 @@ function saveSession(sessionId, data) {
profile: data.profile || existingSession?.profile || null, profile: data.profile || existingSession?.profile || null,
customPrompt: data.customPrompt || existingSession?.customPrompt || null, customPrompt: data.customPrompt || existingSession?.customPrompt || null,
// Conversation data // Conversation data
conversationHistory: conversationHistory: data.conversationHistory || existingSession?.conversationHistory || [],
data.conversationHistory || existingSession?.conversationHistory || [], screenAnalysisHistory: data.screenAnalysisHistory || existingSession?.screenAnalysisHistory || []
screenAnalysisHistory:
data.screenAnalysisHistory ||
existingSession?.screenAnalysisHistory ||
[],
}; };
return writeJsonFile(sessionPath, sessionData); return writeJsonFile(sessionPath, sessionData);
} }
@ -452,19 +437,17 @@ function getAllSessions() {
return []; return [];
} }
const files = fs const files = fs.readdirSync(historyDir)
.readdirSync(historyDir) .filter(f => f.endsWith('.json'))
.filter((f) => f.endsWith(".json"))
.sort((a, b) => { .sort((a, b) => {
// Sort by timestamp descending (newest first) // Sort by timestamp descending (newest first)
const tsA = parseInt(a.replace(".json", "")); const tsA = parseInt(a.replace('.json', ''));
const tsB = parseInt(b.replace(".json", "")); const tsB = parseInt(b.replace('.json', ''));
return tsB - tsA; return tsB - tsA;
}); });
return files return files.map(file => {
.map((file) => { const sessionId = file.replace('.json', '');
const sessionId = file.replace(".json", "");
const data = readJsonFile(path.join(historyDir, file), null); const data = readJsonFile(path.join(historyDir, file), null);
if (data) { if (data) {
return { return {
@ -474,14 +457,13 @@ function getAllSessions() {
messageCount: data.conversationHistory?.length || 0, messageCount: data.conversationHistory?.length || 0,
screenAnalysisCount: data.screenAnalysisHistory?.length || 0, screenAnalysisCount: data.screenAnalysisHistory?.length || 0,
profile: data.profile || null, profile: data.profile || null,
customPrompt: data.customPrompt || null, customPrompt: data.customPrompt || null
}; };
} }
return null; return null;
}) }).filter(Boolean);
.filter(Boolean);
} catch (error) { } catch (error) {
console.error("Error reading sessions:", error.message); console.error('Error reading sessions:', error.message);
return []; return [];
} }
} }
@ -494,7 +476,7 @@ function deleteSession(sessionId) {
return true; return true;
} }
} catch (error) { } catch (error) {
console.error("Error deleting session:", error.message); console.error('Error deleting session:', error.message);
} }
return false; return false;
} }
@ -503,16 +485,14 @@ function deleteAllSessions() {
const historyDir = getHistoryDir(); const historyDir = getHistoryDir();
try { try {
if (fs.existsSync(historyDir)) { if (fs.existsSync(historyDir)) {
const files = fs const files = fs.readdirSync(historyDir).filter(f => f.endsWith('.json'));
.readdirSync(historyDir) files.forEach(file => {
.filter((f) => f.endsWith(".json"));
files.forEach((file) => {
fs.unlinkSync(path.join(historyDir, file)); fs.unlinkSync(path.join(historyDir, file));
}); });
} }
return true; return true;
} catch (error) { } catch (error) {
console.error("Error deleting all sessions:", error.message); console.error('Error deleting all sessions:', error.message);
return false; return false;
} }
} }
@ -570,5 +550,5 @@ module.exports = {
deleteAllSessions, deleteAllSessions,
// Clear all // Clear all
clearAllData, clearAllData
}; };

View File

@ -1,31 +1,17 @@
const { Ollama } = require("ollama"); const { Ollama } = require('ollama');
const { getSystemPrompt } = require("./prompts"); const { getSystemPrompt } = require('./prompts');
const { const { sendToRenderer, initializeNewSession, saveConversationTurn } = require('./gemini');
sendToRenderer,
initializeNewSession,
saveConversationTurn,
} = require("./gemini");
const { fork } = require("child_process");
const path = require("path");
const { getSystemNode } = require("./nodeDetect");
// ── State ── // ── State ──
let ollamaClient = null; let ollamaClient = null;
let ollamaModel = null; let ollamaModel = null;
let whisperWorker = null; let whisperPipeline = null;
let isWhisperLoading = false; let isWhisperLoading = false;
let whisperReady = false;
let localConversationHistory = []; let localConversationHistory = [];
let currentSystemPrompt = null; let currentSystemPrompt = null;
let isLocalActive = false; 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;
// VAD state // VAD state
let isSpeaking = false; let isSpeaking = false;
let speechBuffers = []; let speechBuffers = [];
@ -34,32 +20,13 @@ let speechFrameCount = 0;
// VAD configuration // VAD configuration
const VAD_MODES = { const VAD_MODES = {
NORMAL: { NORMAL: { energyThreshold: 0.01, speechFramesRequired: 3, silenceFramesRequired: 30 },
energyThreshold: 0.01, LOW_BITRATE: { energyThreshold: 0.008, speechFramesRequired: 4, silenceFramesRequired: 35 },
speechFramesRequired: 3, AGGRESSIVE: { energyThreshold: 0.015, speechFramesRequired: 2, silenceFramesRequired: 20 },
silenceFramesRequired: 30, VERY_AGGRESSIVE: { energyThreshold: 0.02, speechFramesRequired: 2, silenceFramesRequired: 15 },
},
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; 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 // Audio resampling buffer
let resampleRemainder = Buffer.alloc(0); let resampleRemainder = Buffer.alloc(0);
@ -80,24 +47,15 @@ function resample24kTo16k(inputBuffer) {
const frac = srcPos - srcIndex; const frac = srcPos - srcIndex;
const s0 = combined.readInt16LE(srcIndex * 2); const s0 = combined.readInt16LE(srcIndex * 2);
const s1 = const s1 = srcIndex + 1 < inputSamples ? combined.readInt16LE((srcIndex + 1) * 2) : s0;
srcIndex + 1 < inputSamples
? combined.readInt16LE((srcIndex + 1) * 2)
: s0;
const interpolated = Math.round(s0 + frac * (s1 - s0)); const interpolated = Math.round(s0 + frac * (s1 - s0));
outputBuffer.writeInt16LE( outputBuffer.writeInt16LE(Math.max(-32768, Math.min(32767, interpolated)), i * 2);
Math.max(-32768, Math.min(32767, interpolated)),
i * 2,
);
} }
// Store remainder for next call // Store remainder for next call
const consumedInputSamples = Math.ceil((outputSamples * 3) / 2); const consumedInputSamples = Math.ceil((outputSamples * 3) / 2);
const remainderStart = consumedInputSamples * 2; const remainderStart = consumedInputSamples * 2;
resampleRemainder = resampleRemainder = remainderStart < combined.length ? combined.slice(remainderStart) : Buffer.alloc(0);
remainderStart < combined.length
? combined.slice(remainderStart)
: Buffer.alloc(0);
return outputBuffer; return outputBuffer;
} }
@ -126,8 +84,8 @@ function processVAD(pcm16kBuffer) {
if (!isSpeaking && speechFrameCount >= vadConfig.speechFramesRequired) { if (!isSpeaking && speechFrameCount >= vadConfig.speechFramesRequired) {
isSpeaking = true; isSpeaking = true;
speechBuffers = []; speechBuffers = [];
console.log("[LocalAI] Speech started (RMS:", rms.toFixed(4), ")"); console.log('[LocalAI] Speech started (RMS:', rms.toFixed(4), ')');
sendToRenderer("update-status", "Listening... (speech detected)"); sendToRenderer('update-status', 'Listening... (speech detected)');
} }
} else { } else {
silenceFrameCount++; silenceFrameCount++;
@ -135,23 +93,13 @@ function processVAD(pcm16kBuffer) {
if (isSpeaking && silenceFrameCount >= vadConfig.silenceFramesRequired) { if (isSpeaking && silenceFrameCount >= vadConfig.silenceFramesRequired) {
isSpeaking = false; isSpeaking = false;
console.log( console.log('[LocalAI] Speech ended, accumulated', speechBuffers.length, 'chunks');
"[LocalAI] Speech ended, accumulated", sendToRenderer('update-status', 'Transcribing...');
speechBuffers.length,
"chunks",
);
sendToRenderer("update-status", "Transcribing...");
// Trigger transcription with accumulated audio // Trigger transcription with accumulated audio
const audioData = Buffer.concat(speechBuffers); const audioData = Buffer.concat(speechBuffers);
speechBuffers = []; speechBuffers = [];
handleSpeechEnd(audioData).catch((err) => { handleSpeechEnd(audioData);
console.error("[LocalAI] handleSpeechEnd crashed:", err);
sendToRenderer(
"update-status",
"Transcription error: " + (err?.message || "unknown"),
);
});
return; return;
} }
} }
@ -159,395 +107,76 @@ function processVAD(pcm16kBuffer) {
// Accumulate audio during speech // Accumulate audio during speech
if (isSpeaking) { if (isSpeaking) {
speechBuffers.push(Buffer.from(pcm16kBuffer)); 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 Worker (isolated child process) ── // ── Whisper Transcription ──
function spawnWhisperWorker() {
if (whisperWorker) return;
const workerPath = path.join(__dirname, "whisperWorker.js");
console.log("[LocalAI] Spawning Whisper worker:", workerPath);
// 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());
});
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;
case "progress":
sendToRenderer("whisper-progress", {
file: msg.file,
progress: msg.progress,
loaded: msg.loaded,
total: msg.total,
status: msg.status,
});
break;
}
});
whisperWorker.on("exit", (code, signal) => {
console.error(
"[LocalAI] Whisper worker exited — code:",
code,
"signal:",
signal,
);
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(
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;
});
}
/**
* 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, device:",
msg.device || "unknown",
")",
);
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;
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();
} catch (_) {
// Already dead
}
whisperWorker = null;
whisperReady = false;
}
}
async function loadWhisperPipeline(modelName) { async function loadWhisperPipeline(modelName) {
if (whisperReady) return true; if (whisperPipeline) return whisperPipeline;
if (isWhisperLoading) return null; if (isWhisperLoading) return null;
isWhisperLoading = true; isWhisperLoading = true;
console.log("[LocalAI] Loading Whisper model via worker:", modelName); console.log('[LocalAI] Loading Whisper model:', modelName);
sendToRenderer("whisper-downloading", true); sendToRenderer('whisper-downloading', true);
sendToRenderer( sendToRenderer('update-status', 'Loading Whisper model (first time may take a while)...');
"update-status",
"Loading Whisper model (first time may take a while)...",
);
spawnWhisperWorker(); try {
// Dynamic import for ESM module
const { app } = require("electron"); const { pipeline, env } = await import('@huggingface/transformers');
const cacheDir = path.join(app.getPath("userData"), "whisper-models"); // Cache models outside the asar archive so ONNX runtime can load them
const { app } = require('electron');
const device = resolveWhisperDevice(); const path = require('path');
console.log("[LocalAI] Whisper device:", device); env.cacheDir = path.join(app.getPath('userData'), 'whisper-models');
whisperPipeline = await pipeline('automatic-speech-recognition', modelName, {
return new Promise((resolve) => { dtype: 'q8',
pendingLoad = { resolve }; device: 'auto',
whisperWorker.send({ type: "load", modelName, cacheDir, device });
}); });
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;
}
}
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;
} }
async function transcribeAudio(pcm16kBuffer) { async function transcribeAudio(pcm16kBuffer) {
if (!whisperReady || !whisperWorker) { if (!whisperPipeline) {
console.error("[LocalAI] Whisper worker not ready"); console.error('[LocalAI] Whisper pipeline not loaded');
return null; 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 { try {
whisperWorker.send({ const float32Audio = pcm16ToFloat32(pcm16kBuffer);
type: "transcribe",
audioBase64, // Whisper expects audio at 16kHz which is what we have
language: resolveWhisperLanguage(), const result = await whisperPipeline(float32Audio, {
sampling_rate: 16000,
language: 'en',
task: 'transcribe',
}); });
} catch (err) {
clearTimeout(timeout); const text = result.text?.trim();
pendingTranscribe = null; console.log('[LocalAI] Transcription:', text);
console.error("[LocalAI] Failed to send to worker:", err); return text;
resolve(null); } catch (error) {
console.error('[LocalAI] Transcription error:', error);
return null;
} }
});
} }
// ── Speech End Handler ── // ── Speech End Handler ──
@ -557,52 +186,35 @@ async function handleSpeechEnd(audioData) {
// Minimum audio length check (~0.5 seconds at 16kHz, 16-bit) // Minimum audio length check (~0.5 seconds at 16kHz, 16-bit)
if (audioData.length < 16000) { if (audioData.length < 16000) {
console.log("[LocalAI] Audio too short, skipping"); console.log('[LocalAI] Audio too short, skipping');
sendToRenderer("update-status", "Listening..."); sendToRenderer('update-status', 'Listening...');
return; return;
} }
console.log("[LocalAI] Processing audio:", audioData.length, "bytes");
try {
const transcription = await transcribeAudio(audioData); const transcription = await transcribeAudio(audioData);
if ( if (!transcription || transcription.trim() === '' || transcription.trim().length < 2) {
!transcription || console.log('[LocalAI] Empty transcription, skipping');
transcription.trim() === "" || sendToRenderer('update-status', 'Listening...');
transcription.trim().length < 2
) {
console.log("[LocalAI] Empty transcription, skipping");
sendToRenderer("update-status", "Listening...");
return; return;
} }
sendToRenderer("update-status", "Generating response..."); sendToRenderer('update-status', 'Generating response...');
await sendToOllama(transcription); await sendToOllama(transcription);
} catch (error) {
console.error("[LocalAI] handleSpeechEnd error:", error);
sendToRenderer(
"update-status",
"Error: " + (error?.message || "transcription failed"),
);
}
} }
// ── Ollama Chat ── // ── Ollama Chat ──
async function sendToOllama(transcription) { async function sendToOllama(transcription) {
if (!ollamaClient || !ollamaModel) { if (!ollamaClient || !ollamaModel) {
console.error("[LocalAI] Ollama not configured"); console.error('[LocalAI] Ollama not configured');
return; return;
} }
console.log( console.log('[LocalAI] Sending to Ollama:', transcription.substring(0, 100) + '...');
"[LocalAI] Sending to Ollama:",
transcription.substring(0, 100) + "...",
);
localConversationHistory.push({ localConversationHistory.push({
role: "user", role: 'user',
content: transcription.trim(), content: transcription.trim(),
}); });
@ -613,10 +225,7 @@ async function sendToOllama(transcription) {
try { try {
const messages = [ const messages = [
{ { role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' },
role: "system",
content: currentSystemPrompt || "You are a helpful assistant.",
},
...localConversationHistory, ...localConversationHistory,
]; ];
@ -626,52 +235,41 @@ async function sendToOllama(transcription) {
stream: true, stream: true,
}); });
let fullText = ""; let fullText = '';
let isFirst = true; let isFirst = true;
for await (const part of response) { for await (const part of response) {
const token = part.message?.content || ""; const token = part.message?.content || '';
if (token) { if (token) {
fullText += token; fullText += token;
sendToRenderer(isFirst ? "new-response" : "update-response", fullText); sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText);
isFirst = false; isFirst = false;
} }
} }
if (fullText.trim()) { if (fullText.trim()) {
localConversationHistory.push({ localConversationHistory.push({
role: "assistant", role: 'assistant',
content: fullText.trim(), content: fullText.trim(),
}); });
saveConversationTurn(transcription, fullText); saveConversationTurn(transcription, fullText);
} }
console.log("[LocalAI] Ollama response completed"); console.log('[LocalAI] Ollama response completed');
sendToRenderer("update-status", "Listening..."); sendToRenderer('update-status', 'Listening...');
} catch (error) { } catch (error) {
console.error("[LocalAI] Ollama error:", error); console.error('[LocalAI] Ollama error:', error);
sendToRenderer("update-status", "Ollama error: " + error.message); sendToRenderer('update-status', 'Ollama error: ' + error.message);
} }
} }
// ── Public API ── // ── Public API ──
async function initializeLocalSession( async function initializeLocalSession(ollamaHost, model, whisperModel, profile, customPrompt) {
ollamaHost, console.log('[LocalAI] Initializing local session:', { ollamaHost, model, whisperModel, profile });
model,
whisperModel,
profile,
customPrompt,
) {
console.log("[LocalAI] Initializing local session:", {
ollamaHost,
model,
whisperModel,
profile,
});
sendToRenderer("session-initializing", true); sendToRenderer('session-initializing', true);
try { try {
// Setup system prompt // Setup system prompt
@ -684,26 +282,18 @@ async function initializeLocalSession(
// Test Ollama connection // Test Ollama connection
try { try {
await ollamaClient.list(); await ollamaClient.list();
console.log("[LocalAI] Ollama connection verified"); console.log('[LocalAI] Ollama connection verified');
} catch (error) { } catch (error) {
console.error( console.error('[LocalAI] Cannot connect to Ollama at', ollamaHost, ':', error.message);
"[LocalAI] Cannot connect to Ollama at", sendToRenderer('session-initializing', false);
ollamaHost, sendToRenderer('update-status', 'Cannot connect to Ollama at ' + ollamaHost);
":",
error.message,
);
sendToRenderer("session-initializing", false);
sendToRenderer(
"update-status",
"Cannot connect to Ollama at " + ollamaHost,
);
return false; return false;
} }
// Load Whisper model // Load Whisper model
const pipeline = await loadWhisperPipeline(whisperModel); const pipeline = await loadWhisperPipeline(whisperModel);
if (!pipeline) { if (!pipeline) {
sendToRenderer("session-initializing", false); sendToRenderer('session-initializing', false);
return false; return false;
} }
@ -719,15 +309,15 @@ async function initializeLocalSession(
initializeNewSession(profile, customPrompt); initializeNewSession(profile, customPrompt);
isLocalActive = true; isLocalActive = true;
sendToRenderer("session-initializing", false); sendToRenderer('session-initializing', false);
sendToRenderer("update-status", "Local AI ready - Listening..."); sendToRenderer('update-status', 'Local AI ready - Listening...');
console.log("[LocalAI] Session initialized successfully"); console.log('[LocalAI] Session initialized successfully');
return true; return true;
} catch (error) { } catch (error) {
console.error("[LocalAI] Initialization error:", error); console.error('[LocalAI] Initialization error:', error);
sendToRenderer("session-initializing", false); sendToRenderer('session-initializing', false);
sendToRenderer("update-status", "Local AI error: " + error.message); sendToRenderer('update-status', 'Local AI error: ' + error.message);
return false; return false;
} }
} }
@ -743,7 +333,7 @@ function processLocalAudio(monoChunk24k) {
} }
function closeLocalSession() { function closeLocalSession() {
console.log("[LocalAI] Closing local session"); console.log('[LocalAI] Closing local session');
isLocalActive = false; isLocalActive = false;
isSpeaking = false; isSpeaking = false;
speechBuffers = []; speechBuffers = [];
@ -754,8 +344,7 @@ function closeLocalSession() {
ollamaClient = null; ollamaClient = null;
ollamaModel = null; ollamaModel = null;
currentSystemPrompt = null; currentSystemPrompt = null;
// Note: whisperWorker is kept alive to avoid reloading model on next session // Note: whisperPipeline is kept loaded to avoid reloading on next session
// To fully clean up, call killWhisperWorker()
} }
function isLocalSessionActive() { function isLocalSessionActive() {
@ -766,7 +355,7 @@ function isLocalSessionActive() {
async function sendLocalText(text) { async function sendLocalText(text) {
if (!isLocalActive || !ollamaClient) { if (!isLocalActive || !ollamaClient) {
return { success: false, error: "No active local session" }; return { success: false, error: 'No active local session' };
} }
try { try {
@ -779,31 +368,28 @@ async function sendLocalText(text) {
async function sendLocalImage(base64Data, prompt) { async function sendLocalImage(base64Data, prompt) {
if (!isLocalActive || !ollamaClient) { if (!isLocalActive || !ollamaClient) {
return { success: false, error: "No active local session" }; return { success: false, error: 'No active local session' };
} }
try { try {
console.log("[LocalAI] Sending image to Ollama"); console.log('[LocalAI] Sending image to Ollama');
sendToRenderer("update-status", "Analyzing image..."); sendToRenderer('update-status', 'Analyzing image...');
const userMessage = { const userMessage = {
role: "user", role: 'user',
content: prompt, content: prompt,
images: [base64Data], images: [base64Data],
}; };
// Store text-only version in history // Store text-only version in history
localConversationHistory.push({ role: "user", content: prompt }); localConversationHistory.push({ role: 'user', content: prompt });
if (localConversationHistory.length > 20) { if (localConversationHistory.length > 20) {
localConversationHistory = localConversationHistory.slice(-20); localConversationHistory = localConversationHistory.slice(-20);
} }
const messages = [ const messages = [
{ { role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' },
role: "system",
content: currentSystemPrompt || "You are a helpful assistant.",
},
...localConversationHistory.slice(0, -1), ...localConversationHistory.slice(0, -1),
userMessage, userMessage,
]; ];
@ -814,32 +400,29 @@ async function sendLocalImage(base64Data, prompt) {
stream: true, stream: true,
}); });
let fullText = ""; let fullText = '';
let isFirst = true; let isFirst = true;
for await (const part of response) { for await (const part of response) {
const token = part.message?.content || ""; const token = part.message?.content || '';
if (token) { if (token) {
fullText += token; fullText += token;
sendToRenderer(isFirst ? "new-response" : "update-response", fullText); sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText);
isFirst = false; isFirst = false;
} }
} }
if (fullText.trim()) { if (fullText.trim()) {
localConversationHistory.push({ localConversationHistory.push({ role: 'assistant', content: fullText.trim() });
role: "assistant",
content: fullText.trim(),
});
saveConversationTurn(prompt, fullText); saveConversationTurn(prompt, fullText);
} }
console.log("[LocalAI] Image response completed"); console.log('[LocalAI] Image response completed');
sendToRenderer("update-status", "Listening..."); sendToRenderer('update-status', 'Listening...');
return { success: true, text: fullText, model: ollamaModel }; return { success: true, text: fullText, model: ollamaModel };
} catch (error) { } catch (error) {
console.error("[LocalAI] Image error:", error); console.error('[LocalAI] Image error:', error);
sendToRenderer("update-status", "Ollama error: " + error.message); sendToRenderer('update-status', 'Ollama error: ' + error.message);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }

View File

@ -1,177 +0,0 @@
/**
* 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 };

View File

@ -1,332 +0,0 @@
/**
* 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, device? }
* { type: 'transcribe', audioBase64, language? } // PCM 16-bit 16kHz as base64
* { type: 'shutdown' }
*
* worker parent:
* { 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) {
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;
}
/**
* 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, device: activeDevice });
return;
}
try {
send({
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;
// 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,
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",
});
}
},
},
);
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, language) {
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;
}
// Build pipeline options with the requested language
const pipelineOpts = {
sampling_rate: 16000,
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 });
} 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, msg.device).catch((err) => {
send({ type: "load-result", success: false, error: err.message });
});
break;
case "transcribe":
transcribe(msg.audioBase64, msg.language).catch((err) => {
send({ type: "transcribe-result", success: false, error: err.message });
});
break;
case "shutdown":
// 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;
}
});
// Signal readiness to parent
send({ type: "ready" });