feat!: rebuild EveFlow as a JARVIS-style voice HUD for Hermes Agent (v2.0.0)

Complete rewrite of the application:

- Toolchain: Electron 44, Vite 8, React 19, TypeScript 5.9, Zustand, Vitest;
  main process rewritten in TypeScript (electron/), typed IPC contract (shared/),
  sandboxed renderer with webSecurity on and an HTTP/SSE proxy in the main process.
- Voice: AudioWorklet microphone capture at 16 kHz with adaptive energy VAD and
  auto-stop, WAV encoder, OpenAI-compatible STT, TTS queue with sentence-level
  streaming, prefetch and Web Audio playback feeding an analyser; hands-free mode,
  global push-to-talk hotkey, system-voice fallback.
- Hermes: full API client (capabilities, health, models, skills, toolsets,
  sessions, jobs) with automatic transport selection: runs API (SSE lifecycle,
  approvals, steer, stop) > sessions stream > chat completions with local tools;
  tolerant event normalisation; webhook receiver hardened (secret, size limit).
- UI: JARVIS arc-reactor core on Canvas 2D reacting to the real audio signal,
  holographic HUD layout (transcript, core, Hermes ops panel with tools/crons/
  skills/sessions/link tabs, telemetry), settings drawer, approval modals,
  compact floating widget, four themes, tray icon and global shortcuts.
- Removed the 3D Eve robot, three.js and legacy assets; migrated 1.x settings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
This commit is contained in:
Claude committed 2026-09-03 13:40:59 +00:00
1 parent 5217eee53a
commit 127a2083a1
239 files changed
+11351 -86495

No files matched your search

