405 lines
13 KiB
JavaScript
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();
|
|
});
|