spb/vquant Public MIT
VibeQuant — AI-powered institutional-grade financial intelligence platform.
TypeScript 84.3%
Python 11.7%
JavaScript 1.6%
CSS 1.5%
HTML 0.7%
1/*2 * =============================================================================3 * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform4 * -----------------------------------------------------------------------------5 * File: desktop/main.js6 *7 * Author: Simon-Pierre Boucher8 * Contact: contact@spboucher.ai9 * Website: https://www.spboucher.ai10 * Demo: https://www.vquant.ai11 * License: MIT (see LICENSE)12 *13 * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.14 * =============================================================================15 */1617const { app, BrowserWindow, Menu, ipcMain, dialog, shell, nativeTheme } = require('electron');18const path = require('path');19const { spawn } = require('child_process');20const fs = require('fs');21const Store = require('electron-store');2223const store = new Store({24 name: 'vquant-config',25 encryptionKey: 'vquant-desktop-secure-key-2024',26 schema: {27 apiKeys: {28 type: 'object',29 properties: {30 ANTHROPIC_API_KEY: { type: 'string', default: '' },31 FMP_API_KEY: { type: 'string', default: '' },32 FIRECRAWL_API_KEY: { type: 'string', default: '' },33 TAVILY_API_KEY: { type: 'string', default: '' },34 EXA_API_KEY: { type: 'string', default: '' },35 SERPAPI_API_KEY: { type: 'string', default: '' },36 ELEVENLABS_API_KEY: { type: 'string', default: '' },37 },38 default: {},39 },40 setupComplete: { type: 'boolean', default: false },41 windowBounds: {42 type: 'object',43 properties: {44 width: { type: 'number', default: 1400 },45 height: { type: 'number', default: 900 },46 x: { type: 'number' },47 y: { type: 'number' },48 },49 default: { width: 1400, height: 900 },50 },51 },52});5354let mainWindow = null;55let setupWindow = null;56let serverProcess = null;57let serverReady = false;58const SERVER_PORT = 15173;5960function isDev() {61 return !app.isPackaged;62}6364function getProjectRoot() {65 if (isDev()) {66 return path.join(__dirname, '..');67 }68 return process.resourcesPath;69}7071function loadEnvFile() {72 const envPath = path.join(getProjectRoot(), '.env');73 const envKeys = {};74 try {75 const content = fs.readFileSync(envPath, 'utf-8');76 for (const line of content.split('\n')) {77 const trimmed = line.trim();78 if (!trimmed || trimmed.startsWith('#')) continue;79 const eqIdx = trimmed.indexOf('=');80 if (eqIdx === -1) continue;81 const key = trimmed.slice(0, eqIdx).trim();82 const val = trimmed.slice(eqIdx + 1).trim();83 if (val && !val.includes('your-') && !val.includes('here')) {84 envKeys[key] = val;85 }86 }87 } catch (_) {}88 return envKeys;89}9091function getEffectiveApiKeys() {92 const storeKeys = store.get('apiKeys', {});93 const envKeys = loadEnvFile();94 return {95 ANTHROPIC_API_KEY: storeKeys.ANTHROPIC_API_KEY || envKeys.ANTHROPIC_API_KEY || '',96 FMP_API_KEY: storeKeys.FMP_API_KEY || envKeys.FMP_API_KEY || '',97 FIRECRAWL_API_KEY: storeKeys.FIRECRAWL_API_KEY || envKeys.FIRECRAWL_API_KEY || '',98 TAVILY_API_KEY: storeKeys.TAVILY_API_KEY || envKeys.TAVILY_API_KEY || '',99 EXA_API_KEY: storeKeys.EXA_API_KEY || envKeys.EXA_API_KEY || '',100 SERPAPI_API_KEY: storeKeys.SERPAPI_API_KEY || envKeys.SERPAPI_API_KEY || '',101 ELEVENLABS_API_KEY: storeKeys.ELEVENLABS_API_KEY || envKeys.ELEVENLABS_API_KEY || '',102 };103}104105// ─── Server Management ───────────────────────────────────106107function findNode() {108 const candidates = [109 path.join(process.env.HOME || '', 'local', 'node-v22.15.0-darwin-arm64', 'bin', 'node'),110 '/opt/homebrew/bin/node',111 '/usr/local/bin/node',112 '/usr/bin/node',113 ];114115 if (process.execPath && !process.execPath.includes('Electron')) {116 return process.execPath;117 }118119 for (const p of candidates) {120 if (fs.existsSync(p)) return p;121 }122123 return 'node';124}125126function findPython() {127 const root = getProjectRoot();128 const candidates = [129 path.join(root, '.venv', 'bin', 'python'),130 '/opt/homebrew/bin/python3',131 '/usr/local/bin/python3',132 '/usr/bin/python3',133 ];134 for (const p of candidates) {135 if (fs.existsSync(p)) return p;136 }137 return null;138}139140function initDatabase(dbPath) {141 return new Promise((resolve) => {142 const sql = `143CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, display_name TEXT NOT NULL, token TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL);144CREATE 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);145CREATE 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);146CREATE 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);147CREATE 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);148CREATE 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);149CREATE 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);150CREATE 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');151CREATE 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);152CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);153CREATE INDEX IF NOT EXISTS idx_conversation_sessions_user_id ON conversation_sessions(user_id);154CREATE INDEX IF NOT EXISTS idx_conversation_sessions_session_id ON conversation_sessions(session_id);155CREATE INDEX IF NOT EXISTS idx_active_users_session ON active_users(session_id);156CREATE INDEX IF NOT EXISTS idx_active_users_heartbeat ON active_users(last_heartbeat);157CREATE INDEX IF NOT EXISTS idx_analytics_timestamp ON analytics_metrics(timestamp);158CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp ON request_logs(timestamp);159PRAGMA journal_mode=WAL;160`;161 const child = spawn('sqlite3', [dbPath], {162 stdio: ['pipe', 'pipe', 'pipe'],163 });164165 child.stdin.write(sql);166 child.stdin.end();167168 child.stdout.on('data', (d) => console.log('[DB Init]', d.toString().trim()));169 child.stderr.on('data', (d) => console.error('[DB Init Err]', d.toString().trim()));170 child.on('close', (code) => {171 if (code === 0) {172 console.log('[VQuant Desktop] Database initialized at:', dbPath);173 } else {174 console.error('[VQuant Desktop] Database init failed with code:', code);175 }176 resolve();177 });178 child.on('error', (err) => {179 console.error('[VQuant Desktop] sqlite3 not found:', err.message);180 resolve();181 });182 });183}184185async function startServer() {186 const apiKeys = getEffectiveApiKeys();187 const root = getProjectRoot();188189 const dbDir = app.getPath('userData');190 const dbPath = path.join(dbDir, 'vquant.db');191192 await initDatabase(dbPath);193194 return new Promise((resolve, reject) => {195196 const env = {197 ...process.env,198 NODE_ENV: 'production',199 PORT: String(SERVER_PORT),200 DATABASE_URL: `sqlite://${dbPath}`,201 SESSION_SECRET: 'vquant-desktop-session-secure-' + Math.random().toString(36).slice(2),202 ANTHROPIC_API_KEY: apiKeys.ANTHROPIC_API_KEY || '',203 FMP_API_KEY: apiKeys.FMP_API_KEY || '',204 FIRECRAWL_API_KEY: apiKeys.FIRECRAWL_API_KEY || '',205 TAVILY_API_KEY: apiKeys.TAVILY_API_KEY || '',206 EXA_API_KEY: apiKeys.EXA_API_KEY || '',207 SERPAPI_API_KEY: apiKeys.SERPAPI_API_KEY || '',208 ELEVENLABS_API_KEY: apiKeys.ELEVENLABS_API_KEY || '',209 };210211 const pythonPath = findPython();212 if (pythonPath) {213 env.PYTHON_PATH = pythonPath;214 }215216 let serverEntry;217 if (isDev()) {218 serverEntry = path.join(root, 'dist', 'index.js');219 if (!fs.existsSync(serverEntry)) {220 serverEntry = null;221 }222 } else {223 serverEntry = path.join(root, 'app-dist', 'index.js');224 }225226 if (!serverEntry) {227 console.log('[VQuant Desktop] Server bundle not found, using tsx dev mode...');228 const tsxBin = path.join(root, 'node_modules', '.bin', 'tsx');229 const serverTs = path.join(root, 'server', 'index.ts');230231 serverProcess = spawn(tsxBin, ['--env-file=.env', serverTs], {232 env: { ...env, NODE_ENV: 'development' },233 cwd: root,234 stdio: ['pipe', 'pipe', 'pipe'],235 });236 } else {237 const nodeBin = findNode();238 console.log('[VQuant Desktop] Starting server:', nodeBin, serverEntry);239240 serverProcess = spawn(nodeBin, [serverEntry], {241 env,242 cwd: root,243 stdio: ['pipe', 'pipe', 'pipe'],244 });245 }246247 let resolved = false;248249 serverProcess.stdout.on('data', (data) => {250 const msg = data.toString();251 console.log('[Server]', msg.trim());252 if (!resolved && (msg.includes('SERVER READY') || msg.includes('Listening on') || msg.includes(`${SERVER_PORT}`))) {253 resolved = true;254 serverReady = true;255 resolve();256 }257 });258259 serverProcess.stderr.on('data', (data) => {260 const msg = data.toString().trim();261 if (msg) console.error('[Server Err]', msg);262 if (!resolved && (msg.includes('Listening on') || msg.includes(`${SERVER_PORT}`))) {263 resolved = true;264 serverReady = true;265 resolve();266 }267 });268269 serverProcess.on('error', (err) => {270 console.error('[Server Process Error]', err);271 if (!resolved) {272 resolved = true;273 reject(err);274 }275 });276277 serverProcess.on('exit', (code) => {278 console.log('[Server] Exited with code:', code);279 serverReady = false;280 serverProcess = null;281 if (!resolved) {282 resolved = true;283 reject(new Error(`Server exited with code ${code}`));284 }285 });286287 setTimeout(() => {288 if (!resolved) {289 resolved = true;290 serverReady = true;291 resolve();292 }293 }, 10000);294 });295}296297function stopServer() {298 if (serverProcess) {299 serverProcess.kill('SIGTERM');300 setTimeout(() => {301 if (serverProcess) {302 serverProcess.kill('SIGKILL');303 }304 }, 3000);305 serverProcess = null;306 serverReady = false;307 }308}309310// ─── Windows ─────────────────────────────────────────────311312function createSetupWindow() {313 setupWindow = new BrowserWindow({314 width: 680,315 height: 780,316 resizable: false,317 maximizable: false,318 titleBarStyle: 'hiddenInset',319 vibrancy: 'sidebar',320 backgroundColor: nativeTheme.shouldUseDarkColors ? '#1a1a1a' : '#ffffff',321 webPreferences: {322 preload: path.join(__dirname, 'preload.js'),323 contextIsolation: true,324 nodeIntegration: false,325 },326 });327328 setupWindow.loadFile(path.join(__dirname, 'setup.html'));329330 setupWindow.on('closed', () => {331 setupWindow = null;332 if (!mainWindow) {333 app.quit();334 }335 });336}337338function createMainWindow() {339 const bounds = store.get('windowBounds', { width: 1400, height: 900 });340341 mainWindow = new BrowserWindow({342 width: bounds.width,343 height: bounds.height,344 x: bounds.x,345 y: bounds.y,346 minWidth: 900,347 minHeight: 600,348 titleBarStyle: 'hiddenInset',349 trafficLightPosition: { x: 15, y: 15 },350 vibrancy: 'sidebar',351 backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff',352 webPreferences: {353 preload: path.join(__dirname, 'preload.js'),354 contextIsolation: true,355 nodeIntegration: false,356 },357 });358359 mainWindow.loadURL(`http://localhost:${SERVER_PORT}`);360361 mainWindow.webContents.on('did-fail-load', () => {362 setTimeout(() => {363 if (mainWindow) {364 mainWindow.loadURL(`http://localhost:${SERVER_PORT}`);365 }366 }, 2000);367 });368369 mainWindow.on('resize', () => {370 if (!mainWindow) return;371 const [width, height] = mainWindow.getSize();372 const [x, y] = mainWindow.getPosition();373 store.set('windowBounds', { width, height, x, y });374 });375376 mainWindow.on('move', () => {377 if (!mainWindow) return;378 const [x, y] = mainWindow.getPosition();379 const bounds = store.get('windowBounds');380 store.set('windowBounds', { ...bounds, x, y });381 });382383 mainWindow.on('closed', () => {384 mainWindow = null;385 });386}387388// ─── macOS Menu ──────────────────────────────────────────389390function buildMenu() {391 const template = [392 {393 label: 'VQuant',394 submenu: [395 { label: 'About VQuant', role: 'about' },396 { type: 'separator' },397 {398 label: 'API Keys Settings...',399 accelerator: 'Cmd+,',400 click: () => {401 if (setupWindow) {402 setupWindow.focus();403 } else {404 createSetupWindow();405 }406 },407 },408 { type: 'separator' },409 {410 label: 'Restart Server',411 click: async () => {412 stopServer();413 try {414 await startServer();415 if (mainWindow) mainWindow.reload();416 } catch (err) {417 dialog.showErrorBox('VQuant', `Failed to restart: ${err.message}`);418 }419 },420 },421 { type: 'separator' },422 { label: 'Hide VQuant', role: 'hide' },423 { label: 'Hide Others', role: 'hideOthers' },424 { label: 'Show All', role: 'unhide' },425 { type: 'separator' },426 { label: 'Quit VQuant', role: 'quit' },427 ],428 },429 {430 label: 'Edit',431 submenu: [432 { role: 'undo' },433 { role: 'redo' },434 { type: 'separator' },435 { role: 'cut' },436 { role: 'copy' },437 { role: 'paste' },438 { role: 'pasteAndMatchStyle' },439 { role: 'selectAll' },440 ],441 },442 {443 label: 'View',444 submenu: [445 { role: 'reload' },446 { role: 'forceReload' },447 { type: 'separator' },448 { role: 'resetZoom' },449 { role: 'zoomIn' },450 { role: 'zoomOut' },451 { type: 'separator' },452 { role: 'togglefullscreen' },453 ],454 },455 {456 label: 'Window',457 submenu: [458 { role: 'minimize' },459 { role: 'zoom' },460 { type: 'separator' },461 {462 label: 'New Conversation',463 accelerator: 'Cmd+N',464 click: () => {465 if (mainWindow) {466 mainWindow.webContents.executeJavaScript('window.location.href = "/"');467 }468 },469 },470 { type: 'separator' },471 { role: 'front' },472 ],473 },474 {475 label: 'Help',476 submenu: [477 {478 label: 'VQuant Documentation',479 click: () => {480 if (mainWindow) {481 mainWindow.webContents.executeJavaScript('window.location.href = "/docs"');482 }483 },484 },485 {486 label: 'Visit vquant.ai',487 click: () => shell.openExternal('https://www.vquant.ai'),488 },489 { type: 'separator' },490 {491 label: 'Toggle Developer Tools',492 accelerator: 'Alt+Cmd+I',493 click: () => {494 const win = BrowserWindow.getFocusedWindow();495 if (win) win.webContents.toggleDevTools();496 },497 },498 ],499 },500 ];501502 const menu = Menu.buildFromTemplate(template);503 Menu.setApplicationMenu(menu);504}505506// ─── IPC Handlers ────────────────────────────────────────507508ipcMain.handle('get-api-keys', () => {509 return store.get('apiKeys', {});510});511512ipcMain.handle('save-api-keys', async (_event, keys) => {513 store.set('apiKeys', keys);514 store.set('setupComplete', true);515516 stopServer();517518 if (setupWindow) {519 setupWindow.webContents.send('setup-status', 'starting-server');520 }521522 try {523 await startServer();524525 if (setupWindow) {526 setupWindow.close();527 }528 if (!mainWindow) {529 createMainWindow();530 } else {531 mainWindow.reload();532 }533534 return { success: true };535 } catch (err) {536 return { success: false, error: err.message };537 }538});539540ipcMain.handle('get-setup-complete', () => {541 return store.get('setupComplete', false);542});543544ipcMain.handle('open-external', (_event, url) => {545 shell.openExternal(url);546});547548ipcMain.handle('get-app-version', () => {549 return app.getVersion();550});551552ipcMain.handle('get-theme', () => {553 return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';554});555556// ─── App Lifecycle ───────────────────────────────────────557558app.whenReady().then(async () => {559 buildMenu();560561 const apiKeys = getEffectiveApiKeys();562 const hasRequiredKeys = apiKeys.ANTHROPIC_API_KEY && apiKeys.FMP_API_KEY;563564 if (!hasRequiredKeys) {565 createSetupWindow();566 } else {567 const splash = new BrowserWindow({568 width: 400,569 height: 300,570 frame: false,571 transparent: true,572 resizable: false,573 alwaysOnTop: true,574 skipTaskbar: true,575 webPreferences: { contextIsolation: true },576 });577578 splash.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(`579 <!DOCTYPE html>580 <html>581 <body style="582 margin: 0; display: flex; flex-direction: column;583 align-items: center; justify-content: center; height: 100vh;584 font-family: -apple-system, BlinkMacSystemFont, sans-serif;585 background: ${nativeTheme.shouldUseDarkColors ? '#1a1a1a' : '#ffffff'};586 color: ${nativeTheme.shouldUseDarkColors ? '#f5f5f7' : '#1d1d1f'};587 border-radius: 16px; -webkit-app-region: drag;588 ">589 <svg width="48" height="48" viewBox="0 0 24 24" fill="none"590 stroke="${nativeTheme.shouldUseDarkColors ? '#2997ff' : '#0071e3'}"591 stroke-width="2" stroke-linecap="round" stroke-linejoin="round">592 <path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>593 </svg>594 <h1 style="font-size: 24px; font-weight: 700; margin: 16px 0 8px;">VQuant</h1>595 <p style="font-size: 13px; color: ${nativeTheme.shouldUseDarkColors ? '#86868b' : '#6e6e73'};">596 Demarrage du serveur...597 </p>598 <div style="599 margin-top: 20px; width: 32px; height: 32px;600 border: 2px solid ${nativeTheme.shouldUseDarkColors ? '#3a3a3a' : '#e8e8ed'};601 border-top-color: ${nativeTheme.shouldUseDarkColors ? '#2997ff' : '#0071e3'};602 border-radius: 50%; animation: spin 0.8s linear infinite;603 "></div>604 <style>@keyframes spin { to { transform: rotate(360deg); } }</style>605 </body>606 </html>607 `)}`);608609 try {610 await startServer();611 splash.close();612 createMainWindow();613 } catch (err) {614 splash.close();615 console.error('[VQuant] Failed to start server:', err);616 const choice = dialog.showMessageBoxSync({617 type: 'error',618 title: 'VQuant - Erreur',619 message: 'Impossible de demarrer le serveur.',620 detail: err.message + '\n\nVoulez-vous reconfigurer vos cles API ?',621 buttons: ['Reconfigurer', 'Quitter'],622 defaultId: 0,623 });624 if (choice === 0) {625 createSetupWindow();626 } else {627 app.quit();628 }629 }630 }631632 app.on('activate', () => {633 if (!mainWindow && !setupWindow) {634 if (serverReady) {635 createMainWindow();636 } else {637 createSetupWindow();638 }639 }640 });641});642643app.on('window-all-closed', () => {644 if (process.platform !== 'darwin') {645 stopServer();646 app.quit();647 }648});649650app.on('before-quit', () => {651 stopServer();652});653