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

+376
View File
@@ -0,0 +1,376 @@
import { useEffect, useState } from 'react';
import {
Wrench, CalendarClock, Sparkles, Layers, History, Play, Pause, Trash2, Pencil, Plus, Save, X, RefreshCw,
CheckCircle2, XCircle, Loader2, Bot, Cpu, GitBranch, Webhook, FolderOpen
} from 'lucide-react';
import { formatDateTime, formatTime, previewText } from '../../lib/text';
import { bridge } from '../../lib/bridge';
import { jobSchedule, jobOutput } from '../../services/hermes/client';
import type { HermesJob, HermesJobDraft } from '../../services/hermes/types';
import { useChat } from '../../state/chat';
import { jobStatus, useHermes } from '../../state/hermes';
import { useSettings } from '../../state/settings';
type Tab = 'activity' | 'jobs' | 'skills' | 'sessions' | 'link';
const emptyDraft = (): HermesJobDraft => ({ name: '', schedule: 'every 1h', prompt: '', deliver: 'local' });
function ActivityTab() {
const activity = useChat((s) => s.activity);
const items = [...activity].reverse();
if (items.length === 0) return <div className="empty">Aucune activité outil pour l’instant.</div>;
return (
<div>
{items.map((a) => (
<div key={a.id} className={`activity-item ${a.status}`}>
<span className="icon">
{a.status === 'running' ? <Loader2 size={13} className="spin" /> : a.status === 'done' ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
</span>
<div>
<div className="name">{a.kind === 'subagent' ? <><Bot size={11} /> </> : null}{a.name}</div>
{a.detail && <div className="detail">{previewText(a.detail, 220)}</div>}
{a.output && <div className="detail muted">{previewText(a.output, 220)}</div>}
</div>
<span className="time">{formatTime(a.startedAt)}{a.endedAt ? ` · ${((a.endedAt - a.startedAt) / 1000).toFixed(1)}s` : ''}</span>
</div>
))}
</div>
);
}
function JobsTab() {
const jobs = useHermes((s) => s.jobs);
const busy = useHermes((s) => s.busy);
const lastSyncAt = useHermes((s) => s.lastSyncAt);
const refreshJobs = useHermes((s) => s.refreshJobs);
const createJob = useHermes((s) => s.createJob);
const updateJob = useHermes((s) => s.updateJob);
const jobAction = useHermes((s) => s.jobAction);
const setError = useChat((s) => s.setError);
const [draft, setDraft] = useState<HermesJobDraft>(emptyDraft);
const [editing, setEditing] = useState<string | null>(null);
const [showEditor, setShowEditor] = useState(false);
const run = async (task: Promise<void>) => {
try {
await task;
} catch (err) {
setError(`Cron Hermes : ${(err as Error).message}`);
}
};
const startEdit = (job: HermesJob) => {
setEditing(job.id);
setDraft({ name: job.name ?? job.id, schedule: jobSchedule(job), prompt: job.prompt ?? '', deliver: job.deliver ?? 'local' });
setShowEditor(true);
};
const save = async () => {
if (editing) await run(updateJob(editing, draft));
else await run(createJob(draft));
setEditing(null);
setDraft(emptyDraft());
setShowEditor(false);
};
const valid = draft.name.trim() && draft.schedule.trim() && draft.prompt.trim();
return (
<div>
<div className="row" style={{ marginBottom: 10, justifyContent: 'space-between' }}>
<span className="muted mono" style={{ fontSize: 11 }}>{lastSyncAt ? `sync ${formatTime(lastSyncAt)}` : 'non synchronisé'}</span>
<span className="row">
<button className="icon-btn" title="Synchroniser" onClick={() => void run(refreshJobs())}><RefreshCw size={14} className={busy ? 'spin' : undefined} /></button>
<button className="btn small" onClick={() => { setEditing(null); setDraft(emptyDraft()); setShowEditor((v) => !v); }}><Plus size={13} /> Cron</button>
</span>
</div>
{showEditor && (
<div className="job-editor">
<input className="input" placeholder="Nom de la mission" value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
<input className="input" placeholder="every 1h · 0 9 * * MON-FRI · in 30m · every monday 9am" value={draft.schedule} onChange={(e) => setDraft({ ...draft, schedule: e.target.value })} />
<textarea className="textarea" placeholder="Instruction à exécuter par Hermes" value={draft.prompt} onChange={(e) => setDraft({ ...draft, prompt: e.target.value })} />
<input className="input" placeholder="deliver : local · origin · telegram · all" value={draft.deliver ?? ''} onChange={(e) => setDraft({ ...draft, deliver: e.target.value })} />
<div className="row">
<button className="btn ghost small" onClick={() => { setShowEditor(false); setEditing(null); }}><X size={13} /> Annuler</button>
<button className="btn primary small" disabled={!valid || busy} onClick={() => void save()}><Save size={13} /> {editing ? 'Mettre à jour' : 'Créer'}</button>
</div>
</div>
)}
{jobs.length === 0 ? (
<div className="empty">Aucun cron Hermes. Créez une mission planifiée en langage naturel.</div>
) : (
<div className="list">
{jobs.map((job) => {
const status = jobStatus(job).toLowerCase();
const paused = status === 'paused' || job.enabled === false;
const output = jobOutput(job);
return (
<div key={job.id} className="row-item">
<div className="main">
<div className="row wrap" style={{ gap: 6 }}>
<span className="name">{job.name || job.id}</span>
<span className={`status-pill ${status}`}>{status}</span>
{job.last_status && <span className={`status-pill ${String(job.last_status).toLowerCase()}`}>{String(job.last_status)}</span>}
</div>
<span className="meta">{jobSchedule(job)} · prochain {formatDateTime(job.next_run_at)}</span>
{job.prompt && <span className="desc">{previewText(job.prompt, 140)}</span>}
{output && <span className="meta">↳ {previewText(output, 120)}</span>}
</div>
<div className="actions">
<button className="icon-btn" title="Exécuter maintenant" onClick={() => void run(jobAction(job.id, 'run'))}><Play size={13} /></button>
<button className="icon-btn" title={paused ? 'Reprendre' : 'Mettre en pause'} onClick={() => void run(jobAction(job.id, paused ? 'resume' : 'pause'))}>{paused ? <Play size={13} /> : <Pause size={13} />}</button>
<button className="icon-btn" title="Modifier" onClick={() => startEdit(job)}><Pencil size={13} /></button>
<button className="icon-btn danger" title="Supprimer" onClick={() => { if (confirm(`Supprimer le cron « ${job.name || job.id} » ?`)) void run(jobAction(job.id, 'delete')); }}><Trash2 size={13} /></button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function HistoryList() {
const runs = useHermes((s) => s.jobRuns);
const markRead = useHermes((s) => s.markRunRead);
const addMessage = useChat((s) => s.addMessage);
if (runs.length === 0) return null;
return (
<>
<div className="section-title"><History size={12} /> Résultats récents</div>
<div className="list">
{runs.slice(0, 20).map((r) => (
<div
key={r.id}
className="row-item clickable"
style={{ opacity: r.read ? 0.7 : 1 }}
onClick={() => {
markRead(r.id);
addMessage({ role: 'assistant', content: r.output, source: 'cron', jobName: r.jobName, status: 'done', timestamp: new Date(r.at).getTime() || Date.now() });
}}
>
<div className="main">
<div className="row wrap" style={{ gap: 6 }}>
<span className="name">{r.jobName}</span>
<span className={`status-pill ${r.status}`}>{r.status}</span>
</div>
<span className="desc">{previewText(r.output, 160)}</span>
<span className="meta">{formatDateTime(r.at)}</span>
</div>
</div>
))}
</div>
</>
);
}
function SkillsTab() {
const skills = useHermes((s) => s.skills);
const toolsets = useHermes((s) => s.toolsets);
const refresh = useHermes((s) => s.refreshCatalog);
const [filter, setFilter] = useState('');
const q = filter.toLowerCase();
const visibleSkills = skills.filter((s) => !q || `${s.name} ${s.description ?? ''} ${s.category ?? ''}`.toLowerCase().includes(q));
const visibleToolsets = toolsets.filter((t) => !q || `${t.name} ${t.label ?? ''} ${t.description ?? ''} ${(t.tools ?? []).join(' ')}`.toLowerCase().includes(q));
return (
<div>
<div className="row" style={{ marginBottom: 10 }}>
<input className="input" placeholder="Filtrer les skills et toolsets…" value={filter} onChange={(e) => setFilter(e.target.value)} />
<button className="icon-btn" title="Recharger" onClick={() => void refresh()}><RefreshCw size={14} /></button>
</div>
<div className="section-title"><Sparkles size={12} /> Skills ({skills.length})</div>
{visibleSkills.length === 0 ? <div className="empty">Aucun skill exposé par ce serveur.</div> : (
<div className="list">
{visibleSkills.map((s) => (
<div key={s.name} className="row-item">
<div className="main">
<div className="row wrap" style={{ gap: 6 }}>
<span className="name">{s.name}</span>
{s.category && <span className="status-pill">{s.category}</span>}
</div>
{s.description && <span className="desc">{s.description}</span>}
</div>
</div>
))}
</div>
)}
<div className="section-title"><Layers size={12} /> Toolsets ({toolsets.length})</div>
{visibleToolsets.length === 0 ? <div className="empty">Aucun toolset listé.</div> : (
<div className="list">
{visibleToolsets.map((t) => (
<div key={t.name} className="row-item">
<div className="main">
<div className="row wrap" style={{ gap: 6 }}>
<span className="name">{t.label || t.name}</span>
{t.enabled !== undefined && <span className={`status-pill ${t.enabled ? 'active' : 'paused'}`}>{t.enabled ? 'actif' : 'inactif'}</span>}
{t.configured === false && <span className="status-pill error">non configuré</span>}
</div>
{t.description && <span className="desc">{t.description}</span>}
{t.tools && t.tools.length > 0 && <span className="meta">{t.tools.slice(0, 12).join(' · ')}{t.tools.length > 12 ? ` · +${t.tools.length - 12}` : ''}</span>}
</div>
</div>
))}
</div>
)}
</div>
);
}
function SessionsTab() {
const sessions = useHermes((s) => s.sessions);
const refresh = useHermes((s) => s.refreshSessions);
const client = useHermes((s) => s.client);
const transport = useHermes((s) => s.transport);
const sessionId = useSettings((s) => s.settings.hermesSessionId);
const setSessionId = useSettings((s) => s.setHermesSessionId);
const chat = useChat();
const activeId = sessionId.replace(/^hs:/, '');
const load = async (id: string) => {
try {
const messages = await client().sessionMessages(id);
chat.clear();
for (const m of messages.slice(-60)) {
if (!m.content) continue;
chat.addMessage({ role: m.role === 'user' ? 'user' : 'assistant', content: String(m.content), status: 'done', source: 'hermes', timestamp: m.timestamp ? new Date(m.timestamp).getTime() : Date.now() });
}
setSessionId(transport === 'sessions' ? `hs:${id}` : id);
} catch (err) {
chat.setError(`Session : ${(err as Error).message}`);
}
};
const fresh = () => {
chat.clear();
setSessionId(`eveflow-${Date.now().toString(36)}`);
};
return (
<div>
<div className="row" style={{ marginBottom: 10, justifyContent: 'space-between' }}>
<span className="muted mono" style={{ fontSize: 11 }}>session {previewText(activeId, 28)}</span>
<span className="row">
<button className="icon-btn" title="Recharger" onClick={() => void refresh()}><RefreshCw size={14} /></button>
<button className="btn small" onClick={fresh}><Plus size={13} /> Nouvelle</button>
</span>
</div>
{sessions.length === 0 ? <div className="empty">Aucune session listée par Hermes.</div> : (
<div className="list">
{sessions.map((s) => (
<div key={s.id} className={`row-item clickable`} style={s.id === activeId ? { borderColor: 'var(--accent)' } : undefined} onClick={() => void load(s.id)}>
<div className="main">
<span className="name">{s.title || s.id}</span>
<span className="meta">{[s.platform ?? s.source, s.message_count ? `${s.message_count} msg` : null, formatDateTime(s.updated_at ?? s.created_at)].filter(Boolean).join(' · ')}</span>
</div>
<div className="actions">
<button className="icon-btn" title="Bifurquer (fork)" onClick={(e) => { e.stopPropagation(); client().forkSession(s.id).then(() => refresh()).catch((err: Error) => chat.setError(err.message)); }}><GitBranch size={13} /></button>
<button className="icon-btn danger" title="Supprimer" onClick={(e) => { e.stopPropagation(); if (confirm('Supprimer cette session Hermes ?')) client().deleteSession(s.id).then(() => refresh()).catch((err: Error) => chat.setError(err.message)); }}><Trash2 size={13} /></button>
</div>
</div>
))}
</div>
)}
</div>
);
}
function LinkTab() {
const { link, linkDetail, health, capabilities, transport, models, webhook } = useHermes();
const connect = useHermes((s) => s.connect);
const hermes = useSettings((s) => s.settings.hermes);
const [info, setInfo] = useState<{ sharedDir: string; logPath: string; version: string } | null>(null);
useEffect(() => {
bridge()?.system.appInfo().then(setInfo).catch(() => undefined);
}, []);
const checks = (health?.readiness as { checks?: Record<string, unknown> } | undefined)?.checks ?? {};
const features = capabilities?.features ?? {};
return (
<div>
<div className="section-title"><Cpu size={12} /> Liaison Hermes</div>
<dl className="kv">
<dt>État</dt><dd><span className={`status-pill ${link}`}>{link}</span> {linkDetail}</dd>
<dt>URL</dt><dd>{hermes.url || '—'}</dd>
<dt>Transport</dt><dd>{transport}{hermes.transport === 'auto' ? ' (auto)' : ''}</dd>
<dt>Modèle</dt><dd>{hermes.model || String(capabilities?.model ?? '—')}</dd>
{capabilities?.version ? <><dt>Version</dt><dd>{String(capabilities.version)}</dd></> : null}
<dt>Modèles</dt><dd>{models.length ? models.map((m) => m.id).slice(0, 6).join(', ') + (models.length > 6 ? '…' : '') : '—'}</dd>
</dl>
<div className="row" style={{ marginTop: 8 }}>
<button className="btn small" onClick={() => void connect()}><RefreshCw size={13} /> Reconnecter</button>
</div>
{Object.keys(features).length > 0 && (
<>
<div className="section-title"><Layers size={12} /> Capacités</div>
<div className="row wrap" style={{ gap: 6 }}>
{Object.entries(features).map(([k, v]) => (
<span key={k} className={`status-pill ${v ? 'ok' : ''}`}>{k}{typeof v === 'string' ? `: ${v}` : ''}</span>
))}
</div>
</>
)}
{Object.keys(checks).length > 0 && (
<>
<div className="section-title"><CheckCircle2 size={12} /> Santé</div>
<dl className="kv">
{Object.entries(checks).map(([k, v]) => (
<><dt key={`${k}-t`}>{k}</dt><dd key={`${k}-d`}>{typeof v === 'object' ? JSON.stringify(v) : String(v)}</dd></>
))}
</dl>
</>
)}
<div className="section-title"><Webhook size={12} /> Webhook entrant</div>
<dl className="kv">
<dt>État</dt><dd><span className={`status-pill ${webhook?.listening ? 'online' : 'offline'}`}>{webhook?.listening ? 'à l’écoute' : 'inactif'}</span> {webhook?.error}</dd>
<dt>Endpoint</dt><dd>POST http://&lt;cette-machine&gt;:{webhook?.port ?? 7842}{webhook?.path ?? '/eveflow/hook'}</dd>
<dt>Secret</dt><dd>{webhook?.secretConfigured ? 'requis (X-EveFlow-Secret)' : 'aucun'}</dd>
</dl>
{info && (
<>
<div className="section-title"><FolderOpen size={12} /> Application</div>
<dl className="kv">
<dt>Version</dt><dd>{info.version}</dd>
<dt>Partage</dt><dd><a href="#" onClick={(e) => { e.preventDefault(); bridge()?.files.openPath(info.sharedDir); }}>{info.sharedDir}</a></dd>
<dt>Journal</dt><dd><a href="#" onClick={(e) => { e.preventDefault(); bridge()?.files.showInFolder(info.logPath); }}>{info.logPath}</a></dd>
</dl>
</>
)}
</div>
);
}
export function OpsPanel() {
const [tab, setTab] = useState<Tab>('activity');
const running = useChat((s) => s.activity.filter((a) => a.status === 'running').length);
const unread = useHermes((s) => s.jobRuns.filter((r) => !r.read).length);
const link = useHermes((s) => s.link);
const tabs: Array<{ id: Tab; label: string; icon: React.ReactNode; badge?: number }> = [
{ id: 'activity', label: 'Outils', icon: <Wrench size={13} />, badge: running },
{ id: 'jobs', label: 'Crons', icon: <CalendarClock size={13} />, badge: unread },
{ id: 'skills', label: 'Skills', icon: <Sparkles size={13} /> },
{ id: 'sessions', label: 'Sessions', icon: <History size={13} /> },
{ id: 'link', label: 'Liaison', icon: <Cpu size={13} /> }
];
return (
<section className="panel bracket ops-panel">
<header className="panel-head">
<span className={`led ${link === 'online' ? '' : link === 'offline' ? 'error' : link === 'unknown' ? 'off' : 'warn'}`} />
<span>Hermes Ops</span>
</header>
<div className="tabs">
{tabs.map((t) => (
<button key={t.id} className={`tab${tab === t.id ? ' active' : ''}`} onClick={() => setTab(t.id)}>
{t.icon} {t.label}
{t.badge ? <span className="badge">{t.badge}</span> : null}
</button>
))}
</div>
<div className="panel-body">
{tab === 'activity' && <ActivityTab />}
{tab === 'jobs' && (<><JobsTab /><HistoryList /></>)}
{tab === 'skills' && <SkillsTab />}
{tab === 'sessions' && <SessionsTab />}
{tab === 'link' && <LinkTab />}
</div>
</section>
);
}