SPB Git

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%
21.0 KB · 653 lines javascript
Raw Blame History
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