mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-21 14:18:13 +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,54 @@
|
||||
import type {
|
||||
AppInfo,
|
||||
HermesPushEvent,
|
||||
HotkeyEvent,
|
||||
HttpProxyRequest,
|
||||
HttpProxyResponse,
|
||||
HttpStreamEvent,
|
||||
HttpStreamStart,
|
||||
LogEntry,
|
||||
SystemMetrics,
|
||||
WebhookStatus,
|
||||
WindowMode
|
||||
} from './ipc';
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
/** The API exposed on `window.eveflow` by the preload script. */
|
||||
export interface EveFlowBridge {
|
||||
window: {
|
||||
control: (action: 'minimize' | 'maximize' | 'close' | 'hide') => void;
|
||||
setMode: (mode: WindowMode) => void;
|
||||
onModeChanged: (cb: (mode: WindowMode) => void) => Unsubscribe;
|
||||
};
|
||||
log: (entry: LogEntry) => void;
|
||||
store: {
|
||||
get: <T = unknown>(key: string) => Promise<T | null>;
|
||||
set: (key: string, value: unknown) => Promise<boolean>;
|
||||
delete: (key: string) => Promise<boolean>;
|
||||
};
|
||||
http: {
|
||||
fetch: (req: HttpProxyRequest) => Promise<HttpProxyResponse>;
|
||||
streamStart: (id: string, req: HttpProxyRequest) => Promise<HttpStreamStart>;
|
||||
streamAbort: (id: string) => void;
|
||||
onStreamEvent: (cb: (event: HttpStreamEvent) => void) => Unsubscribe;
|
||||
};
|
||||
system: {
|
||||
metrics: () => Promise<SystemMetrics>;
|
||||
appInfo: () => Promise<AppInfo>;
|
||||
};
|
||||
files: {
|
||||
readLocal: (filePath: string) => Promise<string>;
|
||||
writeShared: (filename: string, content: string, isBase64?: boolean) => Promise<{ path: string; url: string }>;
|
||||
openPath: (filePath: string) => Promise<boolean>;
|
||||
showInFolder: (filePath: string) => Promise<boolean>;
|
||||
};
|
||||
hermes: {
|
||||
onPush: (cb: (event: HermesPushEvent) => void) => Unsubscribe;
|
||||
webhookStatus: () => Promise<WebhookStatus>;
|
||||
webhookRestart: () => Promise<WebhookStatus>;
|
||||
};
|
||||
hotkeys: {
|
||||
on: (cb: (event: HotkeyEvent) => void) => Unsubscribe;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { HermesPushEvent } from './ipc';
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
const isRecord = (v: unknown): v is AnyRecord => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
||||
|
||||
function collectImages(obj: AnyRecord | undefined): string[] {
|
||||
if (!obj) return [];
|
||||
const images: string[] = [];
|
||||
const push = (value: unknown) => {
|
||||
if (!value) return;
|
||||
if (typeof value === 'string') {
|
||||
if (/^(https?:\/\/|data:image\/|file:\/\/|[a-zA-Z]:\\|\/)/.test(value)) images.push(value);
|
||||
} else if (isRecord(value)) {
|
||||
push(value.url ?? value.href ?? value.path ?? value.file ?? value.file_url ?? value.media_url);
|
||||
}
|
||||
};
|
||||
for (const key of ['image', 'image_url', 'photo', 'media', 'file', 'file_url', 'media_url']) push(obj[key]);
|
||||
for (const key of ['images', 'photos', 'media_urls', 'attachments', 'files']) {
|
||||
const value = obj[key];
|
||||
if (Array.isArray(value)) value.forEach(push);
|
||||
else push(value);
|
||||
}
|
||||
return [...new Set(images)];
|
||||
}
|
||||
|
||||
function role(value: unknown): HermesPushEvent['role'] {
|
||||
const r = str(value).toLowerCase();
|
||||
if (r === 'user' || r === 'human') return 'user';
|
||||
if (r === 'system') return 'system';
|
||||
return 'assistant';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise any payload pushed to the local EveFlow webhook into a list of events.
|
||||
* Accepts the historical EveFlow formats plus Hermes cron/gateway delivery shapes.
|
||||
*/
|
||||
export function normalizeHermesPush(raw: unknown, receivedAt = new Date().toISOString()): HermesPushEvent[] {
|
||||
if (!isRecord(raw)) {
|
||||
return [{ type: 'raw', role: 'system', text: str(raw), source: 'webhook', receivedAt, raw }];
|
||||
}
|
||||
const source = str(raw.platform ?? raw.source ?? raw.channel ?? 'webhook') || 'webhook';
|
||||
const withImages = (event: HermesPushEvent, obj: AnyRecord | undefined): HermesPushEvent => {
|
||||
const images = collectImages(obj);
|
||||
return images.length ? { ...event, images } : event;
|
||||
};
|
||||
|
||||
// Batched: { events: [...] }
|
||||
if (Array.isArray(raw.events)) {
|
||||
return raw.events.flatMap((e) => normalizeHermesPush(e, receivedAt));
|
||||
}
|
||||
|
||||
// Format A: { type:'message', payload:{ text, role?, platform? } }
|
||||
if (raw.type === 'message' && isRecord(raw.payload) && raw.payload.text) {
|
||||
const payload = raw.payload;
|
||||
return [withImages({
|
||||
type: 'message',
|
||||
role: role(payload.role),
|
||||
text: str(payload.text),
|
||||
source: str(payload.platform ?? source),
|
||||
receivedAt
|
||||
}, payload)];
|
||||
}
|
||||
|
||||
// Cron job delivery: { event:'job.completed'|'cron', job|name, output|result, status }
|
||||
const eventName = str(raw.event ?? raw.type).toLowerCase();
|
||||
if (eventName.startsWith('job') || eventName.startsWith('cron') || raw.job_id || raw.job) {
|
||||
const job = isRecord(raw.job) ? raw.job : raw;
|
||||
const output = str(raw.output ?? raw.result ?? raw.text ?? job.output ?? job.last_output ?? '');
|
||||
return [withImages({
|
||||
type: 'job',
|
||||
role: 'assistant',
|
||||
text: output,
|
||||
source: source === 'webhook' ? 'cron' : source,
|
||||
jobName: str(job.name ?? raw.name ?? raw.job_name ?? raw.job_id ?? ''),
|
||||
status: str(raw.status ?? job.last_status ?? job.status ?? ''),
|
||||
receivedAt
|
||||
}, raw)];
|
||||
}
|
||||
|
||||
// Run completed: { event:'run.completed', input?, output }
|
||||
if ((eventName === 'run.completed' || eventName === 'run.complete') && raw.output) {
|
||||
const events: HermesPushEvent[] = [];
|
||||
if (raw.input) events.push({ type: 'message', role: 'user', text: str(raw.input), source, receivedAt });
|
||||
events.push(withImages({ type: 'message', role: 'assistant', text: str(raw.output), source, receivedAt }, raw));
|
||||
return events;
|
||||
}
|
||||
|
||||
// Direct: { role, text|content|message }
|
||||
const text = raw.text ?? raw.content ?? raw.message ?? raw.output;
|
||||
if (raw.role && text) {
|
||||
return [withImages({ type: 'message', role: role(raw.role), text: str(text), source, receivedAt }, raw)];
|
||||
}
|
||||
|
||||
// Legacy: { type:'user_message'|'assistant_message', text }
|
||||
if (text && typeof raw.type === 'string') {
|
||||
return [withImages({
|
||||
type: 'message',
|
||||
role: raw.type.includes('user') ? 'user' : 'assistant',
|
||||
text: str(text),
|
||||
source,
|
||||
receivedAt
|
||||
}, raw)];
|
||||
}
|
||||
|
||||
if (text) {
|
||||
return [withImages({ type: 'message', role: 'assistant', text: str(text), source, receivedAt }, raw)];
|
||||
}
|
||||
|
||||
return [{ type: 'raw', role: 'system', text: JSON.stringify(raw), source, receivedAt, raw }];
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Shared IPC contract between the Electron main process and the renderer.
|
||||
* Imported by both tsconfig projects; must stay free of Node/DOM-specific types.
|
||||
*/
|
||||
|
||||
export type WindowMode = 'hud' | 'compact';
|
||||
|
||||
export interface HttpProxyFile {
|
||||
name: string;
|
||||
type: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface HttpProxyRequest {
|
||||
url: string;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
headers?: Record<string, string>;
|
||||
/** JSON/text body. Ignored when `multipart` is provided. */
|
||||
body?: string;
|
||||
/** Multipart form upload (used for speech-to-text). */
|
||||
multipart?: { fields: Record<string, string>; file: HttpProxyFile; fileField?: string };
|
||||
responseType?: 'text' | 'binary';
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface HttpProxyResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
/** Present when responseType is 'text' (default). */
|
||||
text?: string;
|
||||
/** Present when responseType is 'binary'. */
|
||||
binary?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface HttpStreamStart {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export type HttpStreamEvent =
|
||||
| { id: string; type: 'chunk'; text: string }
|
||||
| { id: string; type: 'end' }
|
||||
| { id: string; type: 'error'; message: string };
|
||||
|
||||
export interface SystemMetrics {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
cpuModel: string;
|
||||
cpuCores: number;
|
||||
cpuLoad: number; // 0-100
|
||||
cpuFreqMhz: number;
|
||||
memUsedPct: number; // 0-100
|
||||
memTotalGb: number;
|
||||
uptimeSec: number;
|
||||
}
|
||||
|
||||
export interface HermesPushEvent {
|
||||
type: 'message' | 'job' | 'raw';
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
text: string;
|
||||
source: string;
|
||||
images?: string[];
|
||||
jobName?: string;
|
||||
status?: string;
|
||||
receivedAt: string;
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
ts: string;
|
||||
level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
|
||||
tag: string;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface WebhookStatus {
|
||||
listening: boolean;
|
||||
port: number;
|
||||
path: string;
|
||||
secretConfigured: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
version: string;
|
||||
platform: string;
|
||||
isPackaged: boolean;
|
||||
userDataPath: string;
|
||||
sharedDir: string;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export const IPC = {
|
||||
windowControl: 'window:control',
|
||||
windowSetMode: 'window:set-mode',
|
||||
windowModeChanged: 'window:mode-changed',
|
||||
log: 'log:write',
|
||||
storeGet: 'store:get',
|
||||
storeSet: 'store:set',
|
||||
storeDelete: 'store:delete',
|
||||
httpFetch: 'http:fetch',
|
||||
httpStreamStart: 'http:stream:start',
|
||||
httpStreamAbort: 'http:stream:abort',
|
||||
httpStreamEvent: 'http:stream:event',
|
||||
metrics: 'system:metrics',
|
||||
appInfo: 'app:info',
|
||||
readLocalFile: 'files:read-local',
|
||||
writeSharedFile: 'files:write-shared',
|
||||
openPath: 'files:open-path',
|
||||
showInFolder: 'files:show-in-folder',
|
||||
hermesPush: 'hermes:push',
|
||||
webhookStatus: 'webhook:status',
|
||||
webhookRestart: 'webhook:restart',
|
||||
hotkey: 'hotkey:event'
|
||||
} as const;
|
||||
|
||||
export type HotkeyEvent = 'ptt-toggle' | 'toggle-window' | 'stop-speaking';
|
||||
Reference in new issue
Block a user