/* * ============================================================================= * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform * ----------------------------------------------------------------------------- * File: desktop/main.js * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Website: https://www.spboucher.ai * Demo: https://www.vquant.ai * License: MIT (see LICENSE) * * Copyright © 2026 Simon-Pierre Boucher. All rights reserved. * ============================================================================= */ const { app, BrowserWindow, Menu, ipcMain, dialog, shell, nativeTheme } = require('electron'); const path = require('path'); const { spawn } = require('child_process'); const fs = require('fs'); const Store = require('electron-store'); const store = new Store({ name: 'vquant-config', encryptionKey: 'vquant-desktop-secure-key-2024', schema: { apiKeys: { type: 'object', properties: { ANTHROPIC_API_KEY: { type: 'string', default: '' }, FMP_API_KEY: { type: 'string', default: '' }, FIRECRAWL_API_KEY: { type: 'string', default: '' }, TAVILY_API_KEY: { type: 'string', default: '' }, EXA_API_KEY: { type: 'string', default: '' }, SERPAPI_API_KEY: { type: 'string', default: '' }, ELEVENLABS_API_KEY: { type: 'string', default: '' }, }, default: {}, }, setupComplete: { type: 'boolean', default: false }, windowBounds: { type: 'object', properties: { width: { type: 'number', default: 1400 }, height: { type: 'number', default: 900 }, x: { type: 'number' }, y: { type: 'number' }, }, default: { width: 1400, height: 900 }, }, }, }); let mainWindow = null; let setupWindow = null; let serverProcess = null; let serverReady = false; const SERVER_PORT = 15173; function isDev() { return !app.isPackaged; } function getProjectRoot() { if (isDev()) { return path.join(__dirname, '..'); } return process.resourcesPath; } function loadEnvFile() { const envPath = path.join(getProjectRoot(), '.env'); const envKeys = {}; try { const content = fs.readFileSync(envPath, 'utf-8'); for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eqIdx = trimmed.indexOf('='); if (eqIdx === -1) continue; const key = trimmed.slice(0, eqIdx).trim(); const val = trimmed.slice(eqIdx + 1).trim(); if (val && !val.includes('your-') && !val.includes('here')) { envKeys[key] = val; } } } catch (_) {} return envKeys; } function getEffectiveApiKeys() { const storeKeys = store.get('apiKeys', {}); const envKeys = loadEnvFile(); return { ANTHROPIC_API_KEY: storeKeys.ANTHROPIC_API_KEY || envKeys.ANTHROPIC_API_KEY || '', FMP_API_KEY: storeKeys.FMP_API_KEY || envKeys.FMP_API_KEY || '', FIRECRAWL_API_KEY: storeKeys.FIRECRAWL_API_KEY || envKeys.FIRECRAWL_API_KEY || '', TAVILY_API_KEY: storeKeys.TAVILY_API_KEY || envKeys.TAVILY_API_KEY || '', EXA_API_KEY: storeKeys.EXA_API_KEY || envKeys.EXA_API_KEY || '', SERPAPI_API_KEY: storeKeys.SERPAPI_API_KEY || envKeys.SERPAPI_API_KEY || '', ELEVENLABS_API_KEY: storeKeys.ELEVENLABS_API_KEY || envKeys.ELEVENLABS_API_KEY || '', }; } // ─── Server Management ─────────────────────────────────── function findNode() { const candidates = [ path.join(process.env.HOME || '', 'local', 'node-v22.15.0-darwin-arm64', 'bin', 'node'), '/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node', ]; if (process.execPath && !process.execPath.includes('Electron')) { return process.execPath; } for (const p of candidates) { if (fs.existsSync(p)) return p; } return 'node'; } function findPython() { const root = getProjectRoot(); const candidates = [ path.join(root, '.venv', 'bin', 'python'), '/opt/homebrew/bin/python3', '/usr/local/bin/python3', '/usr/bin/python3', ]; for (const p of candidates) { if (fs.existsSync(p)) return p; } return null; } function initDatabase(dbPath) { return new Promise((resolve) => { const sql = ` CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, display_name TEXT NOT NULL, token TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS crawled_pages (id TEXT PRIMARY KEY, url TEXT NOT NULL UNIQUE, title TEXT NOT NULL, content TEXT NOT NULL, snippet TEXT, favicon TEXT, crawled_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS embeddings (id TEXT PRIMARY KEY, page_id TEXT NOT NULL UNIQUE, embedding TEXT, token_count INTEGER NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (page_id) REFERENCES crawled_pages(id) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, sources TEXT, created_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS shared_reports (id TEXT PRIMARY KEY, share_id TEXT NOT NULL UNIQUE, question TEXT NOT NULL, answer TEXT NOT NULL, tool_results TEXT, sources TEXT, custom_python_figures TEXT, created_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS conversation_sessions (id TEXT PRIMARY KEY, session_id TEXT NOT NULL UNIQUE, user_id TEXT, title TEXT NOT NULL, messages TEXT NOT NULL, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, total_cost REAL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS active_users (id TEXT PRIMARY KEY, session_id TEXT NOT NULL UNIQUE, user_id TEXT, status TEXT NOT NULL DEFAULT 'idle', current_query TEXT, last_heartbeat INTEGER NOT NULL, user_agent TEXT, ip_address TEXT, created_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS analytics_metrics (id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL, total_requests INTEGER DEFAULT 0, successful_requests INTEGER DEFAULT 0, failed_requests INTEGER DEFAULT 0, active_users INTEGER DEFAULT 0, unique_visitors INTEGER DEFAULT 0, average_response_time REAL DEFAULT 0, peak_response_time REAL DEFAULT 0, tokens_generated INTEGER DEFAULT 0, estimated_cost REAL DEFAULT 0, tool_calls_count INTEGER DEFAULT 0, python_executions INTEGER DEFAULT 0, search_queries INTEGER DEFAULT 0, error_count INTEGER DEFAULT 0, error_rate REAL DEFAULT 0, period_type TEXT NOT NULL DEFAULT 'minute'); CREATE TABLE IF NOT EXISTS request_logs (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, user_id TEXT, query TEXT NOT NULL, response_time REAL, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, total_cost REAL DEFAULT 0, tools_called TEXT, status TEXT NOT NULL, error_message TEXT, timestamp INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id); CREATE INDEX IF NOT EXISTS idx_conversation_sessions_user_id ON conversation_sessions(user_id); CREATE INDEX IF NOT EXISTS idx_conversation_sessions_session_id ON conversation_sessions(session_id); CREATE INDEX IF NOT EXISTS idx_active_users_session ON active_users(session_id); CREATE INDEX IF NOT EXISTS idx_active_users_heartbeat ON active_users(last_heartbeat); CREATE INDEX IF NOT EXISTS idx_analytics_timestamp ON analytics_metrics(timestamp); CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp ON request_logs(timestamp); PRAGMA journal_mode=WAL; `; const child = spawn('sqlite3', [dbPath], { stdio: ['pipe', 'pipe', 'pipe'], }); child.stdin.write(sql); child.stdin.end(); child.stdout.on('data', (d) => console.log('[DB Init]', d.toString().trim())); child.stderr.on('data', (d) => console.error('[DB Init Err]', d.toString().trim())); child.on('close', (code) => { if (code === 0) { console.log('[VQuant Desktop] Database initialized at:', dbPath); } else { console.error('[VQuant Desktop] Database init failed with code:', code); } resolve(); }); child.on('error', (err) => { console.error('[VQuant Desktop] sqlite3 not found:', err.message); resolve(); }); }); } async function startServer() { const apiKeys = getEffectiveApiKeys(); const root = getProjectRoot(); const dbDir = app.getPath('userData'); const dbPath = path.join(dbDir, 'vquant.db'); await initDatabase(dbPath); return new Promise((resolve, reject) => { const env = { ...process.env, NODE_ENV: 'production', PORT: String(SERVER_PORT), DATABASE_URL: `sqlite://${dbPath}`, SESSION_SECRET: 'vquant-desktop-session-secure-' + Math.random().toString(36).slice(2), ANTHROPIC_API_KEY: apiKeys.ANTHROPIC_API_KEY || '', FMP_API_KEY: apiKeys.FMP_API_KEY || '', FIRECRAWL_API_KEY: apiKeys.FIRECRAWL_API_KEY || '', TAVILY_API_KEY: apiKeys.TAVILY_API_KEY || '', EXA_API_KEY: apiKeys.EXA_API_KEY || '', SERPAPI_API_KEY: apiKeys.SERPAPI_API_KEY || '', ELEVENLABS_API_KEY: apiKeys.ELEVENLABS_API_KEY || '', }; const pythonPath = findPython(); if (pythonPath) { env.PYTHON_PATH = pythonPath; } let serverEntry; if (isDev()) { serverEntry = path.join(root, 'dist', 'index.js'); if (!fs.existsSync(serverEntry)) { serverEntry = null; } } else { serverEntry = path.join(root, 'app-dist', 'index.js'); } if (!serverEntry) { console.log('[VQuant Desktop] Server bundle not found, using tsx dev mode...'); const tsxBin = path.join(root, 'node_modules', '.bin', 'tsx'); const serverTs = path.join(root, 'server', 'index.ts'); serverProcess = spawn(tsxBin, ['--env-file=.env', serverTs], { env: { ...env, NODE_ENV: 'development' }, cwd: root, stdio: ['pipe', 'pipe', 'pipe'], }); } else { const nodeBin = findNode(); console.log('[VQuant Desktop] Starting server:', nodeBin, serverEntry); serverProcess = spawn(nodeBin, [serverEntry], { env, cwd: root, stdio: ['pipe', 'pipe', 'pipe'], }); } let resolved = false; serverProcess.stdout.on('data', (data) => { const msg = data.toString(); console.log('[Server]', msg.trim()); if (!resolved && (msg.includes('SERVER READY') || msg.includes('Listening on') || msg.includes(`${SERVER_PORT}`))) { resolved = true; serverReady = true; resolve(); } }); serverProcess.stderr.on('data', (data) => { const msg = data.toString().trim(); if (msg) console.error('[Server Err]', msg); if (!resolved && (msg.includes('Listening on') || msg.includes(`${SERVER_PORT}`))) { resolved = true; serverReady = true; resolve(); } }); serverProcess.on('error', (err) => { console.error('[Server Process Error]', err); if (!resolved) { resolved = true; reject(err); } }); serverProcess.on('exit', (code) => { console.log('[Server] Exited with code:', code); serverReady = false; serverProcess = null; if (!resolved) { resolved = true; reject(new Error(`Server exited with code ${code}`)); } }); setTimeout(() => { if (!resolved) { resolved = true; serverReady = true; resolve(); } }, 10000); }); } function stopServer() { if (serverProcess) { serverProcess.kill('SIGTERM'); setTimeout(() => { if (serverProcess) { serverProcess.kill('SIGKILL'); } }, 3000); serverProcess = null; serverReady = false; } } // ─── Windows ───────────────────────────────────────────── function createSetupWindow() { setupWindow = new BrowserWindow({ width: 680, height: 780, resizable: false, maximizable: false, titleBarStyle: 'hiddenInset', vibrancy: 'sidebar', backgroundColor: nativeTheme.shouldUseDarkColors ? '#1a1a1a' : '#ffffff', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, }); setupWindow.loadFile(path.join(__dirname, 'setup.html')); setupWindow.on('closed', () => { setupWindow = null; if (!mainWindow) { app.quit(); } }); } function createMainWindow() { const bounds = store.get('windowBounds', { width: 1400, height: 900 }); mainWindow = new BrowserWindow({ width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y, minWidth: 900, minHeight: 600, titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 15, y: 15 }, vibrancy: 'sidebar', backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, }); mainWindow.loadURL(`http://localhost:${SERVER_PORT}`); mainWindow.webContents.on('did-fail-load', () => { setTimeout(() => { if (mainWindow) { mainWindow.loadURL(`http://localhost:${SERVER_PORT}`); } }, 2000); }); mainWindow.on('resize', () => { if (!mainWindow) return; const [width, height] = mainWindow.getSize(); const [x, y] = mainWindow.getPosition(); store.set('windowBounds', { width, height, x, y }); }); mainWindow.on('move', () => { if (!mainWindow) return; const [x, y] = mainWindow.getPosition(); const bounds = store.get('windowBounds'); store.set('windowBounds', { ...bounds, x, y }); }); mainWindow.on('closed', () => { mainWindow = null; }); } // ─── macOS Menu ────────────────────────────────────────── function buildMenu() { const template = [ { label: 'VQuant', submenu: [ { label: 'About VQuant', role: 'about' }, { type: 'separator' }, { label: 'API Keys Settings...', accelerator: 'Cmd+,', click: () => { if (setupWindow) { setupWindow.focus(); } else { createSetupWindow(); } }, }, { type: 'separator' }, { label: 'Restart Server', click: async () => { stopServer(); try { await startServer(); if (mainWindow) mainWindow.reload(); } catch (err) { dialog.showErrorBox('VQuant', `Failed to restart: ${err.message}`); } }, }, { type: 'separator' }, { label: 'Hide VQuant', role: 'hide' }, { label: 'Hide Others', role: 'hideOthers' }, { label: 'Show All', role: 'unhide' }, { type: 'separator' }, { label: 'Quit VQuant', role: 'quit' }, ], }, { label: 'Edit', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'pasteAndMatchStyle' }, { role: 'selectAll' }, ], }, { label: 'View', submenu: [ { role: 'reload' }, { role: 'forceReload' }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' }, ], }, { label: 'Window', submenu: [ { role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, { label: 'New Conversation', accelerator: 'Cmd+N', click: () => { if (mainWindow) { mainWindow.webContents.executeJavaScript('window.location.href = "/"'); } }, }, { type: 'separator' }, { role: 'front' }, ], }, { label: 'Help', submenu: [ { label: 'VQuant Documentation', click: () => { if (mainWindow) { mainWindow.webContents.executeJavaScript('window.location.href = "/docs"'); } }, }, { label: 'Visit vquant.ai', click: () => shell.openExternal('https://www.vquant.ai'), }, { type: 'separator' }, { label: 'Toggle Developer Tools', accelerator: 'Alt+Cmd+I', click: () => { const win = BrowserWindow.getFocusedWindow(); if (win) win.webContents.toggleDevTools(); }, }, ], }, ]; const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); } // ─── IPC Handlers ──────────────────────────────────────── ipcMain.handle('get-api-keys', () => { return store.get('apiKeys', {}); }); ipcMain.handle('save-api-keys', async (_event, keys) => { store.set('apiKeys', keys); store.set('setupComplete', true); stopServer(); if (setupWindow) { setupWindow.webContents.send('setup-status', 'starting-server'); } try { await startServer(); if (setupWindow) { setupWindow.close(); } if (!mainWindow) { createMainWindow(); } else { mainWindow.reload(); } return { success: true }; } catch (err) { return { success: false, error: err.message }; } }); ipcMain.handle('get-setup-complete', () => { return store.get('setupComplete', false); }); ipcMain.handle('open-external', (_event, url) => { shell.openExternal(url); }); ipcMain.handle('get-app-version', () => { return app.getVersion(); }); ipcMain.handle('get-theme', () => { return nativeTheme.shouldUseDarkColors ? 'dark' : 'light'; }); // ─── App Lifecycle ─────────────────────────────────────── app.whenReady().then(async () => { buildMenu(); const apiKeys = getEffectiveApiKeys(); const hasRequiredKeys = apiKeys.ANTHROPIC_API_KEY && apiKeys.FMP_API_KEY; if (!hasRequiredKeys) { createSetupWindow(); } else { const splash = new BrowserWindow({ width: 400, height: 300, frame: false, transparent: true, resizable: false, alwaysOnTop: true, skipTaskbar: true, webPreferences: { contextIsolation: true }, }); splash.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(`

VQuant

Demarrage du serveur...

`)}`); try { await startServer(); splash.close(); createMainWindow(); } catch (err) { splash.close(); console.error('[VQuant] Failed to start server:', err); const choice = dialog.showMessageBoxSync({ type: 'error', title: 'VQuant - Erreur', message: 'Impossible de demarrer le serveur.', detail: err.message + '\n\nVoulez-vous reconfigurer vos cles API ?', buttons: ['Reconfigurer', 'Quitter'], defaultId: 0, }); if (choice === 0) { createSetupWindow(); } else { app.quit(); } } } app.on('activate', () => { if (!mainWindow && !setupWindow) { if (serverReady) { createMainWindow(); } else { createSetupWindow(); } } }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { stopServer(); app.quit(); } }); app.on('before-quit', () => { stopServer(); });