From 5f0fa7a1e26188c89e682116785f3d8a82c1265e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 18:08:09 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y --- docs/ROADMAP.md | 64 +++++++ electron/ipc/files.ts | 84 +++++++-- electron/ipc/http.ts | 13 +- electron/ipc/store.ts | 73 ++++++-- electron/logger.ts | 40 +++- electron/main.ts | 35 ++-- electron/preload.ts | 3 +- electron/voice/catalog.ts | 7 +- electron/voice/engine.ts | 70 ++++--- electron/voice/ipc.ts | 12 +- electron/voice/models.ts | 40 +++- electron/voice/worker.ts | 37 +++- electron/webhook.ts | 34 +++- electron/window.ts | 25 ++- index.html | 2 +- package-lock.json | 34 +++- package.json | 8 +- scripts/dist.mjs | 7 +- shared/bridge.ts | 2 + shared/ipc.ts | 1 + src/App.tsx | 40 ++-- src/components/chat/ChatPanel.tsx | 30 ++- src/components/chat/CommandBar.tsx | 25 ++- src/components/chat/MessageBubble.tsx | 13 +- src/components/chat/PendingRequests.tsx | 20 +- src/components/chat/ResilientImage.tsx | 4 + src/components/compact/CompactWidget.tsx | 7 +- src/components/hud/CoreStage.tsx | 34 +++- src/components/hud/JarvisCore.tsx | 202 ++++++++++++++------- src/components/hud/TopBar.tsx | 16 +- src/components/panels/OpsPanel.tsx | 16 +- src/components/settings/ModelsSection.tsx | 11 +- src/components/settings/SettingsDrawer.tsx | 42 +++-- src/hooks/useMetrics.ts | 2 +- src/lib/sse.ts | 2 + src/lib/text.ts | 10 +- src/lib/transport.ts | 5 - src/main.tsx | 8 + src/services/conversation.ts | 90 ++++++--- src/services/hermes/client.ts | 65 +++++-- src/services/hermes/events.ts | 1 + src/services/hermes/types.ts | 4 +- src/services/logService.ts | 70 ------- src/services/voice/audioBus.ts | 4 +- src/services/voice/capture.ts | 13 +- src/services/voice/speech.ts | 5 +- src/services/voice/tts.ts | 98 ++++++---- src/services/voice/voiceController.ts | 43 ++++- src/state/chat.ts | 12 +- src/state/hermes.ts | 21 ++- src/styles/app.css | 62 ++++++- src/styles/chat.css | 1 - src/styles/compact.css | 12 +- src/styles/settings.css | 11 ++ src/styles/tokens.css | 24 ++- 55 files changed, 1181 insertions(+), 433 deletions(-) create mode 100644 docs/ROADMAP.md delete mode 100644 src/services/logService.ts diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..17c3304 --- /dev/null +++ b/docs/ROADMAP.md @@ -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. diff --git a/electron/ipc/files.ts b/electron/ipc/files.ts index 54f17f2..44d5017 100644 --- a/electron/ipc/files.ts +++ b/electron/ipc/files.ts @@ -22,12 +22,25 @@ const READABLE_EXT: Record = { '.html': 'text/html' }; -const FORBIDDEN_EXT = new Set([ - '.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh', - '.com', '.hta', '.vbe', '.wsf', '.lnk', '.jar', '.dll', '.js', '.jse' +/** Documents and media the assistant may open with the default application. */ +const OPENABLE_EXT = new Set([ + ...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 { + if (sharedDir && fs.existsSync(sharedDir)) return sharedDir; const candidates = [ path.join(os.homedir(), 'Documents', 'EveFlow_Shared'), path.join(app.getPath('userData'), 'shared') @@ -35,32 +48,60 @@ export function getSharedDirectory(): string { for (const dir of candidates) { try { fs.mkdirSync(dir, { recursive: true }); + sharedDir = dir; return dir; } catch { /* try next candidate */ } } - return app.getPath('temp'); + sharedDir = app.getPath('temp'); + return sharedDir; } 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); if (normalized.split(/[\\/]/).includes('..')) throw new Error('Traversee de repertoire refusee'); 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 { - const roots = [getSharedDirectory(), os.homedir(), app.getPath('temp')]; - const resolved = path.resolve(target); - return roots.some((root) => resolved.startsWith(path.resolve(root))); + let resolved: string; + try { + resolved = fs.realpathSync.native(target); + } catch { + return false; + } + return allowedRoots().some((root) => isInside(root, resolved)); } 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 if (/^\/[a-zA-Z]:/.test(out)) out = out.slice(1); - return decodeURIComponent(out); + try { + return decodeURIComponent(out); + } catch { + return out; + } } export function registerFilesIpc(): void { @@ -78,8 +119,16 @@ export function registerFilesIpc(): void { 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 (typeof content !== 'string' || content.length > 50 * 1024 * 1024) throw new Error('Contenu invalide'); 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'); } const ext = path.extname(safeName).toLowerCase(); @@ -97,10 +146,17 @@ export function registerFilesIpc(): void { ipcMain.handle(IPC.openPath, async (_e, filePath: string) => { const target = normalizeSafe(stripFileScheme(filePath)); - const ext = path.extname(target).toLowerCase(); - if (FORBIDDEN_EXT.has(ext)) throw new Error(`Ouverture refusee pour ${ext}`); if (!fs.existsSync(target)) throw new Error('Fichier introuvable'); - const result = await shell.openPath(target); + 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); return true; }); diff --git a/electron/ipc/http.ts b/electron/ipc/http.ts index 089779e..ed54ad6 100644 --- a/electron/ipc/http.ts +++ b/electron/ipc/http.ts @@ -105,6 +105,8 @@ async function startStream(sender: WebContents, id: string, req: HttpProxyReques headers: headersToObject(response.headers) }; + const onDestroyed = () => controller.abort(); + sender.once('destroyed', onDestroyed); const pump = async () => { try { if (!response.body) { @@ -133,7 +135,10 @@ async function startStream(sender: WebContents, id: string, req: HttpProxyReques if (e.name === 'AbortError') send({ id, type: 'end' }); else send({ id, type: 'error', message: e.message || String(err) }); } 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(); @@ -147,7 +152,11 @@ export function abortAllStreams(): void { export function registerHttpIpc(): void { ipcMain.handle(IPC.httpFetch, (_e, req: HttpProxyRequest) => proxyFetch(req)); - ipcMain.handle(IPC.httpStreamStart, (event, id: string, req: HttpProxyRequest) => startStream(event.sender, id, req)); + ipcMain.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) => { const controller = activeStreams.get(id); if (controller) { diff --git a/electron/ipc/store.ts b/electron/ipc/store.ts index a505696..4b01497 100644 --- a/electron/ipc/store.ts +++ b/electron/ipc/store.ts @@ -2,10 +2,16 @@ import { app, ipcMain } from 'electron'; import fs from 'node:fs'; import path from 'node:path'; import { IPC } from '../../shared/ipc'; +import { log } from '../logger'; type StoreData = Record; let cache: StoreData | null = null; +let dirty = false; +let timer: ReturnType | null = null; +let writing: Promise = Promise.resolve(); + +const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); function storePath(): string { return path.join(app.getPath('userData'), 'userdata.json'); @@ -13,19 +19,55 @@ function storePath(): string { function load(): StoreData { if (cache) return cache; + const target = storePath(); try { - cache = JSON.parse(fs.readFileSync(storePath(), 'utf8')) as StoreData; - } catch { + cache = JSON.parse(fs.readFileSync(target, 'utf8')) as StoreData; + } 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 = {}; } return cache; } -function persist(): void { - const target = storePath(); - const tmp = target + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(cache ?? {}, null, 2), 'utf8'); - fs.renameSync(tmp, target); +/** Debounced, asynchronous, atomic write with retries (Windows AV scanners lock files briefly). */ +function schedulePersist(): void { + dirty = true; + if (!timer) timer = setTimeout(() => void flushStore(), 250); +} + +export function flushStore(): Promise { + 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(key: string): T | null { @@ -36,19 +78,26 @@ export function storeGet(key: string): T | null { export function storeSet(key: string, value: unknown): void { const data = load(); 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 { - ipcMain.handle(IPC.storeGet, (_e, key: string) => storeGet(key)); - ipcMain.handle(IPC.storeSet, (_e, key: string, value: unknown) => { + ipcMain.handle(IPC.storeGet, (_e, key: unknown) => (validKey(key) ? storeGet(key) : null)); + 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); return true; }); - ipcMain.handle(IPC.storeDelete, (_e, key: string) => { + ipcMain.handle(IPC.storeDelete, (_e, key: unknown) => { + if (!validKey(key)) return false; const data = load(); delete data[key]; - persist(); + schedulePersist(); return true; }); } diff --git a/electron/logger.ts b/electron/logger.ts index 65d52cb..26d48fd 100644 --- a/electron/logger.ts +++ b/electron/logger.ts @@ -5,22 +5,46 @@ import type { LogEntry } from '../shared/ipc'; const MAX_LOG_BYTES = 5 * 1024 * 1024; +let stream: fs.WriteStream | null = null; +let written = 0; + export function getLogPath(): string { 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 { try { - const logPath = getLogPath(); const data = entry.data !== undefined ? ' | ' + safeJson(entry.data) : ''; const line = `[${entry.ts}] [${entry.level}] [${entry.tag}] ${entry.message}${data}\n`; - try { - const stat = fs.statSync(logPath); - if (stat.size > MAX_LOG_BYTES) fs.renameSync(logPath, logPath + '.old'); - } catch { - /* first start: file does not exist yet */ - } - fs.appendFileSync(logPath, line, 'utf8'); + written += Buffer.byteLength(line); + if (written > MAX_LOG_BYTES) rotate(); + output().write(line); } catch (err) { console.error('[logger] cannot write log file:', (err as Error).message); } diff --git a/electron/main.ts b/electron/main.ts index f518430..b1b5b09 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2,17 +2,19 @@ import { app, BrowserWindow, globalShortcut, ipcMain, Menu, nativeImage, session import path from 'node:path'; import { IPC, type AppInfo, type HotkeyEvent, type LogEntry, type WindowMode } from '../shared/ipc'; 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 { registerTelemetryIpc } from './ipc/telemetry'; 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 { registerVoiceIpc } from './voice/ipc'; import { stopEngine } from './voice/engine'; // Audio playback must never be blocked behind a user gesture (TTS starts on incoming events). 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; @@ -37,8 +39,9 @@ interface WebhookSettings { } function readWebhookSettings(): WebhookSettings { - const saved = storeGet('webhook'); - return { enabled: true, port: 7842, secret: '', ...(saved ?? {}) }; + const saved = storeGet('webhook') ?? {}; + 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 { @@ -47,7 +50,7 @@ async function bootWebhook(): Promise { await stopWebhookServer(); 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 { @@ -63,7 +66,8 @@ function registerShortcuts(): void { sendHotkey('ptt-toggle'); }], ['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) { try { @@ -96,7 +100,7 @@ function createTray(): void { }; rebuildMenu(); tray.on('click', () => toggleWindowVisibility()); - ipcMain.on(IPC.windowSetMode, () => setTimeout(rebuildMenu, 0)); + windowEvents.on('mode', rebuildMenu); } catch (err) { 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.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 => ({ version: app.getVersion(), platform: process.platform, @@ -143,7 +150,7 @@ function registerCoreIpc(): void { }); } -app.whenReady().then(async () => { +async function boot(): Promise { log('INFO', 'app', `EveFlow ${app.getVersion()} starting (electron ${process.versions.electron}, chrome ${process.versions.chrome})`); configureSession(); registerStoreIpc(); @@ -160,13 +167,19 @@ app.whenReady().then(async () => { app.on('activate', () => { 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(); abortAllStreams(); stopEngine(); void stopWebhookServer(); + event.preventDefault(); + flushStore().finally(() => app.exit(0)); }); app.on('window-all-closed', () => { diff --git a/electron/preload.ts b/electron/preload.ts index 053027a..329ad1c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -26,7 +26,8 @@ const api: EveFlowBridge = { window: { control: (action: 'minimize' | 'maximize' | 'close' | 'hide') => ipcRenderer.send(IPC.windowControl, action), setMode: (mode: WindowMode) => ipcRenderer.send(IPC.windowSetMode, mode), - onModeChanged: (cb: (mode: WindowMode) => void) => subscribe(IPC.windowModeChanged, cb) + onModeChanged: (cb: (mode: WindowMode) => void) => subscribe(IPC.windowModeChanged, cb), + onVisibility: (cb: (visible: boolean) => void) => subscribe(IPC.windowVisibility, cb) }, log: (entry: LogEntry) => ipcRenderer.send(IPC.log, entry), store: { diff --git a/electron/voice/catalog.ts b/electron/voice/catalog.ts index 6a57a90..2e8045c 100644 --- a/electron/voice/catalog.ts +++ b/electron/voice/catalog.ts @@ -22,7 +22,7 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [ kind: 'stt', engine: 'whisper', 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'], sizeMb: 208, url: `${ASR}/sherpa-onnx-whisper-base.tar.bz2`, @@ -35,12 +35,13 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [ kind: 'stt', engine: 'whisper', 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'], sizeMb: 640, url: `${ASR}/sherpa-onnx-whisper-small.tar.bz2`, 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', diff --git a/electron/voice/engine.ts b/electron/voice/engine.ts index c17f962..a1e398b 100644 --- a/electron/voice/engine.ts +++ b/electron/voice/engine.ts @@ -19,13 +19,22 @@ let child: UtilityProcess | null = null; let nextId = 1; const pending = new Map(); +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 { if (child) return child; const script = path.join(__dirname, 'voice-worker.js'); - child = utilityProcess.fork(script, [], { serviceName: 'eveflow-voice', stdio: 'pipe' }); - child.stdout?.on('data', (d: Buffer) => log('DEBUG', 'voice-worker', d.toString().trim())); - child.stderr?.on('data', (d: Buffer) => log('WARN', 'voice-worker', d.toString().trim())); - child.on('message', (msg: { id: number; ok: boolean; result?: unknown; error?: string }) => { + const proc = utilityProcess.fork(script, [], { serviceName: 'eveflow-voice', stdio: 'pipe' }); + child = proc; + proc.stdout?.on('data', (d: Buffer) => log('DEBUG', 'voice-worker', d.toString().trim())); + 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); if (!p) return; pending.delete(msg.id); @@ -33,17 +42,15 @@ function spawn(): UtilityProcess { if (msg.ok) p.resolve(msg.result); 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}`); + // A newer worker may already have replaced this one; only clean up if we are still current. + if (child !== proc) return; child = null; - for (const [id, p] of pending) { - clearTimeout(p.timer); - p.reject(new Error('Le moteur vocal local s’est arrêté de façon inattendue.')); - pending.delete(id); - } + rejectAll('Le moteur vocal local s’est arrêté de façon inattendue.'); }); log('INFO', 'voice', 'voice worker started'); - return child; + return proc; } function request(message: Record, timeoutMs = 120_000): Promise { @@ -53,9 +60,20 @@ function request(message: Record, timeoutMs = 120_000): Prom const timer = setTimeout(() => { pending.delete(id); 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); 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 { } export function transcribe(req: TranscribeRequest): Promise { - return request({ 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({ type: 'transcribe', model: modelRef(req.modelId), wav, language: req.language }, 180_000); } -export function synthesize(req: SynthesizeRequest): Promise { - return request({ type: 'synthesize', model: modelRef(req.modelId), text: req.text, speaker: req.speaker, speed: req.speed }, 180_000); +export async function synthesize(req: SynthesizeRequest): Promise { + const result = await request & { 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 { - if (!child) return Promise.resolve({ ok: true }); - return request({ type: 'unload', modelId }, 20_000); +/** Native model memory is only released deterministically by restarting the worker. */ +export function unload(_modelId?: string): Promise { + stopEngine(); + return Promise.resolve({ ok: true }); } export function stopEngine(): void { - if (child) { - child.kill(); - child = null; + const proc = child; + child = null; + if (proc) { + rejectAll('Moteur vocal redémarré.'); + proc.kill(); } } diff --git a/electron/voice/ipc.ts b/electron/voice/ipc.ts index 233458b..227864e 100644 --- a/electron/voice/ipc.ts +++ b/electron/voice/ipc.ts @@ -16,7 +16,15 @@ export function registerVoiceIpc(): void { await removeModel(id); return listModels(); }); - ipcMain.handle(VOICE_IPC.transcribe, (_e, req: TranscribeRequest) => transcribe(req)); - ipcMain.handle(VOICE_IPC.synthesize, (_e, req: SynthesizeRequest) => synthesize(req)); + ipcMain.handle(VOICE_IPC.transcribe, (_e, req: TranscribeRequest) => { + 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)); } diff --git a/electron/voice/models.ts b/electron/voice/models.ts index b0fe274..86a8cfa 100644 --- a/electron/voice/models.ts +++ b/electron/voice/models.ts @@ -11,9 +11,13 @@ import * as tar from 'tar'; import unbzip2 from 'unbzip2-stream'; import { VOICE_IPC, type VoiceDownloadProgress, type VoiceModelSpec, type VoiceModelStatus } from '../../shared/voice'; import { VOICE_CATALOG, findModel } from './catalog'; +import { stopEngine } from './engine'; import { log } from '../logger'; const active = new Map(); +/** 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 { const dir = path.join(app.getPath('userData'), 'models'); @@ -59,7 +63,8 @@ export function listModels(): VoiceModelStatus[] { export async function removeModel(id: string): Promise { const spec = findModel(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}`); } @@ -78,8 +83,14 @@ export async function downloadModel(id: string, sender: WebContents | null): Pro }; 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 }); + // Abort the transfer when no byte arrives for a while (fetch itself has no inactivity timeout). + let idleTimer: ReturnType | 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 { 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); let received = 0; let lastReport = 0; + touch(); const counter = new Transform({ transform(chunk: Buffer, _enc, cb) { received += chunk.length; + touch(); const now = Date.now(); if (now - lastReport > 250) { 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(', ')}`); const finalDir = modelDir(spec); - await fs.promises.rm(finalDir, { recursive: true, force: true }); - await fs.promises.rename(extracted, finalDir); - await fs.promises.rm(tmpDir, { recursive: true, force: true }); + if (isInstalled(spec)) stopEngine(); + await fs.promises.rm(finalDir, RM); + 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 }); log('INFO', 'voice', `model installed: ${spec.id}`); } catch (err) { - await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); - const e = err as Error; - const cancelled = e.name === 'AbortError' || controller.signal.aborted; + await fs.promises.rm(tmpDir, RM).catch(() => undefined); + const e = (controller.signal.reason instanceof Error ? controller.signal.reason : err) as Error; + 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 }); log(cancelled ? 'INFO' : 'ERROR', 'voice', `download ${spec.id} ${cancelled ? 'cancelled' : 'failed: ' + e.message}`); if (!cancelled) throw new Error(e.message); } finally { + if (idleTimer) clearTimeout(idleTimer); active.delete(id); } return listModels().find((m) => m.id === id)!; diff --git a/electron/voice/worker.ts b/electron/voice/worker.ts index 9930b8a..d9d1015 100644 --- a/electron/voice/worker.ts +++ b/electron/voice/worker.ts @@ -16,7 +16,7 @@ interface ModelRef { type Request = | { 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: 'unload'; modelId?: string }; @@ -32,7 +32,7 @@ type Sherpa = { OfflineTts: new (config: unknown) => { numSpeakers: 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; }; @@ -136,16 +136,20 @@ function getSynthesizer(model: ModelRef) { // ── audio helpers ────────────────────────────────────────────────────────── function decodeWav(bytes: Uint8Array): { samples: Float32Array; sampleRate: number } { 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 sampleRate = 16000; let channels = 1; let bits = 16; + let format = 1; // 1 = PCM, 3 = IEEE float let data: { start: number; length: number } | null = null; while (offset + 8 <= bytes.byteLength) { const id = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); const size = view.getUint32(offset + 4, true); if (id === 'fmt ') { + format = view.getUint16(offset + 8, true); channels = view.getUint16(offset + 10, true); sampleRate = view.getUint32(offset + 12, true); bits = view.getUint16(offset + 22, true); @@ -163,7 +167,16 @@ function decodeWav(bytes: Uint8Array): { samples: Float32Array; sampleRate: numb let sum = 0; for (let c = 0; c < channels; c++) { 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; } @@ -225,8 +238,11 @@ function handle(req: Request): unknown { } case 'transcribe': { 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); + if (pcm.length < 1600) throw new Error('Audio trop court'); const recognizer = getRecognizer(req.model, req.language); const stream = recognizer.createStream(); stream.acceptWaveform({ sampleRate: 16000, samples: pcm }); @@ -238,8 +254,15 @@ function handle(req: Request): unknown { const started = Date.now(); const tts = getSynthesizer(req.model); 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)) }); - return { wav: encodeWav(audio.samples, audio.sampleRate), sampleRate: audio.sampleRate, durationMs: Date.now() - started, audioSec: audio.samples.length / audio.sampleRate }; + // Electron forbids N-API external buffers: ask sherpa-onnx to copy the samples into a V8 buffer. + 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': { if (req.modelId) { diff --git a/electron/webhook.ts b/electron/webhook.ts index d82be99..e498773 100644 --- a/electron/webhook.ts +++ b/electron/webhook.ts @@ -35,16 +35,20 @@ export function getWebhookStatus(): WebhookStatus { export function stopWebhookServer(): Promise { return new Promise((resolve) => { if (!server) return resolve(); - server.close(() => resolve()); + const current = server; server = null; status = { ...status, listening: false }; + current.closeAllConnections(); + current.close(() => resolve()); }); } export async function startWebhookServer(getWindow: () => BrowserWindow | null, options: WebhookOptions): Promise { await stopWebhookServer(); 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) => { 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; req.on('data', (chunk: Buffer) => { if (tooLarge) return; - body += chunk.toString('utf8'); - if (body.length > MAX_BODY_BYTES) { + bytes += chunk.length; + if (bytes > MAX_BODY_BYTES) { tooLarge = true; - json(413, { error: 'Payload too large' }); - req.destroy(); + res.writeHead(413, { 'Content-Type': 'application/json', Connection: 'close' }); + res.end(JSON.stringify({ error: 'Payload too large' }), () => req.socket.destroy()); + return; } + chunks.push(chunk); }); req.on('end', () => { 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 { const raw: unknown = body.trim() ? JSON.parse(body) : {}; const events = normalizeHermesPush(raw); @@ -99,13 +108,18 @@ export async function startWebhookServer(getWindow: () => BrowserWindow | null, }); 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}`); status = { listening: false, port, path: WEBHOOK_PATH, secretConfigured: !!secret, error: err.message }; - server = null; + if (server === instance) server = null; 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}`); status = { listening: true, port, path: WEBHOOK_PATH, secretConfigured: !!secret }; resolve(status); diff --git a/electron/window.ts b/electron/window.ts index 032c57d..dc8fe20 100644 --- a/electron/window.ts +++ b/electron/window.ts @@ -1,4 +1,5 @@ import { app, BrowserWindow, screen, shell } from 'electron'; +import { EventEmitter } from 'node:events'; import fs from 'node:fs'; import path from 'node:path'; 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 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; export function getMainWindow(): BrowserWindow | null { @@ -49,8 +53,9 @@ export function createMainWindow(): BrowserWindow { const win = new BrowserWindow({ ...HUD_SIZE, frame: false, - transparent: true, - backgroundColor: '#00000000', + transparent: TRANSPARENT, + backgroundColor: TRANSPARENT ? '#00000000' : '#03070d', + roundedCorners: true, hasShadow: true, show: false, title: 'EveFlow', @@ -78,11 +83,18 @@ export function createMainWindow(): BrowserWindow { return { action: 'deny' }; }); 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')) { - event.preventDefault(); - void shell.openExternal(url); - } + if (url === win.webContents.getURL()) return; + event.preventDefault(); + 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', () => { mainWindow = null; @@ -129,6 +141,7 @@ export function setWindowMode(mode: WindowMode): void { } currentMode = mode; win.webContents.send(IPC.windowModeChanged, mode); + windowEvents.emit('mode', mode); } export function toggleWindowVisibility(): void { diff --git a/index.html b/index.html index dff0392..1fc080d 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + EveFlow diff --git a/package-lock.json b/package-lock.json index 6fcdabc..f91b7b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,17 @@ { "name": "eveflow", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eveflow", - "version": "2.0.0", + "version": "2.1.0", "license": "MIT", "dependencies": { + "@fontsource/orbitron": "^5.3.0", + "@fontsource/rajdhani": "^5.3.0", + "@fontsource/share-tech-mono": "^5.3.0", "lucide-react": "^1.40.0", "react": "^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": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", diff --git a/package.json b/package.json index 493d7b9..9883b07 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "eveflow", "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", "main": "dist-electron/main.js", "private": true, @@ -61,9 +61,13 @@ "node_modules/sherpa-onnx-linux-x64/**", "node_modules/sherpa-onnx-darwin-arm64/**", "node_modules/sherpa-onnx-darwin-x64/**" - ] + ], + "npmRebuild": false }, "dependencies": { + "@fontsource/orbitron": "^5.3.0", + "@fontsource/rajdhani": "^5.3.0", + "@fontsource/share-tech-mono": "^5.3.0", "lucide-react": "^1.40.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/scripts/dist.mjs b/scripts/dist.mjs index a32c923..fa84dbf 100644 --- a/scripts/dist.mjs +++ b/scripts/dist.mjs @@ -1,13 +1,18 @@ // 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. 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 release = pkg.releaseVersion || pkg.version; const build = release.split('.')[3] ?? '0'; const args = process.argv.slice(2); 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}`], { stdio: 'inherit', shell: process.platform === 'win32', diff --git a/shared/bridge.ts b/shared/bridge.ts index c06551c..f9e41f9 100644 --- a/shared/bridge.ts +++ b/shared/bridge.ts @@ -29,6 +29,8 @@ export interface EveFlowBridge { control: (action: 'minimize' | 'maximize' | 'close' | 'hide') => void; setMode: (mode: WindowMode) => void; 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; store: { diff --git a/shared/ipc.ts b/shared/ipc.ts index f092bfc..f498ee5 100644 --- a/shared/ipc.ts +++ b/shared/ipc.ts @@ -100,6 +100,7 @@ export const IPC = { windowControl: 'window:control', windowSetMode: 'window:set-mode', windowModeChanged: 'window:mode-changed', + windowVisibility: 'window:visibility', log: 'log:write', storeGet: 'store:get', storeSet: 'store:set', diff --git a/src/App.tsx b/src/App.tsx index 92b3b02..723b7cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { AlertTriangle, X } from 'lucide-react'; import type { WindowMode } from '../shared/ipc'; import { bridge } from './lib/bridge'; @@ -99,15 +99,23 @@ function useWindowMode(): WindowMode { 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 setError = useChat((s) => s.setError); + useEffect(() => { + if (!error) return; + const timer = setTimeout(() => setError(null), 9000); + return () => clearTimeout(timer); + }, [error, setError]); if (!error) return null; return ( -
- - {error} - +
+
+ + {error} + +
); } @@ -118,15 +126,25 @@ export default function App() { const mode = useWindowMode(); const showTelemetry = useSettings((s) => s.settings.ui.showTelemetry); 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(() => { 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.toLowerCase() === 'o' && (e.ctrlKey || e.metaKey) && e.shiftKey) setShowOps((v) => !v); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [showSettings]); + }, []); if (!loaded) { return ( @@ -147,22 +165,22 @@ export default function App() { return (
- setShowSettings(true)} /> + setShowSettings(true)} onToggleOps={() => setShowOps((v) => !v)} opsBadge={opsBadge} />
-
-
+
{showTelemetry && }
+ {showSettings && setShowSettings(false)} />}
diff --git a/src/components/chat/ChatPanel.tsx b/src/components/chat/ChatPanel.tsx index 91080ae..2e1c573 100644 --- a/src/components/chat/ChatPanel.tsx +++ b/src/components/chat/ChatPanel.tsx @@ -12,18 +12,36 @@ interface Props { export function ChatPanel({ compact }: Props) { const messages = useChat((s) => s.messages); 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(null); + const programmatic = useRef(false); const [zoom, setZoom] = useState(null); const [stick, setStick] = useState(true); useEffect(() => { if (!stick) return; const el = listRef.current; - if (el) el.scrollTop = el.scrollHeight; + if (!el) return; + programmatic.current = true; + el.scrollTop = el.scrollHeight; }, [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 = () => { + if (programmatic.current) { + programmatic.current = false; + return; + } const el = listRef.current; if (!el) return; setStick(el.scrollHeight - el.scrollTop - el.clientHeight < 80); @@ -47,7 +65,7 @@ export function ChatPanel({ compact }: Props) {
{visible.length === 0 && (
- {compact ? 'En attente…' : `Canal ouvert. Parlez ou écrivez à ${settings.assistantName}.`} + {compact ? 'En attente…' : `Canal ouvert. Parlez ou écrivez à ${assistantName}.`} {!compact && (
Ctrl + Shift + Espace active le micro depuis n’importe où. @@ -59,9 +77,9 @@ export function ChatPanel({ compact }: Props) { diff --git a/src/components/chat/CommandBar.tsx b/src/components/chat/CommandBar.tsx index f37b976..b8f3879 100644 --- a/src/components/chat/CommandBar.tsx +++ b/src/components/chat/CommandBar.tsx @@ -67,17 +67,34 @@ export function CommandBar({ compact }: Props) { const submit = async () => { const text = draft.trim(); - if (steerMode && currentRunId) { + if (steerMode) { + if (!currentRunId) { + setSteerMode(false); + return; + } if (await steer(text)) setDraft(''); + inputRef.current?.focus(); return; } if (!text && images.length === 0) return; const attached = images; setDraft(''); setImages([]); + inputRef.current?.focus(); 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) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -131,6 +148,8 @@ export function CommandBar({ compact }: Props) { className={`mic-btn ${micClass}`} style={{ ['--level' as string]: level }} title={micTitle} + aria-label={micTitle} + aria-pressed={listening} onClick={() => voiceController.toggle()} disabled={phase === 'transcribing'} > @@ -154,11 +173,11 @@ export function CommandBar({ compact }: Props) { )} void onFiles(e.target.files)} /> {isSending && !steerMode ? ( - ) : ( - )} diff --git a/src/components/chat/MessageBubble.tsx b/src/components/chat/MessageBubble.tsx index 38b9454..296b382 100644 --- a/src/components/chat/MessageBubble.tsx +++ b/src/components/chat/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { memo, useState } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; 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) { const [copied, setCopied] = useState(false); + const copyTimer = useRef(null); + useEffect(() => () => { + if (copyTimer.current) window.clearTimeout(copyTimer.current); + }, []); const isUser = message.role === 'user'; const who = isUser ? userName || 'VOUS' : message.role === 'system' ? 'SYSTÈME' : assistantName; const status = message.status ?? 'done'; @@ -35,7 +39,8 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam const copy = () => { navigator.clipboard.writeText(message.content).then(() => { setCopied(true); - setTimeout(() => setCopied(false), 1200); + if (copyTimer.current) window.clearTimeout(copyTimer.current); + copyTimer.current = window.setTimeout(() => setCopied(false), 1200); }).catch(() => undefined); }; @@ -50,8 +55,8 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam {message.usage?.total_tokens ? {message.usage.total_tokens} tok : null} {message.role === 'assistant' && status === 'done' && ( - - + + )}
diff --git a/src/components/chat/PendingRequests.tsx b/src/components/chat/PendingRequests.tsx index d4f7b58..4cfd41e 100644 --- a/src/components/chat/PendingRequests.tsx +++ b/src/components/chat/PendingRequests.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { ShieldAlert, HelpCircle, KeyRound } from 'lucide-react'; import { resolvePending } from '../../services/conversation'; import { useChat, type PendingRequest } from '../../state/chat'; @@ -7,10 +7,22 @@ function RequestCard({ request }: { request: PendingRequest }) { const [answer, setAnswer] = useState(''); const icon = request.kind === 'approval' ? : request.kind === 'clarify' ? : ; + 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 ( -
+
-
+
{icon} {request.title} {request.tool && {request.tool}} @@ -20,7 +32,7 @@ function RequestCard({ request }: { request: PendingRequest }) { {request.kind === 'approval' ? (
- +
diff --git a/src/components/chat/ResilientImage.tsx b/src/components/chat/ResilientImage.tsx index d6c7a39..51d3f65 100644 --- a/src/components/chat/ResilientImage.tsx +++ b/src/components/chat/ResilientImage.tsx @@ -40,6 +40,10 @@ export function ResilientImage({ src = '', alt, onOpen }: Props) { setResolved(cache.get(src)!); return; } + if (failures.has(src)) { + setError(failures.get(src)!); + return; + } if (src.startsWith('data:') || src.startsWith('blob:') || /^https?:\/\//i.test(src)) { ok(src); return; diff --git a/src/components/compact/CompactWidget.tsx b/src/components/compact/CompactWidget.tsx index a26a3c4..75e242b 100644 --- a/src/components/compact/CompactWidget.tsx +++ b/src/components/compact/CompactWidget.tsx @@ -5,12 +5,13 @@ import { CoreStage } from '../hud/CoreStage'; import { ChatPanel } from '../chat/ChatPanel'; 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(); return ( -
+
- {settings.assistantName} + {assistantName} {api && ( <> diff --git a/src/components/hud/CoreStage.tsx b/src/components/hud/CoreStage.tsx index 89face3..9b7b550 100644 --- a/src/components/hud/CoreStage.tsx +++ b/src/components/hud/CoreStage.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { useChat } from '../../state/chat'; import { useHermes } from '../../state/hermes'; import { useSettings } from '../../state/settings'; @@ -21,21 +22,38 @@ interface Props { export function CoreStage({ compact }: Props) { 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 error = useChat((s) => s.error); + const pulse = useChat((s) => s.pingCount); + const latencyMs = useChat((s) => s.latencyMs); const phase = useVoice((s) => s.phase); const level = useVoice((s) => s.inputLevel); const transcript = useVoice((s) => s.lastTranscript); + const interim = useVoice((s) => s.interim); 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 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 = ''; 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 === 'speaking') sub = 'synthèse vocale'; else if (transcript) sub = `« ${previewText(transcript, 80)} »`; @@ -45,16 +63,18 @@ export function CoreStage({ compact }: Props) {
{!compact && (
- {settings.assistantName} + {assistantName} EVEFLOW · HERMES INTERFACE
)}
- +
-
+
+ {!compact && (phase === 'speech' || phase === 'transcribing') &&
{interim || (phase === 'transcribing' ? '…' : '')}
}
{STATE_LABEL[hud] ?? hud}
{sub &&
{sub}
} + {!compact && latencyMs !== null &&
premier mot · {latencyMs} ms
}
); diff --git a/src/components/hud/JarvisCore.tsx b/src/components/hud/JarvisCore.tsx index 7d094f2..1616253 100644 --- a/src/components/hud/JarvisCore.tsx +++ b/src/components/hud/JarvisCore.tsx @@ -2,8 +2,13 @@ * JARVIS-style arc reactor core rendered on a 2D canvas. * Rotating tick rings, segmented arcs and an audio-reactive radial spectrum that reads the * 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 { bridge } from '../../lib/bridge'; import { audioBus } from '../../services/voice/audioBus'; import type { HudState } from '../../state/chat'; @@ -12,12 +17,15 @@ interface Props { /** 0..1 override for the microphone level (VAD), used while listening. */ inputLevel?: number; reduceMotion?: boolean; + /** Bumped by the parent to trigger a short "ping" flash (send, tool start, speech start). */ + pulse?: number; className?: string; } +type Rgb = [number, number, number]; interface Palette { - main: [number, number, number]; - glow: [number, number, number]; + main: Rgb; + glow: Rgb; } const PALETTES: Record = { @@ -31,23 +39,30 @@ const PALETTES: Record = { }; 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(); if (!raw) return null; 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(null); const stateRef = useRef(state); const inputRef = useRef(inputLevel); const motionRef = useRef(reduceMotion); + const pulseRef = useRef(0); stateRef.current = state; inputRef.current = inputLevel; motionRef.current = reduceMotion; + useEffect(() => { + if (pulse > 0) pulseRef.current = 1; + }, [pulse]); + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -59,13 +74,33 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN let height = 0; let dpr = 1; 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 spin = 0; let spin2 = 0; let spin3 = 0; let last = performance.now(); - let visible = true; + let visible = document.visibilityState === 'visible'; + let frame = 0; + let accent = readThemeAccent(); + + const colorCache = new Map(); + // 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 rect = canvas.getBoundingClientRect(); @@ -79,41 +114,70 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN observer.observe(canvas); 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 = () => { visible = document.visibilityState === 'visible'; + if (visible) start(); }; 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 rgba = (c: [number, number, number], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`; const draw = (now: number) => { + if (!visible) { + raf = 0; + return; + } 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); last = now; - const st = stateRef.current; const motion = motionRef.current ? 0.25 : 1; - // ── target colours & energy 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 glowTarget = themeAccent ?? target.glow; for (let i = 0; i < 3; i++) { 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 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 targetEnergy = st === 'thinking' ? 0.45 : st === 'alert' || st === 'error' ? 0.6 : level; - energy = lerp(energy, targetEnergy, speaking || listening ? 0.35 : 0.08); + pulseRef.current = Math.max(0, pulseRef.current - dt * 2.2); + 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++) { let t = 0; if (spectrum) { 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))); } else if (st === 'thinking') { 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 { 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); } - // ── rotation const speed = (st === 'thinking' ? 1.6 : st === 'alert' || st === 'error' ? 1.2 : speaking ? 0.7 : 0.35) * motion; spin += dt * 0.35 * speed; spin2 -= dt * 0.55 * speed; @@ -139,7 +203,7 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN const main = color.main; const glow = color.glow; - // ── ambient glow + // ambient glow 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.5, rgba(glow, 0.05)); @@ -147,53 +211,51 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN ctx.fillStyle = halo; ctx.fillRect(0, 0, width, height); - // ── outer tick ring + // outer tick ring: three batched paths (long / mid / short ticks) ctx.save(); ctx.translate(cx, cy); ctx.rotate(spin); - ctx.strokeStyle = rgba(main, 0.55); ctx.lineWidth = 1; - for (let i = 0; i < 120; i++) { - 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; + for (let group = 0; group < 3; group++) { ctx.beginPath(); - ctx.moveTo(Math.cos(a) * r0, Math.sin(a) * r0); - ctx.lineTo(Math.cos(a) * R, Math.sin(a) * R); + for (let i = 0; i < TICKS; i++) { + 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.globalAlpha = 1; ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.strokeStyle = rgba(main, 0.35); ctx.stroke(); ctx.restore(); - // ── segmented arc ring + // segmented arc ring ctx.save(); ctx.translate(cx, cy); ctx.rotate(spin2); - ctx.lineCap = 'butt'; - const segments = 6; - for (let i = 0; i < segments; i++) { - const start = (i / segments) * Math.PI * 2; - const span = (Math.PI * 2) / segments - 0.22; + for (let i = 0; i < SEGMENTS; i++) { + const startAngle = (i / SEGMENTS) * Math.PI * 2; + const span = (Math.PI * 2) / SEGMENTS - 0.22; 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.strokeStyle = rgba(main, 0.16 + (i % 2 === 0 ? 0.18 : 0.05) + energy * 0.2); ctx.stroke(); 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.strokeStyle = rgba(main, 0.75); ctx.stroke(); } ctx.restore(); - // ── dashed ring + // dashed ring ctx.save(); ctx.translate(cx, cy); ctx.rotate(spin3); @@ -206,36 +268,46 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN ctx.setLineDash([]); ctx.restore(); - // ── audio spectrum bars + // audio spectrum bars: one wide low-alpha glow pass, then one crisp pass per intensity bucket ctx.save(); ctx.translate(cx, cy); ctx.lineCap = 'round'; const inner = R * 0.5; 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++) { const a = (i / BARS) * Math.PI * 2 - Math.PI / 2; const len = 2 + bars[i] * maxLen; - const x0 = Math.cos(a) * inner; - const y0 = Math.sin(a) * inner; - const x1 = Math.cos(a) * (inner + len); - const y1 = Math.sin(a) * (inner + len); - ctx.lineWidth = 2.4; - ctx.strokeStyle = rgba(main, 0.35 + bars[i] * 0.65); - ctx.shadowBlur = 8 * bars[i]; - ctx.shadowColor = rgba(glow, 0.8); + ctx.moveTo(Math.cos(a) * inner, Math.sin(a) * inner); + ctx.lineTo(Math.cos(a) * (inner + len), Math.sin(a) * (inner + len)); + } + ctx.stroke(); + ctx.lineWidth = 2.4; + for (let bucket = 0; bucket < 4; bucket++) { ctx.beginPath(); - ctx.moveTo(x0, y0); - ctx.lineTo(x1, y1); + let any = false; + 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.shadowBlur = 0; ctx.restore(); - // ── reactor core + // reactor core const coreR = R * 0.36; const breath = 1 + Math.sin(now / 700) * 0.02 + energy * 0.08; 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.7, rgba(glow, 0.12)); 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.stroke(); } - // reactor triangle ctx.rotate(-spin2 * 0.5); ctx.beginPath(); for (let i = 0; i < 3; i++) { @@ -268,24 +339,28 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN ctx.stroke(); ctx.restore(); - // ── thinking orbiters + // thinking orbiters (radial gradient dots instead of shadow blur) if (st === 'thinking' || st === 'alert') { ctx.save(); ctx.translate(cx, cy); for (let i = 0; i < 3; i++) { const a = spin3 * 2 + (i / 3) * Math.PI * 2; 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.arc(Math.cos(a) * r, Math.sin(a) * r, 3.5, 0, Math.PI * 2); - ctx.fillStyle = rgba(main, 0.95); - ctx.shadowBlur = 14; - ctx.shadowColor = rgba(glow, 1); + ctx.arc(x, y, 11, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } - // ── listening ring (input level) + // listening ring (input level) if (listening) { ctx.save(); ctx.translate(cx, cy); @@ -298,11 +373,14 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN } }; - raf = requestAnimationFrame(draw); + start(); return () => { - cancelAnimationFrame(raf); + if (raf) cancelAnimationFrame(raf); + raf = 0; observer.disconnect(); + themeObserver.disconnect(); document.removeEventListener('visibilitychange', onVisibility); + offWindow?.(); }; }, []); diff --git a/src/components/hud/TopBar.tsx b/src/components/hud/TopBar.tsx index f7bd0a4..4865c4c 100644 --- a/src/components/hud/TopBar.tsx +++ b/src/components/hud/TopBar.tsx @@ -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 { useClock } from '../../hooks/useMetrics'; import { useHermes } from '../../state/hermes'; @@ -6,9 +6,11 @@ import { useSettings } from '../../state/settings'; interface Props { onOpenSettings: () => void; + onToggleOps?: () => void; + opsBadge?: number; } -export function TopBar({ onOpenSettings }: Props) { +export function TopBar({ onOpenSettings, onToggleOps, opsBadge = 0 }: Props) { const now = useClock(); const link = useHermes((s) => s.link); const linkDetail = useHermes((s) => s.linkDetail); @@ -55,9 +57,15 @@ export function TopBar({ onOpenSettings }: Props) {
- {now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} + {now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })}
- + {onToggleOps && ( + + )} + {api && ( <> diff --git a/src/components/panels/OpsPanel.tsx b/src/components/panels/OpsPanel.tsx index 333da5a..642ac18 100644 --- a/src/components/panels/OpsPanel.tsx +++ b/src/components/panels/OpsPanel.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; import { Wrench, CalendarClock, Sparkles, Layers, History, Play, Pause, Trash2, Pencil, Plus, Save, X, RefreshCw, CheckCircle2, XCircle, Loader2, Bot, Cpu, GitBranch, Webhook, FolderOpen @@ -142,7 +143,10 @@ function HistoryList() {
{ if (e.key === 'Enter') (e.currentTarget as HTMLDivElement).click(); }} onClick={() => { markRead(r.id); 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 sessionId = useSettings((s) => s.settings.hermesSessionId); const setSessionId = useSettings((s) => s.setHermesSessionId); - const chat = useChat(); + const chat = useChat.getState(); const activeId = sessionId.replace(/^hs:/, ''); const load = async (id: string) => { @@ -255,7 +259,7 @@ function SessionsTab() { {sessions.length === 0 ?
Aucune session listée par Hermes.
: (
{sessions.map((s) => ( -
void load(s.id)}> +
void load(s.id)} onKeyDown={(e) => { if (e.key === 'Enter') void load(s.id); }}>
{s.title || s.id} {[s.platform ?? s.source, s.message_count ? `${s.message_count} msg` : null, formatDateTime(s.updated_at ?? s.created_at)].filter(Boolean).join(' · ')} @@ -273,7 +277,9 @@ function SessionsTab() { } 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 hermes = useSettings((s) => s.settings.hermes); const [info, setInfo] = useState<{ sharedDir: string; logPath: string; version: string } | null>(null); @@ -311,7 +317,7 @@ function LinkTab() {
Santé
{Object.entries(checks).map(([k, v]) => ( - <>
{k}
{typeof v === 'object' ? JSON.stringify(v) : String(v)}
+
{k}
{typeof v === 'object' ? JSON.stringify(v) : String(v)}
))}
diff --git a/src/components/settings/ModelsSection.tsx b/src/components/settings/ModelsSection.tsx index 3212dd2..76e3617 100644 --- a/src/components/settings/ModelsSection.tsx +++ b/src/components/settings/ModelsSection.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { Download, Trash2, X, CheckCircle2, Cpu, Mic, Volume2, AlertTriangle, RefreshCw } from 'lucide-react'; import type { VoiceModelStatus } from '../../../shared/voice'; import { bridge } from '../../lib/bridge'; +import { useShallow } from 'zustand/react/shallow'; import { useVoiceModels } from '../../state/voiceModels'; import { useSettings } from '../../state/settings'; @@ -11,10 +12,11 @@ function formatMb(bytes: number): string { function ModelRow({ model }: { model: VoiceModelStatus }) { const progress = useVoiceModels((s) => s.progress[model.id]); - const { download, cancel, remove } = useVoiceModels(); - const settings = useSettings((s) => s.settings); + const { download, cancel, remove } = useVoiceModels.getState(); + const activeStt = useSettings((s) => s.settings.voice.localModel); + const activeTts = useSettings((s) => s.settings.speech.localModel); 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 activate = () => { @@ -59,7 +61,8 @@ function ModelRow({ model }: { model: VoiceModelStatus }) { } 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(() => { void refresh(); void checkEngine(); diff --git a/src/components/settings/SettingsDrawer.tsx b/src/components/settings/SettingsDrawer.tsx index 2933ebf..e382133 100644 --- a/src/components/settings/SettingsDrawer.tsx +++ b/src/components/settings/SettingsDrawer.tsx @@ -21,23 +21,35 @@ interface Props { function Toggle({ on, onChange, label, hint }: { on: boolean; onChange: (v: boolean) => void; label: string; hint?: string }) { return ( -
onChange(!on)}> +
+ ); } type TestState = { status: 'idle' | 'running' | 'ok' | 'fail'; message: string }; +function TestResult({ t }: { t: TestState }) { + if (t.status === 'idle') return null; + return ( +
+ {t.status === 'running' ? : t.status === 'ok' ? : } + {t.message} +
+ ); +} + export function SettingsDrawer({ onClose }: Props) { const settings = useSettings((s) => s.settings); const update = useSettings((s) => s.update); 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 voiceModels = useVoiceModels((s) => s.models); const refreshModels = useVoiceModels((s) => s.refresh); @@ -74,7 +86,7 @@ export function SettingsDrawer({ onClose }: Props) { } const via = bridge() ? 'via Electron' : 'via navigateur'; setHermesTest({ status: 'ok', message: `${health.status} (${via})${caps}` }); - void hermesStore.connect(); + void hermesConnect(); } catch (err) { setHermesTest({ status: 'fail', message: (err as Error).message }); } @@ -106,14 +118,6 @@ export function SettingsDrawer({ onClose }: Props) { useHermes.setState({ webhook: status }); }; - const Result = ({ t }: { t: TestState }) => - t.status === 'idle' ? null : ( -
- {t.status === 'running' ? : t.status === 'ok' ? : } - {t.message} -
- ); - const nav: Array<[Section, string]> = [ ['hermes', 'Hermes'], ['voice', 'Micro / STT'], @@ -155,7 +159,7 @@ export function SettingsDrawer({ onClose }: Props) { update({ hermes: { model: e.target.value } })} /> - {hermesStore.models.map((m) =>
@@ -191,7 +195,7 @@ export function SettingsDrawer({ onClose }: Props) {
-
+
)} @@ -284,7 +288,7 @@ export function SettingsDrawer({ onClose }: Props) {
-
+
)} @@ -381,7 +385,7 @@ export function SettingsDrawer({ onClose }: Props) {
-
+
)} @@ -412,7 +416,7 @@ export function SettingsDrawer({ onClose }: Props) {
- {hermesStore.webhook && {hermesStore.webhook.listening ? `port ${hermesStore.webhook.port}` : hermesStore.webhook.error ?? 'inactif'}} + {hermesWebhook && {hermesWebhook.listening ? `port ${hermesWebhook.port}` : hermesWebhook.error ?? 'inactif'}}
)} @@ -434,13 +438,13 @@ export function SettingsDrawer({ onClose }: Props) {
{(['arc', 'gold', 'crimson', 'emerald'] as HudTheme[]).map((t) => { const colors: Record = { arc: '#34e4ff', gold: '#ffc35a', crimson: '#ff5c6c', emerald: '#4ef0a8' }; - return
- Ctrl+Shift+Espace micro · Ctrl+Shift+J afficher/masquer · Ctrl+Shift+Échap couper la voix + Ctrl+Shift+Espace micro · Ctrl+Shift+J afficher/masquer · Ctrl+Alt+Échap couper la voix · Ctrl+K saisie
diff --git a/src/hooks/useMetrics.ts b/src/hooks/useMetrics.ts index 6a7784e..a04a583 100644 --- a/src/hooks/useMetrics.ts +++ b/src/hooks/useMetrics.ts @@ -62,7 +62,7 @@ export function useMetrics(enabled = true, intervalMs = 2000): LiveMetrics { export function useClock(): Date { const [now, setNow] = useState(() => new Date()); useEffect(() => { - const timer = setInterval(() => setNow(new Date()), 1000); + const timer = setInterval(() => setNow(new Date()), 15_000); return () => clearInterval(timer); }, []); return now; diff --git a/src/lib/sse.ts b/src/lib/sse.ts index 48b76dd..0b08f55 100644 --- a/src/lib/sse.ts +++ b/src/lib/sse.ts @@ -20,6 +20,8 @@ export class SseParser { this.buffer += chunk; let index: number; 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 sepLength = this.buffer.startsWith('\r\n', index) ? 2 : 1; this.buffer = this.buffer.slice(index + sepLength); diff --git a/src/lib/text.ts b/src/lib/text.ts index 45a5a13..ec2f3a6 100644 --- a/src/lib/text.ts +++ b/src/lib/text.ts @@ -65,12 +65,14 @@ export function cleanForSpeech(raw: string): string { 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 { let out = text; - for (const [key, value] of Object.entries(PHONETIC)) { - const escaped = key.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); - out = out.replace(new RegExp(`(?<=^|\\s|\\p{P})${escaped}(?=$|\\s|\\p{P})`, 'giu'), value); - } + for (const [regex, value] of PHONETIC_RULES) out = out.replace(regex, value); return out; } diff --git a/src/lib/transport.ts b/src/lib/transport.ts index ed539c8..ec7b73c 100644 --- a/src/lib/transport.ts +++ b/src/lib/transport.ts @@ -130,8 +130,3 @@ export async function httpStream(req: HttpProxyRequest, handlers: StreamHandlers 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 { - await handle.done.catch(() => undefined); - return first.join(''); -} diff --git a/src/main.tsx b/src/main.tsx index 3aba9bf..c3649cf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; 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/app.css'; import './styles/chat.css'; diff --git a/src/services/conversation.ts b/src/services/conversation.ts index 58ed827..439d136 100644 --- a/src/services/conversation.ts +++ b/src/services/conversation.ts @@ -1,7 +1,7 @@ /** * 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 - * surfaces approvals / clarifications as pending requests. + * streams the answer into the chat store (coalesced per animation frame), drives the HUD + * state, speaks the reply and surfaces approvals / clarifications as pending requests. */ import type { HermesPushEvent } from '../../shared/ipc'; import { Log } from '../lib/log'; @@ -18,6 +18,34 @@ let activeMessageId: string | null = null; 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() { const chat = useChat.getState(); 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(); switch (event.kind) { case 'run.started': @@ -57,15 +85,20 @@ function handleEvent(event: HermesStreamEvent, messageId: string, runIdRef: { id chat.setSending(true, event.runId); break; 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); break; case 'reasoning': - chat.appendToMessage(messageId, event.text, 'reasoning'); + queueDelta(messageId, event.text, 'reasoning'); break; case 'tool.start': chat.pushActivity({ id: event.id, kind: 'tool', name: event.name, status: 'running', detail: event.args ? previewText(event.args, 160) : undefined }); chat.setHud('thinking'); + pingCore(); break; case 'tool.progress': 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; case 'completed': if (event.usage) chat.updateMessage(messageId, { usage: event.usage }); + if (event.status === 'length') chat.updateMessage(messageId, { status: 'cancelled' }); break; case 'error': chat.setError(event.message); @@ -127,7 +161,8 @@ function addPending(request: Omit): void { const chat = useChat.getState(); chat.addPending(request); 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 { @@ -139,6 +174,7 @@ export async function sendMessage(text: string, images: string[] = [], source = speech.stop(); chat.setError(null); + chat.setLatency(null); chat.addMessage({ role: 'user', content: trimmed || '(image)', images: images.length ? images : undefined, source, status: 'done' }); const history = chat.messages .filter((m) => m.role !== 'system' && m.status !== 'error') @@ -148,7 +184,9 @@ export async function sendMessage(text: string, images: string[] = [], source = activeMessageId = messageId; chat.setSending(true); chat.setHud('thinking'); + pingCore(); const startedAt = Date.now(); + const timing = { firstToken: null as number | null, startedAt }; const runIdRef = { id: null as string | null }; const client = hermes.client(); @@ -158,7 +196,7 @@ export async function sendMessage(text: string, images: string[] = [], source = images, history, sessionId: settings.hermesSessionId, - onEvent: (event) => handleEvent(event, messageId, runIdRef), + onEvent: (event) => handleEvent(event, messageId, runIdRef, timing), localToolDefinitions: LOCAL_TOOL_DEFINITIONS, localToolExecutor: (name, args) => executeLocalTool(name, args, localToolContext()) }, @@ -169,20 +207,26 @@ export async function sendMessage(text: string, images: string[] = [], source = try { const reply = await handle.result; - const current = useChat.getState().messages.find((m) => m.id === messageId); - const finalText = reply || current?.content || ''; - useChat.getState().updateMessage(messageId, { content: finalText, status: 'done' }); - // Anything not streamed (non-delta transports) is spoken now. - if (settings.speech.autoSpeak) { - if (!current?.content && finalText) speech.say(finalText); + flushDelta(); + const state = useChat.getState(); + const current = state.messages.find((m) => m.id === messageId); + const streamedText = current?.content ?? ''; + const finalText = handle.aborted ? streamedText : reply || streamedText; + 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.discardStream(); } - useChat.getState().setHud(speech.isSpeaking() ? 'speaking' : 'idle'); - if (useHermes.getState().link === 'offline') useHermes.getState().connect().catch(() => undefined); - Log.info('hermes', `reply in ${Date.now() - startedAt} ms via ${handle.transport}`, { chars: finalText.length }); + state.setHud(speech.isSpeaking() ? 'speaking' : 'idle'); + if (!handle.aborted && useHermes.getState().link === 'offline') useHermes.getState().connect().catch(() => undefined); + Log.info('hermes', `${handle.aborted ? 'aborted' : 'reply'} in ${Date.now() - startedAt} ms via ${handle.transport}`, { chars: finalText.length, firstToken: timing.firstToken }); } catch (err) { + flushDelta(); const message = (err as Error).message || String(err); const partial = useChat.getState().messages.find((m) => m.id === messageId)?.content ?? ''; useChat.getState().updateMessage(messageId, { @@ -200,10 +244,14 @@ export async function sendMessage(text: string, images: string[] = [], source = } finally { active = null; activeMessageId = null; - useChat.getState().setSending(false); - for (const a of useChat.getState().activity) { - if (a.status === 'running') useChat.getState().finishActivity({ id: a.id }, { status: 'done' }); + const state = useChat.getState(); + state.setSending(false); + 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); const client = useHermes.getState().client(); try { + if (!request.runId) throw new Error('run inconnu'); if (request.kind === 'approval') { 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); chat.addMessage({ role: 'system', content: `Autorisation ${choice === 'deny' ? 'refusée' : 'accordée'}${request.tool ? ` (${toolLabel(request.tool)})` : ''}.`, status: 'done' }); } else { // 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); 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); } chat.setHud(event.status?.includes('fail') ? 'alert' : 'success'); + pingCore(); setTimeout(() => { const s = useChat.getState(); if (s.hud === 'success' || s.hud === 'alert') s.setHud(speech.isSpeaking() ? 'speaking' : 'idle'); diff --git a/src/services/hermes/client.ts b/src/services/hermes/client.ts index c626159..ccfcc4b 100644 --- a/src/services/hermes/client.ts +++ b/src/services/hermes/client.ts @@ -261,6 +261,7 @@ export class HermesClient { for (const event of normalizeLifecycleEvent(eventName, data)) onEvent(event); }); const errorChunks: string[] = []; + let handleReady = false; const handle = await httpStream( { url: `${this.base}${path}`, @@ -271,7 +272,6 @@ export class HermesClient { }, { onChunk: (text) => (handleReady ? parser.feed(text) : errorChunks.push(text)) } ); - let handleReady = handle.start.ok; if (!handle.start.ok) { await handle.done.catch(() => undefined); 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 { 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 = effective === 'runs' - ? this.sendViaRuns(options, (fn) => (abort = fn)) + ? this.sendViaRuns(options, setAbort, isAborted) : effective === 'sessions' - ? this.sendViaSessions(options, (fn) => (abort = fn)) - : this.sendViaCompletions(options, (fn) => (abort = fn)); - return { result, abort: () => abort(), transport: effective }; + ? this.sendViaSessions(options, setAbort, isAborted) + : this.sendViaCompletions(options, setAbort, isAborted); + return { + result, + transport: effective, + get aborted() { + return aborted; + }, + abort: () => { + aborted = true; + abortImpl(); + } + }; } - private async sendViaRuns(options: SendOptions, setAbort: (fn: () => void) => void): Promise { + private async sendViaRuns(options: SendOptions, setAbort: (fn: () => void) => void, isAborted: () => boolean): Promise { const { onEvent } = options; const run = await this.startRun({ input: options.text, - session_id: options.sessionId || undefined, + session_id: plainSession(options.sessionId) || undefined, instructions: this.config.instructions || undefined, model: this.config.model || undefined }); const runId = run.run_id; + if (isAborted()) { + this.stopRun(runId).catch(() => undefined); + return ''; + } onEvent({ kind: 'run.started', runId }); Log.info('hermes', `run ${runId} started`); @@ -329,7 +349,7 @@ export class HermesClient { }); await handle.done; - if (stopped) return streamedText; + if (stopped || isAborted()) return streamedText; if (failure) throw new Error(failure); if (!completed) { @@ -349,7 +369,7 @@ export class HermesClient { return finalText || streamedText; } - private async sendViaSessions(options: SendOptions, setAbort: (fn: () => void) => void): Promise { + private async sendViaSessions(options: SendOptions, setAbort: (fn: () => void) => void, isAborted: () => boolean): Promise { const { onEvent } = options; let sessionId = options.sessionId; if (!sessionId || !sessionId.startsWith('hs:')) { @@ -357,6 +377,7 @@ export class HermesClient { sessionId = `hs:${session.id}`; onEvent({ kind: 'session', sessionId }); } + if (isAborted()) return ''; const realId = sessionId.slice(3); const body: Rec = { input: options.text }; if (this.config.instructions) body.instructions = this.config.instructions; @@ -374,11 +395,12 @@ export class HermesClient { }); setAbort(() => handle.abort()); await handle.done; + if (isAborted()) return streamed; if (failure) throw new Error(failure); return finalText || streamed; } - private async sendViaCompletions(options: SendOptions, setAbort: (fn: () => void) => void): Promise { + private async sendViaCompletions(options: SendOptions, setAbort: (fn: () => void) => void, isAborted: () => boolean): Promise { const { onEvent } = options; const useTools = this.config.localTools && !!options.localToolExecutor && !!options.localToolDefinitions?.length; const messages: HermesChatMessage[] = []; @@ -389,16 +411,13 @@ export class HermesClient { : options.text; messages.push({ role: 'user', content: userContent }); - let aborted = false; let currentHandle: StreamHandle | null = null; - setAbort(() => { - aborted = true; - currentHandle?.abort(); - }); + setAbort(() => currentHandle?.abort()); + const aborted = () => isAborted(); let fullText = ''; 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 }; if (toolsAllowed) { payload.tools = options.localToolDefinitions; @@ -428,7 +447,8 @@ export class HermesClient { }); 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( { 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)) } @@ -454,7 +474,7 @@ export class HermesClient { parser.end(); fullText += iterationText; - if (finishReason === 'tool_calls' && toolsAllowed && toolCalls.size > 0 && !aborted) { + if (finishReason === 'tool_calls' && toolsAllowed && toolCalls.size > 0 && !aborted()) { const calls = [...toolCalls.values()]; messages.push({ role: 'assistant', @@ -471,11 +491,16 @@ export class HermesClient { } break; } - onEvent({ kind: 'completed', status: aborted ? 'cancelled' : 'completed', text: fullText }); + onEvent({ kind: 'completed', status: aborted() ? 'cancelled' : 'completed', text: 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 { const raw = job as Rec; const id = String(raw.id ?? raw.job_id ?? raw.name ?? ''); diff --git a/src/services/hermes/events.ts b/src/services/hermes/events.ts index c70f3af..3508ff7 100644 --- a/src/services/hermes/events.ts +++ b/src/services/hermes/events.ts @@ -212,6 +212,7 @@ export function normalizeCompletionChunk( ): { events: HermesStreamEvent[]; finishReason: string | null } { const events: HermesStreamEvent[] = []; 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; if (!choice) return { events, finishReason: null }; const delta = isRec(choice.delta) ? (choice.delta as Rec) : isRec(choice.message) ? (choice.message as Rec) : null; diff --git a/src/services/hermes/types.ts b/src/services/hermes/types.ts index ec05f8d..f84bc27 100644 --- a/src/services/hermes/types.ts +++ b/src/services/hermes/types.ts @@ -167,8 +167,10 @@ export interface SendOptions { } export interface SendHandle { - /** Resolves with the final assistant text. */ + /** Resolves with the final assistant text (partial text when aborted). */ result: Promise; abort: () => void; + /** True once abort() was called; the caller must not treat the result as a completed reply. */ + readonly aborted: boolean; transport: Exclude; } diff --git a/src/services/logService.ts b/src/services/logService.ts deleted file mode 100644 index 1b40da9..0000000 --- a/src/services/logService.ts +++ /dev/null @@ -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); - } -} diff --git a/src/services/voice/audioBus.ts b/src/services/voice/audioBus.ts index c3a2433..7da89c3 100644 --- a/src/services/voice/audioBus.ts +++ b/src/services/voice/audioBus.ts @@ -71,9 +71,9 @@ class AudioBus { } /** 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 out = new Float32Array(bins); + out.fill(0); if (!analyser) { if (this.synthetic > 0) { const t = performance.now() / 1000; diff --git a/src/services/voice/capture.ts b/src/services/voice/capture.ts index 532cebb..c5f0c3d 100644 --- a/src/services/voice/capture.ts +++ b/src/services/voice/capture.ts @@ -88,6 +88,7 @@ export class MicCapture { private active = false; private options: CaptureOptions | null = null; private sampleRate = 16_000; + private hadSpeech = false; get isActive(): boolean { return this.active; @@ -98,6 +99,7 @@ export class MicCapture { this.options = options; this.chunks = []; this.totalSamples = 0; + this.hadSpeech = false; this.vad = options.mode === 'auto' ? new EnergyVad(options.vad ?? DEFAULT_VAD) : null; if (!navigator.mediaDevices?.getUserMedia) { @@ -169,6 +171,12 @@ export class MicCapture { if (!this.active || !this.options) return; this.chunks.push(samples); 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); this.options.callbacks.onLevel?.(Math.min(1, level * 6)); @@ -184,6 +192,7 @@ export class MicCapture { private handleVad(signal: VadSignal): void { switch (signal) { case 'speech-start': + this.hadSpeech = true; this.options?.callbacks.onSpeechStart?.(); break; case 'speech-end': @@ -222,10 +231,12 @@ export class MicCapture { /** Stop capture. In manual mode this finalises the utterance; otherwise reports the reason. */ stop(reason: 'stopped' | 'no-speech' | 'too-short' = 'stopped'): void { 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'); return; } + if (reason === 'stopped') reason = 'no-speech'; const opts = this.options; this.teardown(); opts?.callbacks.onEnd(reason); diff --git a/src/services/voice/speech.ts b/src/services/voice/speech.ts index 3dd38a2..e4588bf 100644 --- a/src/services/voice/speech.ts +++ b/src/services/voice/speech.ts @@ -35,9 +35,10 @@ class SpeechFacade { return this.engine?.isActive ?? false; } - say(text: string): void { + say(text: string, options: { interrupt?: boolean } = {}): void { 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 { diff --git a/src/services/voice/tts.ts b/src/services/voice/tts.ts index 57e6f90..babae4a 100644 --- a/src/services/voice/tts.ts +++ b/src/services/voice/tts.ts @@ -1,7 +1,7 @@ /** - * Text-to-speech engine with a sentence queue, prefetching and Web Audio playback so the HUD - * reacts to the actual waveform. Providers: OpenAI-compatible /v1/audio/speech, system voices, - * and the legacy Google Translate endpoint (no key, online only). + * Text-to-speech engine with a sentence queue, bounded prefetching and Web Audio playback so + * the HUD reacts to the actual waveform. Providers: in-app sherpa-onnx (Kokoro / Piper), + * OpenAI-compatible /v1/audio/speech, system voices, and the legacy Google Translate endpoint. */ import { Log } from '../../lib/log'; import { bridge } from '../../lib/bridge'; @@ -35,6 +35,8 @@ interface QueueItem { audio?: Promise; } +const LOOKAHEAD = 2; + export function ttsEndpoint(apiUrl: string): string { const base = apiUrl.trim().replace(/\/+$/, ''); if (base.endsWith('/speech')) return base; @@ -46,8 +48,7 @@ export function ttsEndpoint(apiUrl: string): string { export class TtsEngine { private queue: QueueItem[] = []; private playing = false; - private currentSource: AudioBufferSourceNode | null = null; - private currentUtterance: SpeechSynthesisUtterance | null = null; + private cancelCurrent: (() => void) | null = null; private streamBuffer = ''; private generation = 0; private config: TtsConfig; @@ -60,7 +61,6 @@ export class TtsEngine { updateConfig(config: TtsConfig): void { this.config = config; - audioBus.setVolume(config.volume); } onState(listener: (state: TtsState) => void): () => void { @@ -78,10 +78,18 @@ export class TtsEngine { for (const l of this.listeners) l(state); } - /** Speak a finished text (clears any pending speech first). */ - speak(text: string): void { - this.stop(); - for (const chunk of chunkForSpeech(text)) this.enqueue(chunk); + /** + * Speak a finished text. With `interrupt` (default) pending speech is discarded first; + * without it the text is inserted ahead of the queue and streamed speech resumes afterwards. + */ + 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. */ @@ -100,32 +108,32 @@ export class TtsEngine { } enqueue(text: string): void { - if (this.config.provider === 'off') return; - const clean = cleanForSpeech(text); - 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); + const item = this.makeItem(text); + if (!item) return; this.queue.push(item); + this.fillPrefetch(); 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 { this.generation++; this.queue = []; this.streamBuffer = ''; - if (this.currentSource) { - try { - this.currentSource.onended = null; - this.currentSource.stop(); - } catch { - /* already stopped */ - } - this.currentSource = null; - } - if (this.currentUtterance) { - this.currentUtterance.onend = null; - this.currentUtterance = null; - } + this.cancelCurrent?.(); + this.cancelCurrent = null; if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel(); audioBus.setSyntheticLevel(0); this.playing = false; @@ -138,15 +146,17 @@ export class TtsEngine { const gen = this.generation; while (this.queue.length > 0 && gen === this.generation) { const item = this.queue.shift()!; + this.fillPrefetch(); try { - if (item.audio) { + if (this.config.provider !== 'system') { this.setState('loading'); - const buffer = await item.audio; + const buffer = await (item.audio ?? this.prefetch(item.text, gen)); if (gen !== this.generation) break; if (buffer) { this.setState('speaking'); await this.playBuffer(buffer, gen); } else { + this.setState('speaking'); await this.speakSystem(item.text, gen); } } else { @@ -174,7 +184,7 @@ export class TtsEngine { return audioBus.context.decodeAudioData(copy); }) .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; }); } @@ -238,15 +248,26 @@ export class TtsEngine { return new Promise((resolve) => { if (gen !== this.generation) return resolve(); const ctx = audioBus.context; + audioBus.setVolume(this.config.volume); const source = ctx.createBufferSource(); source.buffer = buffer; 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.onended = () => { - if (this.currentSource === source) this.currentSource = null; + const finish = () => { + if (this.cancelCurrent === cancel) this.cancelCurrent = null; resolve(); }; - this.currentSource = source; + const cancel = () => { + source.onended = null; + try { + source.stop(); + } catch { + /* already stopped */ + } + finish(); + }; + source.onended = finish; + this.cancelCurrent = cancel; source.start(); }); } @@ -268,12 +289,17 @@ export class TtsEngine { if (pulse) clearInterval(pulse); pulse = null; audioBus.setSyntheticLevel(0); - if (this.currentUtterance === utterance) this.currentUtterance = null; + if (this.cancelCurrent === cancel) this.cancelCurrent = null; resolve(); }; + const cancel = () => { + utterance.onend = null; + utterance.onerror = null; + finish(); + }; utterance.onend = finish; utterance.onerror = finish; - this.currentUtterance = utterance; + this.cancelCurrent = cancel; speechSynthesis.speak(utterance); }); } diff --git a/src/services/voice/voiceController.ts b/src/services/voice/voiceController.ts index 287cb02..a8d9471 100644 --- a/src/services/voice/voiceController.ts +++ b/src/services/voice/voiceController.ts @@ -51,6 +51,7 @@ class VoiceController { private browser = new BrowserRecognizer(); private handsFreeTimer: ReturnType | null = null; private stopping = false; + private startSeq = 0; private unsubscribeVoice: (() => void) | null = null; init(): void { @@ -61,6 +62,13 @@ class VoiceController { 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 { @@ -83,6 +91,7 @@ class VoiceController { if (on && !this.isListening && !speech.isSpeaking()) void this.start(); if (!on) { this.clearHandsFreeTimer(); + if (this.isListening) this.cancel(); } } @@ -90,7 +99,8 @@ class VoiceController { this.clearHandsFreeTimer(); this.handsFreeTimer = setTimeout(() => { 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); } @@ -99,14 +109,16 @@ class VoiceController { this.handsFreeTimer = null; } - async start(): Promise { + /** @param auto true when re-armed by the hands-free loop (no chime, patient timeout). */ + async start(auto = false): Promise { const voice = useVoice.getState(); if (voice.phase !== 'off') return; const settings = useSettings.getState().settings.voice; + const seq = ++this.startSeq; voice.setError(null); voice.setInterim(''); - if (!settings.bargeIn) speech.stop(); - else speech.stop(); + // Barge-in keeps the assistant talking while the mic opens (echo cancellation handles the overlap). + if (!(auto && settings.bargeIn)) speech.stop(); useChat.getState().setHud('listening'); voice.setPhase('arming'); @@ -125,18 +137,29 @@ class VoiceController { silenceMs: settings.silenceMs, speechRatio: SENSITIVITY_RATIO[sensitivity], minRms: SENSITIVITY_MIN_RMS[sensitivity], - noSpeechTimeoutMs: useVoice.getState().handsFree ? 20_000 : 9_000 + noSpeechTimeoutMs: useVoice.getState().handsFree ? Number.POSITIVE_INFINITY : 9_000 }, callbacks: { - onLevel: (level) => useVoice.getState().setInputLevel(level), - onSpeechStart: () => useVoice.getState().setPhase('speech'), + onLevel: (level) => { + 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), onEnd: (reason) => this.onCaptureEnd(reason), 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'); - this.playChime(true); + if (!auto) this.playChime(true); } catch (err) { const message = (err as Error).message; useVoice.getState().setError(message); @@ -166,6 +189,7 @@ class VoiceController { stop(): void { this.stopping = true; + this.startSeq++; this.clearHandsFreeTimer(); if (useSettings.getState().settings.voice.provider === 'browser') this.browser.stop(); else if (this.capture.isActive) this.capture.stop('stopped'); @@ -174,6 +198,7 @@ class VoiceController { } cancel(): void { + this.startSeq++; this.clearHandsFreeTimer(); this.browser.stop(); this.capture.cancel(); @@ -224,6 +249,7 @@ class VoiceController { this.attentionUntil = 0; if (text.trim()) { await sendMessage(text, [], 'voice'); + if (useVoice.getState().handsFree && !speech.isSpeaking()) this.scheduleHandsFree(350); } else { voice.setError('Transcription vide.'); useChat.getState().setHud('idle'); @@ -237,6 +263,7 @@ class VoiceController { useChat.getState().setHud('error'); Log.error('voice', `transcription failed: ${message}`); setTimeout(() => useChat.getState().setHud('idle'), 3000); + if (useVoice.getState().handsFree) this.scheduleHandsFree(3000); } } diff --git a/src/state/chat.ts b/src/state/chat.ts index 8482fab..699fca7 100644 --- a/src/state/chat.ts +++ b/src/state/chat.ts @@ -53,6 +53,10 @@ interface ChatStore { hudOverride: HudState | null; error: string | null; 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 & Partial>) => string; updateMessage: (id: string, patch: Partial | ((m: ChatMessage) => Partial)) => void; @@ -68,6 +72,8 @@ interface ChatStore { setHudOverride: (state: HudState | null) => void; setError: (error: string | null) => void; setDraft: (draft: string) => void; + ping: () => void; + setLatency: (ms: number | null) => void; } const MAX_MESSAGES = 400; @@ -83,6 +89,8 @@ export const useChat = create((set, get) => ({ hudOverride: null, error: null, draft: '', + pingCount: 0, + latencyMs: null, addMessage: (message) => { const id = message.id ?? uid('msg'); @@ -137,5 +145,7 @@ export const useChat = create((set, get) => ({ }, setHudOverride: (hudOverride) => set({ hudOverride }), setError: (error) => set({ error }), - setDraft: (draft) => set({ draft }) + setDraft: (draft) => set({ draft }), + ping: () => set((s) => ({ pingCount: s.pingCount + 1 })), + setLatency: (latencyMs) => set({ latencyMs }) })); diff --git a/src/state/hermes.ts b/src/state/hermes.ts index d3f62df..4c7eed9 100644 --- a/src/state/hermes.ts +++ b/src/state/hermes.ts @@ -57,6 +57,8 @@ interface HermesStore { } const CACHE_KEY = 'eveflow.hermes.cache.v2'; +let connectInflight: Promise | null = null; +let lastCacheSnapshot = ''; const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase()); function runFromJob(job: HermesJob): JobRun | null { @@ -98,7 +100,9 @@ export const useHermes = create((set, get) => ({ return new HermesClient({ ...config, model }); }, - connect: async () => { + connect: () => { + if (connectInflight) return connectInflight; + connectInflight = (async () => { const config = useSettings.getState().settings.hermes; if (!config.url.trim()) { set({ link: 'offline', linkDetail: 'URL Hermes non configurée' }); @@ -126,6 +130,10 @@ export const useHermes = create((set, get) => ({ set({ link: 'offline', linkDetail: message, transport: resolveTransport(config, null) }); Log.warn('hermes', `connection failed: ${message}`); } + })().finally(() => { + connectInflight = null; + }); + return connectInflight; }, refreshCatalog: async () => { @@ -157,13 +165,18 @@ export const useHermes = create((set, get) => ({ 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()) .slice(0, 100); - set({ jobs, jobRuns: merged, lastSyncAt: Date.now(), link: get().link === 'offline' ? 'online' : get().link }); - persistSet(CACHE_KEY, { jobs, jobRuns: merged, syncedAt: Date.now() }); + set({ jobs, jobRuns: merged, lastSyncAt: Date.now(), link: get().link === 'offline' || get().link === 'degraded' ? 'online' : get().link }); + const snapshot = JSON.stringify({ jobs, jobRuns: merged }); + if (snapshot !== lastCacheSnapshot) { + lastCacheSnapshot = snapshot; + persistSet(CACHE_KEY, { jobs, jobRuns: merged, syncedAt: Date.now() }); + } } catch (err) { const message = (err as Error).message; Log.warn('hermes', `jobs sync failed: ${message}`); 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 }); } }, diff --git a/src/styles/app.css b/src/styles/app.css index e56aaee..2952665 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -7,6 +7,7 @@ grid-template-rows: 46px 1fr; border-radius: 14px; overflow: hidden; + background-clip: padding-box; background: 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%); @@ -51,6 +52,17 @@ grid-template-columns: minmax(340px, 1fr) minmax(380px, 1fr); } .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; } } @@ -68,7 +80,6 @@ background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); - backdrop-filter: blur(14px); display: flex; flex-direction: column; min-height: 0; @@ -310,7 +321,8 @@ background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.06), transparent); -webkit-app-region: drag; } -.topbar > * { +.topbar button, +.topbar .chip { -webkit-app-region: no-drag; } .topbar .brand { @@ -346,9 +358,6 @@ overflow: hidden; -webkit-app-region: drag; } -.topbar .status-strip > * { - -webkit-app-region: no-drag; -} .topbar .clock { font-family: var(--font-mono); font-size: 13px; @@ -421,6 +430,21 @@ .core-state.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 { font-family: var(--font-mono); font-size: 12px; @@ -438,10 +462,13 @@ right: 0; text-align: center; font-family: var(--font-display); - font-size: 22px; + font-size: clamp(13px, 2.2vw, 22px); font-weight: 900; - letter-spacing: 0.5em; - padding-left: 0.5em; + letter-spacing: clamp(0.15em, 0.8vw, 0.5em); + padding: 0 12px 0 calc(12px + 0.4em); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; color: var(--ink-0); text-shadow: 0 0 22px var(--accent-glow); pointer-events: none; @@ -474,6 +501,25 @@ 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 { position: absolute; diff --git a/src/styles/chat.css b/src/styles/chat.css index 6aea0aa..99fcb92 100644 --- a/src/styles/chat.css +++ b/src/styles/chat.css @@ -11,7 +11,6 @@ display: flex; flex-direction: column; gap: 12px; - scroll-behavior: smooth; } .chat-list.compact { padding: 8px 10px; diff --git a/src/styles/compact.css b/src/styles/compact.css index af334a9..b843973 100644 --- a/src/styles/compact.css +++ b/src/styles/compact.css @@ -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))); 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); - 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 { display: flex; @@ -29,7 +37,7 @@ } .compact-core { position: relative; - height: 220px; + height: clamp(140px, 38vh, 220px); flex-shrink: 0; } .compact-core canvas { diff --git a/src/styles/settings.css b/src/styles/settings.css index e614775..433889b 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -104,3 +104,14 @@ background: linear-gradient(90deg, var(--accent), var(--success)); 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; +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 16898e7..474a479 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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 { --font-display: 'Orbitron', 'Rajdhani', 'Segoe UI', sans-serif; --font-ui: 'Rajdhani', 'Segoe UI', 'Helvetica Neue', sans-serif; @@ -137,7 +135,27 @@ textarea { 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; 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; +}