fix: full review pass (main process, voice pipeline, Hermes client, HUD) and e2e-validated local voice (v2.1.0.1)

Electron main
- utility-process channel: sherpa output copied into V8 buffers (Electron rejects external
  buffers), audio exchanged as base64; worker restart on timeout, identity-safe exit handling,
  deterministic native unload by restart
- webhook: loopback by default without secret, UTF-8-safe body assembly, clean restart, port validation
- files IPC: realpath-based root check, openPath allow-list (reveal-only outside document folders),
  Windows reserved names; store: debounced async atomic writes with backup of corrupt files;
  logger: streaming writes with rotation; log message size cap
- HTTP stream proxy: socket released on idle timeout / renderer destroyed, id validation
- model manager: inactivity timeout, retrying rm/rename (Windows locks), engine stopped before
  replacing a model; WAV decoder handles float/24-bit; IPC payload validation
- window: opaque rounded window on Windows, navigation lock-down, visibility events;
  Ctrl+Alt+Escape instead of the Task Manager shortcut; single-instance guard; EVEFLOW_USER_DATA

Voice pipeline
- abort semantics (SendHandle.aborted), abort before the stream opens, session id prefixes per transport
- hands-free re-arm after replies without speech, start/stop race, no chime on auto re-arm,
  no silence shipped to STT (400 ms pre-roll), no transcription of empty manual stops
- TTS: bounded prefetch, cancellable segments, non-interrupting notices, volume applied at play time
- SSE CRLF split, usage in chat completions, finish_reason length, phonetic regex hoisted

HUD
- core renderer: no canvas shadows, cached colours, reusable spectrum buffer, theme read on change,
  30 fps idle, stops when the window is hidden; ping flashes on send / tool / speech
- deltas coalesced per animation frame; stable auto-scroll; narrow selectors everywhere
- bundled fonts (offline), reduce-motion fix, error toasts, ops drawer below 1180 px,
  interim transcript and first-token latency in the core caption, Ctrl+K, dialog semantics,
  switch/aria roles, compact widget cleanup, Whisper small recommended for French
