mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-24 07:38:14 +02:00
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:
239 files changed
+11351
-86495
No files matched your search
@@ -0,0 +1,113 @@
|
||||
import { app, ipcMain, shell } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { IPC } from '../../shared/ipc';
|
||||
import { log } from '../logger';
|
||||
|
||||
const READABLE_EXT: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.txt': 'text/plain',
|
||||
'.log': 'text/plain',
|
||||
'.csv': 'text/csv',
|
||||
'.json': 'application/json',
|
||||
'.md': 'text/markdown',
|
||||
'.xml': 'application/xml',
|
||||
'.html': 'text/html'
|
||||
};
|
||||
|
||||
const FORBIDDEN_EXT = new Set([
|
||||
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh',
|
||||
'.com', '.hta', '.vbe', '.wsf', '.lnk', '.jar', '.dll', '.js', '.jse'
|
||||
]);
|
||||
|
||||
export function getSharedDirectory(): string {
|
||||
const candidates = [
|
||||
path.join(os.homedir(), 'Documents', 'EveFlow_Shared'),
|
||||
path.join(app.getPath('userData'), 'shared')
|
||||
];
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
} catch {
|
||||
/* try next candidate */
|
||||
}
|
||||
}
|
||||
return app.getPath('temp');
|
||||
}
|
||||
|
||||
function normalizeSafe(filePath: string): string {
|
||||
if (!filePath || typeof filePath !== 'string') throw new Error('Chemin invalide');
|
||||
const normalized = path.normalize(filePath);
|
||||
if (normalized.split(/[\\/]/).includes('..')) throw new Error('Traversee de repertoire refusee');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isInsideAllowedRoots(target: string): boolean {
|
||||
const roots = [getSharedDirectory(), os.homedir(), app.getPath('temp')];
|
||||
const resolved = path.resolve(target);
|
||||
return roots.some((root) => resolved.startsWith(path.resolve(root)));
|
||||
}
|
||||
|
||||
function stripFileScheme(value: string): string {
|
||||
let out = value.replace(/^file:\/\//, '');
|
||||
// file:///C:/x -> /C:/x on Windows-style paths
|
||||
if (/^\/[a-zA-Z]:/.test(out)) out = out.slice(1);
|
||||
return decodeURIComponent(out);
|
||||
}
|
||||
|
||||
export function registerFilesIpc(): void {
|
||||
ipcMain.handle(IPC.readLocalFile, async (_e, filePath: string) => {
|
||||
const target = normalizeSafe(stripFileScheme(filePath));
|
||||
const ext = path.extname(target).toLowerCase();
|
||||
const mime = READABLE_EXT[ext];
|
||||
if (!mime) throw new Error(`Type de fichier non autorise : ${ext || '(aucune extension)'}`);
|
||||
if (!isInsideAllowedRoots(target)) throw new Error('Chemin hors des dossiers autorises');
|
||||
const stat = await fs.promises.stat(target);
|
||||
if (stat.size > 25 * 1024 * 1024) throw new Error('Fichier trop volumineux (25 Mo max)');
|
||||
const content = await fs.promises.readFile(target);
|
||||
return `data:${mime};base64,${content.toString('base64')}`;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.writeSharedFile, async (_e, filename: string, content: string, isBase64 = false) => {
|
||||
if (!filename || typeof filename !== 'string') throw new Error('Nom de fichier invalide');
|
||||
const safeName = path.basename(filename);
|
||||
if (safeName !== filename || safeName.startsWith('.') || /[<>:"|?*]/.test(safeName)) {
|
||||
throw new Error('Nom de fichier non securise');
|
||||
}
|
||||
const ext = path.extname(safeName).toLowerCase();
|
||||
if (FORBIDDEN_EXT.has(ext)) throw new Error(`Extension interdite : ${ext}`);
|
||||
const target = path.join(getSharedDirectory(), safeName);
|
||||
if (isBase64) {
|
||||
const payload = content.replace(/^data:[^;]+;base64,/, '');
|
||||
await fs.promises.writeFile(target, Buffer.from(payload, 'base64'));
|
||||
} else {
|
||||
await fs.promises.writeFile(target, content, 'utf8');
|
||||
}
|
||||
log('INFO', 'files', `shared file written: ${target}`);
|
||||
return { path: target, url: `file:///${target.replace(/\\/g, '/')}` };
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.openPath, async (_e, filePath: string) => {
|
||||
const target = normalizeSafe(stripFileScheme(filePath));
|
||||
const ext = path.extname(target).toLowerCase();
|
||||
if (FORBIDDEN_EXT.has(ext)) throw new Error(`Ouverture refusee pour ${ext}`);
|
||||
if (!fs.existsSync(target)) throw new Error('Fichier introuvable');
|
||||
const result = await shell.openPath(target);
|
||||
if (result) throw new Error(result);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.showInFolder, async (_e, filePath: string) => {
|
||||
const target = normalizeSafe(stripFileScheme(filePath));
|
||||
shell.showItemInFolder(target);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* HTTP proxy: the renderer never talks to the network directly (webSecurity stays ON).
|
||||
* Every request to Hermes / STT / TTS endpoints flows through here, which also removes
|
||||
* CORS and mixed-content restrictions for LAN services served over plain HTTP.
|
||||
*/
|
||||
import { ipcMain, type WebContents } from 'electron';
|
||||
import {
|
||||
IPC,
|
||||
type HttpProxyRequest,
|
||||
type HttpProxyResponse,
|
||||
type HttpStreamStart,
|
||||
type HttpStreamEvent
|
||||
} from '../../shared/ipc';
|
||||
import { log } from '../logger';
|
||||
|
||||
const activeStreams = new Map<string, AbortController>();
|
||||
|
||||
function assertUrl(url: string): URL {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`URL invalide : ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`Protocole non autorise : ${parsed.protocol}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function buildInit(req: HttpProxyRequest, signal: AbortSignal): RequestInit {
|
||||
const headers: Record<string, string> = { ...(req.headers ?? {}) };
|
||||
let body: RequestInit['body'];
|
||||
if (req.multipart) {
|
||||
const form = new FormData();
|
||||
for (const [k, v] of Object.entries(req.multipart.fields)) form.append(k, v);
|
||||
const file = req.multipart.file;
|
||||
const bytes = new Uint8Array(file.data);
|
||||
form.append(req.multipart.fileField ?? 'file', new Blob([bytes], { type: file.type }), file.name);
|
||||
body = form;
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === 'content-type') delete headers[key];
|
||||
}
|
||||
} else if (req.body !== undefined) {
|
||||
body = req.body;
|
||||
}
|
||||
return { method: req.method ?? 'GET', headers, body, signal };
|
||||
}
|
||||
|
||||
function headersToObject(headers: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
headers.forEach((v, k) => {
|
||||
out[k.toLowerCase()] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function proxyFetch(req: HttpProxyRequest): Promise<HttpProxyResponse> {
|
||||
assertUrl(req.url);
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = req.timeoutMs ?? 60_000;
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(req.url, buildInit(req, controller.signal));
|
||||
const base = {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: headersToObject(response.headers)
|
||||
};
|
||||
if (req.responseType === 'binary') {
|
||||
return { ...base, binary: new Uint8Array(await response.arrayBuffer()) };
|
||||
}
|
||||
return { ...base, text: await response.text() };
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
if (e.name === 'AbortError') throw new Error(`Delai depasse (${Math.round(timeoutMs / 1000)}s) : ${req.url}`);
|
||||
throw new Error(e.message || String(err));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function startStream(sender: WebContents, id: string, req: HttpProxyRequest): Promise<HttpStreamStart> {
|
||||
assertUrl(req.url);
|
||||
const controller = new AbortController();
|
||||
activeStreams.set(id, controller);
|
||||
const send = (event: HttpStreamEvent) => {
|
||||
if (!sender.isDestroyed()) sender.send(IPC.httpStreamEvent, event);
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(req.url, buildInit(req, controller.signal));
|
||||
} catch (err) {
|
||||
activeStreams.delete(id);
|
||||
throw new Error((err as Error).message || String(err));
|
||||
}
|
||||
|
||||
const start: HttpStreamStart = {
|
||||
id,
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: headersToObject(response.headers)
|
||||
};
|
||||
|
||||
const pump = async () => {
|
||||
try {
|
||||
if (!response.body) {
|
||||
send({ id, type: 'end' });
|
||||
return;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
// Hermes runs can be long, but a totally silent stream is abandoned after the idle timeout.
|
||||
const idleMs = req.timeoutMs ?? 10 * 60_000;
|
||||
for (;;) {
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const idle = new Promise<never>((_, reject) => {
|
||||
idleTimer = setTimeout(() => reject(new Error('Flux silencieux (timeout)')), idleMs);
|
||||
});
|
||||
const { done, value } = await Promise.race([reader.read(), idle]);
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
if (done) break;
|
||||
send({ id, type: 'chunk', text: decoder.decode(value, { stream: true }) });
|
||||
}
|
||||
const tail = decoder.decode();
|
||||
if (tail) send({ id, type: 'chunk', text: tail });
|
||||
send({ id, type: 'end' });
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
if (e.name === 'AbortError') send({ id, type: 'end' });
|
||||
else send({ id, type: 'error', message: e.message || String(err) });
|
||||
} finally {
|
||||
activeStreams.delete(id);
|
||||
}
|
||||
};
|
||||
void pump();
|
||||
return start;
|
||||
}
|
||||
|
||||
export function abortAllStreams(): void {
|
||||
for (const controller of activeStreams.values()) controller.abort();
|
||||
activeStreams.clear();
|
||||
}
|
||||
|
||||
export function registerHttpIpc(): void {
|
||||
ipcMain.handle(IPC.httpFetch, (_e, req: HttpProxyRequest) => proxyFetch(req));
|
||||
ipcMain.handle(IPC.httpStreamStart, (event, id: string, req: HttpProxyRequest) => startStream(event.sender, id, req));
|
||||
ipcMain.on(IPC.httpStreamAbort, (_e, id: string) => {
|
||||
const controller = activeStreams.get(id);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
activeStreams.delete(id);
|
||||
log('DEBUG', 'http', `stream ${id} aborted by renderer`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { app, ipcMain } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IPC } from '../../shared/ipc';
|
||||
|
||||
type StoreData = Record<string, unknown>;
|
||||
|
||||
let cache: StoreData | null = null;
|
||||
|
||||
function storePath(): string {
|
||||
return path.join(app.getPath('userData'), 'userdata.json');
|
||||
}
|
||||
|
||||
function load(): StoreData {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
cache = JSON.parse(fs.readFileSync(storePath(), 'utf8')) as StoreData;
|
||||
} catch {
|
||||
cache = {};
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
const target = storePath();
|
||||
const tmp = target + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(cache ?? {}, null, 2), 'utf8');
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
|
||||
export function storeGet<T = unknown>(key: string): T | null {
|
||||
const data = load();
|
||||
return (data[key] as T) ?? null;
|
||||
}
|
||||
|
||||
export function storeSet(key: string, value: unknown): void {
|
||||
const data = load();
|
||||
data[key] = value;
|
||||
persist();
|
||||
}
|
||||
|
||||
export function registerStoreIpc(): void {
|
||||
ipcMain.handle(IPC.storeGet, (_e, key: string) => storeGet(key));
|
||||
ipcMain.handle(IPC.storeSet, (_e, key: string, value: unknown) => {
|
||||
storeSet(key, value);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle(IPC.storeDelete, (_e, key: string) => {
|
||||
const data = load();
|
||||
delete data[key];
|
||||
persist();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import os from 'node:os';
|
||||
import { IPC, type SystemMetrics } from '../../shared/ipc';
|
||||
|
||||
interface CpuSnapshot {
|
||||
idle: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function snapshot(): CpuSnapshot {
|
||||
let idle = 0;
|
||||
let total = 0;
|
||||
for (const cpu of os.cpus()) {
|
||||
const t = cpu.times;
|
||||
idle += t.idle;
|
||||
total += t.user + t.nice + t.sys + t.idle + t.irq;
|
||||
}
|
||||
return { idle, total };
|
||||
}
|
||||
|
||||
let last = snapshot();
|
||||
|
||||
function cpuLoadPct(): number {
|
||||
const now = snapshot();
|
||||
const idleDiff = now.idle - last.idle;
|
||||
const totalDiff = now.total - last.total;
|
||||
last = now;
|
||||
if (totalDiff <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, 100 * (1 - idleDiff / totalDiff)));
|
||||
}
|
||||
|
||||
export function collectMetrics(): SystemMetrics {
|
||||
const cpus = os.cpus();
|
||||
const totalMem = os.totalmem();
|
||||
const freeMem = os.freemem();
|
||||
return {
|
||||
hostname: os.hostname().toUpperCase(),
|
||||
platform: `${os.platform()} ${os.release()}`,
|
||||
cpuModel: cpus[0]?.model?.trim() ?? 'CPU',
|
||||
cpuCores: cpus.length,
|
||||
cpuLoad: Math.round(cpuLoadPct() * 10) / 10,
|
||||
cpuFreqMhz: cpus[0]?.speed ?? 0,
|
||||
memUsedPct: totalMem > 0 ? Math.round(((totalMem - freeMem) / totalMem) * 1000) / 10 : 0,
|
||||
memTotalGb: Math.round((totalMem / 1024 ** 3) * 10) / 10,
|
||||
uptimeSec: Math.round(os.uptime())
|
||||
};
|
||||
}
|
||||
|
||||
export function registerTelemetryIpc(): void {
|
||||
ipcMain.handle(IPC.metrics, () => collectMetrics());
|
||||
}
|
||||
Reference in new issue
Block a user