mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-23 07:08: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,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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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\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`;
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
Reference in new issue
Block a user