jarvis/main.js
2026-04-13 22:01:33 +02:00

405 lines
13 KiB
JavaScript

'use strict';
const { app, BrowserWindow, ipcMain, Menu, globalShortcut, systemPreferences, nativeImage } = require('electron');
const path = require('path');
const os = require('os');
const fs = require('fs');
const http = require('http');
const { spawn, execSync, execFile } = require('child_process');
// ============================================================
// CONFIG
// ============================================================
const PORT = 52736;
const WHISPER_PORT = 52737;
function pickWhisperModel() {
if (process.env.JARVIS_WHISPER_MODEL) return process.env.JARVIS_WHISPER_MODEL;
const dir = path.join(os.homedir(), 'whisper-models');
const prefer = ['ggml-medium.bin', 'ggml-small.bin', 'ggml-base.bin'];
for (const f of prefer) {
const p = path.join(dir, f);
if (fs.existsSync(p)) return p;
}
return path.join(dir, 'ggml-base.bin');
}
const WHISPER_MODEL = pickWhisperModel();
const SYSTEM_PROMPT = `You are J.A.R.V.I.S. (Just A Rather Very Intelligent System), the AI assistant from Iron Man. You are highly intelligent, efficient, and professional, with a touch of refined British wit.
Critical rules:
- Your responses will be spoken aloud by a text-to-speech engine. Be concise and conversational. No markdown, no bullet points, no asterisks, no code blocks in your verbal responses.
- You can use the Bash tool to execute commands on the user's Mac for real tasks.
- Respond in the same language the user speaks (French or English).
- When you run commands, briefly describe what you are doing in natural speech.
- Address the user respectfully.`;
// ============================================================
// LOCAL HTTP SERVER (sert le renderer sur http://localhost — requis
// pour que l'API getUserMedia fonctionne dans un contexte sécurisé)
// ============================================================
function startLocalServer() {
const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript' };
http.createServer((req, res) => {
const url = req.url.split('?')[0];
if (url.startsWith('/whisper/')) {
const proxyReq = http.request({
host: '127.0.0.1',
port: WHISPER_PORT,
method: req.method,
path: url.replace('/whisper', ''),
headers: req.headers,
}, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});
proxyReq.on('error', (e) => { res.writeHead(502); res.end(e.message); });
req.pipe(proxyReq);
return;
}
const filePath = path.join(__dirname, url === '/' ? 'index.html' : url);
fs.readFile(filePath, (err, data) => {
if (err) { res.writeHead(404); res.end('Not found'); return; }
res.writeHead(200, { 'Content-Type': mime[path.extname(filePath)] || 'text/plain' });
res.end(data);
});
}).listen(PORT, '127.0.0.1');
}
// ============================================================
// WHISPER SERVER (daemon local, modèle chargé une seule fois)
// ============================================================
let whisperProc = null;
function startWhisperServer() {
if (!fs.existsSync(WHISPER_MODEL)) {
console.error('[JARVIS] modèle Whisper introuvable :', WHISPER_MODEL);
console.error('[JARVIS] Télécharge-le avec :');
console.error(' mkdir -p ~/whisper-models && curl -L -o ~/whisper-models/ggml-base.bin \\');
console.error(' https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin');
return false;
}
const whisperBin = findBin('whisper-server');
if (!whisperBin) {
console.error('[JARVIS] whisper-server introuvable dans le PATH. Installe-le avec : brew install whisper-cpp');
return false;
}
console.log('[JARVIS] whisper binary :', whisperBin);
whisperProc = spawn(whisperBin, [
'-m', WHISPER_MODEL,
'--host', '127.0.0.1',
'--port', String(WHISPER_PORT),
'-l', 'auto',
'-nt',
'-t', '6',
], { stdio: ['ignore', 'pipe', 'pipe'] });
whisperProc.on('error', (e) => {
console.error('[JARVIS] whisper-server spawn error :', e.message);
whisperProc = null;
});
whisperProc.stdout.on('data', d => process.stdout.write('[whisper] ' + d));
whisperProc.stderr.on('data', d => process.stderr.write('[whisper] ' + d));
whisperProc.on('exit', (code) => {
console.log('[JARVIS] whisper-server terminé, code =', code);
whisperProc = null;
});
return true;
}
function stopWhisperServer() {
if (whisperProc) {
try { whisperProc.kill('SIGTERM'); } catch (_) {}
whisperProc = null;
}
}
// ============================================================
// CLAUDE CLI
// ============================================================
function findBin(name, extraCandidates = []) {
try {
const result = execSync(`/bin/zsh --login -c "which ${name}"`, {
encoding: 'utf8',
timeout: 5000,
}).trim();
if (result && fs.existsSync(result)) return result;
} catch (_) {}
const candidates = [
`/opt/homebrew/bin/${name}`,
`/usr/local/bin/${name}`,
`/usr/bin/${name}`,
`${os.homedir()}/.local/bin/${name}`,
...extraCandidates,
];
for (const p of candidates) {
try { fs.accessSync(p, fs.constants.X_OK); return p; } catch (_) {}
}
return null;
}
function findClaudeBin() {
return findBin('claude', [`${os.homedir()}/.npm-global/bin/claude`]) || 'claude';
}
const CLAUDE_BIN = findClaudeBin();
console.log('[JARVIS] claude binary :', CLAUDE_BIN);
function stripAnsi(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '').trim();
}
function buildPrompt(userMessage) {
let prompt = SYSTEM_PROMPT + '\n\n';
if (history.length > 0) {
prompt += 'CONVERSATION HISTORY:\n';
for (const msg of history) {
const label = msg.role === 'user' ? 'USER' : 'JARVIS';
prompt += `${label}: ${msg.content}\n`;
}
prompt += '\n';
}
prompt += `USER: ${userMessage}\n\nJARVIS:`;
return prompt;
}
const CLAUDE_CWD = '/Users/david/Documents/jarvis_result';
function ensureClaudeCwd() {
try {
if (!fs.existsSync(CLAUDE_CWD)) {
fs.mkdirSync(CLAUDE_CWD, { recursive: true });
console.log('[JARVIS] créé dossier', CLAUDE_CWD);
}
} catch (e) {
console.error('[JARVIS] impossible de créer', CLAUDE_CWD, ':', e.message);
}
}
async function askClaude(userMessage) {
const prompt = buildPrompt(userMessage);
ensureClaudeCwd();
// PATH enrichi pour que Claude trouve git, node, gh, etc. même en mode .app
const env = {
...process.env,
PATH: [
'/opt/homebrew/bin',
'/usr/local/bin',
'/usr/bin',
'/bin',
`${os.homedir()}/.local/bin`,
`${os.homedir()}/.npm-global/bin`,
process.env.PATH || '',
].join(':'),
HOME: os.homedir(),
};
return new Promise((resolve) => {
const args = [
'-p', prompt,
'--output-format', 'text',
'--model', 'opus',
'--allowed-tools', 'Bash',
'--dangerously-skip-permissions',
];
execFile(CLAUDE_BIN, args, {
timeout: 120000,
maxBuffer: 2 * 1024 * 1024,
cwd: CLAUDE_CWD,
env,
}, (err, stdout, stderr) => {
if (err) {
console.error('[JARVIS claude error]', err.message, stderr);
resolve({ success: false, error: err.message || stderr });
return;
}
const response = stripAnsi(stdout);
console.log('[JARVIS response]', response.slice(0, 200));
history.push({ role: 'user', content: userMessage });
history.push({ role: 'assistant', content: response });
if (history.length > 20) history = history.slice(-20);
resolve({ success: true, response });
});
});
}
// ============================================================
// STATE
// ============================================================
let mainWin = null;
let speakProc = null;
let alwaysOnTop = true;
let history = [];
let BEST_VOICE_FR = null;
let BEST_VOICE_EN = null;
function pickBestVoices() {
try {
const out = execSync('/usr/bin/say -v "?"', { encoding: 'utf8', timeout: 5000 });
const lines = out.split('\n').filter(Boolean);
const parse = lines.map(l => {
const m = l.match(/^(.+?)\s+([a-z]{2}[-_][A-Z]{2})\s+/);
if (!m) return null;
const name = m[1].trim();
const lang = m[2];
const premium = /\(Premium\)/i.test(name);
const enhanced = /\(Enhanced\)/i.test(name);
return { name, lang, premium, enhanced };
}).filter(Boolean);
const rank = v => (v.premium ? 3 : v.enhanced ? 2 : 1);
const fr = parse.filter(v => v.lang.startsWith('fr')).sort((a, b) => rank(b) - rank(a))[0];
const en = parse.filter(v => v.lang.startsWith('en')).sort((a, b) => rank(b) - rank(a))[0];
BEST_VOICE_FR = fr ? fr.name : null;
BEST_VOICE_EN = en ? en.name : null;
console.log('[JARVIS] voix FR :', BEST_VOICE_FR, '| voix EN :', BEST_VOICE_EN);
} catch (e) {
console.error('[JARVIS] pickBestVoices :', e.message);
}
}
// ============================================================
// WINDOW
// ============================================================
function createWindow() {
mainWin = new BrowserWindow({
width: 560,
height: 600,
transparent: true,
frame: false,
alwaysOnTop: true,
hasShadow: false,
backgroundColor: '#00000000',
resizable: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
mainWin.setAlwaysOnTop(true, 'floating');
mainWin.center();
mainWin.loadURL(`http://localhost:${PORT}`);
mainWin.webContents.on('render-process-gone', (_e, d) => console.error('[JARVIS renderer gone]', d));
mainWin.webContents.on('did-fail-load', (_e, code, desc) => console.error('[JARVIS load fail]', code, desc));
mainWin.webContents.session.setPermissionRequestHandler((_wc, permission, callback) => {
callback(['microphone', 'media', 'audioCapture'].includes(permission));
});
mainWin.webContents.session.setPermissionCheckHandler((_wc, permission) => {
return ['microphone', 'media', 'audioCapture'].includes(permission);
});
mainWin.on('closed', () => { mainWin = null; });
}
// ============================================================
// IPC
// ============================================================
ipcMain.handle('askClaude', async (_event, message) => askClaude(message));
ipcMain.handle('whisperUrl', () => `http://localhost:${PORT}/whisper/inference`);
ipcMain.handle('setDockIcon', (_event, dataUrl) => {
try {
const img = nativeImage.createFromDataURL(dataUrl);
if (!img.isEmpty() && app.dock) app.dock.setIcon(img);
if (mainWin) mainWin.setIcon(img);
} catch (e) {
console.error('[JARVIS] setDockIcon :', e.message);
}
});
ipcMain.handle('speak', async (_event, text) => {
if (!text) return;
if (speakProc) { speakProc.kill('SIGTERM'); speakProc = null; }
const frPattern = /\b(je|tu|il|elle|nous|vous|ils|le|la|les|un|une|des|est|sont|avec|pour|dans|sur|qui|que|très|aussi|mais|patron|bonjour|merci|oui|non|votre|notre)\b/i;
const isFr = frPattern.test(text);
const voice = isFr ? (BEST_VOICE_FR || 'Thomas') : (BEST_VOICE_EN || 'Alex');
return new Promise((resolve) => {
speakProc = spawn('/usr/bin/say', ['-v', voice, '-r', '185', text]);
speakProc.on('close', (code) => { console.log('[say] exit', code, 'voice=', voice); speakProc = null; resolve(); });
speakProc.on('error', (e) => { speakProc = null; console.error('[say]', e.message); resolve(); });
});
});
ipcMain.handle('stopSpeak', () => {
if (speakProc) { speakProc.kill('SIGTERM'); speakProc = null; }
});
ipcMain.handle('showContextMenu', () => {
const menu = Menu.buildFromTemplate([
{
label: `${alwaysOnTop ? '✓' : ' '} Toujours au-dessus / Always on Top`,
click: () => {
alwaysOnTop = !alwaysOnTop;
mainWin?.setAlwaysOnTop(alwaysOnTop, 'floating');
},
},
{ type: 'separator' },
{ label: 'Effacer historique / Clear History', click: () => { history = []; } },
{ type: 'separator' },
{ label: 'Quitter / Quit JARVIS', click: () => app.quit() },
]);
menu.popup({ window: mainWin });
});
// ============================================================
// LIFECYCLE
// ============================================================
app.disableHardwareAcceleration();
app.commandLine.appendSwitch('disable-features', 'WebRtcHideLocalIpsWithMdns,HardwareMediaKeyHandling,MediaSessionService');
app.commandLine.appendSwitch('no-sandbox');
app.whenReady().then(async () => {
try {
const status = systemPreferences.getMediaAccessStatus('microphone');
console.log('[JARVIS] mic access status :', status);
if (status !== 'granted') {
const ok = await systemPreferences.askForMediaAccess('microphone');
console.log('[JARVIS] mic access granted :', ok);
}
} catch (e) {
console.error('[JARVIS] mic permission error :', e.message);
}
pickBestVoices();
ensureClaudeCwd();
startLocalServer();
startWhisperServer();
createWindow();
globalShortcut.register('CommandOrControl+Q', () => app.quit());
});
app.on('before-quit', () => {
stopWhisperServer();
if (speakProc) { try { speakProc.kill('SIGTERM'); } catch (_) {} }
});
app.on('window-all-closed', () => {
globalShortcut.unregisterAll();
app.quit();
});
app.on('activate', () => {
if (!mainWin) createWindow();
});