+405
View File
@@ -0,0 +1,405 @@
import { useEffect, useState } from 'react';
import { X, Settings, CheckCircle2, XCircle, Loader2, Mic, Volume2, RotateCcw, Play } from 'lucide-react';
import { bridge } from '../../lib/bridge';
import { HermesClient } from '../../services/hermes/client';
import { listSystemVoices } from '../../services/voice/tts';
import { speech } from '../../services/voice/speech';
import { listMicrophones } from '../../services/voice/capture';
import { transcribeWav } from '../../services/voice/stt';
import { encodeWav } from '../../services/voice/wav';
import { useHermes } from '../../state/hermes';
import { DEFAULT_SETTINGS, useSettings, type HudTheme } from '../../state/settings';
import { useVoice } from '../../state/voice';
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'webhook' | 'ui';
interface Props {
onClose: () => void;
}
function Toggle({ on, onChange, label, hint }: { on: boolean; onChange: (v: boolean) => void; label: string; hint?: string }) {
return (
<div className="switch" onClick={() => onChange(!on)}>
<div className="text">
<span>{label}</span>
{hint && <small>{hint}</small>}
</div>
<span className={`toggle${on ? ' on' : ''}`} />
</div>
);
}
type TestState = { status: 'idle' | 'running' | 'ok' | 'fail'; message: string };
export function SettingsDrawer({ onClose }: Props) {
const settings = useSettings((s) => s.settings);
const update = useSettings((s) => s.update);
const reset = useSettings((s) => s.reset);
const hermesStore = useHermes();
const micDevices = useVoice((s) => s.micDevices);
const [section, setSection] = useState<Section>('hermes');
const [hermesTest, setHermesTest] = useState<TestState>({ status: 'idle', message: '' });
const [sttTest, setSttTest] = useState<TestState>({ status: 'idle', message: '' });
const [ttsTest, setTtsTest] = useState<TestState>({ status: 'idle', message: '' });
const [voices, setVoices] = useState(listSystemVoices());
const [webhookSecretVisible, setWebhookSecretVisible] = useState(false);
useEffect(() => {
const refresh = () => setVoices(listSystemVoices());
refresh();
speechSynthesis.addEventListener?.('voiceschanged', refresh);
void listMicrophones().then((d) => useVoice.getState().setMicDevices(d));
return () => speechSynthesis.removeEventListener?.('voiceschanged', refresh);
}, []);
const testHermes = async () => {
setHermesTest({ status: 'running', message: 'connexion…' });
try {
const client = new HermesClient(settings.hermes);
const health = await client.health();
let caps = '';
try {
const c = await client.capabilities();
caps = ` · ${String(c.model ?? '')} · ${Object.entries(c.features ?? {}).filter(([, v]) => v === true).length} fonctions`;
} catch {
caps = ' · capabilities indisponibles';
}
setHermesTest({ status: 'ok', message: `${health.status}${caps}` });
void hermesStore.connect();
} catch (err) {
setHermesTest({ status: 'fail', message: (err as Error).message });
}
};
const testStt = async () => {
setSttTest({ status: 'running', message: 'envoi d’un WAV de test…' });
try {
const samples = new Float32Array(16000);
for (let i = 0; i < samples.length; i++) samples[i] = Math.sin(i / 20) * 0.05;
const text = await transcribeWav(encodeWav(samples, 16000), settings.voice);
setSttTest({ status: 'ok', message: `endpoint OK (réponse: "${text.slice(0, 40) || 'vide'}")` });
} catch (err) {
setSttTest({ status: 'fail', message: (err as Error).message });
}
};
const testTts = () => {
setTtsTest({ status: 'running', message: 'synthèse…' });
speech.say(`Bonjour, je suis ${settings.assistantName}. Les systèmes sont opérationnels.`);
setTimeout(() => setTtsTest({ status: 'ok', message: 'commande envoyée (écoutez le résultat)' }), 600);
};
const applyWebhook = async () => {
const api = bridge();
if (!api) return;
await api.store.set('webhook', settings.webhook);
const status = await api.hermes.webhookRestart();
useHermes.setState({ webhook: status });
};
const Result = ({ t }: { t: TestState }) =>
t.status === 'idle' ? null : (
<div className={`test-result ${t.status === 'ok' ? 'ok' : t.status === 'fail' ? 'fail' : ''}`}>
{t.status === 'running' ? <Loader2 size={13} className="spin" /> : t.status === 'ok' ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
<span>{t.message}</span>
</div>
);
const nav: Array<[Section, string]> = [
['hermes', 'Hermes'],
['voice', 'Micro / STT'],
['speech', 'Voix / TTS'],
['webhook', 'Webhook'],
['general', 'Général'],
['ui', 'Interface']
];
return (
<aside className="settings-drawer">
<header className="panel-head">
<Settings size={14} />
<span>Configuration</span>
<span className="spacer" />
<button className="icon-btn" onClick={onClose} title="Fermer"><X size={16} /></button>
</header>
<nav className="settings-nav">
{nav.map(([id, label]) => (
<button key={id} className={section === id ? 'active' : ''} onClick={() => setSection(id)}>{label}</button>
))}
</nav>
<div className="settings-body">
{section === 'hermes' && (
<>
<div className="card">
<div className="field">
<label>URL du serveur API Hermes</label>
<input className="input" value={settings.hermes.url} placeholder="http://127.0.0.1:8642" onChange={(e) => update({ hermes: { url: e.target.value } })} />
<span className="hint">Serveur lancé avec <code>hermes gateway</code> (API_SERVER_ENABLED=true, port 8642 par défaut). L’URL peut se terminer par /v1.</span>
</div>
<div className="field">
<label>Clé API (API_SERVER_KEY)</label>
<input className="input" type="password" value={settings.hermes.apiKey} onChange={(e) => update({ hermes: { apiKey: e.target.value } })} />
</div>
<div className="grid-2">
<div className="field">
<label>Modèle</label>
<input className="input" list="hermes-models" value={settings.hermes.model} placeholder="(défaut du serveur)" onChange={(e) => update({ hermes: { model: e.target.value } })} />
<datalist id="hermes-models">
{hermesStore.models.map((m) => <option key={m.id} value={m.id} />)}
</datalist>
</div>
<div className="field">
<label>Clé de mémoire (X-Hermes-Session-Key)</label>
<input className="input" value={settings.hermes.sessionKey} onChange={(e) => update({ hermes: { sessionKey: e.target.value } })} />
</div>
</div>
<div className="grid-2">
<div className="field">
<label>Transport</label>
<select className="select" value={settings.hermes.transport} onChange={(e) => update({ hermes: { transport: e.target.value as typeof settings.hermes.transport } })}>
<option value="auto">Auto (selon /v1/capabilities)</option>
<option value="runs">Runs API (SSE, approbations, steer)</option>
<option value="sessions">Sessions API (mémoire serveur)</option>
<option value="completions">Chat completions (OpenAI + outils EveFlow)</option>
</select>
</div>
<div className="field">
<label>Effort de raisonnement</label>
<select className="select" value={settings.hermes.reasoningEffort} onChange={(e) => update({ hermes: { reasoningEffort: e.target.value as typeof settings.hermes.reasoningEffort } })}>
<option value="">Défaut</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
</div>
<div className="field">
<label>Instructions EveFlow (superposées au prompt Hermes)</label>
<textarea className="textarea" value={settings.hermes.instructions} onChange={(e) => update({ hermes: { instructions: e.target.value } })} />
</div>
<Toggle on={settings.hermes.localTools} onChange={(v) => update({ hermes: { localTools: v } })} label="Outils EveFlow côté client" hint="En mode chat completions : état du HUD, fichiers partagés, notifications." />
<div className="row" style={{ marginTop: 10 }}>
<button className="btn primary small" onClick={() => void testHermes()}><Play size={13} /> Tester la liaison</button>
</div>
<div style={{ marginTop: 8 }}><Result t={hermesTest} /></div>
</div>
</>
)}
{section === 'voice' && (
<div className="card">
<div className="field">
<label>Moteur de reconnaissance</label>
<select className="select" value={settings.voice.provider} onChange={(e) => update({ voice: { provider: e.target.value as typeof settings.voice.provider } })}>
<option value="openai-compatible">API compatible OpenAI (Qwen3-ASR, Whisper, Speaches, LocalAI…)</option>
<option value="browser">Reconnaissance Chromium (en ligne, secours)</option>
</select>
</div>
{settings.voice.provider === 'openai-compatible' && (
<>
<div className="field">
<label>URL de l’API STT</label>
<input className="input" value={settings.voice.apiUrl} placeholder="http://127.0.0.1:8000/v1" onChange={(e) => update({ voice: { apiUrl: e.target.value } })} />
</div>
<div className="grid-2">
<div className="field">
<label>Modèle</label>
<input className="input" value={settings.voice.model} onChange={(e) => update({ voice: { model: e.target.value } })} />
</div>
<div className="field">
<label>Clé API</label>
<input className="input" type="password" value={settings.voice.apiKey} onChange={(e) => update({ voice: { apiKey: e.target.value } })} />
</div>
</div>
</>
)}
<div className="grid-2">
<div className="field">
<label>Langue</label>
<select className="select" value={settings.voice.language} onChange={(e) => update({ voice: { language: e.target.value }, language: e.target.value })}>
<option value="fr-FR">Français</option>
<option value="en-US">English</option>
<option value="es-ES">Español</option>
<option value="de-DE">Deutsch</option>
<option value="it-IT">Italiano</option>
</select>
</div>
<div className="field">
<label>Microphone</label>
<select className="select" value={settings.voice.micDeviceId} onChange={(e) => update({ voice: { micDeviceId: e.target.value } })}>
<option value="">Par défaut du système</option>
{micDevices.map((d) => <option key={d.deviceId} value={d.deviceId}>{d.label}</option>)}
</select>
</div>
</div>
<div className="field">
<label>Mode de capture</label>
<div className="seg">
<button className={settings.voice.captureMode === 'auto' ? 'active' : ''} onClick={() => update({ voice: { captureMode: 'auto' } })}>Auto (détection de silence)</button>
<button className={settings.voice.captureMode === 'manual' ? 'active' : ''} onClick={() => update({ voice: { captureMode: 'manual' } })}>Manuel (clic pour arrêter)</button>
</div>
</div>
<div className="grid-2">
<div className="field">
<label>Sensibilité : {settings.voice.sensitivity}/5</label>
<input className="range" type="range" min={1} max={5} step={1} value={settings.voice.sensitivity} onChange={(e) => update({ voice: { sensitivity: Number(e.target.value) } })} />
</div>
<div className="field">
<label>Silence de fin : {settings.voice.silenceMs} ms</label>
<input className="range" type="range" min={400} max={2500} step={100} value={settings.voice.silenceMs} onChange={(e) => update({ voice: { silenceMs: Number(e.target.value) } })} />
</div>
</div>
<Toggle on={settings.voice.handsFree} onChange={(v) => { update({ voice: { handsFree: v } }); useVoice.getState().setHandsFree(v); }} label="Mains libres au démarrage" hint="Le micro se réactive automatiquement après chaque réponse." />
<Toggle on={settings.voice.wakeChime} onChange={(v) => update({ voice: { wakeChime: v } })} label="Signal sonore d’écoute" />
<div className="row" style={{ marginTop: 10 }}>
<button className="btn small" onClick={() => void testStt()} disabled={settings.voice.provider !== 'openai-compatible'}><Mic size={13} /> Tester l’API STT</button>
</div>
<div style={{ marginTop: 8 }}><Result t={sttTest} /></div>
</div>
)}
{section === 'speech' && (
<div className="card">
<div className="field">
<label>Moteur de synthèse</label>
<select className="select" value={settings.speech.provider} onChange={(e) => update({ speech: { provider: e.target.value as typeof settings.speech.provider } })}>
<option value="openai-compatible">API compatible OpenAI /v1/audio/speech (Kokoro, Piper, OpenAI, LocalAI…)</option>
<option value="system">Voix système Windows</option>
<option value="google-free">Google Translate (gratuit, en ligne)</option>
<option value="off">Désactivée</option>
</select>
</div>
{settings.speech.provider === 'openai-compatible' && (
<>
<div className="field">
<label>URL de l’API TTS</label>
<input className="input" value={settings.speech.apiUrl} placeholder="http://127.0.0.1:8000/v1" onChange={(e) => update({ speech: { apiUrl: e.target.value } })} />
</div>
<div className="grid-2">
<div className="field">
<label>Modèle</label>
<input className="input" value={settings.speech.model} onChange={(e) => update({ speech: { model: e.target.value } })} />
</div>
<div className="field">
<label>Voix</label>
<input className="input" value={settings.speech.voice} placeholder="alloy, onyx, af_heart…" onChange={(e) => update({ speech: { voice: e.target.value } })} />
</div>
<div className="field">
<label>Clé API</label>
<input className="input" type="password" value={settings.speech.apiKey} onChange={(e) => update({ speech: { apiKey: e.target.value } })} />
</div>
<div className="field">
<label>Format</label>
<select className="select" value={settings.speech.format} onChange={(e) => update({ speech: { format: e.target.value as typeof settings.speech.format } })}>
<option value="mp3">mp3</option>
<option value="wav">wav</option>
<option value="opus">opus</option>
</select>
</div>
</div>
</>
)}
{settings.speech.provider === 'system' && (
<div className="field">
<label>Voix système</label>
<select className="select" value={settings.speech.systemVoice} onChange={(e) => update({ speech: { systemVoice: e.target.value } })}>
<option value="">Automatique ({settings.speech.language})</option>
{voices.map((v) => <option key={v.name} value={v.name}>{v.name} · {v.lang}</option>)}
</select>
</div>
)}
<div className="grid-2">
<div className="field">
<label>Vitesse : {settings.speech.speed.toFixed(2)}</label>
<input className="range" type="range" min={0.6} max={1.8} step={0.05} value={settings.speech.speed} onChange={(e) => update({ speech: { speed: Number(e.target.value) } })} />
</div>
<div className="field">
<label>Volume : {Math.round(settings.speech.volume * 100)}%</label>
<input className="range" type="range" min={0} max={1.2} step={0.05} value={settings.speech.volume} onChange={(e) => update({ speech: { volume: Number(e.target.value) } })} />
</div>
</div>
<Toggle on={settings.speech.autoSpeak} onChange={(v) => update({ speech: { autoSpeak: v } })} label="Lire les réponses en streaming" hint="Chaque phrase est prononcée dès qu’elle est complète." />
<Toggle on={settings.speech.speakIncoming} onChange={(v) => update({ speech: { speakIncoming: v } })} label="Lire les messages entrants (webhook, crons)" />
<div className="row" style={{ marginTop: 10 }}>
<button className="btn small" onClick={testTts} disabled={settings.speech.provider === 'off'}><Volume2 size={13} /> Tester la voix</button>
</div>
<div style={{ marginTop: 8 }}><Result t={ttsTest} /></div>
</div>
)}
{section === 'webhook' && (
<div className="card">
<Toggle on={settings.webhook.enabled} onChange={(v) => update({ webhook: { enabled: v } })} label="Serveur webhook local" hint="Hermes (crons, gateway, scripts) peut pousser des messages vers EveFlow." />
<div className="grid-2">
<div className="field">
<label>Port</label>
<input className="input" type="number" min={1024} max={65535} value={settings.webhook.port} onChange={(e) => update({ webhook: { port: Number(e.target.value) || 7842 } })} />
</div>
<div className="field">
<label>Secret (en-tête X-EveFlow-Secret)</label>
<div className="row">
<input className="input" type={webhookSecretVisible ? 'text' : 'password'} value={settings.webhook.secret} onChange={(e) => update({ webhook: { secret: e.target.value } })} />
<button className="btn ghost small" onClick={() => setWebhookSecretVisible((v) => !v)}>{webhookSecretVisible ? 'cacher' : 'voir'}</button>
</div>
</div>
</div>
<div className="field">
<label>Exemple d’appel</label>
<pre className="input" style={{ whiteSpace: 'pre-wrap', fontSize: 11.5, userSelect: 'text' }}>{`curl -X POST http://<ip-de-ce-pc>:${settings.webhook.port}/eveflow/hook \\
-H "Content-Type: application/json"${settings.webhook.secret ? ' \\\n -H "X-EveFlow-Secret: ***"' : ''} \\
-d '{"role":"assistant","text":"Rapport terminé","source":"telegram"}'`}</pre>
<span className="hint">Formats acceptés : {'{role,text}'}, {'{event:"run.completed",input,output}'}, {'{event:"job.completed",job:{name},output,status}'}, {'{type:"message",payload:{text}}'}.</span>
</div>
<div className="row">
<button className="btn primary small" onClick={() => void applyWebhook()} disabled={!bridge()}>Appliquer et redémarrer</button>
{hermesStore.webhook && <span className={`status-pill ${hermesStore.webhook.listening ? 'online' : 'offline'}`}>{hermesStore.webhook.listening ? `port ${hermesStore.webhook.port}` : hermesStore.webhook.error ?? 'inactif'}</span>}
</div>
</div>
)}
{section === 'general' && (
<div className="card">
<div className="grid-2">
<div className="field">
<label>Nom de l’assistant</label>
<input className="input" value={settings.assistantName} onChange={(e) => update({ assistantName: e.target.value.toUpperCase().slice(0, 18) || 'JARVIS' })} />
</div>
<div className="field">
<label>Votre nom</label>
<input className="input" value={settings.userName} placeholder="Monsieur" onChange={(e) => update({ userName: e.target.value.slice(0, 24) })} />
</div>
</div>
<div className="field">
<label>Thème du HUD</label>
<div className="theme-swatches">
{(['arc', 'gold', 'crimson', 'emerald'] as HudTheme[]).map((t) => {
const colors: Record<HudTheme, string> = { arc: '#34e4ff', gold: '#ffc35a', crimson: '#ff5c6c', emerald: '#4ef0a8' };
return <button key={t} className={`swatch${settings.theme === t ? ' active' : ''}`} style={{ background: colors[t] }} title={t} onClick={() => update({ theme: t })} />;
})}
</div>
</div>
<div className="field">
<label>Raccourcis globaux</label>
<span className="hint"><span className="kbd">Ctrl+Shift+Espace</span> micro · <span className="kbd">Ctrl+Shift+J</span> afficher/masquer · <span className="kbd">Ctrl+Shift+Échap</span> couper la voix</span>
</div>
<div className="divider" />
<button className="btn danger small" onClick={() => { if (confirm('Réinitialiser tous les paramètres ?')) reset(); }}><RotateCcw size={13} /> Réinitialiser</button>
</div>
)}
{section === 'ui' && (
<div className="card">
<Toggle on={settings.ui.showTelemetry} onChange={(v) => update({ ui: { showTelemetry: v } })} label="Télémétrie système" />
<Toggle on={settings.ui.showReasoning} onChange={(v) => update({ ui: { showReasoning: v } })} label="Afficher le raisonnement du modèle" hint="Quand Hermes le transmet (reasoning.available)." />
<Toggle on={settings.ui.reduceMotion} onChange={(v) => update({ ui: { reduceMotion: v } })} label="Réduire les animations" />
<div className="field" style={{ marginTop: 10 }}>
<label>Opacité du widget compact : {Math.round(settings.ui.compactOpacity * 100)}%</label>
<input className="range" type="range" min={0.5} max={1} step={0.02} value={settings.ui.compactOpacity} onChange={(e) => update({ ui: { compactOpacity: Number(e.target.value) } })} />
</div>
<span className="hint">Valeurs par défaut : {DEFAULT_SETTINGS.assistantName}, thème {DEFAULT_SETTINGS.theme}.</span>
</div>
)}
</div>
</aside>
);
}