- docs/ROADMAP.md: audit results, e2e results and the plan towards a real JARVIS

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 18:08:09 +00:00
1 parent b1b8d0d5d4
commit 5f0fa7a1e2
55 files changed
+1181 -433

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
# Feuille de route : vers un vrai JARVIS
État au 3 septembre 2026, après la revue complète du code (trois audits : processus principal, services/état, interface) et le test de bout en bout de l'application Electron sous Xvfb avec les vrais modèles.
## Ce qui existe déjà
| Capacité | État | Notes |
|---|---|---|
| HUD arc-reactor réactif au son | Fait | Canvas 2D optimisé (pas d'ombres, couleurs en cache, 30 fps en veille, arrêt fenêtre masquée) |
| Reconnaissance vocale locale | Fait | Whisper base/small/turbo via sherpa-onnx dans un processus utilitaire |
| Synthèse vocale locale | Fait | Kokoro v1.0 (voix française Siwis) et Piper fr |
| Mot d'activation | Fait, mode « après transcription » | Filtre « Jarvis … » en mains libres, tolérant aux erreurs de transcription |
| Détection de fin de phrase | Fait | VAD énergétique adaptatif, pré-roll 400 ms |
| Hermes : runs, sessions, chat completions | Fait | Transport choisi selon `/v1/capabilities` |
| Approbations, steer, stop | Fait | Modales, injection de consigne en cours de run |
| Crons, skills, toolsets, sessions | Fait | Panneau Hermes Ops |
| Webhook local (Telegram, crons) | Fait | Loopback par défaut, secret pour le LAN |
| Télémétrie réelle | Fait | CPU, RAM, fréquence, FPS, uptime |
## Ce que les meilleurs projets « Jarvis » de 2026 font en plus
Sources : [jarvis-desktop-ai](https://github.com/ccarloshenri/jarvis-desktop-ai), [JarvisAi](https://github.com/PanPenek/JarvisAi), [bertrandmbanwi/Jarvis](https://github.com/bertrandmbanwi/Jarvis), [InterGenJLU/jarvis](https://github.com/InterGenJLU/jarvis), [livekit-wakeword](https://livekit.com/blog/livekit-wakeword), [sherpa-onnx keyword spotting](https://k2-fsa.github.io/sherpa/onnx/kws/index.html), [Hermes Agent features](https://hermes-agent.nousresearch.com/docs/user-guide/features/overview).
1. **Mot d'activation permanent, quasi gratuit en CPU.** Les projets de référence utilisent openWakeWord (« hey jarvis ») ou un modèle de keyword spotting qui écoute en continu, au lieu de transcrire chaque phrase. sherpa-onnx fournit un modèle KWS anglais de 3,3 Mo qui accepte n'importe quel mot-clé sans réentraînement ; « JARVIS » s'encode `▁JA R VI S` avec son modèle BPE (vérifié). C'est le prochain chantier prioritaire : streaming du micro vers le worker, détection en continu, puis capture de la commande.
2. **VAD neuronal (Silero) au lieu du seuil d'énergie.** Fin de phrase plus nette (environ 500 ms gagnés) et beaucoup moins de faux départs sur le bruit ambiant. Silero est déjà livré dans sherpa-onnx (`silero_vad.onnx`, 0,6 Mo).
3. **Latence perçue sous la seconde.** Les références visent 1 s entre la fin de parole et le premier mot prononcé : STT rapide, premier token en streaming, TTS phrase par phrase (déjà en place), et un modèle Hermes rapide pour la conversation courante.
4. **Vision d'écran.** Capture d'écran à la demande (« Jarvis, qu'est-ce que je regarde ? ») envoyée à Hermes comme image, ou lecture d'une fenêtre. Hermes accepte déjà les images inline.
5. **Actions système locales.** Ouvrir une application, régler le volume, verrouiller la session, chercher un fichier ; ce sont des outils EveFlow côté client à exposer à Hermes (mode chat completions) ou un petit serveur MCP local que Hermes appelle.
6. **Proactivité.** Notifications parlées à l'arrivée d'un cron, rappel, événement webhook, avec un résumé plutôt que la lecture intégrale ; c'est en partie fait via le webhook, à enrichir avec des règles (heures calmes, priorité).
7. **Mémoire et personnalisation.** Hermes gère la mémoire longue durée (`X-Hermes-Session-Key`) ; côté EveFlow, un profil (nom, préférences de voix, style de réponse) déjà transmis dans les instructions.
## Plan proposé
### Étape 1 (courte) : écoute permanente
- Streamer l'audio du micro (16 kHz, blocs de 128 ms) du renderer vers le worker via IPC.
- Dans le worker : `KeywordSpotter` sherpa-onnx (modèle gigaspeech 3,3 Mo, mot-clé configurable encodé automatiquement avec `bpe.model` via un petit encodeur BPE côté Node ou un dictionnaire pré-encodé pour « jarvis », « eve », « hey jarvis », « ok jarvis »).
- À la détection : chime, capture de la commande avec Silero VAD, transcription, envoi.
- Consommation attendue : quelques pourcents d'un cœur, pas de transcription en continu.
### Étape 2 : vision et actions locales
- Outil `capture_screen` (Electron `desktopCapturer`) qui joint une capture à la requête Hermes.
- Outils système : `open_app`, `set_volume`, `lock_session`, `find_file`, `clipboard` ; exposés en chat completions et via un serveur MCP local pour les transports runs/sessions.
### Étape 3 : conversation plus naturelle
- Barge-in réel : couper la voix dès que l'utilisateur parle (déjà préparé, à valider avec l'annulation d'écho Windows).
- Réponses courtes à l'oral, détails à l'écran : instruction Hermes dédiée déjà en place, à affiner avec des consignes de format (« deux phrases à l'oral, détails en Markdown »).
- Modèle rapide pour le bavardage, modèle puissant pour les missions (choix par transport ou par mot-clé).
### Étape 4 : présence
- Widget compact « glanceable » : dernière phrase, état, badge d'alertes.
- Heures calmes, priorité des notifications, résumé vocal des crons.
- Thèmes et voix par contexte (nuit, travail).
## Résultats du test de bout en bout (Linux, Xvfb, 4 cœurs lents)
| Étape | Résultat |
|---|---|
| Pont Electron, télémétrie, webhook | OK |
| Conversation via runs (streaming, outils, sous-agent) | OK |
| Kokoro (fr) → Whisper base, phrase courte de 2 s | synthèse 2,9 s, transcription 2,5 s, texte approximatif |
| Kokoro (fr) → Whisper base, phrase de 7 s | transcription correcte à un mot près |
| Piper (fr) → Whisper base | transcription exacte |
Sur un PC à 28 cœurs les temps sont nettement plus courts. Whisper small est maintenant recommandé pour le français.
+70 -14
View File
@@ -22,12 +22,25 @@ const READABLE_EXT: Record<string, string> = {
'.html': 'text/html' '.html': 'text/html'
}; };
const FORBIDDEN_EXT = new Set([ /** Documents and media the assistant may open with the default application. */
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh', const OPENABLE_EXT = new Set([
'.com', '.hta', '.vbe', '.wsf', '.lnk', '.jar', '.dll', '.js', '.jse' ...Object.keys(READABLE_EXT),
'.pdf', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt', '.odt', '.ods', '.odp', '.rtf',
'.mp3', '.wav', '.ogg', '.flac', '.mp4', '.mkv', '.webm', '.zip', '.7z', '.py', '.ts', '.tsx', '.yaml', '.yml', '.toml', '.ini'
]); ]);
const FORBIDDEN_EXT = new Set([
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh', '.com', '.hta', '.vbe', '.wsf', '.lnk',
'.jar', '.dll', '.js', '.jse', '.pyw', '.msc', '.cpl', '.inf', '.url', '.scf', '.website', '.appref-ms', '.application',
'.gadget', '.psm1', '.psd1', '.ps1xml', '.wsh', '.wsc', '.sct', '.desktop', '.app', '.command', '.appimage', '.run'
]);
const WIN_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
let sharedDir: string | null = null;
export function getSharedDirectory(): string { export function getSharedDirectory(): string {
if (sharedDir && fs.existsSync(sharedDir)) return sharedDir;
const candidates = [ const candidates = [
path.join(os.homedir(), 'Documents', 'EveFlow_Shared'), path.join(os.homedir(), 'Documents', 'EveFlow_Shared'),
path.join(app.getPath('userData'), 'shared') path.join(app.getPath('userData'), 'shared')
@@ -35,32 +48,60 @@ export function getSharedDirectory(): string {
for (const dir of candidates) { for (const dir of candidates) {
try { try {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
sharedDir = dir;
return dir; return dir;
} catch { } catch {
/* try next candidate */ /* try next candidate */
} }
} }
return app.getPath('temp'); sharedDir = app.getPath('temp');
return sharedDir;
} }
function normalizeSafe(filePath: string): string { function normalizeSafe(filePath: string): string {
if (!filePath || typeof filePath !== 'string') throw new Error('Chemin invalide'); if (!filePath || typeof filePath !== 'string' || filePath.length > 4096) throw new Error('Chemin invalide');
const normalized = path.normalize(filePath); const normalized = path.normalize(filePath);
if (normalized.split(/[\\/]/).includes('..')) throw new Error('Traversee de repertoire refusee'); if (normalized.split(/[\\/]/).includes('..')) throw new Error('Traversee de repertoire refusee');
return normalized; return normalized;
} }
function allowedRoots(): string[] {
return [
getSharedDirectory(),
path.join(os.homedir(), 'Documents'),
path.join(os.homedir(), 'Pictures'),
path.join(os.homedir(), 'Downloads'),
path.join(os.homedir(), 'Desktop'),
app.getPath('temp')
].map((r) => path.resolve(r));
}
function isInside(root: string, target: string): boolean {
const rel = path.relative(root, target);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}
/** Resolves symlinks and checks the real path against the allowed roots (prefix tricks like "bob-backup" are rejected). */
function isInsideAllowedRoots(target: string): boolean { function isInsideAllowedRoots(target: string): boolean {
const roots = [getSharedDirectory(), os.homedir(), app.getPath('temp')]; let resolved: string;
const resolved = path.resolve(target); try {
return roots.some((root) => resolved.startsWith(path.resolve(root))); resolved = fs.realpathSync.native(target);
} catch {
return false;
}
return allowedRoots().some((root) => isInside(root, resolved));
} }
function stripFileScheme(value: string): string { function stripFileScheme(value: string): string {
let out = value.replace(/^file:\/\//, ''); if (!/^file:\/\//i.test(value)) return value;
let out = value.replace(/^file:\/\//i, '');
// file:///C:/x -> /C:/x on Windows-style paths // file:///C:/x -> /C:/x on Windows-style paths
if (/^\/[a-zA-Z]:/.test(out)) out = out.slice(1); if (/^\/[a-zA-Z]:/.test(out)) out = out.slice(1);
return decodeURIComponent(out); try {
return decodeURIComponent(out);
} catch {
return out;
}
} }
export function registerFilesIpc(): void { export function registerFilesIpc(): void {
@@ -78,8 +119,16 @@ export function registerFilesIpc(): void {
ipcMain.handle(IPC.writeSharedFile, async (_e, filename: string, content: string, isBase64 = false) => { ipcMain.handle(IPC.writeSharedFile, async (_e, filename: string, content: string, isBase64 = false) => {
if (!filename || typeof filename !== 'string') throw new Error('Nom de fichier invalide'); if (!filename || typeof filename !== 'string') throw new Error('Nom de fichier invalide');
if (typeof content !== 'string' || content.length > 50 * 1024 * 1024) throw new Error('Contenu invalide');
const safeName = path.basename(filename); const safeName = path.basename(filename);
if (safeName !== filename || safeName.startsWith('.') || /[<>:"|?*]/.test(safeName)) { if (
safeName !== filename ||
safeName.startsWith('.') ||
/[<>:"|?*\u0000-\u001f]/.test(safeName) ||
WIN_RESERVED.test(safeName) ||
/[. ]$/.test(safeName) ||
safeName.length > 200
) {
throw new Error('Nom de fichier non securise'); throw new Error('Nom de fichier non securise');
} }
const ext = path.extname(safeName).toLowerCase(); const ext = path.extname(safeName).toLowerCase();
@@ -97,10 +146,17 @@ export function registerFilesIpc(): void {
ipcMain.handle(IPC.openPath, async (_e, filePath: string) => { ipcMain.handle(IPC.openPath, async (_e, filePath: string) => {
const target = normalizeSafe(stripFileScheme(filePath)); 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'); if (!fs.existsSync(target)) throw new Error('Fichier introuvable');
const result = await shell.openPath(target); const real = await fs.promises.realpath(target);
const stat = await fs.promises.stat(real);
const ext = path.extname(real).toLowerCase();
if (!stat.isDirectory() && (FORBIDDEN_EXT.has(ext) || !OPENABLE_EXT.has(ext))) throw new Error(`Ouverture refusee pour ${ext || 'ce fichier'}`);
// Outside the user's document folders we only reveal the file, never execute its handler.
if (!isInsideAllowedRoots(real)) {
shell.showItemInFolder(real);
return true;
}
const result = await shell.openPath(real);
if (result) throw new Error(result); if (result) throw new Error(result);
return true; return true;
}); });
+11 -2
View File
@@ -105,6 +105,8 @@ async function startStream(sender: WebContents, id: string, req: HttpProxyReques
headers: headersToObject(response.headers) headers: headersToObject(response.headers)
}; };
const onDestroyed = () => controller.abort();
sender.once('destroyed', onDestroyed);
const pump = async () => { const pump = async () => {
try { try {
if (!response.body) { if (!response.body) {
@@ -133,7 +135,10 @@ async function startStream(sender: WebContents, id: string, req: HttpProxyReques
if (e.name === 'AbortError') send({ id, type: 'end' }); if (e.name === 'AbortError') send({ id, type: 'end' });
else send({ id, type: 'error', message: e.message || String(err) }); else send({ id, type: 'error', message: e.message || String(err) });
} finally { } finally {
activeStreams.delete(id); // Releases the socket when we bailed out early (idle timeout, renderer gone).
controller.abort();
sender.removeListener('destroyed', onDestroyed);
if (activeStreams.get(id) === controller) activeStreams.delete(id);
} }
}; };
void pump(); void pump();
@@ -147,7 +152,11 @@ export function abortAllStreams(): void {
export function registerHttpIpc(): void { export function registerHttpIpc(): void {
ipcMain.handle(IPC.httpFetch, (_e, req: HttpProxyRequest) => proxyFetch(req)); ipcMain.handle(IPC.httpFetch, (_e, req: HttpProxyRequest) => proxyFetch(req));
ipcMain.handle(IPC.httpStreamStart, (event, id: string, req: HttpProxyRequest) => startStream(event.sender, id, req)); ipcMain.handle(IPC.httpStreamStart, (event, id: unknown, req: HttpProxyRequest) => {
if (typeof id !== 'string' || !id || id.length > 64 || activeStreams.has(id)) throw new Error('Identifiant de flux invalide');
if (!req || typeof req.url !== 'string') throw new Error('Requête invalide');
return startStream(event.sender, id, req);
});
ipcMain.on(IPC.httpStreamAbort, (_e, id: string) => { ipcMain.on(IPC.httpStreamAbort, (_e, id: string) => {
const controller = activeStreams.get(id); const controller = activeStreams.get(id);
if (controller) { if (controller) {
+61 -12
View File
@@ -2,10 +2,16 @@ import { app, ipcMain } from 'electron';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { IPC } from '../../shared/ipc'; import { IPC } from '../../shared/ipc';
import { log } from '../logger';
type StoreData = Record<string, unknown>; type StoreData = Record<string, unknown>;
let cache: StoreData | null = null; let cache: StoreData | null = null;
let dirty = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let writing: Promise<void> = Promise.resolve();
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function storePath(): string { function storePath(): string {
return path.join(app.getPath('userData'), 'userdata.json'); return path.join(app.getPath('userData'), 'userdata.json');
@@ -13,19 +19,55 @@ function storePath(): string {
function load(): StoreData { function load(): StoreData {
if (cache) return cache; if (cache) return cache;
const target = storePath();
try { try {
cache = JSON.parse(fs.readFileSync(storePath(), 'utf8')) as StoreData; cache = JSON.parse(fs.readFileSync(target, 'utf8')) as StoreData;
} catch { } catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
// Keep a copy of an unreadable file instead of silently overwriting it.
try {
fs.copyFileSync(target, `${target}.corrupt-${Date.now()}`);
} catch {
/* ignore */
}
log('WARN', 'store', `userdata.json unreadable, starting fresh: ${(err as Error).message}`);
}
cache = {}; cache = {};
} }
return cache; return cache;
} }
function persist(): void { /** Debounced, asynchronous, atomic write with retries (Windows AV scanners lock files briefly). */
const target = storePath(); function schedulePersist(): void {
const tmp = target + '.tmp'; dirty = true;
fs.writeFileSync(tmp, JSON.stringify(cache ?? {}, null, 2), 'utf8'); if (!timer) timer = setTimeout(() => void flushStore(), 250);
fs.renameSync(tmp, target); }
export function flushStore(): Promise<void> {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (!dirty) return writing;
dirty = false;
const text = JSON.stringify(cache ?? {}, null, 2);
writing = writing
.then(async () => {
const target = storePath();
const tmp = `${target}.tmp`;
await fs.promises.writeFile(tmp, text, 'utf8');
for (let attempt = 0; ; attempt++) {
try {
await fs.promises.rename(tmp, target);
return;
} catch (err) {
if (attempt >= 5) throw err;
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
}
}
})
.catch((err) => log('ERROR', 'store', `cannot persist userdata.json: ${(err as Error).message}`));
return writing;
} }
export function storeGet<T = unknown>(key: string): T | null { export function storeGet<T = unknown>(key: string): T | null {
@@ -36,19 +78,26 @@ export function storeGet<T = unknown>(key: string): T | null {
export function storeSet(key: string, value: unknown): void { export function storeSet(key: string, value: unknown): void {
const data = load(); const data = load();
data[key] = value; data[key] = value;
persist(); schedulePersist();
}
function validKey(key: unknown): key is string {
return typeof key === 'string' && key.length > 0 && key.length <= 128 && !FORBIDDEN_KEYS.has(key);
} }
export function registerStoreIpc(): void { export function registerStoreIpc(): void {
ipcMain.handle(IPC.storeGet, (_e, key: string) => storeGet(key)); ipcMain.handle(IPC.storeGet, (_e, key: unknown) => (validKey(key) ? storeGet(key) : null));
ipcMain.handle(IPC.storeSet, (_e, key: string, value: unknown) => { ipcMain.handle(IPC.storeSet, (_e, key: unknown, value: unknown) => {
if (!validKey(key)) throw new Error('Clé de stockage invalide');
if (typeof value === 'function' || typeof value === 'symbol') throw new Error('Valeur non sérialisable');
storeSet(key, value); storeSet(key, value);
return true; return true;
}); });
ipcMain.handle(IPC.storeDelete, (_e, key: string) => { ipcMain.handle(IPC.storeDelete, (_e, key: unknown) => {
if (!validKey(key)) return false;
const data = load(); const data = load();
delete data[key]; delete data[key];
persist(); schedulePersist();
return true; return true;
}); });
} }
+32 -8
View File
@@ -5,22 +5,46 @@ import type { LogEntry } from '../shared/ipc';
const MAX_LOG_BYTES = 5 * 1024 * 1024; const MAX_LOG_BYTES = 5 * 1024 * 1024;
let stream: fs.WriteStream | null = null;
let written = 0;
export function getLogPath(): string { export function getLogPath(): string {
return path.join(app.getPath('userData'), 'eveflow.log'); return path.join(app.getPath('userData'), 'eveflow.log');
} }
function output(): fs.WriteStream {
if (!stream) {
const target = getLogPath();
try {
written = fs.statSync(target).size;
} catch {
written = 0;
}
stream = fs.createWriteStream(target, { flags: 'a' });
stream.on('error', (err) => console.error('[logger] write failed:', err.message));
}
return stream;
}
function rotate(): void {
const target = getLogPath();
stream?.end();
stream = null;
try {
fs.renameSync(target, `${target}.old`);
} catch {
/* ignore */
}
written = 0;
}
export function writeLog(entry: LogEntry): void { export function writeLog(entry: LogEntry): void {
try { try {
const logPath = getLogPath();
const data = entry.data !== undefined ? ' | ' + safeJson(entry.data) : ''; const data = entry.data !== undefined ? ' | ' + safeJson(entry.data) : '';
const line = `[${entry.ts}] [${entry.level}] [${entry.tag}] ${entry.message}${data}\n`; const line = `[${entry.ts}] [${entry.level}] [${entry.tag}] ${entry.message}${data}\n`;
try { written += Buffer.byteLength(line);
const stat = fs.statSync(logPath); if (written > MAX_LOG_BYTES) rotate();
if (stat.size > MAX_LOG_BYTES) fs.renameSync(logPath, logPath + '.old'); output().write(line);
} catch {
/* first start: file does not exist yet */
}
fs.appendFileSync(logPath, line, 'utf8');
} catch (err) { } catch (err) {
console.error('[logger] cannot write log file:', (err as Error).message); console.error('[logger] cannot write log file:', (err as Error).message);
} }
+24 -11
View File
@@ -2,17 +2,19 @@ import { app, BrowserWindow, globalShortcut, ipcMain, Menu, nativeImage, session
import path from 'node:path'; import path from 'node:path';
import { IPC, type AppInfo, type HotkeyEvent, type LogEntry, type WindowMode } from '../shared/ipc'; import { IPC, type AppInfo, type HotkeyEvent, type LogEntry, type WindowMode } from '../shared/ipc';
import { getLogPath, log, writeLog } from './logger'; import { getLogPath, log, writeLog } from './logger';
import { registerStoreIpc, storeGet } from './ipc/store'; import { flushStore, registerStoreIpc, storeGet } from './ipc/store';
import { abortAllStreams, registerHttpIpc } from './ipc/http'; import { abortAllStreams, registerHttpIpc } from './ipc/http';
import { registerTelemetryIpc } from './ipc/telemetry'; import { registerTelemetryIpc } from './ipc/telemetry';
import { getSharedDirectory, registerFilesIpc } from './ipc/files'; import { getSharedDirectory, registerFilesIpc } from './ipc/files';
import { createMainWindow, getMainWindow, getWindowMode, setWindowMode, toggleWindowVisibility } from './window'; import { createMainWindow, getMainWindow, getWindowMode, setWindowMode, toggleWindowVisibility, windowEvents } from './window';
import { getWebhookStatus, startWebhookServer, stopWebhookServer } from './webhook'; import { getWebhookStatus, startWebhookServer, stopWebhookServer } from './webhook';
import { registerVoiceIpc } from './voice/ipc'; import { registerVoiceIpc } from './voice/ipc';
import { stopEngine } from './voice/engine'; import { stopEngine } from './voice/engine';
// Audio playback must never be blocked behind a user gesture (TTS starts on incoming events). // Audio playback must never be blocked behind a user gesture (TTS starts on incoming events).
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
// Test/dev aid: isolate settings, logs and models in a custom directory.
if (process.env.EVEFLOW_USER_DATA) app.setPath('userData', process.env.EVEFLOW_USER_DATA);
let tray: Tray | null = null; let tray: Tray | null = null;
@@ -37,8 +39,9 @@ interface WebhookSettings {
} }
function readWebhookSettings(): WebhookSettings { function readWebhookSettings(): WebhookSettings {
const saved = storeGet<WebhookSettings>('webhook'); const saved = storeGet<WebhookSettings>('webhook') ?? {};
return { enabled: true, port: 7842, secret: '', ...(saved ?? {}) }; const port = Number(saved.port);
return { enabled: saved.enabled !== false, port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7842, secret: typeof saved.secret === 'string' ? saved.secret : '' };
} }
async function bootWebhook(): Promise<void> { async function bootWebhook(): Promise<void> {
@@ -47,7 +50,7 @@ async function bootWebhook(): Promise<void> {
await stopWebhookServer(); await stopWebhookServer();
return; return;
} }
await startWebhookServer(getMainWindow, { port: Number(settings.port) || 7842, secret: settings.secret || undefined }); await startWebhookServer(getMainWindow, { port: settings.port ?? 7842, secret: settings.secret || undefined });
} }
function sendHotkey(event: HotkeyEvent): void { function sendHotkey(event: HotkeyEvent): void {
@@ -63,7 +66,8 @@ function registerShortcuts(): void {
sendHotkey('ptt-toggle'); sendHotkey('ptt-toggle');
}], }],
['CommandOrControl+Shift+J', () => toggleWindowVisibility()], ['CommandOrControl+Shift+J', () => toggleWindowVisibility()],
['CommandOrControl+Shift+Escape', () => sendHotkey('stop-speaking')] // Ctrl+Shift+Escape is the Windows Task Manager shortcut; use a free combination.
['CommandOrControl+Alt+Escape', () => sendHotkey('stop-speaking')]
]; ];
for (const [accelerator, handler] of bindings) { for (const [accelerator, handler] of bindings) {
try { try {
@@ -96,7 +100,7 @@ function createTray(): void {
}; };
rebuildMenu(); rebuildMenu();
tray.on('click', () => toggleWindowVisibility()); tray.on('click', () => toggleWindowVisibility());
ipcMain.on(IPC.windowSetMode, () => setTimeout(rebuildMenu, 0)); windowEvents.on('mode', rebuildMenu);
} catch (err) { } catch (err) {
log('WARN', 'tray', `tray unavailable: ${(err as Error).message}`); log('WARN', 'tray', `tray unavailable: ${(err as Error).message}`);
} }
@@ -127,7 +131,10 @@ function registerCoreIpc(): void {
} }
}); });
ipcMain.on(IPC.windowSetMode, (_e, mode: WindowMode) => setWindowMode(mode === 'compact' ? 'compact' : 'hud')); ipcMain.on(IPC.windowSetMode, (_e, mode: WindowMode) => setWindowMode(mode === 'compact' ? 'compact' : 'hud'));
ipcMain.on(IPC.log, (_e, entry: LogEntry) => writeLog(entry)); ipcMain.on(IPC.log, (_e, entry: LogEntry) => {
if (!entry || typeof entry.message !== 'string') return;
writeLog({ ...entry, message: entry.message.slice(0, 8000), tag: String(entry.tag).slice(0, 64) });
});
ipcMain.handle(IPC.appInfo, (): AppInfo => ({ ipcMain.handle(IPC.appInfo, (): AppInfo => ({
version: app.getVersion(), version: app.getVersion(),
platform: process.platform, platform: process.platform,
@@ -143,7 +150,7 @@ function registerCoreIpc(): void {
}); });
} }
app.whenReady().then(async () => { async function boot(): Promise<void> {
log('INFO', 'app', `EveFlow ${app.getVersion()} starting (electron ${process.versions.electron}, chrome ${process.versions.chrome})`); log('INFO', 'app', `EveFlow ${app.getVersion()} starting (electron ${process.versions.electron}, chrome ${process.versions.chrome})`);
configureSession(); configureSession();
registerStoreIpc(); registerStoreIpc();
@@ -160,13 +167,19 @@ app.whenReady().then(async () => {
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
}); });
}); }
app.on('will-quit', () => { if (app.hasSingleInstanceLock()) {
app.whenReady().then(boot).catch((err) => log('ERROR', 'app', `boot failed: ${(err as Error).message}`));
}
app.on('will-quit', (event) => {
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
abortAllStreams(); abortAllStreams();
stopEngine(); stopEngine();
void stopWebhookServer(); void stopWebhookServer();
event.preventDefault();
flushStore().finally(() => app.exit(0));
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
+2 -1
View File
@@ -26,7 +26,8 @@ const api: EveFlowBridge = {
window: { window: {
control: (action: 'minimize' | 'maximize' | 'close' | 'hide') => ipcRenderer.send(IPC.windowControl, action), control: (action: 'minimize' | 'maximize' | 'close' | 'hide') => ipcRenderer.send(IPC.windowControl, action),
setMode: (mode: WindowMode) => ipcRenderer.send(IPC.windowSetMode, mode), setMode: (mode: WindowMode) => ipcRenderer.send(IPC.windowSetMode, mode),
onModeChanged: (cb: (mode: WindowMode) => void) => subscribe<WindowMode>(IPC.windowModeChanged, cb) onModeChanged: (cb: (mode: WindowMode) => void) => subscribe<WindowMode>(IPC.windowModeChanged, cb),
onVisibility: (cb: (visible: boolean) => void) => subscribe<boolean>(IPC.windowVisibility, cb)
}, },
log: (entry: LogEntry) => ipcRenderer.send(IPC.log, entry), log: (entry: LogEntry) => ipcRenderer.send(IPC.log, entry),
store: { store: {
+4 -3
View File
@@ -22,7 +22,7 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [
kind: 'stt', kind: 'stt',
engine: 'whisper', engine: 'whisper',
name: 'Whisper base (multilingue)', name: 'Whisper base (multilingue)',
description: 'Rapide sur CPU, bonne qualité en français pour des commandes courtes.', description: 'Le plus rapide sur CPU ; précision moyenne en français, suffisante pour des commandes courtes.',
languages: ['fr', 'en', 'multi'], languages: ['fr', 'en', 'multi'],
sizeMb: 208, sizeMb: 208,
url: `${ASR}/sherpa-onnx-whisper-base.tar.bz2`, url: `${ASR}/sherpa-onnx-whisper-base.tar.bz2`,
@@ -35,12 +35,13 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [
kind: 'stt', kind: 'stt',
engine: 'whisper', engine: 'whisper',
name: 'Whisper small (multilingue)', name: 'Whisper small (multilingue)',
description: 'Plus précis que base, environ trois fois plus lent.', description: 'Recommandé pour le français : nettement plus précis que base, environ trois fois plus lent.',
languages: ['fr', 'en', 'multi'], languages: ['fr', 'en', 'multi'],
sizeMb: 640, sizeMb: 640,
url: `${ASR}/sherpa-onnx-whisper-small.tar.bz2`, url: `${ASR}/sherpa-onnx-whisper-small.tar.bz2`,
dir: 'sherpa-onnx-whisper-small', dir: 'sherpa-onnx-whisper-small',
files: ['small-encoder.int8.onnx', 'small-decoder.int8.onnx', 'small-tokens.txt'] files: ['small-encoder.int8.onnx', 'small-decoder.int8.onnx', 'small-tokens.txt'],
recommended: true
}, },
{ {
id: 'whisper-turbo', id: 'whisper-turbo',
+49 -21
View File
@@ -19,13 +19,22 @@ let child: UtilityProcess | null = null;
let nextId = 1; let nextId = 1;
const pending = new Map<number, Pending>(); const pending = new Map<number, Pending>();
function rejectAll(message: string): void {
for (const [id, p] of pending) {
clearTimeout(p.timer);
p.reject(new Error(message));
pending.delete(id);
}
}
function spawn(): UtilityProcess { function spawn(): UtilityProcess {
if (child) return child; if (child) return child;
const script = path.join(__dirname, 'voice-worker.js'); const script = path.join(__dirname, 'voice-worker.js');
child = utilityProcess.fork(script, [], { serviceName: 'eveflow-voice', stdio: 'pipe' }); const proc = utilityProcess.fork(script, [], { serviceName: 'eveflow-voice', stdio: 'pipe' });
child.stdout?.on('data', (d: Buffer) => log('DEBUG', 'voice-worker', d.toString().trim())); child = proc;
child.stderr?.on('data', (d: Buffer) => log('WARN', 'voice-worker', d.toString().trim())); proc.stdout?.on('data', (d: Buffer) => log('DEBUG', 'voice-worker', d.toString().trim()));
child.on('message', (msg: { id: number; ok: boolean; result?: unknown; error?: string }) => { proc.stderr?.on('data', (d: Buffer) => log('WARN', 'voice-worker', d.toString().trim()));
proc.on('message', (msg: { id: number; ok: boolean; result?: unknown; error?: string }) => {
const p = pending.get(msg.id); const p = pending.get(msg.id);
if (!p) return; if (!p) return;
pending.delete(msg.id); pending.delete(msg.id);
@@ -33,17 +42,15 @@ function spawn(): UtilityProcess {
if (msg.ok) p.resolve(msg.result); if (msg.ok) p.resolve(msg.result);
else p.reject(new Error(msg.error ?? 'erreur moteur vocal')); else p.reject(new Error(msg.error ?? 'erreur moteur vocal'));
}); });
child.on('exit', (code) => { proc.on('exit', (code) => {
log(code === 0 ? 'INFO' : 'ERROR', 'voice-worker', `exited with code ${code}`); log(code === 0 ? 'INFO' : 'ERROR', 'voice-worker', `exited with code ${code}`);
// A newer worker may already have replaced this one; only clean up if we are still current.
if (child !== proc) return;
child = null; child = null;
for (const [id, p] of pending) { rejectAll('Le moteur vocal local s’est arrêté de façon inattendue.');
clearTimeout(p.timer);
p.reject(new Error('Le moteur vocal local s’est arrêté de façon inattendue.'));
pending.delete(id);
}
}); });
log('INFO', 'voice', 'voice worker started'); log('INFO', 'voice', 'voice worker started');
return child; return proc;
} }
function request<T>(message: Record<string, unknown>, timeoutMs = 120_000): Promise<T> { function request<T>(message: Record<string, unknown>, timeoutMs = 120_000): Promise<T> {
@@ -53,9 +60,20 @@ function request<T>(message: Record<string, unknown>, timeoutMs = 120_000): Prom
const timer = setTimeout(() => { const timer = setTimeout(() => {
pending.delete(id); pending.delete(id);
reject(new Error('Délai dépassé pour le moteur vocal local.')); reject(new Error('Délai dépassé pour le moteur vocal local.'));
// The worker handles requests synchronously: a stuck request wedges everything behind it.
if (child === proc) {
log('WARN', 'voice', 'worker unresponsive, restarting');
stopEngine();
}
}, timeoutMs); }, timeoutMs);
pending.set(id, { resolve: (v) => resolve(v as T), reject, timer }); pending.set(id, { resolve: (v) => resolve(v as T), reject, timer });
proc.postMessage({ id, ...message }); try {
proc.postMessage({ id, ...message });
} catch (err) {
pending.delete(id);
clearTimeout(timer);
reject(err as Error);
}
}); });
} }
@@ -76,21 +94,31 @@ export async function engineStatus(): Promise<VoiceEngineStatus> {
} }
export function transcribe(req: TranscribeRequest): Promise<TranscribeResult> { export function transcribe(req: TranscribeRequest): Promise<TranscribeResult> {
return request<TranscribeResult>({ type: 'transcribe', model: modelRef(req.modelId), wav: req.wav, language: req.language }, 180_000); // Binary payloads are base64-encoded for the worker: the utility process channel rejects external buffers.
const wav = Buffer.from(req.wav.buffer, req.wav.byteOffset, req.wav.byteLength).toString('base64');
return request<TranscribeResult>({ type: 'transcribe', model: modelRef(req.modelId), wav, language: req.language }, 180_000);
} }
export function synthesize(req: SynthesizeRequest): Promise<SynthesizeResult> { export async function synthesize(req: SynthesizeRequest): Promise<SynthesizeResult> {
return request<SynthesizeResult>({ type: 'synthesize', model: modelRef(req.modelId), text: req.text, speaker: req.speaker, speed: req.speed }, 180_000); const result = await request<Omit<SynthesizeResult, 'wav'> & { wav: string }>(
{ type: 'synthesize', model: modelRef(req.modelId), text: req.text, speaker: req.speaker, speed: req.speed },
180_000
);
const buffer = Buffer.from(result.wav, 'base64');
return { ...result, wav: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) };
} }
export function unload(modelId?: string): Promise<unknown> { /** Native model memory is only released deterministically by restarting the worker. */
if (!child) return Promise.resolve({ ok: true }); export function unload(_modelId?: string): Promise<unknown> {
return request({ type: 'unload', modelId }, 20_000); stopEngine();
return Promise.resolve({ ok: true });
} }
export function stopEngine(): void { export function stopEngine(): void {
if (child) { const proc = child;
child.kill(); child = null;
child = null; if (proc) {
rejectAll('Moteur vocal redémarré.');
proc.kill();
} }
} }
+10 -2
View File
@@ -16,7 +16,15 @@ export function registerVoiceIpc(): void {
await removeModel(id); await removeModel(id);
return listModels(); return listModels();
}); });
ipcMain.handle(VOICE_IPC.transcribe, (_e, req: TranscribeRequest) => transcribe(req)); ipcMain.handle(VOICE_IPC.transcribe, (_e, req: TranscribeRequest) => {
ipcMain.handle(VOICE_IPC.synthesize, (_e, req: SynthesizeRequest) => synthesize(req)); if (!req || !(req.wav instanceof Uint8Array) || req.wav.byteLength < 44 || req.wav.byteLength > 64 * 1024 * 1024) throw new Error('Audio invalide');
if (typeof req.modelId !== 'string') throw new Error('Modèle invalide');
return transcribe({ ...req, language: typeof req.language === 'string' ? req.language.slice(0, 8) : 'auto' });
});
ipcMain.handle(VOICE_IPC.synthesize, (_e, req: SynthesizeRequest) => {
if (!req || typeof req.text !== 'string' || !req.text.trim() || req.text.length > 5000) throw new Error('Texte invalide');
if (typeof req.modelId !== 'string') throw new Error('Modèle invalide');
return synthesize({ ...req, speaker: Number.isFinite(req.speaker) ? req.speaker : 0, speed: Number.isFinite(req.speed) ? req.speed : 1 });
});
ipcMain.handle(VOICE_IPC.unload, (_e, id?: string) => unload(id)); ipcMain.handle(VOICE_IPC.unload, (_e, id?: string) => unload(id));
} }
+32 -8
View File
@@ -11,9 +11,13 @@ import * as tar from 'tar';
import unbzip2 from 'unbzip2-stream'; import unbzip2 from 'unbzip2-stream';
import { VOICE_IPC, type VoiceDownloadProgress, type VoiceModelSpec, type VoiceModelStatus } from '../../shared/voice'; import { VOICE_IPC, type VoiceDownloadProgress, type VoiceModelSpec, type VoiceModelStatus } from '../../shared/voice';
import { VOICE_CATALOG, findModel } from './catalog'; import { VOICE_CATALOG, findModel } from './catalog';
import { stopEngine } from './engine';
import { log } from '../logger'; import { log } from '../logger';
const active = new Map<string, AbortController>(); const active = new Map<string, AbortController>();
/** Windows keeps files locked briefly (AV scans, memory-mapped models): retry removals. */
const RM = { recursive: true, force: true, maxRetries: 10, retryDelay: 200 } as const;
const DOWNLOAD_IDLE_MS = 60_000;
export function modelsDir(): string { export function modelsDir(): string {
const dir = path.join(app.getPath('userData'), 'models'); const dir = path.join(app.getPath('userData'), 'models');
@@ -59,7 +63,8 @@ export function listModels(): VoiceModelStatus[] {
export async function removeModel(id: string): Promise<void> { export async function removeModel(id: string): Promise<void> {
const spec = findModel(id); const spec = findModel(id);
if (!spec) throw new Error(`Modèle inconnu : ${id}`); if (!spec) throw new Error(`Modèle inconnu : ${id}`);
await fs.promises.rm(modelDir(spec), { recursive: true, force: true }); stopEngine(); // releases memory-mapped model files before deleting them
await fs.promises.rm(modelDir(spec), RM);
log('INFO', 'voice', `model removed: ${id}`); log('INFO', 'voice', `model removed: ${id}`);
} }
@@ -78,8 +83,14 @@ export async function downloadModel(id: string, sender: WebContents | null): Pro
}; };
const tmpDir = path.join(modelsDir(), `.tmp-${spec.id}`); const tmpDir = path.join(modelsDir(), `.tmp-${spec.id}`);
await fs.promises.rm(tmpDir, { recursive: true, force: true }); await fs.promises.rm(tmpDir, RM);
await fs.promises.mkdir(tmpDir, { recursive: true }); await fs.promises.mkdir(tmpDir, { recursive: true });
// Abort the transfer when no byte arrives for a while (fetch itself has no inactivity timeout).
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const touch = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => controller.abort(new Error('Téléchargement interrompu (aucune donnée reçue)')), DOWNLOAD_IDLE_MS);
};
try { try {
log('INFO', 'voice', `downloading ${spec.id} from ${spec.url}`); log('INFO', 'voice', `downloading ${spec.id} from ${spec.url}`);
@@ -88,9 +99,11 @@ export async function downloadModel(id: string, sender: WebContents | null): Pro
const total = Number(response.headers.get('content-length')) || Math.round(spec.sizeMb * 1024 * 1024); const total = Number(response.headers.get('content-length')) || Math.round(spec.sizeMb * 1024 * 1024);
let received = 0; let received = 0;
let lastReport = 0; let lastReport = 0;
touch();
const counter = new Transform({ const counter = new Transform({
transform(chunk: Buffer, _enc, cb) { transform(chunk: Buffer, _enc, cb) {
received += chunk.length; received += chunk.length;
touch();
const now = Date.now(); const now = Date.now();
if (now - lastReport > 250) { if (now - lastReport > 250) {
lastReport = now; lastReport = now;
@@ -116,19 +129,30 @@ export async function downloadModel(id: string, sender: WebContents | null): Pro
if (missing.length) throw new Error(`Archive incomplète, fichiers manquants : ${missing.join(', ')}`); if (missing.length) throw new Error(`Archive incomplète, fichiers manquants : ${missing.join(', ')}`);
const finalDir = modelDir(spec); const finalDir = modelDir(spec);
await fs.promises.rm(finalDir, { recursive: true, force: true }); if (isInstalled(spec)) stopEngine();
await fs.promises.rename(extracted, finalDir); await fs.promises.rm(finalDir, RM);
await fs.promises.rm(tmpDir, { recursive: true, force: true }); for (let attempt = 0; ; attempt++) {
try {
await fs.promises.rename(extracted, finalDir);
break;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code ?? '';
if (attempt >= 10 || !['EPERM', 'EBUSY', 'ENOTEMPTY', 'EACCES'].includes(code)) throw err;
await new Promise((r) => setTimeout(r, 300));
}
}
await fs.promises.rm(tmpDir, RM);
report({ phase: 'done', received: total, total, percent: 100 }); report({ phase: 'done', received: total, total, percent: 100 });
log('INFO', 'voice', `model installed: ${spec.id}`); log('INFO', 'voice', `model installed: ${spec.id}`);
} catch (err) { } catch (err) {
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); await fs.promises.rm(tmpDir, RM).catch(() => undefined);
const e = err as Error; const e = (controller.signal.reason instanceof Error ? controller.signal.reason : err) as Error;
const cancelled = e.name === 'AbortError' || controller.signal.aborted; const cancelled = (e.name === 'AbortError' || controller.signal.aborted) && !(controller.signal.reason instanceof Error);
report({ phase: cancelled ? 'cancelled' : 'error', received: 0, total: 0, percent: 0, message: cancelled ? 'annulé' : e.message }); report({ phase: cancelled ? 'cancelled' : 'error', received: 0, total: 0, percent: 0, message: cancelled ? 'annulé' : e.message });
log(cancelled ? 'INFO' : 'ERROR', 'voice', `download ${spec.id} ${cancelled ? 'cancelled' : 'failed: ' + e.message}`); log(cancelled ? 'INFO' : 'ERROR', 'voice', `download ${spec.id} ${cancelled ? 'cancelled' : 'failed: ' + e.message}`);
if (!cancelled) throw new Error(e.message); if (!cancelled) throw new Error(e.message);
} finally { } finally {
if (idleTimer) clearTimeout(idleTimer);
active.delete(id); active.delete(id);
} }
return listModels().find((m) => m.id === id)!; return listModels().find((m) => m.id === id)!;
+30 -7
View File
@@ -16,7 +16,7 @@ interface ModelRef {
type Request = type Request =
| { id: number; type: 'status' } | { id: number; type: 'status' }
| { id: number; type: 'transcribe'; model: ModelRef; wav: Uint8Array; language: string } | { id: number; type: 'transcribe'; model: ModelRef; wav: Uint8Array | string; language: string }
| { id: number; type: 'synthesize'; model: ModelRef; text: string; speaker: number; speed: number } | { id: number; type: 'synthesize'; model: ModelRef; text: string; speaker: number; speed: number }
| { id: number; type: 'unload'; modelId?: string }; | { id: number; type: 'unload'; modelId?: string };
@@ -32,7 +32,7 @@ type Sherpa = {
OfflineTts: new (config: unknown) => { OfflineTts: new (config: unknown) => {
numSpeakers: number; numSpeakers: number;
sampleRate: number; sampleRate: number;
generate: (req: { text: string; sid: number; speed: number }) => { samples: Float32Array; sampleRate: number }; generate: (req: { text: string; sid: number; speed: number; enableExternalBuffer?: boolean }) => { samples: Float32Array; sampleRate: number };
}; };
version: string; version: string;
}; };
@@ -136,16 +136,20 @@ function getSynthesizer(model: ModelRef) {
// ── audio helpers ────────────────────────────────────────────────────────── // ── audio helpers ──────────────────────────────────────────────────────────
function decodeWav(bytes: Uint8Array): { samples: Float32Array; sampleRate: number } { function decodeWav(bytes: Uint8Array): { samples: Float32Array; sampleRate: number } {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]) !== 'RIFF') throw new Error('WAV invalide'); if (String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]) !== 'RIFF' || String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]) !== 'WAVE') {
throw new Error('WAV invalide');
}
let offset = 12; let offset = 12;
let sampleRate = 16000; let sampleRate = 16000;
let channels = 1; let channels = 1;
let bits = 16; let bits = 16;
let format = 1; // 1 = PCM, 3 = IEEE float
let data: { start: number; length: number } | null = null; let data: { start: number; length: number } | null = null;
while (offset + 8 <= bytes.byteLength) { while (offset + 8 <= bytes.byteLength) {
const id = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); const id = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
const size = view.getUint32(offset + 4, true); const size = view.getUint32(offset + 4, true);
if (id === 'fmt ') { if (id === 'fmt ') {
format = view.getUint16(offset + 8, true);
channels = view.getUint16(offset + 10, true); channels = view.getUint16(offset + 10, true);
sampleRate = view.getUint32(offset + 12, true); sampleRate = view.getUint32(offset + 12, true);
bits = view.getUint16(offset + 22, true); bits = view.getUint16(offset + 22, true);
@@ -163,7 +167,16 @@ function decodeWav(bytes: Uint8Array): { samples: Float32Array; sampleRate: numb
let sum = 0; let sum = 0;
for (let c = 0; c < channels; c++) { for (let c = 0; c < channels; c++) {
const pos = data.start + (i * channels + c) * bytesPerSample; const pos = data.start + (i * channels + c) * bytesPerSample;
sum += bits === 16 ? view.getInt16(pos, true) / 32768 : bits === 32 ? view.getInt32(pos, true) / 2147483648 : (bytes[pos] - 128) / 128; sum +=
format === 3 && bits === 32
? view.getFloat32(pos, true)
: bits === 16
? view.getInt16(pos, true) / 32768
: bits === 24
? (((bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16)) << 8) >> 8) / 8388608
: bits === 32
? view.getInt32(pos, true) / 2147483648
: (bytes[pos] - 128) / 128;
} }
samples[i] = sum / channels; samples[i] = sum / channels;
} }
@@ -225,8 +238,11 @@ function handle(req: Request): unknown {
} }
case 'transcribe': { case 'transcribe': {
const started = Date.now(); const started = Date.now();
const { samples, sampleRate } = decodeWav(req.wav); // Audio crosses the process boundary as base64: V8 refuses to serialize external buffers.
const bytes = typeof req.wav === 'string' ? new Uint8Array(Buffer.from(req.wav, 'base64')) : req.wav;
const { samples, sampleRate } = decodeWav(bytes);
const pcm = resampleTo16k(samples, sampleRate); const pcm = resampleTo16k(samples, sampleRate);
if (pcm.length < 1600) throw new Error('Audio trop court');
const recognizer = getRecognizer(req.model, req.language); const recognizer = getRecognizer(req.model, req.language);
const stream = recognizer.createStream(); const stream = recognizer.createStream();
stream.acceptWaveform({ sampleRate: 16000, samples: pcm }); stream.acceptWaveform({ sampleRate: 16000, samples: pcm });
@@ -238,8 +254,15 @@ function handle(req: Request): unknown {
const started = Date.now(); const started = Date.now();
const tts = getSynthesizer(req.model); const tts = getSynthesizer(req.model);
const sid = Math.max(0, Math.min(tts.numSpeakers - 1, Math.floor(req.speaker))); const sid = Math.max(0, Math.min(tts.numSpeakers - 1, Math.floor(req.speaker)));
const audio = tts.generate({ text: req.text, sid, speed: Math.max(0.5, Math.min(2, req.speed || 1)) }); // Electron forbids N-API external buffers: ask sherpa-onnx to copy the samples into a V8 buffer.
return { wav: encodeWav(audio.samples, audio.sampleRate), sampleRate: audio.sampleRate, durationMs: Date.now() - started, audioSec: audio.samples.length / audio.sampleRate }; const audio = tts.generate({ text: req.text, sid, speed: Math.max(0.5, Math.min(2, req.speed || 1)), enableExternalBuffer: false });
const wav = encodeWav(audio.samples, audio.sampleRate);
return {
wav: Buffer.from(wav.buffer, wav.byteOffset, wav.byteLength).toString('base64'),
sampleRate: audio.sampleRate,
durationMs: Date.now() - started,
audioSec: audio.samples.length / audio.sampleRate
};
} }
case 'unload': { case 'unload': {
if (req.modelId) { if (req.modelId) {
+24 -10
View File
@@ -35,16 +35,20 @@ export function getWebhookStatus(): WebhookStatus {
export function stopWebhookServer(): Promise<void> { export function stopWebhookServer(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!server) return resolve(); if (!server) return resolve();
server.close(() => resolve()); const current = server;
server = null; server = null;
status = { ...status, listening: false }; status = { ...status, listening: false };
current.closeAllConnections();
current.close(() => resolve());
}); });
} }
export async function startWebhookServer(getWindow: () => BrowserWindow | null, options: WebhookOptions): Promise<WebhookStatus> { export async function startWebhookServer(getWindow: () => BrowserWindow | null, options: WebhookOptions): Promise<WebhookStatus> {
await stopWebhookServer(); await stopWebhookServer();
const { port, secret } = options; const { port, secret } = options;
const host = options.host ?? '0.0.0.0'; // Without a shared secret the hook only listens on loopback: anyone on the LAN could otherwise inject messages.
const host = options.host ?? (secret ? '0.0.0.0' : '127.0.0.1');
if (host !== '127.0.0.1' && !secret) log('WARN', 'webhook', `listening on ${host} without a secret`);
server = http.createServer((req, res) => { server = http.createServer((req, res) => {
const json = (code: number, payload: unknown) => { const json = (code: number, payload: unknown) => {
@@ -69,19 +73,24 @@ export async function startWebhookServer(getWindow: () => BrowserWindow | null,
} }
} }
let body = ''; const chunks: Buffer[] = [];
let bytes = 0;
let tooLarge = false; let tooLarge = false;
req.on('data', (chunk: Buffer) => { req.on('data', (chunk: Buffer) => {
if (tooLarge) return; if (tooLarge) return;
body += chunk.toString('utf8'); bytes += chunk.length;
if (body.length > MAX_BODY_BYTES) { if (bytes > MAX_BODY_BYTES) {
tooLarge = true; tooLarge = true;
json(413, { error: 'Payload too large' }); res.writeHead(413, { 'Content-Type': 'application/json', Connection: 'close' });
req.destroy(); res.end(JSON.stringify({ error: 'Payload too large' }), () => req.socket.destroy());
return;
} }
chunks.push(chunk);
}); });
req.on('end', () => { req.on('end', () => {
if (tooLarge) return; if (tooLarge) return;
// Concatenate before decoding so multibyte UTF-8 (accents, emoji) split across chunks stays intact.
const body = Buffer.concat(chunks).toString('utf8');
try { try {
const raw: unknown = body.trim() ? JSON.parse(body) : {}; const raw: unknown = body.trim() ? JSON.parse(body) : {};
const events = normalizeHermesPush(raw); const events = normalizeHermesPush(raw);
@@ -99,13 +108,18 @@ export async function startWebhookServer(getWindow: () => BrowserWindow | null,
}); });
return new Promise((resolve) => { return new Promise((resolve) => {
server!.once('error', (err: NodeJS.ErrnoException) => { const instance = server!;
instance.on('error', (err: NodeJS.ErrnoException) => {
log('ERROR', 'webhook', `server error: ${err.message}`); log('ERROR', 'webhook', `server error: ${err.message}`);
status = { listening: false, port, path: WEBHOOK_PATH, secretConfigured: !!secret, error: err.message }; status = { listening: false, port, path: WEBHOOK_PATH, secretConfigured: !!secret, error: err.message };
server = null; if (server === instance) server = null;
resolve(status); resolve(status);
}); });
server!.listen(port, host, () => { if (!Number.isInteger(port) || port < 1 || port > 65535) {
instance.emit('error', new Error(`port invalide : ${port}`));
return;
}
instance.listen(port, host, () => {
log('INFO', 'webhook', `listening on http://${host}:${port}${WEBHOOK_PATH}`); log('INFO', 'webhook', `listening on http://${host}:${port}${WEBHOOK_PATH}`);
status = { listening: true, port, path: WEBHOOK_PATH, secretConfigured: !!secret }; status = { listening: true, port, path: WEBHOOK_PATH, secretConfigured: !!secret };
resolve(status); resolve(status);
+19 -6
View File
@@ -1,4 +1,5 @@
import { app, BrowserWindow, screen, shell } from 'electron'; import { app, BrowserWindow, screen, shell } from 'electron';
import { EventEmitter } from 'node:events';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { IPC, type WindowMode } from '../shared/ipc'; import { IPC, type WindowMode } from '../shared/ipc';
@@ -9,6 +10,9 @@ const COMPACT_SIZE = { width: 380, height: 620, minWidth: 320, minHeight: 420 };
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let currentMode: WindowMode = 'hud'; let currentMode: WindowMode = 'hud';
export const windowEvents = new EventEmitter();
// Transparent frameless windows cannot be resized reliably on Windows; use an opaque, rounded window there.
const TRANSPARENT = process.platform !== 'win32';
let hudBounds: Electron.Rectangle | null = null; let hudBounds: Electron.Rectangle | null = null;
export function getMainWindow(): BrowserWindow | null { export function getMainWindow(): BrowserWindow | null {
@@ -49,8 +53,9 @@ export function createMainWindow(): BrowserWindow {
const win = new BrowserWindow({ const win = new BrowserWindow({
...HUD_SIZE, ...HUD_SIZE,
frame: false, frame: false,
transparent: true, transparent: TRANSPARENT,
backgroundColor: '#00000000', backgroundColor: TRANSPARENT ? '#00000000' : '#03070d',
roundedCorners: true,
hasShadow: true, hasShadow: true,
show: false, show: false,
title: 'EveFlow', title: 'EveFlow',
@@ -78,11 +83,18 @@ export function createMainWindow(): BrowserWindow {
return { action: 'deny' }; return { action: 'deny' };
}); });
win.webContents.on('will-navigate', (event, url) => { win.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith('file://') && !url.startsWith(process.env.VITE_DEV_SERVER_URL ?? 'http://127.0.0.1:5173')) { if (url === win.webContents.getURL()) return;
event.preventDefault(); event.preventDefault();
void shell.openExternal(url); if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
}
}); });
win.webContents.on('will-redirect', (event) => event.preventDefault());
const notifyVisibility = () => {
if (!win.isDestroyed()) win.webContents.send(IPC.windowVisibility, win.isVisible() && !win.isMinimized());
};
win.on('show', notifyVisibility);
win.on('hide', notifyVisibility);
win.on('minimize', notifyVisibility);
win.on('restore', notifyVisibility);
win.on('closed', () => { win.on('closed', () => {
mainWindow = null; mainWindow = null;
@@ -129,6 +141,7 @@ export function setWindowMode(mode: WindowMode): void {
} }
currentMode = mode; currentMode = mode;
win.webContents.send(IPC.windowModeChanged, mode); win.webContents.send(IPC.windowModeChanged, mode);
windowEvents.emit('mode', mode);
} }
export function toggleWindowVisibility(): void { export function toggleWindowVisibility(): void {
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data:; connect-src 'self' http: https: ws: wss: blob: data:; worker-src 'self' blob:;" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data:; connect-src 'self' http: https: ws: wss: blob: data:; worker-src 'self' blob:;" />
<meta name="description" content="EveFlow — interface vocale JARVIS pour Hermes Agent." /> <meta name="description" content="EveFlow — interface vocale JARVIS pour Hermes Agent." />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='26' fill='none' stroke='%2334e4ff' stroke-width='4'/%3E%3Ccircle cx='32' cy='32' r='10' fill='%2334e4ff'/%3E%3C/svg%3E" /> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='26' fill='none' stroke='%2334e4ff' stroke-width='4'/%3E%3Ccircle cx='32' cy='32' r='10' fill='%2334e4ff'/%3E%3C/svg%3E" />
<title>EveFlow</title> <title>EveFlow</title>
+32 -2
View File
@@ -1,14 +1,17 @@
{ {
"name": "eveflow", "name": "eveflow",
"version": "2.0.0", "version": "2.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "eveflow", "name": "eveflow",
"version": "2.0.0", "version": "2.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fontsource/orbitron": "^5.3.0",
"@fontsource/rajdhani": "^5.3.0",
"@fontsource/share-tech-mono": "^5.3.0",
"lucide-react": "^1.40.0", "lucide-react": "^1.40.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
@@ -1060,6 +1063,33 @@
} }
} }
}, },
"node_modules/@fontsource/orbitron": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/orbitron/-/orbitron-5.3.0.tgz",
"integrity": "sha512-+BhPsbUAo/AF7sRcf+TEexm+EWWkgTiR9Zwb7AhYIPkc5oplfvw0C45Oc2xlKPhwVEmxG8hHuA6/Ansbl+Bc6w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/rajdhani": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.3.0.tgz",
"integrity": "sha512-oBJgSIibWJvVBKseme1JczqPLoDHN97LHti4uh0LjfcJqQoVmnQQlRKIpEjTJV559rmVQ6qbVZZwKwk2wkBTtw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/share-tech-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/share-tech-mono/-/share-tech-mono-5.3.0.tgz",
"integrity": "sha512-hVEzVCzEid/fBfhggMyvkfDzn9ODrMihxC0EiOeQK3BuQNvdjmith2ERnoDNUkbW8zhufbKNuCgaSoT8LUNpuQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hapi/address": { "node_modules/@hapi/address": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
+6 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "eveflow", "name": "eveflow",
"version": "2.1.0", "version": "2.1.0",
"releaseVersion": "2.1.0", "releaseVersion": "2.1.0.1",
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry", "description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"private": true, "private": true,
@@ -61,9 +61,13 @@
"node_modules/sherpa-onnx-linux-x64/**", "node_modules/sherpa-onnx-linux-x64/**",
"node_modules/sherpa-onnx-darwin-arm64/**", "node_modules/sherpa-onnx-darwin-arm64/**",
"node_modules/sherpa-onnx-darwin-x64/**" "node_modules/sherpa-onnx-darwin-x64/**"
] ],
"npmRebuild": false
}, },
"dependencies": { "dependencies": {
"@fontsource/orbitron": "^5.3.0",
"@fontsource/rajdhani": "^5.3.0",
"@fontsource/share-tech-mono": "^5.3.0",
"lucide-react": "^1.40.0", "lucide-react": "^1.40.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
+6 -1
View File
@@ -1,13 +1,18 @@
// Packages the app with electron-builder using the four-part release version // Packages the app with electron-builder using the four-part release version
// (package.json "releaseVersion", e.g. 2.0.0.1). package.json "version" must stay semver. // (package.json "releaseVersion", e.g. 2.0.0.1). package.json "version" must stay semver.
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
const pkg = JSON.parse(readFileSync('package.json', 'utf8')); const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
const release = pkg.releaseVersion || pkg.version; const release = pkg.releaseVersion || pkg.version;
const build = release.split('.')[3] ?? '0'; const build = release.split('.')[3] ?? '0';
const args = process.argv.slice(2); const args = process.argv.slice(2);
if (!args.some((a) => a.startsWith('--win') || a.startsWith('--linux') || a.startsWith('--mac'))) args.unshift('--win'); if (!args.some((a) => a.startsWith('--win') || a.startsWith('--linux') || a.startsWith('--mac'))) args.unshift('--win');
// The Windows build must embed the Windows native addon (an optional dependency installed only on win32).
if (args.some((a) => a.startsWith('--win')) && !existsSync('node_modules/sherpa-onnx-win-x64/sherpa-onnx.node')) {
console.error('sherpa-onnx-win-x64 is not installed: build the Windows installer on Windows (npm ci) or install it with\n npm i --no-save --os=win32 --cpu=x64 sherpa-onnx-win-x64');
process.exit(1);
}
const result = spawnSync('npx', ['electron-builder', ...args, `--config.extraMetadata.version=${release}`], { const result = spawnSync('npx', ['electron-builder', ...args, `--config.extraMetadata.version=${release}`], {
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32', shell: process.platform === 'win32',
+2
View File
@@ -29,6 +29,8 @@ export interface EveFlowBridge {
control: (action: 'minimize' | 'maximize' | 'close' | 'hide') => void; control: (action: 'minimize' | 'maximize' | 'close' | 'hide') => void;
setMode: (mode: WindowMode) => void; setMode: (mode: WindowMode) => void;
onModeChanged: (cb: (mode: WindowMode) => void) => Unsubscribe; onModeChanged: (cb: (mode: WindowMode) => void) => Unsubscribe;
/** Fires when the window is shown/hidden/minimised (the Page Visibility API is disabled by backgroundThrottling:false). */
onVisibility: (cb: (visible: boolean) => void) => Unsubscribe;
}; };
log: (entry: LogEntry) => void; log: (entry: LogEntry) => void;
store: { store: {
+1
View File
@@ -100,6 +100,7 @@ export const IPC = {
windowControl: 'window:control', windowControl: 'window:control',
windowSetMode: 'window:set-mode', windowSetMode: 'window:set-mode',
windowModeChanged: 'window:mode-changed', windowModeChanged: 'window:mode-changed',
windowVisibility: 'window:visibility',
log: 'log:write', log: 'log:write',
storeGet: 'store:get', storeGet: 'store:get',
storeSet: 'store:set', storeSet: 'store:set',
+29 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { AlertTriangle, X } from 'lucide-react'; import { AlertTriangle, X } from 'lucide-react';
import type { WindowMode } from '../shared/ipc'; import type { WindowMode } from '../shared/ipc';
import { bridge } from './lib/bridge'; import { bridge } from './lib/bridge';
@@ -99,15 +99,23 @@ function useWindowMode(): WindowMode {
return mode; return mode;
} }
function ErrorBanner() { /** Errors surface as a transient toast over the core instead of shifting the layout. */
function ErrorToast() {
const error = useChat((s) => s.error); const error = useChat((s) => s.error);
const setError = useChat((s) => s.setError); const setError = useChat((s) => s.setError);
useEffect(() => {
if (!error) return;
const timer = setTimeout(() => setError(null), 9000);
return () => clearTimeout(timer);
}, [error, setError]);
if (!error) return null; if (!error) return null;
return ( return (
<div className="error-banner"> <div className="toast-stack">
<AlertTriangle size={16} /> <div className="error-banner" role="alert">
<span>{error}</span> <AlertTriangle size={16} />
<button className="icon-btn" onClick={() => setError(null)}><X size={14} /></button> <span>{error}</span>
<button className="icon-btn" aria-label="Fermer" onClick={() => setError(null)}><X size={14} /></button>
</div>
</div> </div>
); );
} }
@@ -118,15 +126,25 @@ export default function App() {
const mode = useWindowMode(); const mode = useWindowMode();
const showTelemetry = useSettings((s) => s.settings.ui.showTelemetry); const showTelemetry = useSettings((s) => s.settings.ui.showTelemetry);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showOps, setShowOps] = useState(false);
const opsBadge = useChat((s) => s.activity.filter((a) => a.status === 'running').length) + useHermes((s) => s.jobRuns.filter((r) => !r.read).length);
const showSettingsRef = useRef(false);
const showOpsRef = useRef(false);
showSettingsRef.current = showSettings;
showOpsRef.current = showOps;
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && showSettings) setShowSettings(false); if (e.key === 'Escape') {
if (showSettingsRef.current) setShowSettings(false);
else if (showOpsRef.current) setShowOps(false);
}
if (e.key === ',' && (e.ctrlKey || e.metaKey)) setShowSettings((v) => !v); if (e.key === ',' && (e.ctrlKey || e.metaKey)) setShowSettings((v) => !v);
if (e.key.toLowerCase() === 'o' && (e.ctrlKey || e.metaKey) && e.shiftKey) setShowOps((v) => !v);
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [showSettings]); }, []);
if (!loaded) { if (!loaded) {
return ( return (
@@ -147,22 +165,22 @@ export default function App() {
return ( return (
<div className="hud-root"> <div className="hud-root">
<TopBar onOpenSettings={() => setShowSettings(true)} /> <TopBar onOpenSettings={() => setShowSettings(true)} onToggleOps={() => setShowOps((v) => !v)} opsBadge={opsBadge} />
<div className="hud-body"> <div className="hud-body">
<div className="hud-column"> <div className="hud-column">
<ChatPanel /> <ChatPanel />
</div> </div>
<div className="hud-column"> <div className="hud-column">
<ErrorBanner />
<section className="panel bracket" style={{ flex: 1 }}> <section className="panel bracket" style={{ flex: 1 }}>
<CoreStage /> <CoreStage />
</section> </section>
</div> </div>
<div className="hud-column ops-column"> <div className={`hud-column ops-column${showOps ? ' open' : ''}`}>
<OpsPanel /> <OpsPanel />
{showTelemetry && <Telemetry />} {showTelemetry && <Telemetry />}
</div> </div>
</div> </div>
<ErrorToast />
{showSettings && <SettingsDrawer onClose={() => setShowSettings(false)} />} {showSettings && <SettingsDrawer onClose={() => setShowSettings(false)} />}
<PendingRequests /> <PendingRequests />
</div> </div>
+24 -6
View File
@@ -12,18 +12,36 @@ interface Props {
export function ChatPanel({ compact }: Props) { export function ChatPanel({ compact }: Props) {
const messages = useChat((s) => s.messages); const messages = useChat((s) => s.messages);
const clear = useChat((s) => s.clear); const clear = useChat((s) => s.clear);
const settings = useSettings((s) => s.settings); const assistantName = useSettings((s) => s.settings.assistantName);
const userName = useSettings((s) => s.settings.userName);
const showReasoning = useSettings((s) => s.settings.ui.showReasoning);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const programmatic = useRef(false);
const [zoom, setZoom] = useState<string | null>(null); const [zoom, setZoom] = useState<string | null>(null);
const [stick, setStick] = useState(true); const [stick, setStick] = useState(true);
useEffect(() => { useEffect(() => {
if (!stick) return; if (!stick) return;
const el = listRef.current; const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight; if (!el) return;
programmatic.current = true;
el.scrollTop = el.scrollHeight;
}, [messages, stick]); }, [messages, stick]);
useEffect(() => {
if (!zoom) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setZoom(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [zoom]);
const onScroll = () => { const onScroll = () => {
if (programmatic.current) {
programmatic.current = false;
return;
}
const el = listRef.current; const el = listRef.current;
if (!el) return; if (!el) return;
setStick(el.scrollHeight - el.scrollTop - el.clientHeight < 80); setStick(el.scrollHeight - el.scrollTop - el.clientHeight < 80);
@@ -47,7 +65,7 @@ export function ChatPanel({ compact }: Props) {
<div ref={listRef} className={`chat-list${compact ? ' compact' : ''}`} onScroll={onScroll}> <div ref={listRef} className={`chat-list${compact ? ' compact' : ''}`} onScroll={onScroll}>
{visible.length === 0 && ( {visible.length === 0 && (
<div className="empty"> <div className="empty">
{compact ? 'En attente…' : `Canal ouvert. Parlez ou écrivez à ${settings.assistantName}.`} {compact ? 'En attente…' : `Canal ouvert. Parlez ou écrivez à ${assistantName}.`}
{!compact && ( {!compact && (
<div className="muted" style={{ marginTop: 8, fontSize: 12 }}> <div className="muted" style={{ marginTop: 8, fontSize: 12 }}>
<span className="kbd">Ctrl</span> + <span className="kbd">Shift</span> + <span className="kbd">Espace</span> active le micro depuis n’importe où. <span className="kbd">Ctrl</span> + <span className="kbd">Shift</span> + <span className="kbd">Espace</span> active le micro depuis n’importe où.
@@ -59,9 +77,9 @@ export function ChatPanel({ compact }: Props) {
<MessageBubble <MessageBubble
key={m.id} key={m.id}
message={m} message={m}
assistantName={settings.assistantName} assistantName={assistantName}
userName={settings.userName} userName={userName}
showReasoning={settings.ui.showReasoning} showReasoning={showReasoning}
compact={compact} compact={compact}
onOpenImage={setZoom} onOpenImage={setZoom}
/> />
+22 -3
View File
@@ -67,17 +67,34 @@ export function CommandBar({ compact }: Props) {
const submit = async () => { const submit = async () => {
const text = draft.trim(); const text = draft.trim();
if (steerMode && currentRunId) { if (steerMode) {
if (!currentRunId) {
setSteerMode(false);
return;
}
if (await steer(text)) setDraft(''); if (await steer(text)) setDraft('');
inputRef.current?.focus();
return; return;
} }
if (!text && images.length === 0) return; if (!text && images.length === 0) return;
const attached = images; const attached = images;
setDraft(''); setDraft('');
setImages([]); setImages([]);
inputRef.current?.focus();
await sendMessage(text, attached); await sendMessage(text, attached);
}; };
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
inputRef.current?.focus();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -131,6 +148,8 @@ export function CommandBar({ compact }: Props) {
className={`mic-btn ${micClass}`} className={`mic-btn ${micClass}`}
style={{ ['--level' as string]: level }} style={{ ['--level' as string]: level }}
title={micTitle} title={micTitle}
aria-label={micTitle}
aria-pressed={listening}
onClick={() => voiceController.toggle()} onClick={() => voiceController.toggle()}
disabled={phase === 'transcribing'} disabled={phase === 'transcribing'}
> >
@@ -154,11 +173,11 @@ export function CommandBar({ compact }: Props) {
)} )}
<input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void onFiles(e.target.files)} /> <input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void onFiles(e.target.files)} />
{isSending && !steerMode ? ( {isSending && !steerMode ? (
<button className="send-btn stop" title="Interrompre (Échap)" onClick={stopGeneration}> <button className="send-btn stop" title="Interrompre (Échap)" aria-label="Interrompre" onClick={stopGeneration}>
<Square size={18} /> <Square size={18} />
</button> </button>
) : ( ) : (
<button className="send-btn primary" title={steerMode ? 'Envoyer la consigne' : 'Envoyer (Entrée)'} onClick={() => void submit()} disabled={!draft.trim() && images.length === 0}> <button className="send-btn primary" title={steerMode ? 'Envoyer la consigne' : 'Envoyer (Entrée)'} aria-label={steerMode ? 'Envoyer la consigne' : 'Envoyer'} onClick={() => void submit()} disabled={!draft.trim() && images.length === 0}>
{steerMode ? <Navigation size={18} /> : <Send size={18} />} {steerMode ? <Navigation size={18} /> : <Send size={18} />}
</button> </button>
)} )}
+9 -4
View File
@@ -1,4 +1,4 @@
import { memo, useState } from 'react'; import { memo, useEffect, useRef, useState } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { Copy, Volume2, Check } from 'lucide-react'; import { Copy, Volume2, Check } from 'lucide-react';
@@ -28,6 +28,10 @@ function openLink(href: string): void {
export const MessageBubble = memo(function MessageBubble({ message, assistantName, userName, showReasoning, compact, onOpenImage }: Props) { export const MessageBubble = memo(function MessageBubble({ message, assistantName, userName, showReasoning, compact, onOpenImage }: Props) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyTimer = useRef<number | null>(null);
useEffect(() => () => {
if (copyTimer.current) window.clearTimeout(copyTimer.current);
}, []);
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const who = isUser ? userName || 'VOUS' : message.role === 'system' ? 'SYSTÈME' : assistantName; const who = isUser ? userName || 'VOUS' : message.role === 'system' ? 'SYSTÈME' : assistantName;
const status = message.status ?? 'done'; const status = message.status ?? 'done';
@@ -35,7 +39,8 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
const copy = () => { const copy = () => {
navigator.clipboard.writeText(message.content).then(() => { navigator.clipboard.writeText(message.content).then(() => {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1200); if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1200);
}).catch(() => undefined); }).catch(() => undefined);
}; };
@@ -50,8 +55,8 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
{message.usage?.total_tokens ? <span>{message.usage.total_tokens} tok</span> : null} {message.usage?.total_tokens ? <span>{message.usage.total_tokens} tok</span> : null}
{message.role === 'assistant' && status === 'done' && ( {message.role === 'assistant' && status === 'done' && (
<span className="msg-actions"> <span className="msg-actions">
<button className="icon-btn" title="Copier" onClick={copy}>{copied ? <Check size={13} /> : <Copy size={13} />}</button> <button className="icon-btn" title="Copier" aria-label="Copier le message" onClick={copy}>{copied ? <Check size={13} /> : <Copy size={13} />}</button>
<button className="icon-btn" title="Lire à voix haute" onClick={() => speech.say(message.content)}><Volume2 size={13} /></button> <button className="icon-btn" title="Lire à voix haute" aria-label="Lire à voix haute" onClick={() => speech.say(message.content)}><Volume2 size={13} /></button>
</span> </span>
)} )}
</div> </div>
+16 -4
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { ShieldAlert, HelpCircle, KeyRound } from 'lucide-react'; import { ShieldAlert, HelpCircle, KeyRound } from 'lucide-react';
import { resolvePending } from '../../services/conversation'; import { resolvePending } from '../../services/conversation';
import { useChat, type PendingRequest } from '../../state/chat'; import { useChat, type PendingRequest } from '../../state/chat';
@@ -7,10 +7,22 @@ function RequestCard({ request }: { request: PendingRequest }) {
const [answer, setAnswer] = useState(''); const [answer, setAnswer] = useState('');
const icon = request.kind === 'approval' ? <ShieldAlert size={16} /> : request.kind === 'clarify' ? <HelpCircle size={16} /> : <KeyRound size={16} />; const icon = request.kind === 'approval' ? <ShieldAlert size={16} /> : request.kind === 'clarify' ? <HelpCircle size={16} /> : <KeyRound size={16} />;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
if (request.kind === 'approval') void resolvePending(request, 'deny');
else useChat.getState().removePending(request.id);
}
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [request]);
return ( return (
<div className="modal"> <div className="modal" role="dialog" aria-modal="true" aria-labelledby={`req-${request.id}`}>
<div className="request-card"> <div className="request-card">
<div className="title"> <div className="title" id={`req-${request.id}`}>
{icon} {icon}
{request.title} {request.title}
{request.tool && <span className="chip warn">{request.tool}</span>} {request.tool && <span className="chip warn">{request.tool}</span>}
@@ -20,7 +32,7 @@ function RequestCard({ request }: { request: PendingRequest }) {
{request.kind === 'approval' ? ( {request.kind === 'approval' ? (
<div className="actions"> <div className="actions">
<button className="btn danger" onClick={() => void resolvePending(request, 'deny')}>Refuser</button> <button className="btn danger" onClick={() => void resolvePending(request, 'deny')}>Refuser</button>
<button className="btn" onClick={() => void resolvePending(request, 'once')}>Autoriser une fois</button> <button className="btn" autoFocus onClick={() => void resolvePending(request, 'once')}>Autoriser une fois</button>
<button className="btn" onClick={() => void resolvePending(request, 'session')}>Pour la session</button> <button className="btn" onClick={() => void resolvePending(request, 'session')}>Pour la session</button>
<button className="btn primary" onClick={() => void resolvePending(request, 'always')}>Toujours</button> <button className="btn primary" onClick={() => void resolvePending(request, 'always')}>Toujours</button>
</div> </div>
+4
View File
@@ -40,6 +40,10 @@ export function ResilientImage({ src = '', alt, onOpen }: Props) {
setResolved(cache.get(src)!); setResolved(cache.get(src)!);
return; return;
} }
if (failures.has(src)) {
setError(failures.get(src)!);
return;
}
if (src.startsWith('data:') || src.startsWith('blob:') || /^https?:\/\//i.test(src)) { if (src.startsWith('data:') || src.startsWith('blob:') || /^https?:\/\//i.test(src)) {
ok(src); ok(src);
return; return;
+4 -3
View File
@@ -5,12 +5,13 @@ import { CoreStage } from '../hud/CoreStage';
import { ChatPanel } from '../chat/ChatPanel'; import { ChatPanel } from '../chat/ChatPanel';
export function CompactWidget() { export function CompactWidget() {
const settings = useSettings((s) => s.settings); const assistantName = useSettings((s) => s.settings.assistantName);
const compactOpacity = useSettings((s) => s.settings.ui.compactOpacity);
const api = bridge(); const api = bridge();
return ( return (
<div className="compact-root" style={{ ['--compact-opacity' as string]: settings.ui.compactOpacity }}> <div className="compact-root" style={{ ['--compact-opacity' as string]: compactOpacity }}>
<div className="compact-head"> <div className="compact-head">
<span className="title">{settings.assistantName}</span> <span className="title">{assistantName}</span>
{api && ( {api && (
<> <>
<button className="icon-btn" title="Mode HUD complet" onClick={() => api.window.setMode('hud')}><Maximize2 size={14} /></button> <button className="icon-btn" title="Mode HUD complet" onClick={() => api.window.setMode('hud')}><Maximize2 size={14} /></button>
+27 -7
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { useChat } from '../../state/chat'; import { useChat } from '../../state/chat';
import { useHermes } from '../../state/hermes'; import { useHermes } from '../../state/hermes';
import { useSettings } from '../../state/settings'; import { useSettings } from '../../state/settings';
@@ -21,21 +22,38 @@ interface Props {
export function CoreStage({ compact }: Props) { export function CoreStage({ compact }: Props) {
const hud = useChat((s) => s.hudOverride ?? s.hud); const hud = useChat((s) => s.hudOverride ?? s.hud);
const activity = useChat((s) => s.activity); const running = useChat((s) => {
for (let i = s.activity.length - 1; i >= 0; i--) if (s.activity[i].status === 'running') return s.activity[i];
return undefined;
});
const isSending = useChat((s) => s.isSending); const isSending = useChat((s) => s.isSending);
const error = useChat((s) => s.error); const error = useChat((s) => s.error);
const pulse = useChat((s) => s.pingCount);
const latencyMs = useChat((s) => s.latencyMs);
const phase = useVoice((s) => s.phase); const phase = useVoice((s) => s.phase);
const level = useVoice((s) => s.inputLevel); const level = useVoice((s) => s.inputLevel);
const transcript = useVoice((s) => s.lastTranscript); const transcript = useVoice((s) => s.lastTranscript);
const interim = useVoice((s) => s.interim);
const voiceError = useVoice((s) => s.error); const voiceError = useVoice((s) => s.error);
const settings = useSettings((s) => s.settings); const assistantName = useSettings((s) => s.settings.assistantName);
const reduceMotion = useSettings((s) => s.settings.ui.reduceMotion);
const transport = useHermes((s) => s.transport); const transport = useHermes((s) => s.transport);
const link = useHermes((s) => s.link); const link = useHermes((s) => s.link);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!isSending) {
setElapsed(0);
return;
}
const started = Date.now();
const timer = setInterval(() => setElapsed(Date.now() - started), 250);
return () => clearInterval(timer);
}, [isSending]);
const running = activity.filter((a) => a.status === 'running').slice(-1)[0];
let sub = ''; let sub = '';
if (hud === 'listening') sub = phase === 'speech' ? 'parole détectée…' : phase === 'transcribing' ? 'transcription…' : 'parlez maintenant'; if (hud === 'listening') sub = phase === 'speech' ? 'parole détectée…' : phase === 'transcribing' ? 'transcription…' : 'parlez maintenant';
else if (hud === 'thinking') sub = running ? `${running.name}${running.detail ? ` · ${previewText(running.detail, 60)}` : ''}` : isSending ? `Hermes · ${transport}` : ''; else if (hud === 'thinking') sub = running ? `${running.name}${running.detail ? ` · ${previewText(running.detail, 60)}` : ''}` : isSending ? `Hermes · ${transport} · ${(elapsed / 1000).toFixed(1)} s` : '';
else if (hud === 'error') sub = previewText(error ?? voiceError ?? 'erreur', 90); else if (hud === 'error') sub = previewText(error ?? voiceError ?? 'erreur', 90);
else if (hud === 'speaking') sub = 'synthèse vocale'; else if (hud === 'speaking') sub = 'synthèse vocale';
else if (transcript) sub = `« ${previewText(transcript, 80)} »`; else if (transcript) sub = `« ${previewText(transcript, 80)} »`;
@@ -45,16 +63,18 @@ export function CoreStage({ compact }: Props) {
<div className={compact ? 'compact-core' : 'core-stage'}> <div className={compact ? 'compact-core' : 'core-stage'}>
{!compact && ( {!compact && (
<div className="core-name"> <div className="core-name">
{settings.assistantName} {assistantName}
<small>EVEFLOW · HERMES INTERFACE</small> <small>EVEFLOW · HERMES INTERFACE</small>
</div> </div>
)} )}
<div className={compact ? undefined : 'core-canvas-wrap'} style={compact ? { position: 'absolute', inset: '0 0 36px 0' } : undefined}> <div className={compact ? undefined : 'core-canvas-wrap'} style={compact ? { position: 'absolute', inset: '0 0 36px 0' } : undefined}>
<JarvisCore state={hud} inputLevel={level} reduceMotion={settings.ui.reduceMotion} /> <JarvisCore state={hud} inputLevel={level} reduceMotion={reduceMotion} pulse={pulse} />
</div> </div>
<div className="core-caption"> <div className="core-caption" aria-live="polite">
{!compact && (phase === 'speech' || phase === 'transcribing') && <div className="core-interim">{interim || (phase === 'transcribing' ? '…' : '')}</div>}
<div className={`core-state ${hud}`}>{STATE_LABEL[hud] ?? hud}</div> <div className={`core-state ${hud}`}>{STATE_LABEL[hud] ?? hud}</div>
{sub && <div className="core-sub">{sub}</div>} {sub && <div className="core-sub">{sub}</div>}
{!compact && latencyMs !== null && <div className="core-latency">premier mot · {latencyMs} ms</div>}
</div> </div>
</div> </div>
); );
+140 -62
View File
@@ -2,8 +2,13 @@
* JARVIS-style arc reactor core rendered on a 2D canvas. * JARVIS-style arc reactor core rendered on a 2D canvas.
* Rotating tick rings, segmented arcs and an audio-reactive radial spectrum that reads the * Rotating tick rings, segmented arcs and an audio-reactive radial spectrum that reads the
* real TTS output (or the microphone while listening). * real TTS output (or the microphone while listening).
*
* Performance notes: no canvas shadows (glow is a second wide low-alpha pass), colour strings
* are cached, the spectrum buffer is reused, the theme accent is read once per theme change,
* idle runs at 30 fps and the loop stops entirely while the window is hidden.
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { bridge } from '../../lib/bridge';
import { audioBus } from '../../services/voice/audioBus'; import { audioBus } from '../../services/voice/audioBus';
import type { HudState } from '../../state/chat'; import type { HudState } from '../../state/chat';
@@ -12,12 +17,15 @@ interface Props {
/** 0..1 override for the microphone level (VAD), used while listening. */ /** 0..1 override for the microphone level (VAD), used while listening. */
inputLevel?: number; inputLevel?: number;
reduceMotion?: boolean; reduceMotion?: boolean;
/** Bumped by the parent to trigger a short "ping" flash (send, tool start, speech start). */
pulse?: number;
className?: string; className?: string;
} }
type Rgb = [number, number, number];
interface Palette { interface Palette {
main: [number, number, number]; main: Rgb;
glow: [number, number, number]; glow: Rgb;
} }
const PALETTES: Record<HudState, Palette> = { const PALETTES: Record<HudState, Palette> = {
@@ -31,23 +39,30 @@ const PALETTES: Record<HudState, Palette> = {
}; };
const BARS = 72; const BARS = 72;
const TICKS = 120;
const SEGMENTS = 6;
function readThemeAccent(): [number, number, number] | null { function readThemeAccent(): Rgb | null {
const raw = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim(); const raw = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim();
if (!raw) return null; if (!raw) return null;
const parts = raw.split(',').map((p) => Number(p.trim())); const parts = raw.split(',').map((p) => Number(p.trim()));
return parts.length === 3 && parts.every((n) => Number.isFinite(n)) ? (parts as [number, number, number]) : null; return parts.length === 3 && parts.every((n) => Number.isFinite(n)) ? (parts as Rgb) : null;
} }
export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, className }: Props) { export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, pulse = 0, className }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const stateRef = useRef(state); const stateRef = useRef(state);
const inputRef = useRef(inputLevel); const inputRef = useRef(inputLevel);
const motionRef = useRef(reduceMotion); const motionRef = useRef(reduceMotion);
const pulseRef = useRef(0);
stateRef.current = state; stateRef.current = state;
inputRef.current = inputLevel; inputRef.current = inputLevel;
motionRef.current = reduceMotion; motionRef.current = reduceMotion;
useEffect(() => {
if (pulse > 0) pulseRef.current = 1;
}, [pulse]);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
@@ -59,13 +74,33 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
let height = 0; let height = 0;
let dpr = 1; let dpr = 1;
const bars = new Float32Array(BARS); const bars = new Float32Array(BARS);
const color = { main: [52, 228, 255] as [number, number, number], glow: [52, 228, 255] as [number, number, number] }; const spectrumBuffer = new Float32Array(BARS);
const color = { main: [52, 228, 255] as Rgb, glow: [52, 228, 255] as Rgb };
let energy = 0; let energy = 0;
let spin = 0; let spin = 0;
let spin2 = 0; let spin2 = 0;
let spin3 = 0; let spin3 = 0;
let last = performance.now(); let last = performance.now();
let visible = true; let visible = document.visibilityState === 'visible';
let frame = 0;
let accent = readThemeAccent();
const colorCache = new Map<number, string>();
// Quantised rgba cache: colours are quantised to integers and alpha to 1/64 steps.
const rgba = (c: Rgb, a: number): string => {
const r = c[0] | 0;
const g = c[1] | 0;
const b = c[2] | 0;
const q = Math.max(0, Math.min(64, Math.round(a * 64)));
const key = (((r << 8) | g) << 8 | b) * 65 + q;
let s = colorCache.get(key);
if (!s) {
s = `rgba(${r},${g},${b},${(q / 64).toFixed(3)})`;
if (colorCache.size > 4000) colorCache.clear();
colorCache.set(key, s);
}
return s;
};
const resize = () => { const resize = () => {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
@@ -79,41 +114,70 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
observer.observe(canvas); observer.observe(canvas);
resize(); resize();
const themeObserver = new MutationObserver(() => {
accent = readThemeAccent();
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
const start = () => {
if (!raf) {
last = performance.now();
raf = requestAnimationFrame(draw);
}
};
const onVisibility = () => { const onVisibility = () => {
visible = document.visibilityState === 'visible'; visible = document.visibilityState === 'visible';
if (visible) start();
}; };
document.addEventListener('visibilitychange', onVisibility); document.addEventListener('visibilitychange', onVisibility);
// backgroundThrottling is off, so the Page Visibility API never fires: main tells us instead.
const offWindow = bridge()?.window.onVisibility?.((shown) => {
visible = shown;
if (visible) start();
});
const lerp = (a: number, b: number, t: number) => a + (b - a) * t; const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const rgba = (c: [number, number, number], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const draw = (now: number) => { const draw = (now: number) => {
if (!visible) {
raf = 0;
return;
}
raf = requestAnimationFrame(draw); raf = requestAnimationFrame(draw);
if (!visible) return; const st = stateRef.current;
frame++;
// Idle economy: 30 fps is plenty for the breathing animation.
if (st === 'idle' && pulseRef.current < 0.01 && frame % 2 === 1) return;
const dt = Math.min(0.05, (now - last) / 1000); const dt = Math.min(0.05, (now - last) / 1000);
last = now; last = now;
const st = stateRef.current;
const motion = motionRef.current ? 0.25 : 1; const motion = motionRef.current ? 0.25 : 1;
// ── target colours & energy
const target = PALETTES[st]; const target = PALETTES[st];
const themeAccent = st === 'idle' || st === 'listening' || st === 'speaking' ? readThemeAccent() : null; const themeAccent = st === 'idle' || st === 'listening' || st === 'speaking' ? accent : null;
const mainTarget = themeAccent ?? target.main; const mainTarget = themeAccent ?? target.main;
const glowTarget = themeAccent ?? target.glow;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
color.main[i] = lerp(color.main[i], mainTarget[i], 0.08); color.main[i] = lerp(color.main[i], mainTarget[i], 0.08);
color.glow[i] = lerp(color.glow[i], (themeAccent ?? target.glow)[i], 0.08); color.glow[i] = lerp(color.glow[i], glowTarget[i], 0.08);
} }
const speaking = st === 'speaking'; const speaking = st === 'speaking';
const listening = st === 'listening'; const listening = st === 'listening';
const spectrum = speaking ? audioBus.spectrum(BARS, 'output') : listening ? audioBus.spectrum(BARS, 'input') : null; const spectrum = speaking
? audioBus.spectrum(BARS, 'output', spectrumBuffer)
: listening
? audioBus.spectrum(BARS, 'input', spectrumBuffer)
: null;
const level = speaking ? audioBus.outputLevel() : listening ? Math.max(inputRef.current, audioBus.inputLevel()) : 0; const level = speaking ? audioBus.outputLevel() : listening ? Math.max(inputRef.current, audioBus.inputLevel()) : 0;
const targetEnergy = st === 'thinking' ? 0.45 : st === 'alert' || st === 'error' ? 0.6 : level; pulseRef.current = Math.max(0, pulseRef.current - dt * 2.2);
energy = lerp(energy, targetEnergy, speaking || listening ? 0.35 : 0.08); const ping = pulseRef.current;
const targetEnergy = Math.max(ping, st === 'thinking' ? 0.45 : st === 'alert' || st === 'error' ? 0.6 : level);
energy = lerp(energy, targetEnergy, speaking || listening || ping > 0 ? 0.35 : 0.08);
for (let i = 0; i < BARS; i++) { for (let i = 0; i < BARS; i++) {
let t = 0; let t = 0;
if (spectrum) { if (spectrum) {
const v = spectrum[i]; const v = spectrum[i];
t = Math.pow(v, 1.4); t = v * Math.sqrt(v);
if (listening) t = Math.max(t, inputRef.current * (0.5 + 0.5 * Math.sin(now / 90 + i))); if (listening) t = Math.max(t, inputRef.current * (0.5 + 0.5 * Math.sin(now / 90 + i)));
} else if (st === 'thinking') { } else if (st === 'thinking') {
t = 0.15 + 0.12 * Math.sin(now / 260 + i * 0.35); t = 0.15 + 0.12 * Math.sin(now / 260 + i * 0.35);
@@ -122,10 +186,10 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
} else { } else {
t = 0.12 + 0.08 * Math.sin(now / 140 + i * 0.7); t = 0.12 + 0.08 * Math.sin(now / 140 + i * 0.7);
} }
t = Math.max(t, ping * 0.6);
bars[i] = lerp(bars[i], t, t > bars[i] ? 0.5 : 0.12); bars[i] = lerp(bars[i], t, t > bars[i] ? 0.5 : 0.12);
} }
// ── rotation
const speed = (st === 'thinking' ? 1.6 : st === 'alert' || st === 'error' ? 1.2 : speaking ? 0.7 : 0.35) * motion; const speed = (st === 'thinking' ? 1.6 : st === 'alert' || st === 'error' ? 1.2 : speaking ? 0.7 : 0.35) * motion;
spin += dt * 0.35 * speed; spin += dt * 0.35 * speed;
spin2 -= dt * 0.55 * speed; spin2 -= dt * 0.55 * speed;
@@ -139,7 +203,7 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
const main = color.main; const main = color.main;
const glow = color.glow; const glow = color.glow;
// ── ambient glow // ambient glow
const halo = ctx.createRadialGradient(cx, cy, R * 0.1, cx, cy, R * 1.05); const halo = ctx.createRadialGradient(cx, cy, R * 0.1, cx, cy, R * 1.05);
halo.addColorStop(0, rgba(glow, 0.18 + energy * 0.25)); halo.addColorStop(0, rgba(glow, 0.18 + energy * 0.25));
halo.addColorStop(0.5, rgba(glow, 0.05)); halo.addColorStop(0.5, rgba(glow, 0.05));
@@ -147,53 +211,51 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
ctx.fillStyle = halo; ctx.fillStyle = halo;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
// ── outer tick ring // outer tick ring: three batched paths (long / mid / short ticks)
ctx.save(); ctx.save();
ctx.translate(cx, cy); ctx.translate(cx, cy);
ctx.rotate(spin); ctx.rotate(spin);
ctx.strokeStyle = rgba(main, 0.55);
ctx.lineWidth = 1; ctx.lineWidth = 1;
for (let i = 0; i < 120; i++) { for (let group = 0; group < 3; group++) {
const a = (i / 120) * Math.PI * 2;
const long = i % 10 === 0;
const mid = i % 5 === 0;
const r0 = R * (long ? 0.93 : mid ? 0.955 : 0.97);
ctx.globalAlpha = long ? 0.9 : mid ? 0.6 : 0.3;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(Math.cos(a) * r0, Math.sin(a) * r0); for (let i = 0; i < TICKS; i++) {
ctx.lineTo(Math.cos(a) * R, Math.sin(a) * R); const kind = i % 10 === 0 ? 0 : i % 5 === 0 ? 1 : 2;
if (kind !== group) continue;
const a = (i / TICKS) * Math.PI * 2;
const r0 = R * (kind === 0 ? 0.93 : kind === 1 ? 0.955 : 0.97);
ctx.moveTo(Math.cos(a) * r0, Math.sin(a) * r0);
ctx.lineTo(Math.cos(a) * R, Math.sin(a) * R);
}
ctx.strokeStyle = rgba(main, group === 0 ? 0.5 : group === 1 ? 0.33 : 0.17);
ctx.stroke(); ctx.stroke();
} }
ctx.globalAlpha = 1;
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.arc(0, 0, R, 0, Math.PI * 2);
ctx.strokeStyle = rgba(main, 0.35); ctx.strokeStyle = rgba(main, 0.35);
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
// ── segmented arc ring // segmented arc ring
ctx.save(); ctx.save();
ctx.translate(cx, cy); ctx.translate(cx, cy);
ctx.rotate(spin2); ctx.rotate(spin2);
ctx.lineCap = 'butt'; for (let i = 0; i < SEGMENTS; i++) {
const segments = 6; const startAngle = (i / SEGMENTS) * Math.PI * 2;
for (let i = 0; i < segments; i++) { const span = (Math.PI * 2) / SEGMENTS - 0.22;
const start = (i / segments) * Math.PI * 2;
const span = (Math.PI * 2) / segments - 0.22;
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, 0, R * 0.86, start, start + span); ctx.arc(0, 0, R * 0.86, startAngle, startAngle + span);
ctx.lineWidth = 6; ctx.lineWidth = 6;
ctx.strokeStyle = rgba(main, 0.16 + (i % 2 === 0 ? 0.18 : 0.05) + energy * 0.2); ctx.strokeStyle = rgba(main, 0.16 + (i % 2 === 0 ? 0.18 : 0.05) + energy * 0.2);
ctx.stroke(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, 0, R * 0.82, start + 0.08, start + span * 0.45); ctx.arc(0, 0, R * 0.82, startAngle + 0.08, startAngle + span * 0.45);
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.strokeStyle = rgba(main, 0.75); ctx.strokeStyle = rgba(main, 0.75);
ctx.stroke(); ctx.stroke();
} }
ctx.restore(); ctx.restore();
// ── dashed ring // dashed ring
ctx.save(); ctx.save();
ctx.translate(cx, cy); ctx.translate(cx, cy);
ctx.rotate(spin3); ctx.rotate(spin3);
@@ -206,36 +268,46 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
ctx.setLineDash([]); ctx.setLineDash([]);
ctx.restore(); ctx.restore();
// ── audio spectrum bars // audio spectrum bars: one wide low-alpha glow pass, then one crisp pass per intensity bucket
ctx.save(); ctx.save();
ctx.translate(cx, cy); ctx.translate(cx, cy);
ctx.lineCap = 'round'; ctx.lineCap = 'round';
const inner = R * 0.5; const inner = R * 0.5;
const maxLen = R * 0.16; const maxLen = R * 0.16;
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(glow, 0.1 + energy * 0.12);
ctx.beginPath();
for (let i = 0; i < BARS; i++) { for (let i = 0; i < BARS; i++) {
const a = (i / BARS) * Math.PI * 2 - Math.PI / 2; const a = (i / BARS) * Math.PI * 2 - Math.PI / 2;
const len = 2 + bars[i] * maxLen; const len = 2 + bars[i] * maxLen;
const x0 = Math.cos(a) * inner; ctx.moveTo(Math.cos(a) * inner, Math.sin(a) * inner);
const y0 = Math.sin(a) * inner; ctx.lineTo(Math.cos(a) * (inner + len), Math.sin(a) * (inner + len));
const x1 = Math.cos(a) * (inner + len); }
const y1 = Math.sin(a) * (inner + len); ctx.stroke();
ctx.lineWidth = 2.4; ctx.lineWidth = 2.4;
ctx.strokeStyle = rgba(main, 0.35 + bars[i] * 0.65); for (let bucket = 0; bucket < 4; bucket++) {
ctx.shadowBlur = 8 * bars[i];
ctx.shadowColor = rgba(glow, 0.8);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x0, y0); let any = false;
ctx.lineTo(x1, y1); for (let i = 0; i < BARS; i++) {
const b = Math.min(3, Math.floor(bars[i] * 4));
if (b !== bucket) continue;
any = true;
const a = (i / BARS) * Math.PI * 2 - Math.PI / 2;
const len = 2 + bars[i] * maxLen;
ctx.moveTo(Math.cos(a) * inner, Math.sin(a) * inner);
ctx.lineTo(Math.cos(a) * (inner + len), Math.sin(a) * (inner + len));
}
if (!any) continue;
ctx.strokeStyle = rgba(main, 0.35 + (bucket + 0.5) * 0.16);
ctx.stroke(); ctx.stroke();
} }
ctx.shadowBlur = 0;
ctx.restore(); ctx.restore();
// ── reactor core // reactor core
const coreR = R * 0.36; const coreR = R * 0.36;
const breath = 1 + Math.sin(now / 700) * 0.02 + energy * 0.08; const breath = 1 + Math.sin(now / 700) * 0.02 + energy * 0.08;
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR * breath); const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR * breath);
core.addColorStop(0, `rgba(255,255,255,${0.75 + energy * 0.25})`); core.addColorStop(0, `rgba(255,255,255,${(0.75 + energy * 0.25).toFixed(3)})`);
core.addColorStop(0.25, rgba(main, 0.55 + energy * 0.3)); core.addColorStop(0.25, rgba(main, 0.55 + energy * 0.3));
core.addColorStop(0.7, rgba(glow, 0.12)); core.addColorStop(0.7, rgba(glow, 0.12));
core.addColorStop(1, 'rgba(0,0,0,0)'); core.addColorStop(1, 'rgba(0,0,0,0)');
@@ -253,7 +325,6 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
ctx.strokeStyle = rgba(main, 0.7 - i * 0.2); ctx.strokeStyle = rgba(main, 0.7 - i * 0.2);
ctx.stroke(); ctx.stroke();
} }
// reactor triangle
ctx.rotate(-spin2 * 0.5); ctx.rotate(-spin2 * 0.5);
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
@@ -268,24 +339,28 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
// ── thinking orbiters // thinking orbiters (radial gradient dots instead of shadow blur)
if (st === 'thinking' || st === 'alert') { if (st === 'thinking' || st === 'alert') {
ctx.save(); ctx.save();
ctx.translate(cx, cy); ctx.translate(cx, cy);
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const a = spin3 * 2 + (i / 3) * Math.PI * 2; const a = spin3 * 2 + (i / 3) * Math.PI * 2;
const r = R * 0.78; const r = R * 0.78;
const x = Math.cos(a) * r;
const y = Math.sin(a) * r;
const dot = ctx.createRadialGradient(x, y, 0, x, y, 11);
dot.addColorStop(0, rgba(main, 1));
dot.addColorStop(0.35, rgba(glow, 0.6));
dot.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = dot;
ctx.beginPath(); ctx.beginPath();
ctx.arc(Math.cos(a) * r, Math.sin(a) * r, 3.5, 0, Math.PI * 2); ctx.arc(x, y, 11, 0, Math.PI * 2);
ctx.fillStyle = rgba(main, 0.95);
ctx.shadowBlur = 14;
ctx.shadowColor = rgba(glow, 1);
ctx.fill(); ctx.fill();
} }
ctx.restore(); ctx.restore();
} }
// ── listening ring (input level) // listening ring (input level)
if (listening) { if (listening) {
ctx.save(); ctx.save();
ctx.translate(cx, cy); ctx.translate(cx, cy);
@@ -298,11 +373,14 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
} }
}; };
raf = requestAnimationFrame(draw); start();
return () => { return () => {
cancelAnimationFrame(raf); if (raf) cancelAnimationFrame(raf);
raf = 0;
observer.disconnect(); observer.disconnect();
themeObserver.disconnect();
document.removeEventListener('visibilitychange', onVisibility); document.removeEventListener('visibilitychange', onVisibility);
offWindow?.();
}; };
}, []); }, []);
+12 -4
View File
@@ -1,4 +1,4 @@
import { Minus, X, Settings, PictureInPicture2, RefreshCw, Webhook } from 'lucide-react'; import { Minus, X, Settings, PictureInPicture2, RefreshCw, Webhook, PanelRight } from 'lucide-react';
import { bridge } from '../../lib/bridge'; import { bridge } from '../../lib/bridge';
import { useClock } from '../../hooks/useMetrics'; import { useClock } from '../../hooks/useMetrics';
import { useHermes } from '../../state/hermes'; import { useHermes } from '../../state/hermes';
@@ -6,9 +6,11 @@ import { useSettings } from '../../state/settings';
interface Props { interface Props {
onOpenSettings: () => void; onOpenSettings: () => void;
onToggleOps?: () => void;
opsBadge?: number;
} }
export function TopBar({ onOpenSettings }: Props) { export function TopBar({ onOpenSettings, onToggleOps, opsBadge = 0 }: Props) {
const now = useClock(); const now = useClock();
const link = useHermes((s) => s.link); const link = useHermes((s) => s.link);
const linkDetail = useHermes((s) => s.linkDetail); const linkDetail = useHermes((s) => s.linkDetail);
@@ -55,9 +57,15 @@ export function TopBar({ onOpenSettings }: Props) {
<RefreshCw size={14} className={link === 'checking' ? 'spin' : undefined} /> <RefreshCw size={14} className={link === 'checking' ? 'spin' : undefined} />
</button> </button>
</div> </div>
<span className="clock">{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span> <span className="clock">{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })}</span>
<div className="controls"> <div className="controls">
<button className="icon-btn" title="Paramètres" onClick={onOpenSettings}><Settings size={16} /></button> {onToggleOps && (
<button className="icon-btn ops-toggle" title="Panneau Hermes Ops" aria-label="Panneau Hermes Ops" onClick={onToggleOps} style={{ position: 'relative' }}>
<PanelRight size={16} />
{opsBadge > 0 && <span className="badge" style={{ position: 'absolute', top: 2, right: 2, minWidth: 14, height: 14, fontSize: 9, borderRadius: 7, background: 'var(--danger)', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: '0 3px' }}>{opsBadge}</span>}
</button>
)}
<button className="icon-btn" title="Paramètres (Ctrl+,)" aria-label="Paramètres" onClick={onOpenSettings}><Settings size={16} /></button>
{api && ( {api && (
<> <>
<button className="icon-btn" title="Mode compact flottant" onClick={() => api.window.setMode('compact')}><PictureInPicture2 size={16} /></button> <button className="icon-btn" title="Mode compact flottant" onClick={() => api.window.setMode('compact')}><PictureInPicture2 size={16} /></button>
+11 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { import {
Wrench, CalendarClock, Sparkles, Layers, History, Play, Pause, Trash2, Pencil, Plus, Save, X, RefreshCw, Wrench, CalendarClock, Sparkles, Layers, History, Play, Pause, Trash2, Pencil, Plus, Save, X, RefreshCw,
CheckCircle2, XCircle, Loader2, Bot, Cpu, GitBranch, Webhook, FolderOpen CheckCircle2, XCircle, Loader2, Bot, Cpu, GitBranch, Webhook, FolderOpen
@@ -142,7 +143,10 @@ function HistoryList() {
<div <div
key={r.id} key={r.id}
className="row-item clickable" className="row-item clickable"
role="button"
tabIndex={0}
style={{ opacity: r.read ? 0.7 : 1 }} style={{ opacity: r.read ? 0.7 : 1 }}
onKeyDown={(e) => { if (e.key === 'Enter') (e.currentTarget as HTMLDivElement).click(); }}
onClick={() => { onClick={() => {
markRead(r.id); markRead(r.id);
addMessage({ role: 'assistant', content: r.output, source: 'cron', jobName: r.jobName, status: 'done', timestamp: new Date(r.at).getTime() || Date.now() }); addMessage({ role: 'assistant', content: r.output, source: 'cron', jobName: r.jobName, status: 'done', timestamp: new Date(r.at).getTime() || Date.now() });
@@ -222,7 +226,7 @@ function SessionsTab() {
const transport = useHermes((s) => s.transport); const transport = useHermes((s) => s.transport);
const sessionId = useSettings((s) => s.settings.hermesSessionId); const sessionId = useSettings((s) => s.settings.hermesSessionId);
const setSessionId = useSettings((s) => s.setHermesSessionId); const setSessionId = useSettings((s) => s.setHermesSessionId);
const chat = useChat(); const chat = useChat.getState();
const activeId = sessionId.replace(/^hs:/, ''); const activeId = sessionId.replace(/^hs:/, '');
const load = async (id: string) => { const load = async (id: string) => {
@@ -255,7 +259,7 @@ function SessionsTab() {
{sessions.length === 0 ? <div className="empty">Aucune session listée par Hermes.</div> : ( {sessions.length === 0 ? <div className="empty">Aucune session listée par Hermes.</div> : (
<div className="list"> <div className="list">
{sessions.map((s) => ( {sessions.map((s) => (
<div key={s.id} className={`row-item clickable`} style={s.id === activeId ? { borderColor: 'var(--accent)' } : undefined} onClick={() => void load(s.id)}> <div key={s.id} className="row-item clickable" role="button" tabIndex={0} style={s.id === activeId ? { borderColor: 'var(--accent)' } : undefined} onClick={() => void load(s.id)} onKeyDown={(e) => { if (e.key === 'Enter') void load(s.id); }}>
<div className="main"> <div className="main">
<span className="name">{s.title || s.id}</span> <span className="name">{s.title || s.id}</span>
<span className="meta">{[s.platform ?? s.source, s.message_count ? `${s.message_count} msg` : null, formatDateTime(s.updated_at ?? s.created_at)].filter(Boolean).join(' · ')}</span> <span className="meta">{[s.platform ?? s.source, s.message_count ? `${s.message_count} msg` : null, formatDateTime(s.updated_at ?? s.created_at)].filter(Boolean).join(' · ')}</span>
@@ -273,7 +277,9 @@ function SessionsTab() {
} }
function LinkTab() { function LinkTab() {
const { link, linkDetail, health, capabilities, transport, models, webhook } = useHermes(); const { link, linkDetail, health, capabilities, transport, models, webhook } = useHermes(
useShallow((s) => ({ link: s.link, linkDetail: s.linkDetail, health: s.health, capabilities: s.capabilities, transport: s.transport, models: s.models, webhook: s.webhook }))
);
const connect = useHermes((s) => s.connect); const connect = useHermes((s) => s.connect);
const hermes = useSettings((s) => s.settings.hermes); const hermes = useSettings((s) => s.settings.hermes);
const [info, setInfo] = useState<{ sharedDir: string; logPath: string; version: string } | null>(null); const [info, setInfo] = useState<{ sharedDir: string; logPath: string; version: string } | null>(null);
@@ -311,7 +317,7 @@ function LinkTab() {
<div className="section-title"><CheckCircle2 size={12} /> Santé</div> <div className="section-title"><CheckCircle2 size={12} /> Santé</div>
<dl className="kv"> <dl className="kv">
{Object.entries(checks).map(([k, v]) => ( {Object.entries(checks).map(([k, v]) => (
<><dt key={`${k}-t`}>{k}</dt><dd key={`${k}-d`}>{typeof v === 'object' ? JSON.stringify(v) : String(v)}</dd></> <Fragment key={k}><dt>{k}</dt><dd>{typeof v === 'object' ? JSON.stringify(v) : String(v)}</dd></Fragment>
))} ))}
</dl> </dl>
</> </>
+7 -4
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { Download, Trash2, X, CheckCircle2, Cpu, Mic, Volume2, AlertTriangle, RefreshCw } from 'lucide-react'; import { Download, Trash2, X, CheckCircle2, Cpu, Mic, Volume2, AlertTriangle, RefreshCw } from 'lucide-react';
import type { VoiceModelStatus } from '../../../shared/voice'; import type { VoiceModelStatus } from '../../../shared/voice';
import { bridge } from '../../lib/bridge'; import { bridge } from '../../lib/bridge';
import { useShallow } from 'zustand/react/shallow';
import { useVoiceModels } from '../../state/voiceModels'; import { useVoiceModels } from '../../state/voiceModels';
import { useSettings } from '../../state/settings'; import { useSettings } from '../../state/settings';
@@ -11,10 +12,11 @@ function formatMb(bytes: number): string {
function ModelRow({ model }: { model: VoiceModelStatus }) { function ModelRow({ model }: { model: VoiceModelStatus }) {
const progress = useVoiceModels((s) => s.progress[model.id]); const progress = useVoiceModels((s) => s.progress[model.id]);
const { download, cancel, remove } = useVoiceModels(); const { download, cancel, remove } = useVoiceModels.getState();
const settings = useSettings((s) => s.settings); const activeStt = useSettings((s) => s.settings.voice.localModel);
const activeTts = useSettings((s) => s.settings.speech.localModel);
const update = useSettings((s) => s.update); const update = useSettings((s) => s.update);
const isActive = model.kind === 'stt' ? settings.voice.localModel === model.id : settings.speech.localModel === model.id; const isActive = model.kind === 'stt' ? activeStt === model.id : activeTts === model.id;
const busy = !!progress && (progress.phase === 'download' || progress.phase === 'extract'); const busy = !!progress && (progress.phase === 'download' || progress.phase === 'extract');
const activate = () => { const activate = () => {
@@ -59,7 +61,8 @@ function ModelRow({ model }: { model: VoiceModelStatus }) {
} }
export function ModelsSection() { export function ModelsSection() {
const { models, engine, error, refresh, checkEngine } = useVoiceModels(); const { models, engine, error } = useVoiceModels(useShallow((s) => ({ models: s.models, engine: s.engine, error: s.error })));
const { refresh, checkEngine } = useVoiceModels.getState();
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
void checkEngine(); void checkEngine();
+23 -19
View File
@@ -21,23 +21,35 @@ interface Props {
function Toggle({ on, onChange, label, hint }: { on: boolean; onChange: (v: boolean) => void; label: string; hint?: string }) { function Toggle({ on, onChange, label, hint }: { on: boolean; onChange: (v: boolean) => void; label: string; hint?: string }) {
return ( return (
<div className="switch" onClick={() => onChange(!on)}> <button type="button" role="switch" aria-checked={on} className="switch" onClick={() => onChange(!on)}>
<div className="text"> <div className="text">
<span>{label}</span> <span>{label}</span>
{hint && <small>{hint}</small>} {hint && <small>{hint}</small>}
</div> </div>
<span className={`toggle${on ? ' on' : ''}`} /> <span className={`toggle${on ? ' on' : ''}`} />
</div> </button>
); );
} }
type TestState = { status: 'idle' | 'running' | 'ok' | 'fail'; message: string }; type TestState = { status: 'idle' | 'running' | 'ok' | 'fail'; message: string };
function TestResult({ t }: { t: TestState }) {
if (t.status === 'idle') return null;
return (
<div className={`test-result ${t.status === 'ok' ? 'ok' : t.status === 'fail' ? 'fail' : ''}`}>
{t.status === 'running' ? <Loader2 size={13} className="spin" /> : t.status === 'ok' ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
<span>{t.message}</span>
</div>
);
}
export function SettingsDrawer({ onClose }: Props) { export function SettingsDrawer({ onClose }: Props) {
const settings = useSettings((s) => s.settings); const settings = useSettings((s) => s.settings);
const update = useSettings((s) => s.update); const update = useSettings((s) => s.update);
const reset = useSettings((s) => s.reset); const reset = useSettings((s) => s.reset);
const hermesStore = useHermes(); const hermesModels = useHermes((s) => s.models);
const hermesWebhook = useHermes((s) => s.webhook);
const hermesConnect = useHermes((s) => s.connect);
const micDevices = useVoice((s) => s.micDevices); const micDevices = useVoice((s) => s.micDevices);
const voiceModels = useVoiceModels((s) => s.models); const voiceModels = useVoiceModels((s) => s.models);
const refreshModels = useVoiceModels((s) => s.refresh); const refreshModels = useVoiceModels((s) => s.refresh);
@@ -74,7 +86,7 @@ export function SettingsDrawer({ onClose }: Props) {
} }
const via = bridge() ? 'via Electron' : 'via navigateur'; const via = bridge() ? 'via Electron' : 'via navigateur';
setHermesTest({ status: 'ok', message: `${health.status} (${via})${caps}` }); setHermesTest({ status: 'ok', message: `${health.status} (${via})${caps}` });
void hermesStore.connect(); void hermesConnect();
} catch (err) { } catch (err) {
setHermesTest({ status: 'fail', message: (err as Error).message }); setHermesTest({ status: 'fail', message: (err as Error).message });
} }
@@ -106,14 +118,6 @@ export function SettingsDrawer({ onClose }: Props) {
useHermes.setState({ webhook: status }); useHermes.setState({ webhook: status });
}; };
const Result = ({ t }: { t: TestState }) =>
t.status === 'idle' ? null : (
<div className={`test-result ${t.status === 'ok' ? 'ok' : t.status === 'fail' ? 'fail' : ''}`}>
{t.status === 'running' ? <Loader2 size={13} className="spin" /> : t.status === 'ok' ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
<span>{t.message}</span>
</div>
);
const nav: Array<[Section, string]> = [ const nav: Array<[Section, string]> = [
['hermes', 'Hermes'], ['hermes', 'Hermes'],
['voice', 'Micro / STT'], ['voice', 'Micro / STT'],
@@ -155,7 +159,7 @@ export function SettingsDrawer({ onClose }: Props) {
<label>Modèle</label> <label>Modèle</label>
<input className="input" list="hermes-models" value={settings.hermes.model} placeholder="(défaut du serveur)" onChange={(e) => update({ hermes: { model: e.target.value } })} /> <input className="input" list="hermes-models" value={settings.hermes.model} placeholder="(défaut du serveur)" onChange={(e) => update({ hermes: { model: e.target.value } })} />
<datalist id="hermes-models"> <datalist id="hermes-models">
{hermesStore.models.map((m) => <option key={m.id} value={m.id} />)} {hermesModels.map((m) => <option key={m.id} value={m.id} />)}
</datalist> </datalist>
</div> </div>
<div className="field"> <div className="field">
@@ -191,7 +195,7 @@ export function SettingsDrawer({ onClose }: Props) {
<div className="row" style={{ marginTop: 10 }}> <div className="row" style={{ marginTop: 10 }}>
<button className="btn primary small" onClick={() => void testHermes()}><Play size={13} /> Tester la liaison</button> <button className="btn primary small" onClick={() => void testHermes()}><Play size={13} /> Tester la liaison</button>
</div> </div>
<div style={{ marginTop: 8 }}><Result t={hermesTest} /></div> <div style={{ marginTop: 8 }}><TestResult t={hermesTest} /></div>
</div> </div>
</> </>
)} )}
@@ -284,7 +288,7 @@ export function SettingsDrawer({ onClose }: Props) {
<div className="row" style={{ marginTop: 10 }}> <div className="row" style={{ marginTop: 10 }}>
<button className="btn small" onClick={() => void testStt()} disabled={settings.voice.provider === 'browser'}><Mic size={13} /> Tester la reconnaissance</button> <button className="btn small" onClick={() => void testStt()} disabled={settings.voice.provider === 'browser'}><Mic size={13} /> Tester la reconnaissance</button>
</div> </div>
<div style={{ marginTop: 8 }}><Result t={sttTest} /></div> <div style={{ marginTop: 8 }}><TestResult t={sttTest} /></div>
</div> </div>
)} )}
@@ -381,7 +385,7 @@ export function SettingsDrawer({ onClose }: Props) {
<div className="row" style={{ marginTop: 10 }}> <div className="row" style={{ marginTop: 10 }}>
<button className="btn small" onClick={testTts} disabled={settings.speech.provider === 'off'}><Volume2 size={13} /> Tester la voix</button> <button className="btn small" onClick={testTts} disabled={settings.speech.provider === 'off'}><Volume2 size={13} /> Tester la voix</button>
</div> </div>
<div style={{ marginTop: 8 }}><Result t={ttsTest} /></div> <div style={{ marginTop: 8 }}><TestResult t={ttsTest} /></div>
</div> </div>
)} )}
@@ -412,7 +416,7 @@ export function SettingsDrawer({ onClose }: Props) {
</div> </div>
<div className="row"> <div className="row">
<button className="btn primary small" onClick={() => void applyWebhook()} disabled={!bridge()}>Appliquer et redémarrer</button> <button className="btn primary small" onClick={() => void applyWebhook()} disabled={!bridge()}>Appliquer et redémarrer</button>
{hermesStore.webhook && <span className={`status-pill ${hermesStore.webhook.listening ? 'online' : 'offline'}`}>{hermesStore.webhook.listening ? `port ${hermesStore.webhook.port}` : hermesStore.webhook.error ?? 'inactif'}</span>} {hermesWebhook && <span className={`status-pill ${hermesWebhook.listening ? 'online' : 'offline'}`}>{hermesWebhook.listening ? `port ${hermesWebhook.port}` : hermesWebhook.error ?? 'inactif'}</span>}
</div> </div>
</div> </div>
)} )}
@@ -434,13 +438,13 @@ export function SettingsDrawer({ onClose }: Props) {
<div className="theme-swatches"> <div className="theme-swatches">
{(['arc', 'gold', 'crimson', 'emerald'] as HudTheme[]).map((t) => { {(['arc', 'gold', 'crimson', 'emerald'] as HudTheme[]).map((t) => {
const colors: Record<HudTheme, string> = { arc: '#34e4ff', gold: '#ffc35a', crimson: '#ff5c6c', emerald: '#4ef0a8' }; const colors: Record<HudTheme, string> = { arc: '#34e4ff', gold: '#ffc35a', crimson: '#ff5c6c', emerald: '#4ef0a8' };
return <button key={t} className={`swatch${settings.theme === t ? ' active' : ''}`} style={{ background: colors[t] }} title={t} onClick={() => update({ theme: t })} />; return <button key={t} className={`swatch${settings.theme === t ? ' active' : ''}`} style={{ background: colors[t] }} title={t} aria-label={`Thème ${t}`} aria-pressed={settings.theme === t} onClick={() => update({ theme: t })} />;
})} })}
</div> </div>
</div> </div>
<div className="field"> <div className="field">
<label>Raccourcis globaux</label> <label>Raccourcis globaux</label>
<span className="hint"><span className="kbd">Ctrl+Shift+Espace</span> micro · <span className="kbd">Ctrl+Shift+J</span> afficher/masquer · <span className="kbd">Ctrl+Shift+Échap</span> couper la voix</span> <span className="hint"><span className="kbd">Ctrl+Shift+Espace</span> micro · <span className="kbd">Ctrl+Shift+J</span> afficher/masquer · <span className="kbd">Ctrl+Alt+Échap</span> couper la voix · <span className="kbd">Ctrl+K</span> saisie</span>
</div> </div>
<div className="divider" /> <div className="divider" />
<button className="btn danger small" onClick={() => { if (confirm('Réinitialiser tous les paramètres ?')) reset(); }}><RotateCcw size={13} /> Réinitialiser</button> <button className="btn danger small" onClick={() => { if (confirm('Réinitialiser tous les paramètres ?')) reset(); }}><RotateCcw size={13} /> Réinitialiser</button>
+1 -1
View File
@@ -62,7 +62,7 @@ export function useMetrics(enabled = true, intervalMs = 2000): LiveMetrics {
export function useClock(): Date { export function useClock(): Date {
const [now, setNow] = useState(() => new Date()); const [now, setNow] = useState(() => new Date());
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000); const timer = setInterval(() => setNow(new Date()), 15_000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
return now; return now;
+2
View File
@@ -20,6 +20,8 @@ export class SseParser {
this.buffer += chunk; this.buffer += chunk;
let index: number; let index: number;
while ((index = this.buffer.search(/\r\n|\n|\r/)) !== -1) { while ((index = this.buffer.search(/\r\n|\n|\r/)) !== -1) {
// A trailing "\r" may be the first half of a "\r\n" split across chunks: wait for more data.
if (this.buffer[index] === '\r' && index === this.buffer.length - 1) break;
const line = this.buffer.slice(0, index); const line = this.buffer.slice(0, index);
const sepLength = this.buffer.startsWith('\r\n', index) ? 2 : 1; const sepLength = this.buffer.startsWith('\r\n', index) ? 2 : 1;
this.buffer = this.buffer.slice(index + sepLength); this.buffer = this.buffer.slice(index + sepLength);
+6 -4
View File
@@ -65,12 +65,14 @@ export function cleanForSpeech(raw: string): string {
return applyPhonetics(text); return applyPhonetics(text);
} }
const PHONETIC_RULES: Array<[RegExp, string]> = Object.entries(PHONETIC).map(([key, value]) => {
const escaped = key.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
return [new RegExp(`(?<=^|\\s|\\p{P})${escaped}(?=$|\\s|\\p{P})`, 'giu'), value];
});
function applyPhonetics(text: string): string { function applyPhonetics(text: string): string {
let out = text; let out = text;
for (const [key, value] of Object.entries(PHONETIC)) { for (const [regex, value] of PHONETIC_RULES) out = out.replace(regex, value);
const escaped = key.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
out = out.replace(new RegExp(`(?<=^|\\s|\\p{P})${escaped}(?=$|\\s|\\p{P})`, 'giu'), value);
}
return out; return out;
} }
-5
View File
@@ -130,8 +130,3 @@ export async function httpStream(req: HttpProxyRequest, handlers: StreamHandlers
return { start, done, abort: () => controller.abort() }; 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('');
}
+8
View File
@@ -1,6 +1,14 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import '@fontsource/orbitron/500.css';
import '@fontsource/orbitron/700.css';
import '@fontsource/orbitron/900.css';
import '@fontsource/rajdhani/400.css';
import '@fontsource/rajdhani/500.css';
import '@fontsource/rajdhani/600.css';
import '@fontsource/rajdhani/700.css';
import '@fontsource/share-tech-mono/400.css';
import './styles/tokens.css'; import './styles/tokens.css';
import './styles/app.css'; import './styles/app.css';
import './styles/chat.css'; import './styles/chat.css';
+69 -21
View File
@@ -1,7 +1,7 @@
/** /**
* Conversation orchestrator: sends user input to Hermes through the resolved transport, * Conversation orchestrator: sends user input to Hermes through the resolved transport,
* streams the answer into the chat store, drives the HUD state, speaks the reply and * streams the answer into the chat store (coalesced per animation frame), drives the HUD
* surfaces approvals / clarifications as pending requests. * state, speaks the reply and surfaces approvals / clarifications as pending requests.
*/ */
import type { HermesPushEvent } from '../../shared/ipc'; import type { HermesPushEvent } from '../../shared/ipc';
import { Log } from '../lib/log'; import { Log } from '../lib/log';
@@ -18,6 +18,34 @@ let activeMessageId: string | null = null;
const toolLabel = (name: string) => name.replace(/_/g, ' '); const toolLabel = (name: string) => name.replace(/_/g, ' ');
// ── delta coalescing: one store update per animation frame ─────────────────
const pendingDelta = { id: '', content: '', reasoning: '', raf: 0 };
function flushDelta(): void {
if (pendingDelta.raf) cancelAnimationFrame(pendingDelta.raf);
const { id, content, reasoning } = pendingDelta;
pendingDelta.id = '';
pendingDelta.content = '';
pendingDelta.reasoning = '';
pendingDelta.raf = 0;
if (!id) return;
const chat = useChat.getState();
if (content) chat.appendToMessage(id, content);
if (reasoning) chat.appendToMessage(id, reasoning, 'reasoning');
}
function queueDelta(id: string, text: string, field: 'content' | 'reasoning'): void {
if (pendingDelta.id && pendingDelta.id !== id) flushDelta();
pendingDelta.id = id;
pendingDelta[field] += text;
if (!pendingDelta.raf) pendingDelta.raf = requestAnimationFrame(flushDelta);
}
/** Bumps a counter the core reads to flash briefly (send, tool start, speech start). */
export function pingCore(): void {
useChat.getState().ping();
}
function localToolContext() { function localToolContext() {
const chat = useChat.getState(); const chat = useChat.getState();
const settings = useSettings.getState().settings; const settings = useSettings.getState().settings;
@@ -49,7 +77,7 @@ function localToolContext() {
}; };
} }
function handleEvent(event: HermesStreamEvent, messageId: string, runIdRef: { id: string | null }): void { function handleEvent(event: HermesStreamEvent, messageId: string, runIdRef: { id: string | null }, timing: { firstToken: number | null; startedAt: number }): void {
const chat = useChat.getState(); const chat = useChat.getState();
switch (event.kind) { switch (event.kind) {
case 'run.started': case 'run.started':
@@ -57,15 +85,20 @@ function handleEvent(event: HermesStreamEvent, messageId: string, runIdRef: { id
chat.setSending(true, event.runId); chat.setSending(true, event.runId);
break; break;
case 'delta': case 'delta':
chat.appendToMessage(messageId, event.text); if (timing.firstToken === null) {
timing.firstToken = Date.now() - timing.startedAt;
chat.setLatency(timing.firstToken);
}
queueDelta(messageId, event.text, 'content');
speech.pushStream(event.text); speech.pushStream(event.text);
break; break;
case 'reasoning': case 'reasoning':
chat.appendToMessage(messageId, event.text, 'reasoning'); queueDelta(messageId, event.text, 'reasoning');
break; break;
case 'tool.start': case 'tool.start':
chat.pushActivity({ id: event.id, kind: 'tool', name: event.name, status: 'running', detail: event.args ? previewText(event.args, 160) : undefined }); chat.pushActivity({ id: event.id, kind: 'tool', name: event.name, status: 'running', detail: event.args ? previewText(event.args, 160) : undefined });
chat.setHud('thinking'); chat.setHud('thinking');
pingCore();
break; break;
case 'tool.progress': case 'tool.progress':
if (!chat.activity.some((a) => (event.id && a.id === event.id) || (a.name === event.name && a.status === 'running'))) { if (!chat.activity.some((a) => (event.id && a.id === event.id) || (a.name === event.name && a.status === 'running'))) {
@@ -113,6 +146,7 @@ function handleEvent(event: HermesStreamEvent, messageId: string, runIdRef: { id
break; break;
case 'completed': case 'completed':
if (event.usage) chat.updateMessage(messageId, { usage: event.usage }); if (event.usage) chat.updateMessage(messageId, { usage: event.usage });
if (event.status === 'length') chat.updateMessage(messageId, { status: 'cancelled' });
break; break;
case 'error': case 'error':
chat.setError(event.message); chat.setError(event.message);
@@ -127,7 +161,8 @@ function addPending(request: Omit<PendingRequest, 'id' | 'createdAt'>): void {
const chat = useChat.getState(); const chat = useChat.getState();
chat.addPending(request); chat.addPending(request);
chat.setHud('alert'); chat.setHud('alert');
speech.say(request.kind === 'approval' ? 'Autorisation requise.' : request.kind === 'clarify' ? request.description : 'Saisie requise.'); // Spoken without interrupting the streamed answer.
speech.say(request.kind === 'approval' ? 'Autorisation requise.' : request.kind === 'clarify' ? request.description : 'Saisie requise.', { interrupt: false });
} }
export async function sendMessage(text: string, images: string[] = [], source = 'eveflow'): Promise<void> { export async function sendMessage(text: string, images: string[] = [], source = 'eveflow'): Promise<void> {
@@ -139,6 +174,7 @@ export async function sendMessage(text: string, images: string[] = [], source =
speech.stop(); speech.stop();
chat.setError(null); chat.setError(null);
chat.setLatency(null);
chat.addMessage({ role: 'user', content: trimmed || '(image)', images: images.length ? images : undefined, source, status: 'done' }); chat.addMessage({ role: 'user', content: trimmed || '(image)', images: images.length ? images : undefined, source, status: 'done' });
const history = chat.messages const history = chat.messages
.filter((m) => m.role !== 'system' && m.status !== 'error') .filter((m) => m.role !== 'system' && m.status !== 'error')
@@ -148,7 +184,9 @@ export async function sendMessage(text: string, images: string[] = [], source =
activeMessageId = messageId; activeMessageId = messageId;
chat.setSending(true); chat.setSending(true);
chat.setHud('thinking'); chat.setHud('thinking');
pingCore();
const startedAt = Date.now(); const startedAt = Date.now();
const timing = { firstToken: null as number | null, startedAt };
const runIdRef = { id: null as string | null }; const runIdRef = { id: null as string | null };
const client = hermes.client(); const client = hermes.client();
@@ -158,7 +196,7 @@ export async function sendMessage(text: string, images: string[] = [], source =
images, images,
history, history,
sessionId: settings.hermesSessionId, sessionId: settings.hermesSessionId,
onEvent: (event) => handleEvent(event, messageId, runIdRef), onEvent: (event) => handleEvent(event, messageId, runIdRef, timing),
localToolDefinitions: LOCAL_TOOL_DEFINITIONS, localToolDefinitions: LOCAL_TOOL_DEFINITIONS,
localToolExecutor: (name, args) => executeLocalTool(name, args, localToolContext()) localToolExecutor: (name, args) => executeLocalTool(name, args, localToolContext())
}, },
@@ -169,20 +207,26 @@ export async function sendMessage(text: string, images: string[] = [], source =
try { try {
const reply = await handle.result; const reply = await handle.result;
const current = useChat.getState().messages.find((m) => m.id === messageId); flushDelta();
const finalText = reply || current?.content || ''; const state = useChat.getState();
useChat.getState().updateMessage(messageId, { content: finalText, status: 'done' }); const current = state.messages.find((m) => m.id === messageId);
// Anything not streamed (non-delta transports) is spoken now. const streamedText = current?.content ?? '';
if (settings.speech.autoSpeak) { const finalText = handle.aborted ? streamedText : reply || streamedText;
if (!current?.content && finalText) speech.say(finalText); const truncated = current?.status === 'cancelled';
state.updateMessage(messageId, { content: finalText || (handle.aborted ? '(interrompu)' : ''), status: handle.aborted || truncated ? 'cancelled' : 'done' });
if (handle.aborted) {
speech.discardStream();
} else if (settings.speech.autoSpeak) {
if (!streamedText && finalText) speech.say(finalText);
else speech.endStream(); else speech.endStream();
} else { } else {
speech.discardStream(); speech.discardStream();
} }
useChat.getState().setHud(speech.isSpeaking() ? 'speaking' : 'idle'); state.setHud(speech.isSpeaking() ? 'speaking' : 'idle');
if (useHermes.getState().link === 'offline') useHermes.getState().connect().catch(() => undefined); if (!handle.aborted && useHermes.getState().link === 'offline') useHermes.getState().connect().catch(() => undefined);
Log.info('hermes', `reply in ${Date.now() - startedAt} ms via ${handle.transport}`, { chars: finalText.length }); Log.info('hermes', `${handle.aborted ? 'aborted' : 'reply'} in ${Date.now() - startedAt} ms via ${handle.transport}`, { chars: finalText.length, firstToken: timing.firstToken });
} catch (err) { } catch (err) {
flushDelta();
const message = (err as Error).message || String(err); const message = (err as Error).message || String(err);
const partial = useChat.getState().messages.find((m) => m.id === messageId)?.content ?? ''; const partial = useChat.getState().messages.find((m) => m.id === messageId)?.content ?? '';
useChat.getState().updateMessage(messageId, { useChat.getState().updateMessage(messageId, {
@@ -200,10 +244,14 @@ export async function sendMessage(text: string, images: string[] = [], source =
} finally { } finally {
active = null; active = null;
activeMessageId = null; activeMessageId = null;
useChat.getState().setSending(false); const state = useChat.getState();
for (const a of useChat.getState().activity) { state.setSending(false);
if (a.status === 'running') useChat.getState().finishActivity({ id: a.id }, { status: 'done' }); state.setLatency(null);
for (const a of state.activity) {
if (a.status === 'running') state.finishActivity({ id: a.id }, { status: 'done' });
} }
// Requests bound to this run cannot be answered once it is over.
for (const p of state.pending) if (!runIdRef.id || p.runId === runIdRef.id) state.removePending(p.id);
} }
} }
@@ -235,14 +283,13 @@ export async function resolvePending(request: PendingRequest, decision: string):
chat.removePending(request.id); chat.removePending(request.id);
const client = useHermes.getState().client(); const client = useHermes.getState().client();
try { try {
if (!request.runId) throw new Error('run inconnu');
if (request.kind === 'approval') { if (request.kind === 'approval') {
const choice = (['once', 'session', 'always', 'deny'].includes(decision) ? decision : decision === 'approve' ? 'once' : 'deny') as 'once' | 'session' | 'always' | 'deny'; const choice = (['once', 'session', 'always', 'deny'].includes(decision) ? decision : decision === 'approve' ? 'once' : 'deny') as 'once' | 'session' | 'always' | 'deny';
if (!request.runId) throw new Error('run inconnu');
await client.approveRun(request.runId, choice, request.requestId || undefined); await client.approveRun(request.runId, choice, request.requestId || undefined);
chat.addMessage({ role: 'system', content: `Autorisation ${choice === 'deny' ? 'refusée' : 'accordée'}${request.tool ? ` (${toolLabel(request.tool)})` : ''}.`, status: 'done' }); chat.addMessage({ role: 'system', content: `Autorisation ${choice === 'deny' ? 'refusée' : 'accordée'}${request.tool ? ` (${toolLabel(request.tool)})` : ''}.`, status: 'done' });
} else { } else {
// Clarifications and secrets are answered by steering the run with the reply. // Clarifications and secrets are answered by steering the run with the reply.
if (!request.runId) throw new Error('run inconnu');
await client.steerRun(request.runId, decision); await client.steerRun(request.runId, decision);
chat.addMessage({ role: 'system', content: request.secret ? 'Saisie transmise à Hermes.' : `Réponse transmise : ${decision}`, status: 'done' }); chat.addMessage({ role: 'system', content: request.secret ? 'Saisie transmise à Hermes.' : `Réponse transmise : ${decision}`, status: 'done' });
} }
@@ -278,6 +325,7 @@ export function handlePush(event: HermesPushEvent): void {
speech.say(event.jobName ? `Résultat de ${event.jobName}. ${event.text}` : event.text); speech.say(event.jobName ? `Résultat de ${event.jobName}. ${event.text}` : event.text);
} }
chat.setHud(event.status?.includes('fail') ? 'alert' : 'success'); chat.setHud(event.status?.includes('fail') ? 'alert' : 'success');
pingCore();
setTimeout(() => { setTimeout(() => {
const s = useChat.getState(); const s = useChat.getState();
if (s.hud === 'success' || s.hud === 'alert') s.setHud(speech.isSpeaking() ? 'speaking' : 'idle'); if (s.hud === 'success' || s.hud === 'alert') s.setHud(speech.isSpeaking() ? 'speaking' : 'idle');
+45 -20
View File
@@ -261,6 +261,7 @@ export class HermesClient {
for (const event of normalizeLifecycleEvent(eventName, data)) onEvent(event); for (const event of normalizeLifecycleEvent(eventName, data)) onEvent(event);
}); });
const errorChunks: string[] = []; const errorChunks: string[] = [];
let handleReady = false;
const handle = await httpStream( const handle = await httpStream(
{ {
url: `${this.base}${path}`, url: `${this.base}${path}`,
@@ -271,7 +272,6 @@ export class HermesClient {
}, },
{ onChunk: (text) => (handleReady ? parser.feed(text) : errorChunks.push(text)) } { onChunk: (text) => (handleReady ? parser.feed(text) : errorChunks.push(text)) }
); );
let handleReady = handle.start.ok;
if (!handle.start.ok) { if (!handle.start.ok) {
await handle.done.catch(() => undefined); await handle.done.catch(() => undefined);
throw new HttpError(handle.start.status, errorMessage(handle.start.status, errorChunks.join(''))); throw new HttpError(handle.start.status, errorMessage(handle.start.status, errorChunks.join('')));
@@ -285,25 +285,45 @@ export class HermesClient {
send(options: SendOptions, transport: ResolvedTransport): SendHandle { send(options: SendOptions, transport: ResolvedTransport): SendHandle {
const effective: ResolvedTransport = options.images?.length && transport === 'runs' ? 'completions' : transport; const effective: ResolvedTransport = options.images?.length && transport === 'runs' ? 'completions' : transport;
let abort: () => void = () => undefined; let aborted = false;
let abortImpl: () => void = () => undefined;
const setAbort = (fn: () => void) => {
abortImpl = fn;
if (aborted) fn();
};
const isAborted = () => aborted;
const result = const result =
effective === 'runs' effective === 'runs'
? this.sendViaRuns(options, (fn) => (abort = fn)) ? this.sendViaRuns(options, setAbort, isAborted)
: effective === 'sessions' : effective === 'sessions'
? this.sendViaSessions(options, (fn) => (abort = fn)) ? this.sendViaSessions(options, setAbort, isAborted)
: this.sendViaCompletions(options, (fn) => (abort = fn)); : this.sendViaCompletions(options, setAbort, isAborted);
return { result, abort: () => abort(), transport: effective }; return {
result,
transport: effective,
get aborted() {
return aborted;
},
abort: () => {
aborted = true;
abortImpl();
}
};
} }
private async sendViaRuns(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> { private async sendViaRuns(options: SendOptions, setAbort: (fn: () => void) => void, isAborted: () => boolean): Promise<string> {
const { onEvent } = options; const { onEvent } = options;
const run = await this.startRun({ const run = await this.startRun({
input: options.text, input: options.text,
session_id: options.sessionId || undefined, session_id: plainSession(options.sessionId) || undefined,
instructions: this.config.instructions || undefined, instructions: this.config.instructions || undefined,
model: this.config.model || undefined model: this.config.model || undefined
}); });
const runId = run.run_id; const runId = run.run_id;
if (isAborted()) {
this.stopRun(runId).catch(() => undefined);
return '';
}
onEvent({ kind: 'run.started', runId }); onEvent({ kind: 'run.started', runId });
Log.info('hermes', `run ${runId} started`); Log.info('hermes', `run ${runId} started`);
@@ -329,7 +349,7 @@ export class HermesClient {
}); });
await handle.done; await handle.done;
if (stopped) return streamedText; if (stopped || isAborted()) return streamedText;
if (failure) throw new Error(failure); if (failure) throw new Error(failure);
if (!completed) { if (!completed) {
@@ -349,7 +369,7 @@ export class HermesClient {
return finalText || streamedText; return finalText || streamedText;
} }
private async sendViaSessions(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> { private async sendViaSessions(options: SendOptions, setAbort: (fn: () => void) => void, isAborted: () => boolean): Promise<string> {
const { onEvent } = options; const { onEvent } = options;
let sessionId = options.sessionId; let sessionId = options.sessionId;
if (!sessionId || !sessionId.startsWith('hs:')) { if (!sessionId || !sessionId.startsWith('hs:')) {
@@ -357,6 +377,7 @@ export class HermesClient {
sessionId = `hs:${session.id}`; sessionId = `hs:${session.id}`;
onEvent({ kind: 'session', sessionId }); onEvent({ kind: 'session', sessionId });
} }
if (isAborted()) return '';
const realId = sessionId.slice(3); const realId = sessionId.slice(3);
const body: Rec = { input: options.text }; const body: Rec = { input: options.text };
if (this.config.instructions) body.instructions = this.config.instructions; if (this.config.instructions) body.instructions = this.config.instructions;
@@ -374,11 +395,12 @@ export class HermesClient {
}); });
setAbort(() => handle.abort()); setAbort(() => handle.abort());
await handle.done; await handle.done;
if (isAborted()) return streamed;
if (failure) throw new Error(failure); if (failure) throw new Error(failure);
return finalText || streamed; return finalText || streamed;
} }
private async sendViaCompletions(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> { private async sendViaCompletions(options: SendOptions, setAbort: (fn: () => void) => void, isAborted: () => boolean): Promise<string> {
const { onEvent } = options; const { onEvent } = options;
const useTools = this.config.localTools && !!options.localToolExecutor && !!options.localToolDefinitions?.length; const useTools = this.config.localTools && !!options.localToolExecutor && !!options.localToolDefinitions?.length;
const messages: HermesChatMessage[] = []; const messages: HermesChatMessage[] = [];
@@ -389,16 +411,13 @@ export class HermesClient {
: options.text; : options.text;
messages.push({ role: 'user', content: userContent }); messages.push({ role: 'user', content: userContent });
let aborted = false;
let currentHandle: StreamHandle | null = null; let currentHandle: StreamHandle | null = null;
setAbort(() => { setAbort(() => currentHandle?.abort());
aborted = true; const aborted = () => isAborted();
currentHandle?.abort();
});
let fullText = ''; let fullText = '';
let toolsAllowed = useTools; let toolsAllowed = useTools;
for (let iteration = 0; iteration < 6 && !aborted; iteration++) { for (let iteration = 0; iteration < 6 && !aborted(); iteration++) {
const payload: Rec = { model: this.config.model || 'hermes-agent', messages, stream: true }; const payload: Rec = { model: this.config.model || 'hermes-agent', messages, stream: true };
if (toolsAllowed) { if (toolsAllowed) {
payload.tools = options.localToolDefinitions; payload.tools = options.localToolDefinitions;
@@ -428,7 +447,8 @@ export class HermesClient {
}); });
const headers = this.headers({ Accept: 'text/event-stream' }); const headers = this.headers({ Accept: 'text/event-stream' });
if (options.sessionId) headers['X-Hermes-Session-Id'] = options.sessionId; const sessionId = plainSession(options.sessionId);
if (sessionId) headers['X-Hermes-Session-Id'] = sessionId;
const handle = await httpStream( const handle = await httpStream(
{ url: `${this.base}/v1/chat/completions`, method: 'POST', headers, body: JSON.stringify(payload), timeoutMs: 10 * 60_000 }, { url: `${this.base}/v1/chat/completions`, method: 'POST', headers, body: JSON.stringify(payload), timeoutMs: 10 * 60_000 },
{ onChunk: (text) => (ready ? parser.feed(text) : errorChunks.push(text)) } { onChunk: (text) => (ready ? parser.feed(text) : errorChunks.push(text)) }
@@ -454,7 +474,7 @@ export class HermesClient {
parser.end(); parser.end();
fullText += iterationText; fullText += iterationText;
if (finishReason === 'tool_calls' && toolsAllowed && toolCalls.size > 0 && !aborted) { if (finishReason === 'tool_calls' && toolsAllowed && toolCalls.size > 0 && !aborted()) {
const calls = [...toolCalls.values()]; const calls = [...toolCalls.values()];
messages.push({ messages.push({
role: 'assistant', role: 'assistant',
@@ -471,11 +491,16 @@ export class HermesClient {
} }
break; break;
} }
onEvent({ kind: 'completed', status: aborted ? 'cancelled' : 'completed', text: fullText }); onEvent({ kind: 'completed', status: aborted() ? 'cancelled' : 'completed', text: fullText });
return fullText; return fullText;
} }
} }
/** Session ids created by the sessions transport carry an `hs:` prefix; other endpoints get the bare id. */
export function plainSession(id: string): string {
return id.startsWith('hs:') ? id.slice(3) : id;
}
export function normalizeJob(job: HermesJob): HermesJob { export function normalizeJob(job: HermesJob): HermesJob {
const raw = job as Rec; const raw = job as Rec;
const id = String(raw.id ?? raw.job_id ?? raw.name ?? ''); const id = String(raw.id ?? raw.job_id ?? raw.name ?? '');
+1
View File
@@ -212,6 +212,7 @@ export function normalizeCompletionChunk(
): { events: HermesStreamEvent[]; finishReason: string | null } { ): { events: HermesStreamEvent[]; finishReason: string | null } {
const events: HermesStreamEvent[] = []; const events: HermesStreamEvent[] = [];
if (!isRec(chunk)) return { events, finishReason: null }; if (!isRec(chunk)) return { events, finishReason: null };
if (isRec(chunk.usage)) events.push({ kind: 'completed', status: 'usage', usage: chunk.usage as HermesUsage });
const choice = Array.isArray(chunk.choices) && isRec(chunk.choices[0]) ? (chunk.choices[0] as Rec) : null; const choice = Array.isArray(chunk.choices) && isRec(chunk.choices[0]) ? (chunk.choices[0] as Rec) : null;
if (!choice) return { events, finishReason: null }; if (!choice) return { events, finishReason: null };
const delta = isRec(choice.delta) ? (choice.delta as Rec) : isRec(choice.message) ? (choice.message as Rec) : null; const delta = isRec(choice.delta) ? (choice.delta as Rec) : isRec(choice.message) ? (choice.message as Rec) : null;
+3 -1
View File
@@ -167,8 +167,10 @@ export interface SendOptions {
} }
export interface SendHandle { export interface SendHandle {
/** Resolves with the final assistant text. */ /** Resolves with the final assistant text (partial text when aborted). */
result: Promise<string>; result: Promise<string>;
abort: () => void; abort: () => void;
/** True once abort() was called; the caller must not treat the result as a completed reply. */
readonly aborted: boolean;
transport: Exclude<HermesTransport, 'auto'>; transport: Exclude<HermesTransport, 'auto'>;
} }
-70
View File
@@ -1,70 +0,0 @@
/**
* LogService — Centralized structured logger for EveFlow.
*
* In Electron (production/dev): sends log entries via window.electronAPI.log()
* which the main process writes to %APPDATA%/EveFlow/eveflow.log
*
* Fallback (browser only): console only.
*/
export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
export interface LogEntry {
ts: string; // ISO timestamp
level: LogLevel;
tag: string; // Component / service tag (e.g. "HermesAgent", "Minimax")
message: string;
data?: unknown; // Any extra serializable payload
}
export class LogService {
private static readonly MAX_CONSOLE_DATA_LEN = 2000;
// Core write method
public static log(level: LogLevel, tag: string, message: string, data?: unknown): void {
const entry: LogEntry = {
ts: new Date().toISOString(),
level,
tag,
message,
data
};
// Always write to console for DevTools visibility
const consoleMsg = `[${entry.ts}] [${level}] [${tag}] ${message}`;
const dataStr = data !== undefined ? JSON.stringify(data, null, 2) : '';
switch (level) {
case 'ERROR': console.error(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
case 'WARN': console.warn(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
case 'DEBUG': console.debug(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
default: console.info(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
}
// Send to Electron main process for file persistence
try {
const electronAPI = (window as any).electronAPI;
if (electronAPI?.writeLog) {
electronAPI.writeLog(entry);
}
} catch {
// Silently ignore: browser context has no electronAPI
}
}
public static debug(tag: string, message: string, data?: unknown): void {
this.log('DEBUG', tag, message, data);
}
public static info(tag: string, message: string, data?: unknown): void {
this.log('INFO', tag, message, data);
}
public static warn(tag: string, message: string, data?: unknown): void {
this.log('WARN', tag, message, data);
}
public static error(tag: string, message: string, data?: unknown): void {
this.log('ERROR', tag, message, data);
}
}
+2 -2
View File
@@ -71,9 +71,9 @@ class AudioBus {
} }
/** Normalised output spectrum (0..1) with `bins` values, low to high frequency. */ /** Normalised output spectrum (0..1) with `bins` values, low to high frequency. */
spectrum(bins: number, source: 'output' | 'input' = 'output'): Float32Array { spectrum(bins: number, source: 'output' | 'input' = 'output', out: Float32Array = new Float32Array(bins)): Float32Array {
const analyser = source === 'output' ? this.outputAnalyser : this.inputAnalyser; const analyser = source === 'output' ? this.outputAnalyser : this.inputAnalyser;
const out = new Float32Array(bins); out.fill(0);
if (!analyser) { if (!analyser) {
if (this.synthetic > 0) { if (this.synthetic > 0) {
const t = performance.now() / 1000; const t = performance.now() / 1000;
+12 -1
View File
@@ -88,6 +88,7 @@ export class MicCapture {
private active = false; private active = false;
private options: CaptureOptions | null = null; private options: CaptureOptions | null = null;
private sampleRate = 16_000; private sampleRate = 16_000;
private hadSpeech = false;
get isActive(): boolean { get isActive(): boolean {
return this.active; return this.active;
@@ -98,6 +99,7 @@ export class MicCapture {
this.options = options; this.options = options;
this.chunks = []; this.chunks = [];
this.totalSamples = 0; this.totalSamples = 0;
this.hadSpeech = false;
this.vad = options.mode === 'auto' ? new EnergyVad(options.vad ?? DEFAULT_VAD) : null; this.vad = options.mode === 'auto' ? new EnergyVad(options.vad ?? DEFAULT_VAD) : null;
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
@@ -169,6 +171,12 @@ export class MicCapture {
if (!this.active || !this.options) return; if (!this.active || !this.options) return;
this.chunks.push(samples); this.chunks.push(samples);
this.totalSamples += samples.length; this.totalSamples += samples.length;
// Before speech starts only a short pre-roll (~400 ms) is kept, so silence is never shipped to STT.
if (this.vad && !this.hadSpeech) {
while (this.chunks.length > 1 && this.totalSamples - this.chunks[0].length > this.sampleRate * 0.4) {
this.totalSamples -= this.chunks.shift()!.length;
}
}
const level = rms(samples); const level = rms(samples);
this.options.callbacks.onLevel?.(Math.min(1, level * 6)); this.options.callbacks.onLevel?.(Math.min(1, level * 6));
@@ -184,6 +192,7 @@ export class MicCapture {
private handleVad(signal: VadSignal): void { private handleVad(signal: VadSignal): void {
switch (signal) { switch (signal) {
case 'speech-start': case 'speech-start':
this.hadSpeech = true;
this.options?.callbacks.onSpeechStart?.(); this.options?.callbacks.onSpeechStart?.();
break; break;
case 'speech-end': case 'speech-end':
@@ -222,10 +231,12 @@ export class MicCapture {
/** Stop capture. In manual mode this finalises the utterance; otherwise reports the reason. */ /** Stop capture. In manual mode this finalises the utterance; otherwise reports the reason. */
stop(reason: 'stopped' | 'no-speech' | 'too-short' = 'stopped'): void { stop(reason: 'stopped' | 'no-speech' | 'too-short' = 'stopped'): void {
if (!this.active) return; if (!this.active) return;
if (reason === 'stopped') { // Manual stop: transcribe only if something was actually said (or no VAD is running).
if (reason === 'stopped' && (!this.vad || this.hadSpeech)) {
this.finishUtterance('stopped'); this.finishUtterance('stopped');
return; return;
} }
if (reason === 'stopped') reason = 'no-speech';
const opts = this.options; const opts = this.options;
this.teardown(); this.teardown();
opts?.callbacks.onEnd(reason); opts?.callbacks.onEnd(reason);
+3 -2
View File
@@ -35,9 +35,10 @@ class SpeechFacade {
return this.engine?.isActive ?? false; return this.engine?.isActive ?? false;
} }
say(text: string): void { say(text: string, options: { interrupt?: boolean } = {}): void {
if (useSettings.getState().settings.speech.provider === 'off') return; if (useSettings.getState().settings.speech.provider === 'off') return;
this.tts.speak(text); // While an answer streams, spoken notices are inserted without discarding the rest.
this.tts.speak(text, { interrupt: options.interrupt ?? !this.streaming });
} }
pushStream(delta: string): void { pushStream(delta: string): void {
+62 -36
View File
@@ -1,7 +1,7 @@
/** /**
* Text-to-speech engine with a sentence queue, prefetching and Web Audio playback so the HUD * Text-to-speech engine with a sentence queue, bounded prefetching and Web Audio playback so
* reacts to the actual waveform. Providers: OpenAI-compatible /v1/audio/speech, system voices, * the HUD reacts to the actual waveform. Providers: in-app sherpa-onnx (Kokoro / Piper),
* and the legacy Google Translate endpoint (no key, online only). * OpenAI-compatible /v1/audio/speech, system voices, and the legacy Google Translate endpoint.
*/ */
import { Log } from '../../lib/log'; import { Log } from '../../lib/log';
import { bridge } from '../../lib/bridge'; import { bridge } from '../../lib/bridge';
@@ -35,6 +35,8 @@ interface QueueItem {
audio?: Promise<AudioBuffer | null>; audio?: Promise<AudioBuffer | null>;
} }
const LOOKAHEAD = 2;
export function ttsEndpoint(apiUrl: string): string { export function ttsEndpoint(apiUrl: string): string {
const base = apiUrl.trim().replace(/\/+$/, ''); const base = apiUrl.trim().replace(/\/+$/, '');
if (base.endsWith('/speech')) return base; if (base.endsWith('/speech')) return base;
@@ -46,8 +48,7 @@ export function ttsEndpoint(apiUrl: string): string {
export class TtsEngine { export class TtsEngine {
private queue: QueueItem[] = []; private queue: QueueItem[] = [];
private playing = false; private playing = false;
private currentSource: AudioBufferSourceNode | null = null; private cancelCurrent: (() => void) | null = null;
private currentUtterance: SpeechSynthesisUtterance | null = null;
private streamBuffer = ''; private streamBuffer = '';
private generation = 0; private generation = 0;
private config: TtsConfig; private config: TtsConfig;
@@ -60,7 +61,6 @@ export class TtsEngine {
updateConfig(config: TtsConfig): void { updateConfig(config: TtsConfig): void {
this.config = config; this.config = config;
audioBus.setVolume(config.volume);
} }
onState(listener: (state: TtsState) => void): () => void { onState(listener: (state: TtsState) => void): () => void {
@@ -78,10 +78,18 @@ export class TtsEngine {
for (const l of this.listeners) l(state); for (const l of this.listeners) l(state);
} }
/** Speak a finished text (clears any pending speech first). */ /**
speak(text: string): void { * Speak a finished text. With `interrupt` (default) pending speech is discarded first;
this.stop(); * without it the text is inserted ahead of the queue and streamed speech resumes afterwards.
for (const chunk of chunkForSpeech(text)) this.enqueue(chunk); */
speak(text: string, options: { interrupt?: boolean } = {}): void {
const interrupt = options.interrupt ?? true;
if (interrupt) this.stop();
const items = chunkForSpeech(text).map((chunk) => this.makeItem(chunk)).filter((i): i is QueueItem => !!i);
if (interrupt) this.queue.push(...items);
else this.queue.unshift(...items);
this.fillPrefetch();
void this.drain();
} }
/** Feed streamed tokens; complete sentences are spoken as soon as they are available. */ /** Feed streamed tokens; complete sentences are spoken as soon as they are available. */
@@ -100,32 +108,32 @@ export class TtsEngine {
} }
enqueue(text: string): void { enqueue(text: string): void {
if (this.config.provider === 'off') return; const item = this.makeItem(text);
const clean = cleanForSpeech(text); if (!item) return;
if (!clean || !/[\p{L}\p{N}]/u.test(clean)) return;
const item: QueueItem = { text: clean };
if (this.config.provider !== 'system') item.audio = this.prefetch(clean, this.generation);
this.queue.push(item); this.queue.push(item);
this.fillPrefetch();
void this.drain(); void this.drain();
} }
private makeItem(text: string): QueueItem | null {
if (this.config.provider === 'off') return null;
const clean = cleanForSpeech(text);
if (!clean || !/[\p{L}\p{N}]/u.test(clean)) return null;
return { text: clean };
}
/** Keep at most LOOKAHEAD synthesis requests in flight ahead of playback. */
private fillPrefetch(): void {
if (this.config.provider === 'system') return;
for (const item of this.queue.slice(0, LOOKAHEAD)) item.audio ??= this.prefetch(item.text, this.generation);
}
stop(): void { stop(): void {
this.generation++; this.generation++;
this.queue = []; this.queue = [];
this.streamBuffer = ''; this.streamBuffer = '';
if (this.currentSource) { this.cancelCurrent?.();
try { this.cancelCurrent = null;
this.currentSource.onended = null;
this.currentSource.stop();
} catch {
/* already stopped */
}
this.currentSource = null;
}
if (this.currentUtterance) {
this.currentUtterance.onend = null;
this.currentUtterance = null;
}
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel(); if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel();
audioBus.setSyntheticLevel(0); audioBus.setSyntheticLevel(0);
this.playing = false; this.playing = false;
@@ -138,15 +146,17 @@ export class TtsEngine {
const gen = this.generation; const gen = this.generation;
while (this.queue.length > 0 && gen === this.generation) { while (this.queue.length > 0 && gen === this.generation) {
const item = this.queue.shift()!; const item = this.queue.shift()!;
this.fillPrefetch();
try { try {
if (item.audio) { if (this.config.provider !== 'system') {
this.setState('loading'); this.setState('loading');
const buffer = await item.audio; const buffer = await (item.audio ?? this.prefetch(item.text, gen));
if (gen !== this.generation) break; if (gen !== this.generation) break;
if (buffer) { if (buffer) {
this.setState('speaking'); this.setState('speaking');
await this.playBuffer(buffer, gen); await this.playBuffer(buffer, gen);
} else { } else {
this.setState('speaking');
await this.speakSystem(item.text, gen); await this.speakSystem(item.text, gen);
} }
} else { } else {
@@ -174,7 +184,7 @@ export class TtsEngine {
return audioBus.context.decodeAudioData(copy); return audioBus.context.decodeAudioData(copy);
}) })
.catch((err) => { .catch((err) => {
Log.warn('tts', `synthesis failed, falling back to system voice: ${(err as Error).message}`); if (gen === this.generation) Log.warn('tts', `synthesis failed, falling back to system voice: ${(err as Error).message}`);
return null; return null;
}); });
} }
@@ -238,15 +248,26 @@ export class TtsEngine {
return new Promise((resolve) => { return new Promise((resolve) => {
if (gen !== this.generation) return resolve(); if (gen !== this.generation) return resolve();
const ctx = audioBus.context; const ctx = audioBus.context;
audioBus.setVolume(this.config.volume);
const source = ctx.createBufferSource(); const source = ctx.createBufferSource();
source.buffer = buffer; source.buffer = buffer;
if (this.config.provider === 'google-free') source.playbackRate.value = Math.max(0.5, Math.min(2, this.config.speed || 1)); if (this.config.provider === 'google-free') source.playbackRate.value = Math.max(0.5, Math.min(2, this.config.speed || 1));
source.connect(audioBus.output); source.connect(audioBus.output);
source.onended = () => { const finish = () => {
if (this.currentSource === source) this.currentSource = null; if (this.cancelCurrent === cancel) this.cancelCurrent = null;
resolve(); resolve();
}; };
this.currentSource = source; const cancel = () => {
source.onended = null;
try {
source.stop();
} catch {
/* already stopped */
}
finish();
};
source.onended = finish;
this.cancelCurrent = cancel;
source.start(); source.start();
}); });
} }
@@ -268,12 +289,17 @@ export class TtsEngine {
if (pulse) clearInterval(pulse); if (pulse) clearInterval(pulse);
pulse = null; pulse = null;
audioBus.setSyntheticLevel(0); audioBus.setSyntheticLevel(0);
if (this.currentUtterance === utterance) this.currentUtterance = null; if (this.cancelCurrent === cancel) this.cancelCurrent = null;
resolve(); resolve();
}; };
const cancel = () => {
utterance.onend = null;
utterance.onerror = null;
finish();
};
utterance.onend = finish; utterance.onend = finish;
utterance.onerror = finish; utterance.onerror = finish;
this.currentUtterance = utterance; this.cancelCurrent = cancel;
speechSynthesis.speak(utterance); speechSynthesis.speak(utterance);
}); });
} }
+35 -8
View File
@@ -51,6 +51,7 @@ class VoiceController {
private browser = new BrowserRecognizer(); private browser = new BrowserRecognizer();
private handsFreeTimer: ReturnType<typeof setTimeout> | null = null; private handsFreeTimer: ReturnType<typeof setTimeout> | null = null;
private stopping = false; private stopping = false;
private startSeq = 0;
private unsubscribeVoice: (() => void) | null = null; private unsubscribeVoice: (() => void) | null = null;
init(): void { init(): void {
@@ -61,6 +62,13 @@ class VoiceController {
this.scheduleHandsFree(350); this.scheduleHandsFree(350);
} }
}); });
// Hands-free also re-arms when a reply ends without speech (voice off, empty answer, error).
useChat.subscribe((state, prev) => {
if (prev.isSending && !state.isSending) {
const v = useVoice.getState();
if (v.handsFree && v.phase === 'off' && !speech.isSpeaking()) this.scheduleHandsFree(400);
}
});
} }
dispose(): void { dispose(): void {
@@ -83,6 +91,7 @@ class VoiceController {
if (on && !this.isListening && !speech.isSpeaking()) void this.start(); if (on && !this.isListening && !speech.isSpeaking()) void this.start();
if (!on) { if (!on) {
this.clearHandsFreeTimer(); this.clearHandsFreeTimer();
if (this.isListening) this.cancel();
} }
} }
@@ -90,7 +99,8 @@ class VoiceController {
this.clearHandsFreeTimer(); this.clearHandsFreeTimer();
this.handsFreeTimer = setTimeout(() => { this.handsFreeTimer = setTimeout(() => {
const v = useVoice.getState(); const v = useVoice.getState();
if (v.handsFree && v.phase === 'off' && !useChat.getState().isSending && !speech.isSpeaking()) void this.start(); const bargeIn = useSettings.getState().settings.voice.bargeIn;
if (v.handsFree && v.phase === 'off' && !useChat.getState().isSending && (bargeIn || !speech.isSpeaking())) void this.start(true);
}, delay); }, delay);
} }
@@ -99,14 +109,16 @@ class VoiceController {
this.handsFreeTimer = null; this.handsFreeTimer = null;
} }
async start(): Promise<void> { /** @param auto true when re-armed by the hands-free loop (no chime, patient timeout). */
async start(auto = false): Promise<void> {
const voice = useVoice.getState(); const voice = useVoice.getState();
if (voice.phase !== 'off') return; if (voice.phase !== 'off') return;
const settings = useSettings.getState().settings.voice; const settings = useSettings.getState().settings.voice;
const seq = ++this.startSeq;
voice.setError(null); voice.setError(null);
voice.setInterim(''); voice.setInterim('');
if (!settings.bargeIn) speech.stop(); // Barge-in keeps the assistant talking while the mic opens (echo cancellation handles the overlap).
else speech.stop(); if (!(auto && settings.bargeIn)) speech.stop();
useChat.getState().setHud('listening'); useChat.getState().setHud('listening');
voice.setPhase('arming'); voice.setPhase('arming');
@@ -125,18 +137,29 @@ class VoiceController {
silenceMs: settings.silenceMs, silenceMs: settings.silenceMs,
speechRatio: SENSITIVITY_RATIO[sensitivity], speechRatio: SENSITIVITY_RATIO[sensitivity],
minRms: SENSITIVITY_MIN_RMS[sensitivity], minRms: SENSITIVITY_MIN_RMS[sensitivity],
noSpeechTimeoutMs: useVoice.getState().handsFree ? 20_000 : 9_000 noSpeechTimeoutMs: useVoice.getState().handsFree ? Number.POSITIVE_INFINITY : 9_000
}, },
callbacks: { callbacks: {
onLevel: (level) => useVoice.getState().setInputLevel(level), onLevel: (level) => {
onSpeechStart: () => useVoice.getState().setPhase('speech'), const q = Math.round(level * 20) / 20;
if (q !== useVoice.getState().inputLevel) useVoice.getState().setInputLevel(q);
},
onSpeechStart: () => {
useVoice.getState().setPhase('speech');
useChat.getState().ping();
},
onUtterance: (wav) => void this.transcribe(wav), onUtterance: (wav) => void this.transcribe(wav),
onEnd: (reason) => this.onCaptureEnd(reason), onEnd: (reason) => this.onCaptureEnd(reason),
onError: (message) => useVoice.getState().setError(message) onError: (message) => useVoice.getState().setError(message)
} }
}); });
if (seq !== this.startSeq || useVoice.getState().phase !== 'arming') {
// stop() was called while the microphone was opening.
this.capture.cancel();
return;
}
useVoice.getState().setPhase('listening'); useVoice.getState().setPhase('listening');
this.playChime(true); if (!auto) this.playChime(true);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
useVoice.getState().setError(message); useVoice.getState().setError(message);
@@ -166,6 +189,7 @@ class VoiceController {
stop(): void { stop(): void {
this.stopping = true; this.stopping = true;
this.startSeq++;
this.clearHandsFreeTimer(); this.clearHandsFreeTimer();
if (useSettings.getState().settings.voice.provider === 'browser') this.browser.stop(); if (useSettings.getState().settings.voice.provider === 'browser') this.browser.stop();
else if (this.capture.isActive) this.capture.stop('stopped'); else if (this.capture.isActive) this.capture.stop('stopped');
@@ -174,6 +198,7 @@ class VoiceController {
} }
cancel(): void { cancel(): void {
this.startSeq++;
this.clearHandsFreeTimer(); this.clearHandsFreeTimer();
this.browser.stop(); this.browser.stop();
this.capture.cancel(); this.capture.cancel();
@@ -224,6 +249,7 @@ class VoiceController {
this.attentionUntil = 0; this.attentionUntil = 0;
if (text.trim()) { if (text.trim()) {
await sendMessage(text, [], 'voice'); await sendMessage(text, [], 'voice');
if (useVoice.getState().handsFree && !speech.isSpeaking()) this.scheduleHandsFree(350);
} else { } else {
voice.setError('Transcription vide.'); voice.setError('Transcription vide.');
useChat.getState().setHud('idle'); useChat.getState().setHud('idle');
@@ -237,6 +263,7 @@ class VoiceController {
useChat.getState().setHud('error'); useChat.getState().setHud('error');
Log.error('voice', `transcription failed: ${message}`); Log.error('voice', `transcription failed: ${message}`);
setTimeout(() => useChat.getState().setHud('idle'), 3000); setTimeout(() => useChat.getState().setHud('idle'), 3000);
if (useVoice.getState().handsFree) this.scheduleHandsFree(3000);
} }
} }
+11 -1
View File
@@ -53,6 +53,10 @@ interface ChatStore {
hudOverride: HudState | null; hudOverride: HudState | null;
error: string | null; error: string | null;
draft: string; draft: string;
/** Incremented to make the core flash briefly. */
pingCount: number;
/** Time to first token of the current reply, in ms. */
latencyMs: number | null;
addMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'> & Partial<Pick<ChatMessage, 'id' | 'timestamp'>>) => string; addMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'> & Partial<Pick<ChatMessage, 'id' | 'timestamp'>>) => string;
updateMessage: (id: string, patch: Partial<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => void; updateMessage: (id: string, patch: Partial<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => void;
@@ -68,6 +72,8 @@ interface ChatStore {
setHudOverride: (state: HudState | null) => void; setHudOverride: (state: HudState | null) => void;
setError: (error: string | null) => void; setError: (error: string | null) => void;
setDraft: (draft: string) => void; setDraft: (draft: string) => void;
ping: () => void;
setLatency: (ms: number | null) => void;
} }
const MAX_MESSAGES = 400; const MAX_MESSAGES = 400;
@@ -83,6 +89,8 @@ export const useChat = create<ChatStore>((set, get) => ({
hudOverride: null, hudOverride: null,
error: null, error: null,
draft: '', draft: '',
pingCount: 0,
latencyMs: null,
addMessage: (message) => { addMessage: (message) => {
const id = message.id ?? uid('msg'); const id = message.id ?? uid('msg');
@@ -137,5 +145,7 @@ export const useChat = create<ChatStore>((set, get) => ({
}, },
setHudOverride: (hudOverride) => set({ hudOverride }), setHudOverride: (hudOverride) => set({ hudOverride }),
setError: (error) => set({ error }), setError: (error) => set({ error }),
setDraft: (draft) => set({ draft }) setDraft: (draft) => set({ draft }),
ping: () => set((s) => ({ pingCount: s.pingCount + 1 })),
setLatency: (latencyMs) => set({ latencyMs })
})); }));
+17 -4
View File
@@ -57,6 +57,8 @@ interface HermesStore {
} }
const CACHE_KEY = 'eveflow.hermes.cache.v2'; const CACHE_KEY = 'eveflow.hermes.cache.v2';
let connectInflight: Promise<void> | null = null;
let lastCacheSnapshot = '';
const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase()); const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase());
function runFromJob(job: HermesJob): JobRun | null { function runFromJob(job: HermesJob): JobRun | null {
@@ -98,7 +100,9 @@ export const useHermes = create<HermesStore>((set, get) => ({
return new HermesClient({ ...config, model }); return new HermesClient({ ...config, model });
}, },
connect: async () => { connect: () => {
if (connectInflight) return connectInflight;
connectInflight = (async () => {
const config = useSettings.getState().settings.hermes; const config = useSettings.getState().settings.hermes;
if (!config.url.trim()) { if (!config.url.trim()) {
set({ link: 'offline', linkDetail: 'URL Hermes non configurée' }); set({ link: 'offline', linkDetail: 'URL Hermes non configurée' });
@@ -126,6 +130,10 @@ export const useHermes = create<HermesStore>((set, get) => ({
set({ link: 'offline', linkDetail: message, transport: resolveTransport(config, null) }); set({ link: 'offline', linkDetail: message, transport: resolveTransport(config, null) });
Log.warn('hermes', `connection failed: ${message}`); Log.warn('hermes', `connection failed: ${message}`);
} }
})().finally(() => {
connectInflight = null;
});
return connectInflight;
}, },
refreshCatalog: async () => { refreshCatalog: async () => {
@@ -157,13 +165,18 @@ export const useHermes = create<HermesStore>((set, get) => ({
const merged = [...incoming, ...previous.filter((r) => !incoming.some((i) => i.id === r.id))] 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()) .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
.slice(0, 100); .slice(0, 100);
set({ jobs, jobRuns: merged, lastSyncAt: Date.now(), link: get().link === 'offline' ? 'online' : get().link }); set({ jobs, jobRuns: merged, lastSyncAt: Date.now(), link: get().link === 'offline' || get().link === 'degraded' ? 'online' : get().link });
persistSet(CACHE_KEY, { jobs, jobRuns: merged, syncedAt: Date.now() }); const snapshot = JSON.stringify({ jobs, jobRuns: merged });
if (snapshot !== lastCacheSnapshot) {
lastCacheSnapshot = snapshot;
persistSet(CACHE_KEY, { jobs, jobRuns: merged, syncedAt: Date.now() });
}
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
Log.warn('hermes', `jobs sync failed: ${message}`); Log.warn('hermes', `jobs sync failed: ${message}`);
if (/HTTP 404/.test(message)) return; // jobs API disabled on this server if (/HTTP 404/.test(message)) return; // jobs API disabled on this server
set({ link: 'offline', linkDetail: message }); // A failing jobs poll does not mean chat is down: degrade, and let the next health probe decide.
set({ linkDetail: message, link: get().link === 'online' ? 'degraded' : get().link });
} }
}, },
+54 -8
View File
@@ -7,6 +7,7 @@
grid-template-rows: 46px 1fr; grid-template-rows: 46px 1fr;
border-radius: 14px; border-radius: 14px;
overflow: hidden; overflow: hidden;
background-clip: padding-box;
background: background:
radial-gradient(1200px 700px at 50% 40%, rgba(var(--accent-rgb), 0.08), transparent 60%), radial-gradient(1200px 700px at 50% 40%, rgba(var(--accent-rgb), 0.08), transparent 60%),
linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 100%); linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 100%);
@@ -51,6 +52,17 @@
grid-template-columns: minmax(340px, 1fr) minmax(380px, 1fr); grid-template-columns: minmax(340px, 1fr) minmax(380px, 1fr);
} }
.hud-body > .ops-column { .hud-body > .ops-column {
position: absolute;
right: 14px;
top: 12px;
bottom: 14px;
width: min(360px, 60%);
z-index: 6;
background: var(--bg-1);
border-radius: var(--radius);
box-shadow: -20px 0 60px rgba(0, 0, 0, 0.5);
}
.hud-body > .ops-column:not(.open) {
display: none; display: none;
} }
} }
@@ -68,7 +80,6 @@
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
backdrop-filter: blur(14px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
@@ -310,7 +321,8 @@
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.06), transparent); background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.06), transparent);
-webkit-app-region: drag; -webkit-app-region: drag;
} }
.topbar > * { .topbar button,
.topbar .chip {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.topbar .brand { .topbar .brand {
@@ -346,9 +358,6 @@
overflow: hidden; overflow: hidden;
-webkit-app-region: drag; -webkit-app-region: drag;
} }
.topbar .status-strip > * {
-webkit-app-region: no-drag;
}
.topbar .clock { .topbar .clock {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 13px; font-size: 13px;
@@ -421,6 +430,21 @@
.core-state.success { .core-state.success {
color: var(--success); color: var(--success);
} }
.core-interim {
font-family: var(--font-ui);
font-size: 15px;
color: var(--ink-1);
max-width: 82%;
text-align: center;
opacity: 0.85;
min-height: 1.4em;
}
.core-latency {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.2em;
color: var(--ink-3);
}
.core-sub { .core-sub {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
@@ -438,10 +462,13 @@
right: 0; right: 0;
text-align: center; text-align: center;
font-family: var(--font-display); font-family: var(--font-display);
font-size: 22px; font-size: clamp(13px, 2.2vw, 22px);
font-weight: 900; font-weight: 900;
letter-spacing: 0.5em; letter-spacing: clamp(0.15em, 0.8vw, 0.5em);
padding-left: 0.5em; padding: 0 12px 0 calc(12px + 0.4em);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--ink-0); color: var(--ink-0);
text-shadow: 0 0 22px var(--accent-glow); text-shadow: 0 0 22px var(--accent-glow);
pointer-events: none; pointer-events: none;
@@ -474,6 +501,25 @@
margin-left: auto; margin-left: auto;
} }
/* ── Toasts ────────────────────────────────────────────────────────────── */
.toast-stack {
position: absolute;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
z-index: 18;
display: flex;
flex-direction: column;
gap: 8px;
width: min(560px, 80%);
pointer-events: none;
}
.toast-stack .error-banner {
pointer-events: auto;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
animation: rise-in 200ms ease-out;
}
/* ── Overlay / modal ───────────────────────────────────────────────────── */ /* ── Overlay / modal ───────────────────────────────────────────────────── */
.overlay { .overlay {
position: absolute; position: absolute;
-1
View File
@@ -11,7 +11,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
scroll-behavior: smooth;
} }
.chat-list.compact { .chat-list.compact {
padding: 8px 10px; padding: 8px 10px;
+10 -2
View File
@@ -7,7 +7,15 @@
background: linear-gradient(180deg, rgba(5, 12, 22, var(--compact-opacity, 0.92)), rgba(2, 6, 12, var(--compact-opacity, 0.92))); background: linear-gradient(180deg, rgba(5, 12, 22, var(--compact-opacity, 0.92)), rgba(2, 6, 12, var(--compact-opacity, 0.92)));
border: 1px solid rgba(var(--accent-rgb), 0.3); border: 1px solid rgba(var(--accent-rgb), 0.3);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 30px rgba(var(--accent-rgb), 0.12); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 30px rgba(var(--accent-rgb), 0.12);
backdrop-filter: blur(16px); }
.compact-body .panel {
background: transparent;
border: none;
border-radius: 0;
}
.compact-body .panel.bracket::before,
.compact-body .panel.bracket::after {
display: none;
} }
.compact-head { .compact-head {
display: flex; display: flex;
@@ -29,7 +37,7 @@
} }
.compact-core { .compact-core {
position: relative; position: relative;
height: 220px; height: clamp(140px, 38vh, 220px);
flex-shrink: 0; flex-shrink: 0;
} }
.compact-core canvas { .compact-core canvas {
+11
View File
@@ -104,3 +104,14 @@
background: linear-gradient(90deg, var(--accent), var(--success)); background: linear-gradient(90deg, var(--accent), var(--success));
transition: width 60ms linear; transition: width 60ms linear;
} }
.switch {
width: 100%;
background: transparent;
border-left: none;
border-right: none;
border-top: none;
color: inherit;
text-align: left;
font: inherit;
}
+21 -3
View File
@@ -1,5 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@400;500;600;700&family=Share+Tech+Mono&display=swap');
:root { :root {
--font-display: 'Orbitron', 'Rajdhani', 'Segoe UI', sans-serif; --font-display: 'Orbitron', 'Rajdhani', 'Segoe UI', sans-serif;
--font-ui: 'Rajdhani', 'Segoe UI', 'Helvetica Neue', sans-serif; --font-ui: 'Rajdhani', 'Segoe UI', 'Helvetica Neue', sans-serif;
@@ -137,7 +135,27 @@ textarea {
background: rgba(var(--accent-rgb), 0.45); background: rgba(var(--accent-rgb), 0.45);
} }
:root[data-motion='reduce'] * { :root[data-motion='reduce'] *,
:root[data-motion='reduce'] *::before,
:root[data-motion='reduce'] *::after {
animation-iteration-count: 1 !important;
animation-duration: 0.001s !important; animation-duration: 0.001s !important;
transition-duration: 0.001s !important; transition-duration: 0.001s !important;
} }
@media (prefers-reduced-motion: reduce) {
:root:not([data-motion='full']) *,
:root:not([data-motion='full']) *::before,
:root:not([data-motion='full']) *::after {
animation-iteration-count: 1 !important;
animation-duration: 0.001s !important;
transition-duration: 0.001s !important;
}
}
.error-banner span,
.activity-item .detail,
.row-item .desc,
.row-item .meta,
.core-sub,
.kv dd {
user-select: text;
}