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

+8
View File
@@ -0,0 +1,8 @@
import type { EveFlowBridge } from '../../shared/bridge';
/** Returns the Electron bridge, or null when running in a plain browser (Vite dev without Electron). */
export function bridge(): EveFlowBridge | null {
return typeof window !== 'undefined' && window.eveflow ? window.eveflow : null;
}
export const isElectron = (): boolean => bridge() !== null;
+10
View File
@@ -0,0 +1,10 @@
export function uid(prefix = ''): string {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
return prefix ? `${prefix}-${random}` : random;
}
+37
View File
@@ -0,0 +1,37 @@
import type { LogEntry } from '../../shared/ipc';
import { bridge } from './bridge';
type Level = LogEntry['level'];
const MAX_CONSOLE = 1500;
function emit(level: Level, tag: string, message: string, data?: unknown): void {
const entry: LogEntry = { ts: new Date().toISOString(), level, tag, message, data };
const line = `[${tag}] ${message}`;
const extra = data !== undefined ? safe(data) : '';
if (level === 'ERROR') console.error(line, extra);
else if (level === 'WARN') console.warn(line, extra);
else if (level === 'DEBUG') console.debug(line, extra);
else console.info(line, extra);
try {
bridge()?.log(entry);
} catch {
/* browser context */
}
}
function safe(data: unknown): string {
try {
const text = JSON.stringify(data);
return text.length > MAX_CONSOLE ? text.slice(0, MAX_CONSOLE) + '...' : text;
} catch {
return String(data);
}
}
export const Log = {
debug: (tag: string, message: string, data?: unknown) => emit('DEBUG', tag, message, data),
info: (tag: string, message: string, data?: unknown) => emit('INFO', tag, message, data),
warn: (tag: string, message: string, data?: unknown) => emit('WARN', tag, message, data),
error: (tag: string, message: string, data?: unknown) => emit('ERROR', tag, message, data)
};
+32
View File
@@ -0,0 +1,32 @@
import { bridge } from './bridge';
/**
* Persistent key/value storage: Electron userData JSON file when available,
* localStorage otherwise. Values are JSON-serialisable objects.
*/
export async function persistGet<T>(key: string): Promise<T | null> {
const api = bridge();
if (api) {
try {
const value = await api.store.get<T>(key);
if (value !== null && value !== undefined) return value;
} catch {
/* fall through to localStorage */
}
}
try {
const raw = window.localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
export function persistSet(key: string, value: unknown): void {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* quota or unavailable */
}
void bridge()?.store.set(key, value).catch(() => undefined);
}
+73
View File
@@ -0,0 +1,73 @@
export interface SseMessage {
event: string;
data: string;
id?: string;
}
/**
* Incremental Server-Sent Events parser (spec-compliant subset: event/data/id/comments,
* multi-line data joined with "\n", CRLF tolerant). Feed arbitrary text chunks.
*/
export class SseParser {
private buffer = '';
private event = '';
private data: string[] = [];
private id: string | undefined;
constructor(private readonly onMessage: (message: SseMessage) => void) {}
feed(chunk: string): void {
this.buffer += chunk;
let index: number;
while ((index = this.buffer.search(/\r\n|\n|\r/)) !== -1) {
const line = this.buffer.slice(0, index);
const sepLength = this.buffer.startsWith('\r\n', index) ? 2 : 1;
this.buffer = this.buffer.slice(index + sepLength);
this.processLine(line);
}
}
/** Flush any pending message (call when the stream ends). */
end(): void {
if (this.buffer.length) {
const rest = this.buffer;
this.buffer = '';
this.processLine(rest);
}
this.dispatch();
}
private processLine(line: string): void {
if (line === '') {
this.dispatch();
return;
}
if (line.startsWith(':')) return;
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? '' : line.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1);
switch (field) {
case 'event': this.event = value; break;
case 'data': this.data.push(value); break;
case 'id': this.id = value; break;
default: break;
}
}
private dispatch(): void {
if (this.data.length === 0 && !this.event) return;
const message: SseMessage = { event: this.event || 'message', data: this.data.join('\n'), id: this.id };
this.event = '';
this.data = [];
this.onMessage(message);
}
}
export function tryParseJson<T = unknown>(text: string): T | null {
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
+154
View File
@@ -0,0 +1,154 @@
/** Text utilities shared by the chat renderer and the speech pipeline. */
const PHONETIC: Record<string, string> = {
api: 'a-pé-i',
cors: 'korss',
svg: 'ess-vé-gé',
cpu: 'cé-pé-u',
gpu: 'gé-pé-u',
fps: 'eff-pé-ess',
tts: 'té-té-ess',
stt: 'ess-té-té',
ui: 'u-i',
json: 'djé-zone',
url: 'u-err-el',
html: 'ach-té-em-el',
css: 'cé-ess-ess',
js: 'ji-ess',
github: 'guite-heub',
git: 'guite',
npm: 'enne-pé-em',
cli: 'cé-el-i',
ipc: 'i-pé-cé',
hermes: 'hermès',
ok: 'oké'
};
/** Convert `MEDIA:/path` tokens (Hermes gateway shorthand) to markdown images. */
export function preprocessMedia(content: string): string {
if (!content) return content;
return content.replace(/MEDIA:\s*(\S+)/g, (_m, p: string) => `\n\n![image](${p})\n\n`);
}
/** Strip markdown, code, links, URLs and emojis so the text can be spoken naturally. */
export function cleanForSpeech(raw: string): string {
if (!raw) return '';
let text = raw.replace(/MEDIA:\s*\S+/g, '');
text = text.replace(/```[\s\S]*?```/g, ' ');
text = text.replace(/<think>[\s\S]*?<\/think>/gi, ' ');
text = text.replace(/`([^`]+)`/g, '$1');
text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
text = text.replace(/\[([^\]]+)\]\((?:[^)(]+|\([^)(]*\))*\)/g, '$1');
text = text.replace(/(?:https?|ftp|file):\/\/\S+/gi, '');
text = text.replace(/www\.\S+/gi, '');
text = text.replace(/^\s{0,3}#{1,6}\s+/gm, '');
text = text.replace(/^\s*[-*+]\s+/gm, ', ');
text = text.replace(/^\s*\d+[.)]\s+/gm, ', ');
text = text.replace(/^\s*>\s?/gm, '');
text = text.replace(/\|/g, ' ');
text = text.replace(/:\s*(?:\r?\n)/g, '. ');
text = text.replace(/(?:\r?\n){2,}/g, '. ');
text = text.replace(/\r?\n/g, ' ');
text = text
.split(/\s+/)
.filter((word) => !(word.includes('/') && word.length > 3) && !/^[\d.]+:\d+$/.test(word) && !/^\d{1,3}(\.\d{1,3}){3}$/.test(word))
.join(' ');
text = text
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE00}-\u{FEFF}\u{200D}]/gu, '')
.replace(/(\*\*|__|~~|[*_#`~])/g, '')
.replace(/\[[^\]]*\]/g, '')
.replace(/(-{2,}|={2,}|~{2,})/g, ' ')
.replace(/\.{3,}/g, '...')
.replace(/[<>{}\\^]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
return applyPhonetics(text);
}
function applyPhonetics(text: string): string {
let out = text;
for (const [key, value] of Object.entries(PHONETIC)) {
const escaped = key.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
out = out.replace(new RegExp(`(?<=^|\\s|\\p{P})${escaped}(?=$|\\s|\\p{P})`, 'giu'), value);
}
return out;
}
/**
* Pull complete sentences out of a streaming buffer.
* Returns the sentences ready to be spoken and the remainder to keep buffering.
*/
export function extractSentences(buffer: string, minLength = 12): { sentences: string[]; rest: string } {
const sentences: string[] = [];
let rest = buffer;
// Never split inside an unfinished code block.
const fences = (buffer.match(/```/g) ?? []).length;
if (fences % 2 === 1) return { sentences, rest };
const regex = /[^.!?\n\r]+(?:[.!?]+(?=\s|$)|[\n\r]+)/g;
let consumed = 0;
let match: RegExpExecArray | null;
let pending = '';
while ((match = regex.exec(buffer)) !== null) {
if (match.index !== consumed) break; // gap: stop at first non-contiguous match
const candidate = (pending + match[0]).trim();
consumed = match.index + match[0].length;
if (candidate.length < minLength || /\b(?:\d+|[A-Z]|M|Mme|Dr|etc|ex)\.$/.test(candidate)) {
pending = pending + match[0];
continue;
}
sentences.push(candidate);
pending = '';
}
rest = pending + buffer.slice(consumed);
return { sentences, rest };
}
/** Split a finished text into speakable chunks of bounded size. */
export function chunkForSpeech(text: string, maxLength = 220): string[] {
const { sentences, rest } = extractSentences(text + '\n', 1);
const all = [...sentences, rest.trim()].filter(Boolean);
const out: string[] = [];
for (const sentence of all) {
if (sentence.length <= maxLength) {
out.push(sentence);
continue;
}
let current = '';
for (const word of sentence.split(/\s+/)) {
if ((current + ' ' + word).trim().length > maxLength) {
if (current) out.push(current.trim());
current = word;
} else {
current = current ? `${current} ${word}` : word;
}
}
if (current) out.push(current.trim());
}
return out;
}
export function previewText(value: string, max = 96): string {
const flat = value.replace(/\s+/g, ' ').trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
export function formatTime(date: Date | string | number): string {
const d = date instanceof Date ? date : new Date(date);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
export function formatDateTime(date: Date | string | number | undefined): string {
if (!date) return '—';
const d = date instanceof Date ? date : new Date(date);
return Number.isNaN(d.getTime()) ? String(date) : d.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' });
}
export function formatDuration(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}j ${h}h`;
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`;
return `${m}m ${(s % 60).toString().padStart(2, '0')}s`;
}
+137
View File
@@ -0,0 +1,137 @@
import type { HttpProxyRequest, HttpProxyResponse, HttpStreamEvent, HttpStreamStart } from '../../shared/ipc';
import { bridge } from './bridge';
import { uid } from './id';
export class HttpError extends Error {
constructor(public readonly status: number, message: string, public readonly body?: string) {
super(message);
this.name = 'HttpError';
}
}
/** One-shot HTTP request routed through the Electron main process (or fetch in a browser). */
export async function httpFetch(req: HttpProxyRequest): Promise<HttpProxyResponse> {
const api = bridge();
if (api) return api.http.fetch(req);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), req.timeoutMs ?? 60_000);
try {
const headers: Record<string, string> = { ...(req.headers ?? {}) };
let body: BodyInit | undefined;
if (req.multipart) {
const form = new FormData();
for (const [k, v] of Object.entries(req.multipart.fields)) form.append(k, v);
const f = req.multipart.file;
const bytes = new Uint8Array(f.data);
form.append(req.multipart.fileField ?? 'file', new Blob([bytes], { type: f.type }), f.name);
body = form;
delete headers['Content-Type'];
} else {
body = req.body;
}
const res = await fetch(req.url, { method: req.method ?? 'GET', headers, body, signal: controller.signal });
const outHeaders: Record<string, string> = {};
res.headers.forEach((v, k) => (outHeaders[k.toLowerCase()] = v));
const base = { ok: res.ok, status: res.status, statusText: res.statusText, headers: outHeaders };
if (req.responseType === 'binary') return { ...base, binary: new Uint8Array(await res.arrayBuffer()) };
return { ...base, text: await res.text() };
} finally {
clearTimeout(timer);
}
}
export interface StreamHandlers {
onChunk: (text: string) => void;
}
export interface StreamHandle {
start: HttpStreamStart;
done: Promise<void>;
abort: () => void;
}
// One global listener dispatches IPC stream events to per-request handlers.
const streamHandlers = new Map<string, { onChunk: (t: string) => void; onEnd: () => void; onError: (m: string) => void }>();
let listenerInstalled = false;
function installListener(): void {
if (listenerInstalled) return;
const api = bridge();
if (!api) return;
listenerInstalled = true;
api.http.onStreamEvent((event: HttpStreamEvent) => {
const h = streamHandlers.get(event.id);
if (!h) return;
if (event.type === 'chunk') h.onChunk(event.text);
else if (event.type === 'end') {
streamHandlers.delete(event.id);
h.onEnd();
} else {
streamHandlers.delete(event.id);
h.onError(event.message);
}
});
}
/**
* Streaming HTTP request. Resolves once headers are received; `done` resolves when the body ends.
* Text chunks are delivered incrementally (SSE parsing is up to the caller).
*/
export async function httpStream(req: HttpProxyRequest, handlers: StreamHandlers): Promise<StreamHandle> {
const api = bridge();
if (api) {
installListener();
const id = uid('stream');
let resolveDone!: () => void;
let rejectDone!: (err: Error) => void;
const done = new Promise<void>((resolve, reject) => {
resolveDone = resolve;
rejectDone = reject;
});
streamHandlers.set(id, {
onChunk: handlers.onChunk,
onEnd: () => resolveDone(),
onError: (message) => rejectDone(new Error(message))
});
let start: HttpStreamStart;
try {
start = await api.http.streamStart(id, req);
} catch (err) {
streamHandlers.delete(id);
throw err;
}
return { start, done, abort: () => api.http.streamAbort(id) };
}
// Browser fallback (Vite dev in a normal browser)
const controller = new AbortController();
const res = await fetch(req.url, {
method: req.method ?? 'GET',
headers: req.headers,
body: req.body,
signal: controller.signal
});
const headers: Record<string, string> = {};
res.headers.forEach((v, k) => (headers[k.toLowerCase()] = v));
const start: HttpStreamStart = { id: uid('stream'), ok: res.ok, status: res.status, statusText: res.statusText, headers };
const done = (async () => {
if (!res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done: finished, value } = await reader.read();
if (finished) break;
handlers.onChunk(decoder.decode(value, { stream: true }));
}
const tail = decoder.decode();
if (tail) handlers.onChunk(tail);
})();
return { start, done, abort: () => controller.abort() };
}
/** Collects the whole body of a streaming response (used to read error payloads). */
export async function collectStreamBody(handle: StreamHandle, first: string[]): Promise<string> {
await handle.done.catch(() => undefined);
return first.join('');
}