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

+217
View File
@@ -0,0 +1,217 @@
import { create } from 'zustand';
import type { WebhookStatus } from '../../shared/ipc';
import { Log } from '../lib/log';
import { persistGet, persistSet } from '../lib/persist';
import { HermesClient, jobOutput, jobStatus, resolveTransport, type ResolvedTransport } from '../services/hermes/client';
import type {
HermesCapabilities,
HermesHealth,
HermesJob,
HermesJobDraft,
HermesModel,
HermesSession,
HermesSkill,
HermesToolset
} from '../services/hermes/types';
import { useSettings } from './settings';
export type LinkState = 'unknown' | 'checking' | 'online' | 'degraded' | 'offline';
export interface JobRun {
id: string;
jobId: string;
jobName: string;
status: 'ok' | 'failed';
output: string;
at: string;
read: boolean;
}
interface HermesStore {
link: LinkState;
linkDetail: string;
capabilities: HermesCapabilities | null;
health: HermesHealth | null;
models: HermesModel[];
skills: HermesSkill[];
toolsets: HermesToolset[];
sessions: HermesSession[];
jobs: HermesJob[];
jobRuns: JobRun[];
transport: ResolvedTransport;
lastSyncAt: number | null;
webhook: WebhookStatus | null;
busy: boolean;
client: () => HermesClient;
connect: () => Promise<void>;
refreshJobs: () => Promise<void>;
refreshSessions: () => Promise<void>;
refreshCatalog: () => Promise<void>;
refreshWebhook: () => Promise<void>;
createJob: (draft: HermesJobDraft) => Promise<void>;
updateJob: (id: string, patch: Partial<HermesJobDraft>) => Promise<void>;
jobAction: (id: string, action: 'pause' | 'resume' | 'run' | 'delete') => Promise<void>;
markRunRead: (id: string) => void;
loadCache: () => Promise<void>;
}
const CACHE_KEY = 'eveflow.hermes.cache.v2';
const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase());
function runFromJob(job: HermesJob): JobRun | null {
const output = jobOutput(job);
const status = String(job.last_status ?? '').toLowerCase();
if (!output || !job.last_run_at || !isTerminal(status || 'ok')) return null;
const at = String(job.last_run_at);
return {
id: `${job.id}:${at}`,
jobId: job.id,
jobName: job.name || job.id,
status: status.includes('fail') || status === 'error' ? 'failed' : 'ok',
output,
at,
read: false
};
}
export const useHermes = create<HermesStore>((set, get) => ({
link: 'unknown',
linkDetail: '',
capabilities: null,
health: null,
models: [],
skills: [],
toolsets: [],
sessions: [],
jobs: [],
jobRuns: [],
transport: 'completions',
lastSyncAt: null,
webhook: null,
busy: false,
client: () => new HermesClient(useSettings.getState().settings.hermes),
connect: async () => {
const config = useSettings.getState().settings.hermes;
if (!config.url.trim()) {
set({ link: 'offline', linkDetail: 'URL Hermes non configurée' });
return;
}
set({ link: 'checking', linkDetail: '' });
const client = get().client();
try {
const health = await client.health();
let capabilities: HermesCapabilities | null = null;
try {
capabilities = await client.capabilities();
} catch (err) {
Log.warn('hermes', `capabilities unavailable: ${(err as Error).message}`);
}
const transport = resolveTransport(config, capabilities);
const degraded = String(health.status ?? 'ok').toLowerCase() !== 'ok';
set({ health, capabilities, transport, link: degraded ? 'degraded' : 'online', linkDetail: degraded ? `état ${health.status}` : '' });
Log.info('hermes', `connected (${transport})`, { status: health.status, model: capabilities?.model });
void get().refreshCatalog();
void get().refreshJobs();
void get().refreshSessions();
} catch (err) {
const message = (err as Error).message;
set({ link: 'offline', linkDetail: message, transport: resolveTransport(config, null) });
Log.warn('hermes', `connection failed: ${message}`);
}
},
refreshCatalog: async () => {
const client = get().client();
const [models, skills, toolsets] = await Promise.all([
client.models().catch(() => [] as HermesModel[]),
client.skills().catch(() => [] as HermesSkill[]),
client.toolsets().catch(() => [] as HermesToolset[])
]);
set({ models, skills, toolsets });
},
refreshSessions: async () => {
try {
const sessions = await get().client().listSessions();
set({ sessions });
} catch (err) {
Log.debug('hermes', `sessions unavailable: ${(err as Error).message}`);
}
},
refreshJobs: async () => {
if (!useSettings.getState().settings.hermes.url.trim()) return;
try {
const jobs = await get().client().listJobs();
const previous = get().jobRuns;
const known = new Map(previous.map((r) => [r.id, r]));
const incoming = jobs.map(runFromJob).filter((r): r is JobRun => !!r).map((r) => ({ ...r, read: known.get(r.id)?.read ?? false }));
const merged = [...incoming, ...previous.filter((r) => !incoming.some((i) => i.id === r.id))]
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
.slice(0, 100);
set({ jobs, jobRuns: merged, lastSyncAt: Date.now(), link: get().link === 'offline' ? 'online' : get().link });
persistSet(CACHE_KEY, { jobs, jobRuns: merged, syncedAt: Date.now() });
} catch (err) {
const message = (err as Error).message;
Log.warn('hermes', `jobs sync failed: ${message}`);
if (/HTTP 404/.test(message)) return; // jobs API disabled on this server
set({ link: 'offline', linkDetail: message });
}
},
refreshWebhook: async () => {
const api = window.eveflow;
if (!api) return;
try {
set({ webhook: await api.hermes.webhookStatus() });
} catch {
/* ignore */
}
},
createJob: async (draft) => {
set({ busy: true });
try {
await get().client().createJob(draft);
await get().refreshJobs();
} finally {
set({ busy: false });
}
},
updateJob: async (id, patch) => {
set({ busy: true });
try {
await get().client().updateJob(id, patch);
await get().refreshJobs();
} finally {
set({ busy: false });
}
},
jobAction: async (id, action) => {
set({ busy: true });
const client = get().client();
try {
if (action === 'pause') await client.pauseJob(id);
else if (action === 'resume') await client.resumeJob(id);
else if (action === 'run') await client.runJob(id);
else await client.deleteJob(id);
await get().refreshJobs();
} finally {
set({ busy: false });
}
},
markRunRead: (id) => {
const jobRuns = get().jobRuns.map((r) => (r.id === id ? { ...r, read: true } : r));
set({ jobRuns });
persistSet(CACHE_KEY, { jobs: get().jobs, jobRuns, syncedAt: get().lastSyncAt });
},
loadCache: async () => {
const cache = await persistGet<{ jobs?: HermesJob[]; jobRuns?: JobRun[]; syncedAt?: number }>(CACHE_KEY);
if (cache) set({ jobs: cache.jobs ?? [], jobRuns: cache.jobRuns ?? [], lastSyncAt: cache.syncedAt ?? null });
}
}));
export { jobStatus };