Compare commits

..
2 Commits
Author SHA1 Message Date
LogiFlowandClaude 6fc38d335b feat: serveur MCP, heures calmes, mode mission, widget glanceable, barge-in (v2.4.0) (#14)
Serveur MCP local
- Endpoint /mcp (JSON-RPC 2.0, Streamable HTTP, réponses JSON) sur le serveur
  webhook : initialize, tools/list, tools/call, ping. Même secret que le
  webhook. 14 outils : capture_screen (image MCP), lock_session, open_app,
  open_url, media_key, clipboard_get/set, find_files, speak_text,
  notify_user, set_hud_state, get_app_status, get_conversation_history,
  show_message. Les outils UI transitent par IPC vers le renderer.
- Les mêmes actions système sont proposées au modèle en chat completions.

Notifications
- Heures calmes (plage horaire, franchissement de minuit), mots prioritaires,
  échecs de crons toujours lus, résumé vocal des messages entrants (n premières
  phrases), thème nuit automatique, compteur de non-lus.

Conversation
- Mode mission : bouton dans la barre de commande, second modèle Hermes
  (hermes.missionModel) pour les tâches longues.
- Barge-in : la parole de l'utilisateur coupe la voix ; seuil d'énergie et
  ratio relevés pendant la synthèse pour ignorer l'écho.

Widget compact
- Bandeau glanceable : état, dernière phrase, non-lus, indicateurs heures
  calmes et mission.

Tests : 28 tests unitaires (heures calmes, priorité, résumé), e2e Electron
sous Xvfb : MCP initialize/tools/list/tools/call (presse-papiers, capture en
image, état et message via le renderer), bandeau compact, aucune erreur.


Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 00:47:34 +02:00
LogiFlowandClaude af8bdc5e9d feat: fin de phrase Silero, vision d'écran et actions locales (v2.3.0) (#13)
Écoute permanente
- Silero VAD dans le worker sherpa-onnx (vad.start/audio/stop) ; le renderer
  envoie des trames de 128 ms pendant la commande et reçoit le segment WAV
  complet (speech-start / segment). Repli sur le VAD énergétique si le modèle
  n'est pas installé ou si l'option est désactivée.
- Modèle « silero-vad » (0,6 Mo) dans le catalogue, section « Fin de phrase »
  dans Modèles locaux, réglage dans Paramètres → Micro.

Vision d'écran
- IPC system:screen-capture (desktopCapturer, JPEG 1600 px) ; bouton dans la
  barre de commande ; « regarde mon écran… » joint la capture à la question
  envoyée à Hermes (transport chat completions pour les images).

Actions locales
- IPC system:action à liste blanche : verrouiller la session, touches média
  (volume, mute, lecture, piste), ouvrir une application connue ou une URL
  http(s), presse-papiers, recherche de fichiers dans les dossiers utilisateur.
- Routeur d'intentions FR/EN exécuté avant Hermes (« coupe le son », « ouvre
  Spotify », « verrouille la session »…), résultat affiché et lu ; désactivable.

Correctif
- Chat completions : les premiers fragments SSE arrivés avant l'événement de
  démarrage étaient perdus (premier mot manquant) ; ils sont maintenant rejoués.

Tests : 26 tests unitaires (intentions locales ajoutées), e2e Electron sous
Xvfb avec les vrais modèles (Silero : un segment par phrase, 6,7 s d'audio
traités en 190 ms ; capture 109 ko reçue par Hermes ; intention locale
traitée sans Hermes).


Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-03 20:46:26 +02:00
37 changed files with 1354 additions and 73 deletions

No files matched your search

+23 -1
View File
@@ -1,7 +1,7 @@
# EveFlow 2 — Interface vocale JARVIS pour Hermes Agent
[![Build](https://img.shields.io/github/actions/workflow/status/R0m1k3/EveFlow/windows-release.yml?style=flat-square)](https://github.com/R0m1k3/EveFlow/actions)
[![Version](https://img.shields.io/badge/version-2.2.0-brightgreen.svg?style=flat-square)](https://github.com/R0m1k3/EveFlow/releases)
[![Version](https://img.shields.io/badge/version-2.4.0-brightgreen.svg?style=flat-square)](https://github.com/R0m1k3/EveFlow/releases)
[![License](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat-square)](LICENSE)
**EveFlow** est un compagnon de bureau Windows qui transforme [Hermes Agent](https://hermes-agent.nousresearch.com/) en assistant vocal à la JARVIS : un noyau holographique réactif au son, une conversation en streaming, les outils, sous-agents, approbations, crons, skills et sessions d'Hermes pilotés depuis un seul HUD.
@@ -23,6 +23,14 @@ La version 2 est une réécriture complète : plus de robot 3D, un pipeline voca
* **Détection d'activité vocale** (seuil adaptatif, sensibilité et silence de fin réglables) : l'enregistrement s'arrête tout seul quand vous avez fini de parler.
* **Mains libres** : le micro se réactive après chaque réponse.
* **Modèles intégrés, hors ligne** (sherpa-onnx dans un processus séparé) : reconnaissance Whisper (base, small, large-v3 turbo) ou SenseVoice, synthèse Kokoro v1.0 (voix française Siwis et voix anglaises) ou Piper (Siwis, Tom, UPMC). Les modèles se téléchargent depuis **Paramètres → Modèles locaux** et tournent sur le processeur.
* **Fin de phrase neuronale** : en écoute permanente, Silero VAD (0,6 Mo, sherpa-onnx) décide du début et de la fin de la commande à la place du seuil d'énergie ; moins de faux départs sur le bruit, coupure plus nette. Repli automatique sur le VAD énergétique si le modèle n'est pas installé.
* **Vision d'écran** : « Jarvis, regarde mon écran » (ou le bouton de la barre de commande) joint une capture de l'écran principal à la question envoyée à Hermes.
* **Actions locales instantanées** : « verrouille la session », « monte le son », « coupe le son », « piste suivante », « ouvre Spotify », « ouvre github.com »… exécutées sur le PC sans passer par Hermes, résultat lu à voix haute. Liste blanche d'actions dans le processus principal, désactivable dans les paramètres.
* **Serveur MCP intégré** : Hermes se connecte à `http://<pc>:7842/mcp` et obtient les outils du PC (capture d'écran renvoyée en image, verrouillage, applications, URL, touches média, presse-papiers, recherche de fichiers, voix, notifications, état du HUD, affichage dans le fil). Même port et même secret que le webhook ; en mode chat completions, les mêmes outils sont proposés directement au modèle.
* **Heures calmes et priorités** : plage horaire pendant laquelle les messages poussés s'affichent sans être lus ni faire clignoter le noyau (badge « non lus » à la place), thème nuit automatique, mots prioritaires lus quand même, résumé vocal des rapports longs (les premières phrases seulement).
* **Mode mission** : un bouton dans la barre de commande bascule sur un second modèle Hermes (plus puissant) pour les tâches longues ; le modèle rapide reste utilisé pour la conversation courante.
* **Widget compact « glanceable »** : état (veille, écoute, réflexion, parle), dernière phrase de l'assistant, badge de non-lus, indicateurs heures calmes et mission.
* **Barge-in** : en mains libres, parler par-dessus l'assistant coupe sa voix ; le seuil est relevé pendant qu'il parle pour ignorer l'écho du haut-parleur.
* **Écoute permanente** : un détecteur de mot-clé de 3 Mo (sherpa-onnx, keyword spotting) tourne en continu sur le micro, quasi gratuit en CPU. « Jarvis » (ou n'importe quel mot-clé) ouvre l'écoute, « Jarvis, allume… » envoie directement la commande, et le mot coupe la voix en cours. Alternative : filtre du mot après transcription en mains libres.
* **STT externe** : n'importe quelle API `/v1/audio/transcriptions` compatible OpenAI (Qwen3-ASR, Whisper, Speaches, faster-whisper-server, LocalAI, OpenAI). Repli sur la reconnaissance Chromium.
* **TTS externe** : API `/v1/audio/speech` compatible OpenAI, voix système Windows ou Google Translate. Lecture phrase par phrase pendant le streaming, préchargement du segment suivant, coupure instantanée.
@@ -89,6 +97,20 @@ Pour recevoir les résultats de crons ou le miroir d'autres canaux dans EveFlow,
---
### Donner à Hermes les outils du PC (MCP)
Dans `~/.hermes/config.yaml` côté Hermes :
```yaml
mcp_servers:
eveflow:
url: "http://<ip-du-pc>:7842/mcp"
headers:
Authorization: "Bearer <secret du webhook EveFlow>"
```
Sans secret, EveFlow n'écoute qu'en local (`127.0.0.1`) ; définissez un secret dans Paramètres → Webhook pour un Hermes distant. Outils exposés : `capture_screen`, `lock_session`, `open_app`, `open_url`, `media_key`, `clipboard_get`, `clipboard_set`, `find_files`, `speak_text`, `notify_user`, `set_hud_state`, `get_app_status`, `get_conversation_history`, `show_message`.
## Développement
```bash
+33 -16
View File
@@ -11,7 +11,14 @@
| Synthèse vocale locale | Fait | Kokoro v1.0 (voix française Siwis) et Piper fr |
| Mot d'activation permanent | Fait (2.2.0) | Keyword spotting sherpa-onnx en continu ; mot-clé libre encodé en BPE ; validé sur audio réel (détection, zéro faux positif sur le test anglais) |
| Mot d'activation après transcription | Fait | 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 |
| Détection de fin de phrase | Fait (2.3.0) | Silero VAD neuronal dans le worker (segment renvoyé au renderer), repli sur le VAD énergétique si le modèle manque |
| Vision d'écran | Fait (2.3.0) | Capture `desktopCapturer` jointe à la requête Hermes (bouton, ou « regarde mon écran ») |
| Serveur MCP local (outils du PC pour Hermes) | Fait (2.4.0) | `/mcp` sur le serveur webhook, JSON-RPC Streamable HTTP, 14 outils dont la capture d'écran renvoyée en image |
| Heures calmes, priorités, résumé vocal | Fait (2.4.0) | Paramètres → Notifications ; thème nuit automatique ; badge non-lus |
| Mode mission (second modèle) | Fait (2.4.0) | Bouton dans la barre de commande ; modèle dédié aux tâches longues |
| Widget compact glanceable | Fait (2.4.0) | État, dernière phrase, non-lus, indicateurs |
| Barge-in | Fait (2.4.0) | Coupe la voix dès que l'utilisateur parle ; seuil relevé pendant la synthèse (à valider avec l'annulation d'écho Windows) |
| Actions système locales | Fait (2.3.0) | Verrouillage, volume et touches média, ouvrir une application ou une URL, presse-papiers, recherche de fichiers ; intentions courtes exécutées sans passer par Hermes |
| 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 |
@@ -23,10 +30,10 @@
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é). Livré en 2.2.0 (voir l'étape 1 ci-dessous).
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).
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). Livré en 2.3.0.
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.
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. Livré en 2.3.0 (transport chat completions pour les images).
5. **Actions système locales.** Ouvrir une application, régler le volume, verrouiller la session, chercher un fichier. Livré en 2.3.0 sous forme d'intentions courtes exécutées localement avant Hermes ; reste à exposer les mêmes actions à Hermes via un serveur MCP local.
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.
@@ -36,21 +43,27 @@ Sources : [jarvis-desktop-ai](https://github.com/ccarloshenri/jarvis-desktop-ai)
- Le renderer garde un seul flux micro (AudioWorklet 16 kHz) et envoie des blocs de 256 ms au processus principal, qui alimente le `KeywordSpotter` sherpa-onnx dans le worker.
- Mots-clés encodés en BPE (table SentencePiece pour les mots courants, repli glouton sur le vocabulaire du modèle), sensibilité réglable (seuil 0,45 → 0,12).
- À la détection : chime, capture de la commande sur le même flux (VAD énergétique, pré-roll 400 ms), transcription locale ou API, envoi à Hermes ; retour automatique à l'écoute.
- Reste à faire : remplacer le VAD énergétique par Silero pour la fin de phrase.
- Fin de phrase Silero livrée en 2.3.0 : le renderer envoie des trames de 128 ms au worker pendant la commande, le worker renvoie le segment WAV complet ; VAD énergétique en repli.
### É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 2 : vision et actions locales — livrée en 2.3.0
- Capture d'écran (`desktopCapturer`, JPEG 1600 px) jointe à la requête Hermes : bouton dans la barre de commande, ou phrase « regarde mon écran… » à l'oral comme à l'écrit.
- Actions locales (processus principal, liste blanche) : verrouiller, volume/mute/lecture/piste, ouvrir une application connue ou une URL http(s), presse-papiers, recherche de fichiers dans Documents/Bureau/Téléchargements/Images.
- Routeur d'intentions FR/EN (`src/services/localCommands.ts`) exécuté avant l'envoi à Hermes ; désactivable dans Paramètres → Micro. Les phrases composées (« ouvre X puis… ») partent à Hermes.
- Exposition à Hermes via le serveur MCP local livrée en 2.4.0.
### É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 3 : conversation plus naturelle — livrée en 2.4.0
- Barge-in : l'utilisateur qui parle par-dessus coupe la voix ; seuil d'énergie relevé pendant la synthèse pour ignorer l'écho (à valider sur Windows avec l'annulation d'écho du micro).
- Mode mission : second modèle Hermes choisi d'un clic pour les tâches longues.
- Serveur MCP local : Hermes enchaîne lui-même les actions du PC dans ses runs.
### É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).
### Étape 4 : présence — livrée en 2.4.0
- Widget compact glanceable : état, dernière phrase, non-lus, indicateurs heures calmes / mission.
- Heures calmes, mots prioritaires, résumé vocal des messages entrants, thème nuit.
### Pistes suivantes
- Voix différente par contexte (nuit, travail) et profils de réponse.
- Mémoire locale des préférences transmise à Hermes (`X-Hermes-Session-Key` déjà en place).
- Validation du barge-in et des touches média sur Windows réel (retours utilisateurs).
## Résultats du test de bout en bout (Linux, Xvfb, 4 cœurs lents)
@@ -61,5 +74,9 @@ Sources : [jarvis-desktop-ai](https://github.com/ccarloshenri/jarvis-desktop-ai)
| 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 |
| Silero VAD sur phrase Kokoro de 2,3 s (2.3.0) | un seul segment, début et fin détectés, 6,7 s d'audio traités en 190 ms |
| Capture d'écran → Hermes (2.3.0) | JPEG de 107 ko reçu côté Hermes (mock chat completions) |
| Intention locale « coupe le son » (2.3.0) | traitée sans Hermes, résultat affiché dans le fil |
| Serveur MCP (2.4.0) | initialize, tools/list (14 outils), tools/call côté principal (presse-papiers, capture image) et côté renderer (état, message dans le fil) |
Sur un PC à 28 cœurs les temps sont nettement plus courts. Whisper small est maintenant recommandé pour le français.
+184
View File
@@ -0,0 +1,184 @@
/**
* Local "JARVIS" actions: screenshot for vision, and an allow-list of system actions
* (lock, open an application or URL, media keys, clipboard, file search).
*/
import { app, clipboard, desktopCapturer, ipcMain, screen, shell } from 'electron';
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { IPC } from '../../shared/ipc';
import type { SystemAction, SystemActionResult } from '../../shared/bridge';
import { log } from '../logger';
const MEDIA_KEYS: Record<string, number> = {
'volume-up': 175,
'volume-down': 174,
mute: 173,
'play-pause': 179,
next: 176,
previous: 177
};
/** Applications the assistant may launch by name (Windows aliases + common Linux/macOS names). */
const APP_ALIASES: Record<string, string[]> = {
'bloc-notes': ['notepad.exe', 'gedit', 'TextEdit'],
notepad: ['notepad.exe', 'gedit', 'TextEdit'],
calculatrice: ['calc.exe', 'gnome-calculator', 'Calculator'],
calc: ['calc.exe', 'gnome-calculator', 'Calculator'],
explorateur: ['explorer.exe', 'nautilus', 'Finder'],
explorer: ['explorer.exe', 'nautilus', 'Finder'],
terminal: ['wt.exe', 'cmd.exe', 'gnome-terminal', 'Terminal'],
cmd: ['cmd.exe'],
powershell: ['powershell.exe'],
paint: ['mspaint.exe'],
chrome: ['chrome', 'google-chrome', 'Google Chrome'],
edge: ['msedge', 'microsoft-edge', 'Microsoft Edge'],
firefox: ['firefox', 'Firefox'],
vscode: ['code', 'Visual Studio Code'],
code: ['code', 'Visual Studio Code'],
spotify: ['spotify', 'Spotify'],
discord: ['discord', 'Discord'],
steam: ['steam', 'Steam'],
word: ['winword', 'Microsoft Word'],
excel: ['excel', 'Microsoft Excel'],
outlook: ['outlook', 'Microsoft Outlook'],
teams: ['ms-teams', 'Microsoft Teams'],
'task manager': ['taskmgr.exe'],
'gestionnaire des tâches': ['taskmgr.exe'],
paramètres: ['ms-settings:'],
settings: ['ms-settings:']
};
function run(cmd: string, args: string[], timeoutMs = 8000): Promise<{ code: number; out: string }> {
return new Promise((resolve) => {
execFile(cmd, args, { timeout: timeoutMs, windowsHide: true }, (err, stdout, stderr) => {
resolve({ code: err ? 1 : 0, out: `${stdout}${stderr}`.trim() });
});
});
}
async function openApp(name: string): Promise<SystemActionResult> {
const key = name.trim().toLowerCase();
if (!key || key.length > 40) return { ok: false, message: 'Nom d’application invalide' };
const candidates = APP_ALIASES[key] ?? [key.replace(/[^a-z0-9 ._-]/gi, '')];
if (process.platform === 'win32') {
for (const candidate of candidates) {
if (candidate.endsWith(':')) {
await shell.openExternal(candidate);
return { ok: true, message: `${name} ouvert` };
}
// `start` resolves App Paths, PATH and Start Menu names.
const result = await run('cmd.exe', ['/c', 'start', '', candidate]);
if (result.code === 0) return { ok: true, message: `${name} lancé` };
}
return { ok: false, message: `Impossible de lancer ${name}` };
}
for (const candidate of candidates) {
const result = process.platform === 'darwin' ? await run('open', ['-a', candidate]) : await run('sh', ['-c', `command -v ${JSON.stringify(candidate)} >/dev/null && (nohup ${JSON.stringify(candidate)} >/dev/null 2>&1 &)`]);
if (result.code === 0) return { ok: true, message: `${name} lancé` };
}
return { ok: false, message: `Application introuvable : ${name}` };
}
async function lockSession(): Promise<SystemActionResult> {
if (process.platform === 'win32') {
const r = await run('rundll32.exe', ['user32.dll,LockWorkStation']);
return { ok: r.code === 0, message: r.code === 0 ? 'Session verrouillée' : r.out };
}
if (process.platform === 'darwin') {
const r = await run('osascript', ['-e', 'tell application "System Events" to keystroke "q" using {command down, control down}']);
return { ok: r.code === 0, message: r.out };
}
const r = await run('sh', ['-c', 'loginctl lock-session || xdg-screensaver lock || gnome-screensaver-command -l']);
return { ok: r.code === 0, message: r.code === 0 ? 'Session verrouillée' : r.out };
}
async function mediaKey(key: string): Promise<SystemActionResult> {
const code = MEDIA_KEYS[key];
if (!code) return { ok: false, message: 'Touche inconnue' };
if (process.platform === 'win32') {
const script = `$s=Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern void keybd_event(byte b,byte s,uint f,UIntPtr e);' -Name K -Namespace W -PassThru; $s::keybd_event(${code},0,0,[UIntPtr]::Zero); $s::keybd_event(${code},0,2,[UIntPtr]::Zero)`;
const r = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
return { ok: r.code === 0, message: r.code === 0 ? key : r.out };
}
const xdo: Record<string, string> = { 'volume-up': 'XF86AudioRaiseVolume', 'volume-down': 'XF86AudioLowerVolume', mute: 'XF86AudioMute', 'play-pause': 'XF86AudioPlay', next: 'XF86AudioNext', previous: 'XF86AudioPrev' };
const r = await run('sh', ['-c', `xdotool key ${xdo[key]}`]);
return { ok: r.code === 0, message: r.code === 0 ? key : 'xdotool indisponible' };
}
async function findFiles(query: string): Promise<SystemActionResult> {
const needle = query.trim().toLowerCase();
if (needle.length < 2) return { ok: false, message: 'Recherche trop courte' };
const roots = ['Documents', 'Desktop', 'Downloads', 'Pictures'].map((d) => path.join(os.homedir(), d));
const hits: string[] = [];
const deadline = Date.now() + 4000;
const walk = async (dir: string, depth: number) => {
if (depth > 4 || hits.length >= 25 || Date.now() > deadline) return;
let entries: fs.Dirent[] = [];
try {
entries = await fs.promises.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const full = path.join(dir, entry.name);
if (entry.name.toLowerCase().includes(needle)) hits.push(full);
if (entry.isDirectory()) await walk(full, depth + 1);
if (hits.length >= 25) return;
}
};
for (const root of roots) await walk(root, 0);
return { ok: true, message: `${hits.length} résultat(s)`, data: hits };
}
export async function captureScreen(maxWidth = 1600): Promise<string> {
const display = screen.getPrimaryDisplay();
const scale = Math.min(1, maxWidth / display.size.width);
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: Math.round(display.size.width * scale), height: Math.round(display.size.height * scale) }
});
const primary = sources.find((s) => s.display_id === String(display.id)) ?? sources[0];
if (!primary) throw new Error('Aucun écran capturable');
return primary.thumbnail.toJPEG(82).toString('base64').replace(/^/, 'data:image/jpeg;base64,');
}
export async function runSystemAction(action: SystemAction): Promise<SystemActionResult> {
switch (action.type) {
case 'lock':
return lockSession();
case 'open-app':
return openApp(String(action.name ?? ''));
case 'open-url': {
const url = String(action.url ?? '');
if (!/^https?:\/\//i.test(url) || url.length > 2048) return { ok: false, message: 'URL refusée' };
await shell.openExternal(url);
return { ok: true, message: 'Ouvert dans le navigateur' };
}
case 'media':
return mediaKey(String(action.key));
case 'clipboard-read':
return { ok: true, data: (await clipboard.readText()).slice(0, 20_000) };
case 'clipboard-write':
await clipboard.writeText(String(action.text ?? '').slice(0, 100_000));
return { ok: true, message: 'Copié dans le presse-papiers' };
case 'find-files':
return findFiles(String(action.query ?? ''));
default:
return { ok: false, message: 'Action inconnue' };
}
}
export function registerSystemIpc(): void {
ipcMain.handle(IPC.screenCapture, (_e, maxWidth?: number) => captureScreen(Number(maxWidth) || 1600));
ipcMain.handle(IPC.systemAction, async (_e, action: SystemAction) => {
if (!action || typeof action.type !== 'string') throw new Error('Action invalide');
log('INFO', 'system', `action ${action.type}`);
const result = await runSystemAction(action);
if (!result.ok) log('WARN', 'system', `action ${action.type} failed: ${result.message}`);
return result;
});
void app;
}
+2
View File
@@ -9,6 +9,7 @@ import { getSharedDirectory, registerFilesIpc } from './ipc/files';
import { createMainWindow, getMainWindow, getWindowMode, setWindowMode, toggleWindowVisibility, windowEvents } from './window';
import { getWebhookStatus, startWebhookServer, stopWebhookServer } from './webhook';
import { registerVoiceIpc } from './voice/ipc';
import { registerSystemIpc } from './ipc/system';
import { stopEngine } from './voice/engine';
// Audio playback must never be blocked behind a user gesture (TTS starts on incoming events).
@@ -159,6 +160,7 @@ async function boot(): Promise<void> {
registerFilesIpc();
registerCoreIpc();
registerVoiceIpc();
registerSystemIpc();
createMainWindow();
createTray();
registerShortcuts();
+191
View File
@@ -0,0 +1,191 @@
/**
* Minimal MCP server (Streamable HTTP, JSON-RPC 2.0) mounted on the webhook HTTP server at /mcp.
* Hermes Agent connects to it as a remote MCP server and gains the PC-side tools: screen capture,
* system actions, voice, notifications and HUD state. No SDK: initialize / tools/list / tools/call
* are the only methods a client needs, and every reply is a plain JSON body.
*/
import { ipcMain, type BrowserWindow } from 'electron';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { IPC, type McpToolRequest, type McpToolResponse } from '../shared/ipc';
import type { SystemAction } from '../shared/bridge';
import { log } from './logger';
import { captureScreen, runSystemAction } from './ipc/system';
export const MCP_PATH = '/mcp';
const PROTOCOL = '2025-03-26';
const RENDERER_TIMEOUT_MS = 20_000;
interface ToolSpec {
name: string;
description: string;
inputSchema: Record<string, unknown>;
/** 'main' = executed here; 'renderer' = forwarded to the UI process. */
where: 'main' | 'renderer';
}
const obj = (properties: Record<string, unknown>, required: string[] = []) => ({ type: 'object', properties, required, additionalProperties: false });
export const MCP_TOOLS: ToolSpec[] = [
{ name: 'capture_screen', description: "Capture l'écran principal de l'utilisateur et renvoie l'image (JPEG). À utiliser quand l'utilisateur parle de ce qu'il voit ou demande de l'aide sur son écran.", inputSchema: obj({ max_width: { type: 'integer', description: 'Largeur maximale en pixels (défaut 1600)' } }), where: 'main' },
{ name: 'lock_session', description: "Verrouille la session Windows/Linux/macOS de l'utilisateur.", inputSchema: obj({}), where: 'main' },
{ name: 'open_app', description: "Lance une application sur le PC de l'utilisateur (bloc-notes, calculatrice, chrome, spotify, vscode, terminal, explorateur…).", inputSchema: obj({ name: { type: 'string' } }, ['name']), where: 'main' },
{ name: 'open_url', description: "Ouvre une URL http(s) dans le navigateur par défaut de l'utilisateur.", inputSchema: obj({ url: { type: 'string' } }, ['url']), where: 'main' },
{ name: 'media_key', description: 'Envoie une touche média : volume-up, volume-down, mute, play-pause, next, previous.', inputSchema: obj({ key: { type: 'string', enum: ['volume-up', 'volume-down', 'mute', 'play-pause', 'next', 'previous'] } }, ['key']), where: 'main' },
{ name: 'clipboard_get', description: 'Lit le texte du presse-papiers.', inputSchema: obj({}), where: 'main' },
{ name: 'clipboard_set', description: 'Place un texte dans le presse-papiers.', inputSchema: obj({ text: { type: 'string' } }, ['text']), where: 'main' },
{ name: 'find_files', description: "Cherche des fichiers par nom dans Documents, Bureau, Téléchargements et Images de l'utilisateur (25 résultats max).", inputSchema: obj({ query: { type: 'string' } }, ['query']), where: 'main' },
{ name: 'speak_text', description: "Fait prononcer un texte par la voix d'EveFlow, immédiatement.", inputSchema: obj({ text: { type: 'string' } }, ['text']), where: 'renderer' },
{ name: 'notify_user', description: 'Affiche une notification système (titre + corps).', inputSchema: obj({ title: { type: 'string' }, body: { type: 'string' } }, ['body']), where: 'renderer' },
{ name: 'set_hud_state', description: 'Change momentanément l’état visuel du HUD : neutral, happy, thinking, alert, error.', inputSchema: obj({ state: { type: 'string', enum: ['neutral', 'happy', 'thinking', 'alert', 'error'] } }, ['state']), where: 'renderer' },
{ name: 'get_app_status', description: "État d'EveFlow : transport, liaison, nom de l'assistant, mains libres, voix en cours, heures calmes.", inputSchema: obj({}), where: 'renderer' },
{ name: 'get_conversation_history', description: 'Derniers messages de la conversation EveFlow (n ≤ 30).', inputSchema: obj({ n: { type: 'integer' } }), where: 'renderer' },
{ name: 'show_message', description: "Affiche un message dans le fil EveFlow (sans le prononcer) — pour les rapports longs ou les résultats de crons.", inputSchema: obj({ text: { type: 'string' }, title: { type: 'string' } }, ['text']), where: 'renderer' }
];
type Rec = Record<string, unknown>;
interface RpcRequest {
jsonrpc?: string;
id?: number | string | null;
method?: string;
params?: Rec;
}
const pending = new Map<string, { resolve: (r: McpToolResponse) => void; timer: ReturnType<typeof setTimeout> }>();
let seq = 0;
let ipcRegistered = false;
function ensureIpc(): void {
if (ipcRegistered) return;
ipcRegistered = true;
ipcMain.on(IPC.mcpResponse, (_e, res: McpToolResponse) => {
if (!res || typeof res.id !== 'string') return;
const entry = pending.get(res.id);
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(res.id);
entry.resolve(res);
});
}
function askRenderer(win: BrowserWindow | null, name: string, args: Rec): Promise<McpToolResponse> {
ensureIpc();
if (!win || win.isDestroyed()) return Promise.resolve({ id: '', ok: false, error: 'EveFlow window unavailable' });
const id = `mcp-${++seq}-${Date.now()}`;
const req: McpToolRequest = { id, name, args };
return new Promise((resolve) => {
const timer = setTimeout(() => {
pending.delete(id);
resolve({ id, ok: false, error: 'renderer timeout' });
}, RENDERER_TIMEOUT_MS);
pending.set(id, { resolve, timer });
win.webContents.send(IPC.mcpRequest, req);
});
}
function text(value: unknown): { content: Array<Rec>; isError?: boolean } {
return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }] };
}
function failure(message: string): { content: Array<Rec>; isError: boolean } {
return { content: [{ type: 'text', text: message }], isError: true };
}
async function callTool(win: BrowserWindow | null, name: string, args: Rec): Promise<{ content: Array<Rec>; isError?: boolean }> {
const spec = MCP_TOOLS.find((t) => t.name === name);
if (!spec) return failure(`Outil inconnu : ${name}`);
if (spec.where === 'renderer') {
const res = await askRenderer(win, name, args);
return res.ok ? text(res.result ?? { ok: true }) : failure(res.error ?? 'échec');
}
const sys = async (action: SystemAction) => {
const r = await runSystemAction(action);
return r.ok ? text(r.data !== undefined ? { ok: true, message: r.message, data: r.data } : { ok: true, message: r.message }) : failure(r.message ?? 'échec');
};
switch (name) {
case 'capture_screen': {
const dataUrl = await captureScreen(Number(args.max_width) || 1600);
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
return { content: [{ type: 'image', data: base64, mimeType: 'image/jpeg' }, { type: 'text', text: `Capture de l'écran principal (${Math.round(base64.length * 0.75 / 1024)} ko).` }] };
}
case 'lock_session':
return sys({ type: 'lock' });
case 'open_app':
return sys({ type: 'open-app', name: String(args.name ?? '') });
case 'open_url':
return sys({ type: 'open-url', url: String(args.url ?? '') });
case 'media_key':
return sys({ type: 'media', key: String(args.key ?? '') as 'mute' });
case 'clipboard_get':
return sys({ type: 'clipboard-read' });
case 'clipboard_set':
return sys({ type: 'clipboard-write', text: String(args.text ?? '') });
case 'find_files':
return sys({ type: 'find-files', query: String(args.query ?? '') });
default:
return failure(`Outil non implémenté : ${name}`);
}
}
/** Handle one JSON-RPC message; returns the response object (or null for notifications). */
export async function handleMcpMessage(win: BrowserWindow | null, msg: RpcRequest): Promise<Rec | null> {
const id = msg.id ?? null;
const reply = (result: unknown) => ({ jsonrpc: '2.0', id, result });
const error = (code: number, message: string) => ({ jsonrpc: '2.0', id, error: { code, message } });
if (!msg.method) return error(-32600, 'Invalid Request');
if (msg.method.startsWith('notifications/')) return null;
switch (msg.method) {
case 'initialize':
return reply({ protocolVersion: PROTOCOL, capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'eveflow', version: process.env.npm_package_version ?? '2.4.0' }, instructions: "Outils du PC de l'utilisateur via EveFlow : écran, applications, volume, presse-papiers, voix et notifications." });
case 'ping':
return reply({});
case 'tools/list':
return reply({ tools: MCP_TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
case 'tools/call': {
const params = (msg.params ?? {}) as Rec;
const name = String(params.name ?? '');
const args = (params.arguments && typeof params.arguments === 'object' ? params.arguments : {}) as Rec;
log('INFO', 'mcp', `tools/call ${name}`);
try {
return reply(await callTool(win, name, args));
} catch (err) {
return reply(failure((err as Error).message));
}
}
case 'resources/list':
return reply({ resources: [] });
case 'prompts/list':
return reply({ prompts: [] });
default:
return error(-32601, `Method not found: ${msg.method}`);
}
}
/** HTTP entry point: POST /mcp with one JSON-RPC message or a batch. */
export async function handleMcpHttp(win: BrowserWindow | null, req: IncomingMessage, res: ServerResponse, body: string): Promise<void> {
const send = (code: number, payload: unknown) => {
if (payload === undefined) {
res.writeHead(code, { 'Cache-Control': 'no-store' });
res.end();
return;
}
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(payload));
};
if (req.method === 'GET') return send(405, { error: 'SSE stream not supported; use POST' });
if (req.method === 'DELETE') return send(204, undefined);
if (req.method !== 'POST') return send(405, { error: 'Method Not Allowed' });
let parsed: unknown;
try {
parsed = body.trim() ? JSON.parse(body) : {};
} catch {
return send(400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
}
const messages = Array.isArray(parsed) ? (parsed as RpcRequest[]) : [parsed as RpcRequest];
const replies: Rec[] = [];
for (const msg of messages) {
const out = await handleMcpMessage(win, msg && typeof msg === 'object' ? msg : {});
if (out) replies.push(out);
}
if (replies.length === 0) return send(202, undefined);
return send(200, Array.isArray(parsed) ? replies : replies[0]);
}
+13 -4
View File
@@ -13,8 +13,9 @@ import {
type WebhookStatus,
type WindowMode
} from '../shared/ipc';
import type { EveFlowBridge, Unsubscribe } from '../shared/bridge';
import { VOICE_IPC, type KwsDetection, type KwsStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VoiceDownloadProgress, type VoiceEngineStatus, type VoiceModelStatus } from '../shared/voice';
import type { EveFlowBridge, SystemAction, SystemActionResult, Unsubscribe } from '../shared/bridge';
import type { McpToolRequest, McpToolResponse } from '../shared/ipc';
import { VOICE_IPC, type KwsDetection, type KwsStartRequest, type VadEvent, type VadStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VoiceDownloadProgress, type VoiceEngineStatus, type VoiceModelStatus } from '../shared/voice';
function subscribe<T>(channel: string, callback: (payload: T) => void): Unsubscribe {
const listener = (_event: Electron.IpcRendererEvent, payload: T) => callback(payload);
@@ -43,7 +44,9 @@ const api: EveFlowBridge = {
},
system: {
metrics: () => ipcRenderer.invoke(IPC.metrics) as Promise<SystemMetrics>,
appInfo: () => ipcRenderer.invoke(IPC.appInfo) as Promise<AppInfo>
appInfo: () => ipcRenderer.invoke(IPC.appInfo) as Promise<AppInfo>,
captureScreen: (maxWidth?: number) => ipcRenderer.invoke(IPC.screenCapture, maxWidth) as Promise<string>,
action: (action: SystemAction) => ipcRenderer.invoke(IPC.systemAction, action) as Promise<SystemActionResult>
},
files: {
readLocal: (filePath: string) => ipcRenderer.invoke(IPC.readLocalFile, filePath) as Promise<string>,
@@ -55,6 +58,8 @@ const api: EveFlowBridge = {
hermes: {
onPush: (cb: (event: HermesPushEvent) => void) => subscribe<HermesPushEvent>(IPC.hermesPush, cb),
webhookStatus: () => ipcRenderer.invoke(IPC.webhookStatus) as Promise<WebhookStatus>,
onToolRequest: (cb: (req: McpToolRequest) => void) => subscribe<McpToolRequest>(IPC.mcpRequest, cb),
toolResponse: (res: McpToolResponse) => ipcRenderer.send(IPC.mcpResponse, res),
webhookRestart: () => ipcRenderer.invoke(IPC.webhookRestart) as Promise<WebhookStatus>
},
hotkeys: {
@@ -73,7 +78,11 @@ const api: EveFlowBridge = {
kwsStart: (req: KwsStartRequest) => ipcRenderer.invoke(VOICE_IPC.kwsStart, req) as Promise<{ accepted: string[]; rejected: string[] }>,
kwsStop: () => ipcRenderer.invoke(VOICE_IPC.kwsStop) as Promise<void>,
kwsAudio: (pcm: Uint8Array, sampleRate: number) => ipcRenderer.send(VOICE_IPC.kwsAudio, pcm, sampleRate),
onKwsDetected: (cb: (detection: KwsDetection) => void) => subscribe<KwsDetection>(VOICE_IPC.kwsDetected, cb)
onKwsDetected: (cb: (detection: KwsDetection) => void) => subscribe<KwsDetection>(VOICE_IPC.kwsDetected, cb),
vadStart: (req: VadStartRequest) => ipcRenderer.invoke(VOICE_IPC.vadStart, req) as Promise<void>,
vadStop: () => ipcRenderer.invoke(VOICE_IPC.vadStop) as Promise<void>,
vadAudio: (pcm: Uint8Array, sampleRate: number) => ipcRenderer.send(VOICE_IPC.vadAudio, pcm, sampleRate),
onVadEvent: (cb: (event: VadEvent) => void) => subscribe<VadEvent>(VOICE_IPC.vadEvent, cb)
}
};
+13
View File
@@ -86,6 +86,19 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [
],
recommended: true
},
{
id: 'silero-vad',
kind: 'vad',
engine: 'silero',
name: 'Silero VAD (fin de phrase neuronale, 0,6 Mo)',
description: 'Détecte précisément le début et la fin de la parole pendant l’écoute permanente ; moins de faux départs sur le bruit.',
languages: ['multi'],
sizeMb: 1,
url: `${ASR}/silero_vad.onnx`,
dir: 'silero-vad',
files: ['silero_vad.onnx'],
recommended: true
},
{
id: 'kokoro-v1',
kind: 'tts',
+46 -2
View File
@@ -6,7 +6,7 @@ import { utilityProcess, type UtilityProcess, type WebContents } from 'electron'
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { VOICE_IPC, type KwsDetection, type KwsStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VoiceEngineStatus } from '../../shared/voice';
import { VOICE_IPC, type KwsDetection, type KwsStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VadEvent, type VadStartRequest, type VoiceEngineStatus } from '../../shared/voice';
import { buildKeywordsFile, parseTokens } from '../../shared/keywords';
import { findModel } from './catalog';
import { isInstalled, modelDir, modelsDir } from './models';
@@ -16,6 +16,8 @@ import { log } from '../logger';
let kwsSubscriber: WebContents | null = null;
let kwsActive = false;
let kwsRequest: KwsStartRequest | null = null;
let vadSubscriber: WebContents | null = null;
let vadActive = false;
interface Pending {
resolve: (value: unknown) => void;
@@ -42,7 +44,20 @@ function spawn(): UtilityProcess {
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; type?: string; ok?: boolean; result?: unknown; error?: string; keyword?: string; at?: number }) => {
proc.on('message', (msg: { id?: number; type?: string; ok?: boolean; result?: unknown; error?: string; keyword?: string; at?: number; event?: { type: string; wav?: string; durationSec?: number } }) => {
if (msg.type === 'vad.event' && msg.event) {
if (vadSubscriber && !vadSubscriber.isDestroyed()) {
const ev = msg.event;
const payload: VadEvent =
ev.type === 'segment' && ev.wav
? { type: 'segment', wav: new Uint8Array(Buffer.from(ev.wav, 'base64')), durationSec: ev.durationSec ?? 0 }
: ev.type === 'speech-start'
? { type: 'speech-start' }
: { type: 'error', message: 'événement VAD inconnu' };
vadSubscriber.send(VOICE_IPC.vadEvent, payload);
}
return;
}
if (msg.type === 'kws.detected') {
if (kwsSubscriber && !kwsSubscriber.isDestroyed()) {
kwsSubscriber.send(VOICE_IPC.kwsDetected, { keyword: msg.keyword ?? '', at: msg.at ?? Date.now() } satisfies KwsDetection);
@@ -180,6 +195,35 @@ export function kwsFeed(pcm: Uint8Array, sampleRate: number): void {
}
}
/** Neural end-of-speech detection (Silero) on frames streamed by the renderer. */
export async function vadStart(req: VadStartRequest, sender: WebContents): Promise<void> {
const spec = findModel(req.modelId);
if (!spec || spec.kind !== 'vad') throw new Error('Modèle VAD introuvable');
if (!isInstalled(spec)) throw new Error('Silero VAD non installé (Paramètres → Modèles locaux).');
vadSubscriber = sender;
await request(
{ type: 'vad.start', model: { id: spec.id, engine: spec.engine, dir: modelDir(spec), files: spec.files }, silenceMs: req.silenceMs, threshold: req.threshold, maxUtteranceSec: req.maxUtteranceSec },
30_000
);
vadActive = true;
}
export async function vadStop(): Promise<void> {
vadActive = false;
vadSubscriber = null;
if (child) await request({ type: 'vad.stop' }, 10_000).catch(() => undefined);
}
export function vadFeed(pcm: Uint8Array, sampleRate: number): void {
if (!vadActive || !child) return;
const b64 = Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).toString('base64');
try {
child.postMessage({ id: -1, type: 'vad.audio', pcm: b64, sampleRate });
} catch (err) {
log('WARN', 'voice', `vad feed failed: ${(err as Error).message}`);
}
}
/** Native model memory is only released deterministically by restarting the worker. */
export function unload(_modelId?: string): Promise<unknown> {
stopEngine();
+11 -2
View File
@@ -1,6 +1,6 @@
import { ipcMain } from 'electron';
import { VOICE_IPC, type KwsStartRequest, type SynthesizeRequest, type TranscribeRequest } from '../../shared/voice';
import { engineStatus, kwsFeed, kwsStart, kwsStop, synthesize, transcribe, unload } from './engine';
import { VOICE_IPC, type KwsStartRequest, type SynthesizeRequest, type TranscribeRequest, type VadStartRequest } from '../../shared/voice';
import { engineStatus, kwsFeed, kwsStart, kwsStop, synthesize, transcribe, unload, vadFeed, vadStart, vadStop } from './engine';
import { cancelDownload, downloadModel, listModels, removeModel } from './models';
export function registerVoiceIpc(): void {
@@ -32,6 +32,15 @@ export function registerVoiceIpc(): void {
return kwsStart({ ...req, keywords: req.keywords.filter((k) => typeof k === 'string'), sensitivity: Number(req.sensitivity) || 3 }, event.sender);
});
ipcMain.handle(VOICE_IPC.kwsStop, () => kwsStop());
ipcMain.handle(VOICE_IPC.vadStart, (event, req: VadStartRequest) => {
if (!req || typeof req.modelId !== 'string') throw new Error('Requête invalide');
return vadStart({ modelId: req.modelId, silenceMs: Number(req.silenceMs) || 700, threshold: Number(req.threshold) || 0.5, maxUtteranceSec: Number(req.maxUtteranceSec) || 30 }, event.sender);
});
ipcMain.handle(VOICE_IPC.vadStop, () => vadStop());
ipcMain.on(VOICE_IPC.vadAudio, (_e, pcm: unknown, sampleRate: unknown) => {
if (!(pcm instanceof Uint8Array) || pcm.byteLength === 0 || pcm.byteLength > 1024 * 1024) return;
vadFeed(pcm, typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 16000);
});
ipcMain.on(VOICE_IPC.kwsAudio, (_e, pcm: unknown, sampleRate: unknown) => {
if (!(pcm instanceof Uint8Array) || pcm.byteLength === 0 || pcm.byteLength > 1024 * 1024) return;
kwsFeed(pcm, typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 16000);
+81 -1
View File
@@ -21,7 +21,10 @@ type Request =
| { id: number; type: 'unload'; modelId?: string }
| { id: number; type: 'kws.start'; model: ModelRef; keywordsFile: string; threshold: number; score: number }
| { id: number; type: 'kws.audio'; pcm: string; sampleRate: number }
| { id: number; type: 'kws.stop' };
| { id: number; type: 'kws.stop' }
| { id: number; type: 'vad.start'; model: ModelRef; silenceMs: number; threshold: number; maxUtteranceSec: number }
| { id: number; type: 'vad.audio'; pcm: string; sampleRate: number }
| { id: number; type: 'vad.stop' };
type Response = { id: number; ok: true; result: unknown } | { id: number; ok: false; error: string };
@@ -32,6 +35,16 @@ type Sherpa = {
decode: (s: unknown) => void;
getResult: (s: unknown) => { text: string; lang?: string };
};
Vad: new (config: unknown, bufferSizeInSeconds: number) => {
acceptWaveform: (samples: Float32Array) => void;
isEmpty: () => boolean;
isDetected: () => boolean;
pop: () => void;
clear: () => void;
front: (enableExternalBuffer?: boolean) => { start: number; samples: Float32Array };
reset: () => void;
flush: () => void;
};
KeywordSpotter: new (config: unknown) => {
createStream: () => KwsStream;
isReady: (s: KwsStream) => boolean;
@@ -50,6 +63,7 @@ type Sherpa = {
type KwsStream = { acceptWaveform: (w: { sampleRate: number; samples: Float32Array }) => void };
let sherpa: Sherpa | null = null;
let kws: { spotter: InstanceType<Sherpa['KeywordSpotter']>; stream: KwsStream } | null = null;
let vad: { detector: InstanceType<Sherpa['Vad']>; speaking: boolean; windowSize: number; carry: Float32Array } | null = null;
let notify: ((message: unknown) => void) | null = null;
let loadError: string | null = null;
@@ -275,9 +289,74 @@ function feedKws(pcmBase64: string, sampleRate: number): void {
}
}
function startVad(model: ModelRef, silenceMs: number, threshold: number, maxUtteranceSec: number): void {
const s = loadSherpa();
const windowSize = 512;
const detector = new s.Vad(
{
sileroVad: {
model: path.join(model.dir, 'silero_vad.onnx'),
threshold: Math.max(0.1, Math.min(0.95, threshold)),
minSilenceDuration: Math.max(0.15, silenceMs / 1000),
minSpeechDuration: 0.2,
windowSize,
maxSpeechDuration: Math.max(3, maxUtteranceSec)
},
sampleRate: 16000,
numThreads: 1,
provider: 'cpu',
debug: 0
},
Math.max(10, maxUtteranceSec + 5)
);
vad = { detector, speaking: false, windowSize, carry: new Float32Array(0) };
}
function feedVad(pcmBase64: string, sampleRate: number): void {
if (!vad) return;
const bytes = Buffer.from(pcmBase64, 'base64');
const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
let samples: Float32Array = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) samples[i] = int16[i] / 32768;
if (sampleRate !== 16000) samples = resampleTo16k(samples, sampleRate);
// Silero expects fixed windows: keep the remainder for the next frame.
const merged = new Float32Array(vad.carry.length + samples.length);
merged.set(vad.carry, 0);
merged.set(samples, vad.carry.length);
const usable = merged.length - (merged.length % vad.windowSize);
for (let i = 0; i < usable; i += vad.windowSize) {
vad.detector.acceptWaveform(merged.subarray(i, i + vad.windowSize));
const detected = vad.detector.isDetected();
if (detected && !vad.speaking) {
vad.speaking = true;
notify?.({ type: 'vad.event', event: { type: 'speech-start' } });
}
while (!vad.detector.isEmpty()) {
const segment = vad.detector.front(false);
vad.detector.pop();
vad.speaking = false;
const wav = encodeWav(segment.samples, 16000);
notify?.({
type: 'vad.event',
event: { type: 'segment', wav: Buffer.from(wav.buffer, wav.byteOffset, wav.byteLength).toString('base64'), durationSec: segment.samples.length / 16000 }
});
}
}
vad.carry = merged.slice(usable);
}
// ── request handling ───────────────────────────────────────────────────────
function handle(req: Request): unknown {
switch (req.type) {
case 'vad.start':
startVad(req.model, req.silenceMs, req.threshold, req.maxUtteranceSec);
return { ok: true };
case 'vad.audio':
feedVad(req.pcm, req.sampleRate);
return { ok: true };
case 'vad.stop':
vad = null;
return { ok: true };
case 'kws.start':
startKws(req.model, req.keywordsFile, req.threshold, req.score);
return { ok: true };
@@ -331,6 +410,7 @@ function handle(req: Request): unknown {
recognizers.clear();
synthesizers.clear();
kws = null;
vad = null;
}
return { ok: true };
}
+11 -2
View File
@@ -8,6 +8,7 @@ import type { BrowserWindow } from 'electron';
import { IPC, type WebhookStatus } from '../shared/ipc';
import { normalizeHermesPush } from '../shared/hermesPush';
import { log } from './logger';
import { handleMcpHttp, MCP_PATH } from './mcp';
export const WEBHOOK_PATH = '/eveflow/hook';
const MAX_BODY_BYTES = 2 * 1024 * 1024;
@@ -57,9 +58,10 @@ export async function startWebhookServer(getWindow: () => BrowserWindow | null,
};
if (req.method === 'GET' && (req.url === '/health' || req.url === '/')) {
return json(200, { ok: true, app: 'eveflow', path: WEBHOOK_PATH });
return json(200, { ok: true, app: 'eveflow', path: WEBHOOK_PATH, mcp: MCP_PATH });
}
if (req.method !== 'POST' || !req.url?.startsWith(WEBHOOK_PATH)) {
const isMcp = !!req.url && (req.url === MCP_PATH || req.url.startsWith(`${MCP_PATH}?`));
if (!isMcp && (req.method !== 'POST' || !req.url?.startsWith(WEBHOOK_PATH))) {
return json(404, { error: 'Not Found' });
}
@@ -91,6 +93,13 @@ export async function startWebhookServer(getWindow: () => BrowserWindow | null,
if (tooLarge) return;
// Concatenate before decoding so multibyte UTF-8 (accents, emoji) split across chunks stays intact.
const body = Buffer.concat(chunks).toString('utf8');
if (isMcp) {
handleMcpHttp(getWindow(), req, res, body).catch((err: Error) => {
log('ERROR', 'mcp', err.message);
if (!res.headersSent) json(500, { error: err.message });
});
return;
}
try {
const raw: unknown = body.trim() ? JSON.parse(body) : {};
const events = normalizeHermesPush(raw);
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "eveflow",
"version": "2.2.0",
"releaseVersion": "2.2.0",
"version": "2.4.0",
"releaseVersion": "2.4.0",
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
"main": "dist-electron/main.js",
"private": true,
+33 -4
View File
@@ -11,9 +11,12 @@ import type {
WebhookStatus,
WindowMode
} from './ipc';
import type { McpToolRequest, McpToolResponse } from './ipc';
import type {
KwsDetection,
KwsStartRequest,
VadEvent,
VadStartRequest,
SynthesizeRequest,
SynthesizeResult,
TranscribeRequest,
@@ -46,10 +49,6 @@ export interface EveFlowBridge {
streamAbort: (id: string) => void;
onStreamEvent: (cb: (event: HttpStreamEvent) => void) => Unsubscribe;
};
system: {
metrics: () => Promise<SystemMetrics>;
appInfo: () => Promise<AppInfo>;
};
files: {
readLocal: (filePath: string) => Promise<string>;
writeShared: (filename: string, content: string, isBase64?: boolean) => Promise<{ path: string; url: string }>;
@@ -59,6 +58,9 @@ export interface EveFlowBridge {
hermes: {
onPush: (cb: (event: HermesPushEvent) => void) => Unsubscribe;
webhookStatus: () => Promise<WebhookStatus>;
/** Tool calls from Hermes via the local MCP endpoint that need the renderer. */
onToolRequest: (cb: (req: McpToolRequest) => void) => Unsubscribe;
toolResponse: (res: McpToolResponse) => void;
webhookRestart: () => Promise<WebhookStatus>;
};
hotkeys: {
@@ -79,5 +81,32 @@ export interface EveFlowBridge {
/** Fire-and-forget 16-bit PCM frames for the keyword spotter. */
kwsAudio: (pcm: Uint8Array, sampleRate: number) => void;
onKwsDetected: (cb: (detection: KwsDetection) => void) => Unsubscribe;
vadStart: (req: VadStartRequest) => Promise<void>;
vadStop: () => Promise<void>;
vadAudio: (pcm: Uint8Array, sampleRate: number) => void;
onVadEvent: (cb: (event: VadEvent) => void) => Unsubscribe;
};
system: {
metrics: () => Promise<SystemMetrics>;
appInfo: () => Promise<AppInfo>;
/** Screenshot of the primary display as a JPEG data URL. */
captureScreen: (maxWidth?: number) => Promise<string>;
/** Allow-listed local actions (lock session, open app/url, media keys, clipboard). */
action: (action: SystemAction) => Promise<SystemActionResult>;
};
}
export type SystemAction =
| { type: 'lock' }
| { type: 'open-app'; name: string }
| { type: 'open-url'; url: string }
| { type: 'media'; key: 'volume-up' | 'volume-down' | 'mute' | 'play-pause' | 'next' | 'previous' }
| { type: 'clipboard-read' }
| { type: 'clipboard-write'; text: string }
| { type: 'find-files'; query: string };
export interface SystemActionResult {
ok: boolean;
message?: string;
data?: unknown;
}
+18
View File
@@ -111,6 +111,10 @@ export const IPC = {
httpStreamEvent: 'http:stream:event',
metrics: 'system:metrics',
appInfo: 'app:info',
screenCapture: 'system:screen-capture',
systemAction: 'system:action',
mcpRequest: 'mcp:request',
mcpResponse: 'mcp:response',
readLocalFile: 'files:read-local',
writeSharedFile: 'files:write-shared',
openPath: 'files:open-path',
@@ -122,3 +126,17 @@ export const IPC = {
} as const;
export type HotkeyEvent = 'ptt-toggle' | 'toggle-window' | 'stop-speaking';
/** A tool call arriving from Hermes through the local MCP endpoint that needs the renderer (UI, voice, chat). */
export interface McpToolRequest {
id: string;
name: string;
args: Record<string, unknown>;
}
export interface McpToolResponse {
id: string;
ok: boolean;
result?: unknown;
error?: string;
}
+21 -3
View File
@@ -1,7 +1,7 @@
/** Local voice engine contract (sherpa-onnx in a utility process). Shared by main and renderer. */
export type VoiceModelKind = 'stt' | 'tts' | 'kws';
export type VoiceEngineKind = 'whisper' | 'sense-voice' | 'nemo-transducer' | 'kokoro' | 'piper' | 'kws-transducer';
export type VoiceModelKind = 'stt' | 'tts' | 'kws' | 'vad';
export type VoiceEngineKind = 'whisper' | 'sense-voice' | 'nemo-transducer' | 'kokoro' | 'piper' | 'kws-transducer' | 'silero';
export interface VoiceSpeaker {
id: number;
@@ -82,6 +82,20 @@ export interface KwsDetection {
at: number;
}
export interface VadStartRequest {
modelId: string;
/** Silence that ends an utterance, in ms. */
silenceMs: number;
/** Detection threshold 0..1 (0.5 default). */
threshold: number;
maxUtteranceSec: number;
}
export type VadEvent =
| { type: 'speech-start' }
| { type: 'segment'; wav: Uint8Array; durationSec: number }
| { type: 'error'; message: string };
export interface VoiceEngineStatus {
available: boolean;
error?: string;
@@ -103,5 +117,9 @@ export const VOICE_IPC = {
kwsStart: 'voice:kws:start',
kwsStop: 'voice:kws:stop',
kwsAudio: 'voice:kws:audio',
kwsDetected: 'voice:kws:detected'
kwsDetected: 'voice:kws:detected',
vadStart: 'voice:vad:start',
vadStop: 'voice:vad:stop',
vadAudio: 'voice:vad:audio',
vadEvent: 'voice:vad:event'
} as const;
+22 -1
View File
@@ -3,7 +3,9 @@ import { AlertTriangle, X } from 'lucide-react';
import type { WindowMode } from '../shared/ipc';
import { bridge } from './lib/bridge';
import { Log } from './lib/log';
import { handlePush, stopGeneration } from './services/conversation';
import { handlePush, localToolContext, stopGeneration } from './services/conversation';
import { initMcpBridge } from './services/mcpBridge';
import { isQuietTime } from './lib/quietHours';
import { speech } from './services/voice/speech';
import { voiceController } from './services/voice/voiceController';
import { useChat } from './state/chat';
@@ -57,6 +59,7 @@ function useBoot(): boolean {
})
);
disposers.push(api.hermes.onPush(handlePush));
initMcpBridge(localToolContext);
disposers.push(
api.hotkeys.on((event) => {
if (event === 'ptt-toggle') voiceController.toggle();
@@ -95,6 +98,24 @@ function useBoot(): boolean {
function useThemeSync(): void {
const theme = useSettings((s) => s.settings.theme);
const reduce = useSettings((s) => s.settings.ui.reduceMotion);
// Quiet hours: computed every 30 s; dims the HUD (night theme) and silences pushes.
const notif = useSettings((s) => s.settings.notifications);
useEffect(() => {
const tick = () => {
const quiet = notif.quietEnabled && isQuietTime(notif.quietStart, notif.quietEnd);
useChat.getState().setQuiet(quiet);
document.documentElement.toggleAttribute('data-night', quiet && notif.nightTheme);
};
tick();
const timer = setInterval(tick, 30_000);
return () => clearInterval(timer);
}, [notif]);
useEffect(() => {
const onVisible = () => { if (!document.hidden) useChat.getState().markRead(); };
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, []);
useEffect(() => {
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.motion = reduce ? 'reduce' : 'full';
+21 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Mic, Paperclip, Send, Square, X, Radio, Loader2, Volume2, VolumeX, Navigation } from 'lucide-react';
import { sendMessage, steer, stopGeneration } from '../../services/conversation';
import { Mic, Paperclip, Send, Square, X, Radio, Loader2, Volume2, VolumeX, Navigation, ScanEye, Crosshair } from 'lucide-react';
import { sendMessage, steer, stopGeneration , captureScreen } from '../../services/conversation';
import { speech } from '../../services/voice/speech';
import { voiceController } from '../../services/voice/voiceController';
import { useChat } from '../../state/chat';
@@ -50,6 +50,8 @@ export function CommandBar({ compact }: Props) {
const autoSpeak = useSettings((s) => s.settings.speech.autoSpeak);
const update = useSettings((s) => s.update);
const [images, setImages] = useState<string[]>([]);
const missionMode = useChat((s) => s.missionMode);
const missionModel = useSettings((s) => s.settings.hermes.missionModel);
const [steerMode, setSteerMode] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -167,9 +169,20 @@ export function CommandBar({ compact }: Props) {
spellCheck={false}
/>
{!compact && (
<button className="icon-btn" title="Joindre une image" onClick={() => fileRef.current?.click()} style={{ height: 46, width: 40 }}>
<Paperclip size={18} />
</button>
<>
<button
className="icon-btn"
title="Joindre une capture de l’écran (Hermes la voit)"
aria-label="Capturer l’écran"
onClick={() => void captureScreen().then((shot) => { if (shot) setImages((prev) => [...prev, shot].slice(-4)); })}
style={{ height: 46, width: 40 }}
>
<ScanEye size={18} />
</button>
<button className="icon-btn" title="Joindre une image" onClick={() => fileRef.current?.click()} style={{ height: 46, width: 40 }}>
<Paperclip size={18} />
</button>
</>
)}
<input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void onFiles(e.target.files)} />
{isSending && !steerMode ? (
@@ -187,6 +200,9 @@ export function CommandBar({ compact }: Props) {
<button className={`toggle-btn${handsFree ? ' on' : ''}`} onClick={() => voiceController.setHandsFree(!handsFree)} title="Écoute continue : le micro se réactive après chaque réponse">
<Radio size={12} /> mains libres
</button>
<button className={`toggle-btn${missionMode ? ' on' : ''}`} onClick={() => useChat.getState().setMissionMode(!missionMode)} title={missionModel ? `Mode mission : modèle ${missionModel} pour les tâches longues` : 'Mode mission (définissez un modèle « mission » dans Paramètres → Hermes)'}>
<Crosshair size={14} /> Mission
</button>
<button className={`toggle-btn${autoSpeak && speechProvider !== 'off' ? ' on' : ''}`} onClick={() => update({ speech: { autoSpeak: !autoSpeak } })} title="Lire les réponses à voix haute">
{autoSpeak && speechProvider !== 'off' ? <Volume2 size={12} /> : <VolumeX size={12} />} voix
</button>
+34 -1
View File
@@ -1,13 +1,37 @@
import { Maximize2, X, Minus } from 'lucide-react';
import { Maximize2, X, Minus, Moon, Crosshair } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { bridge } from '../../lib/bridge';
import { useSettings } from '../../state/settings';
import { useChat } from '../../state/chat';
import { useVoice } from '../../state/voice';
import { CoreStage } from '../hud/CoreStage';
import { ChatPanel } from '../chat/ChatPanel';
const STATE_LABEL: Record<string, string> = {
idle: 'veille',
listening: 'écoute',
thinking: 'réflexion',
speaking: 'parle',
alert: 'alerte',
error: 'erreur',
success: 'terminé'
};
/** Compact mode: a glanceable strip (state, unread, last sentence) above the mini chat. */
export function CompactWidget() {
const assistantName = useSettings((s) => s.settings.assistantName);
const compactOpacity = useSettings((s) => s.settings.ui.compactOpacity);
const { hud, hudOverride, unread, missionMode, quiet, last } = useChat(
useShallow((s) => {
const lastAssistant = [...s.messages].reverse().find((m) => m.role === 'assistant' && m.content.trim());
return { hud: s.hud, hudOverride: s.hudOverride, unread: s.unread, missionMode: s.missionMode, quiet: s.quiet, last: lastAssistant?.content ?? '' };
})
);
const wake = useVoice((s) => s.wake);
const api = bridge();
const state = hudOverride ?? hud;
const label = state === 'idle' && wake === 'spotting' ? 'à l’écoute du mot-clé' : STATE_LABEL[state] ?? state;
const sentence = last.replace(/```[\s\S]*?```/g, ' ').replace(/[#*_>`|]/g, '').replace(/\s+/g, ' ').trim().slice(0, 140);
return (
<div className="compact-root" style={{ ['--compact-opacity' as string]: compactOpacity }}>
<div className="compact-head">
@@ -21,6 +45,15 @@ export function CompactWidget() {
)}
</div>
<CoreStage compact />
<div className="compact-glance" onClick={() => useChat.getState().markRead()} title={sentence}>
<span className={`state${state === 'alert' || state === 'error' ? ' alert' : ''}`}>{label}</span>
<span className="last">{sentence || 'Aucun message pour l’instant.'}</span>
<span className="badges">
{missionMode && <Crosshair size={12} className="mission" aria-label="mode mission" />}
{quiet && <Moon size={12} aria-label="heures calmes" />}
{unread > 0 && <span className="unread" aria-label={`${unread} non lus`}>{unread}</span>}
</span>
</div>
<div className="compact-body">
<ChatPanel compact />
</div>
+10 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Download, Trash2, X, CheckCircle2, Cpu, Mic, Volume2, AlertTriangle, RefreshCw, Ear } from 'lucide-react';
import { Download, Trash2, X, CheckCircle2, Cpu, Mic, Volume2, AlertTriangle, RefreshCw, Ear, Activity } from 'lucide-react';
import type { VoiceModelStatus } from '../../../shared/voice';
import { bridge } from '../../lib/bridge';
import { useShallow } from 'zustand/react/shallow';
@@ -23,9 +23,11 @@ function ModelRow({ model }: { model: VoiceModelStatus }) {
const activate = () => {
if (model.kind === 'stt') update({ voice: { localModel: model.id, provider: 'local' } });
else if (model.kind === 'kws') update({ voice: { wakeMode: 'kws' } });
else if (model.kind === 'vad') update({ voice: { neuralVad: true } });
else update({ speech: { localModel: model.id, provider: 'local', localSpeaker: model.speakers?.[0]?.id ?? 0 } });
};
const activeNow = model.kind === 'kws' ? wakeMode === 'kws' : isActive;
const neuralVad = useSettings((s) => s.settings.voice.neuralVad);
const activeNow = model.kind === 'kws' ? wakeMode === 'kws' : model.kind === 'vad' ? neuralVad : isActive;
return (
<div className="row-item" style={activeNow && model.installed ? { borderColor: 'var(--accent)' } : undefined}>
@@ -76,6 +78,7 @@ export function ModelsSection() {
const stt = models.filter((m) => m.kind === 'stt');
const tts = models.filter((m) => m.kind === 'tts');
const kws = models.filter((m) => m.kind === 'kws');
const vad = models.filter((m) => m.kind === 'vad');
return (
<>
<div className="card">
@@ -99,6 +102,11 @@ export function ModelsSection() {
<div className="section-title"><Ear size={12} /> Mot d’activation</div>
<div className="list">{kws.map((m) => <ModelRow key={m.id} model={m} />)}</div>
</div>
<div className="card">
<div className="section-title"><Activity size={12} /> Fin de phrase</div>
<div className="list">{vad.map((m) => <ModelRow key={m.id} model={m} />)}</div>
<span className="hint" style={{ display: 'block', marginTop: 6 }}>Utilisé automatiquement en écoute permanente dès qu’il est installé ; sinon le VAD énergétique prend le relais.</span>
</div>
<div className="card">
<div className="section-title"><Mic size={12} /> Reconnaissance vocale</div>
<div className="list">{stt.map((m) => <ModelRow key={m.id} model={m} />)}</div>
+49 -2
View File
@@ -13,7 +13,7 @@ import { useVoice } from '../../state/voice';
import { installedModels, useVoiceModels } from '../../state/voiceModels';
import { ModelsSection } from './ModelsSection';
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'ui';
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui';
interface Props {
onClose: () => void;
@@ -151,6 +151,7 @@ export function SettingsDrawer({ onClose }: Props) {
['speech', 'Voix / TTS'],
['models', 'Modèles locaux'],
['webhook', 'Webhook'],
['notifications', 'Notifications'],
['general', 'Général'],
['ui', 'Interface']
];
@@ -215,6 +216,11 @@ export function SettingsDrawer({ onClose }: Props) {
</div>
</div>
<div className="field">
<label>Modèle « mission » (tâches longues)</label>
<input className="input" list="hermes-models" value={settings.hermes.missionModel} placeholder="(même modèle)" onChange={(e) => update({ hermes: { missionModel: e.target.value } })} />
<span className="hint">Utilisé quand le mode Mission est activé dans la barre de commande. Laissez vide pour garder le modèle principal.</span>
</div>
<div className="field">
<label>Instructions EveFlow (superposées au prompt Hermes)</label>
<textarea className="textarea" value={settings.hermes.instructions} onChange={(e) => update({ hermes: { instructions: e.target.value } })} />
</div>
@@ -336,8 +342,12 @@ export function SettingsDrawer({ onClose }: Props) {
</div>
)}
{settings.voice.wakeMode === 'kws' && (
<WakeStatus />
<>
<WakeStatus />
<Toggle on={settings.voice.neuralVad} onChange={(v) => update({ voice: { neuralVad: v } })} label="Fin de phrase neuronale (Silero)" hint="Coupe l’écoute au bon moment, même avec du bruit de fond. Nécessite le modèle Silero VAD (0,6 Mo) dans Modèles locaux." />
</>
)}
<Toggle on={settings.voice.localCommands} onChange={(v) => update({ voice: { localCommands: v } })} label="Commandes locales instantanées" hint="« Verrouille la session », « monte le son », « ouvre Spotify », « regarde mon écran »… exécutées sur ce PC sans passer par Hermes." />
<div className="row" style={{ marginTop: 10 }}>
<button className="btn small" onClick={() => void testStt()} disabled={settings.voice.provider === 'browser'}><Mic size={13} /> Tester la reconnaissance</button>
</div>
@@ -467,6 +477,14 @@ export function SettingsDrawer({ onClose }: Props) {
-d '{"role":"assistant","text":"Rapport terminé","source":"telegram"}'`}</pre>
<span className="hint">Formats acceptés : {'{role,text}'}, {'{event:"run.completed",input,output}'}, {'{event:"job.completed",job:{name},output,status}'}, {'{type:"message",payload:{text}}'}.</span>
</div>
<div className="field">
<label>Serveur MCP pour Hermes (outils du PC : écran, applications, volume, presse-papiers, voix)</label>
<pre className="input" style={{ whiteSpace: 'pre-wrap', fontSize: 11.5, userSelect: 'text' }}>{`# ~/.hermes/config.yaml côté Hermes
mcp_servers:
eveflow:
url: "http://<ip-de-ce-pc>:${settings.webhook.port}/mcp"${settings.webhook.secret ? '\n headers:\n Authorization: "Bearer <secret>"' : ''}`}</pre>
<span className="hint">Même port et même secret que le webhook. Sans secret, EveFlow n’écoute qu’en local (127.0.0.1) : définissez un secret pour un Hermes distant.</span>
</div>
<div className="row">
<button className="btn primary small" onClick={() => void applyWebhook()} disabled={!bridge()}>Appliquer et redémarrer</button>
{hermesWebhook && <span className={`status-pill ${hermesWebhook.listening ? 'online' : 'offline'}`}>{hermesWebhook.listening ? `port ${hermesWebhook.port}` : hermesWebhook.error ?? 'inactif'}</span>}
@@ -474,6 +492,35 @@ export function SettingsDrawer({ onClose }: Props) {
</div>
)}
{section === 'notifications' && (
<div className="card">
<Toggle on={settings.notifications.quietEnabled} onChange={(v) => update({ notifications: { quietEnabled: v } })} label="Heures calmes" hint="Les messages poussés (crons, Telegram) s’affichent sans être lus à voix haute ni faire clignoter le noyau ; le HUD passe en mode nuit." />
<div className="grid-2">
<div className="field">
<label>Début</label>
<input className="input" type="time" value={settings.notifications.quietStart} onChange={(e) => update({ notifications: { quietStart: e.target.value } })} />
</div>
<div className="field">
<label>Fin</label>
<input className="input" type="time" value={settings.notifications.quietEnd} onChange={(e) => update({ notifications: { quietEnd: e.target.value } })} />
</div>
</div>
<Toggle on={settings.notifications.nightTheme} onChange={(v) => update({ notifications: { nightTheme: v } })} label="Thème nuit pendant les heures calmes" />
<div className="field" style={{ marginTop: 10 }}>
<label>Mots prioritaires (lus même en heures calmes)</label>
<input className="input" value={settings.notifications.priorityKeywords} onChange={(e) => update({ notifications: { priorityKeywords: e.target.value } })} placeholder="urgent, alerte, panne" />
<span className="hint">Séparés par des virgules ; comparés au texte et au nom du cron. Les échecs de crons sont toujours prioritaires.</span>
</div>
<Toggle on={settings.notifications.summarizeIncoming} onChange={(v) => update({ notifications: { summarizeIncoming: v } })} label="Résumé vocal des messages entrants" hint="Seules les premières phrases sont lues ; le message complet reste dans le fil." />
{settings.notifications.summarizeIncoming && (
<div className="field">
<label>Phrases lues : {settings.notifications.summarySentences}</label>
<input className="range" type="range" min={1} max={5} step={1} value={settings.notifications.summarySentences} onChange={(e) => update({ notifications: { summarySentences: Number(e.target.value) } })} />
</div>
)}
</div>
)}
{section === 'general' && (
<div className="card">
<div className="grid-2">
+43
View File
@@ -0,0 +1,43 @@
/** Quiet-hours and spoken-summary helpers (pure, unit tested). */
function minutesOf(hhmm: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (h > 23 || min > 59) return null;
return h * 60 + min;
}
/** True when `now` falls inside [start, end), with ranges crossing midnight ("22:30" → "07:30"). */
export function isQuietTime(start: string, end: string, now: Date = new Date()): boolean {
const s = minutesOf(start);
const e = minutesOf(end);
if (s === null || e === null || s === e) return false;
const cur = now.getHours() * 60 + now.getMinutes();
return s < e ? cur >= s && cur < e : cur >= s || cur < e;
}
/** True when the text or job name contains one of the comma-separated priority words. */
export function isPriority(text: string, keywords: string, jobName = ''): boolean {
const words = keywords
.split(/[,;\n]/)
.map((w) => w.trim().toLowerCase())
.filter(Boolean);
if (!words.length) return false;
const hay = `${jobName} ${text}`.toLowerCase();
return words.some((w) => hay.includes(w));
}
/** First `count` sentences of a text, for a short spoken summary of a long push. */
export function summarize(text: string, count: number): string {
const clean = text
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#*_>`|]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!clean) return '';
const sentences = clean.match(/[^.!?…]+[.!?…]+["»)]?\s*|[^.!?…]+$/g) ?? [clean];
const picked = sentences.slice(0, Math.max(1, count)).join('').trim();
return picked.length < clean.length ? picked : clean;
}
+66 -8
View File
@@ -9,9 +9,12 @@ import { previewText } from '../lib/text';
import { useChat, type PendingRequest } from '../state/chat';
import { useHermes } from '../state/hermes';
import { useSettings } from '../state/settings';
import { executeLocalTool, LOCAL_TOOL_DEFINITIONS } from './hermes/localTools';
import { executeLocalTool, LOCAL_TOOL_DEFINITIONS, type LocalToolContext } from './hermes/localTools';
import { isPriority, summarize } from '../lib/quietHours';
import type { HermesStreamEvent, SendHandle } from './hermes/types';
import { speech } from './voice/speech';
import { bridge } from '../lib/bridge';
import { parseLocalIntent, runLocalIntent } from './localCommands';
let active: SendHandle | null = null;
let activeMessageId: string | null = null;
@@ -46,7 +49,7 @@ export function pingCore(): void {
useChat.getState().ping();
}
function localToolContext() {
export function localToolContext(): LocalToolContext {
const chat = useChat.getState();
const settings = useSettings.getState().settings;
return {
@@ -58,13 +61,20 @@ function localToolContext() {
setTimeout(() => useChat.getState().setHudOverride(null), 6000);
},
speak: (text: string) => speech.say(text),
showMessage: (text: string, title?: string) => {
if (!text.trim()) return;
useChat.getState().addMessage({ role: 'assistant', content: text, source: 'hermes', jobName: title, status: 'done' });
},
getStatus: () => ({
transport: useHermes.getState().transport,
link: useHermes.getState().link,
assistant: settings.assistantName,
messages: chat.messages.length,
speaking: speech.isSpeaking(),
handsFree: settings.voice.handsFree
handsFree: settings.voice.handsFree,
quietHours: chat.quiet,
missionMode: chat.missionMode,
wakeMode: settings.voice.wakeMode
}),
getHistory: (n: number) => chat.messages.slice(-n).map((m) => ({ role: m.role, content: m.content })),
notify: (title: string, body: string) => {
@@ -165,9 +175,49 @@ function addPending(request: Omit<PendingRequest, 'id' | 'createdAt'>): void {
speech.say(request.kind === 'approval' ? 'Autorisation requise.' : request.kind === 'clarify' ? request.description : 'Saisie requise.', { interrupt: false });
}
/** Screenshot of the primary display as a data URL (Electron only). */
export async function captureScreen(): Promise<string | null> {
const api = bridge();
if (!api) return null;
try {
return await api.system.captureScreen(1600);
} catch (err) {
useChat.getState().setError(`Capture d’écran : ${(err as Error).message}`);
return null;
}
}
/** Short system intents handled on the machine without a Hermes round trip. Returns true when consumed. */
async function tryLocalIntent(text: string, source: string): Promise<{ handled: boolean; images?: string[]; text?: string }> {
const settings = useSettings.getState().settings;
if (!settings.voice.localCommands || !bridge()) return { handled: false };
const intent = parseLocalIntent(text);
if (!intent) return { handled: false };
if (intent.kind === 'screenshot') {
const shot = await captureScreen();
if (!shot) return { handled: false };
return { handled: false, images: [shot], text: intent.question || text };
}
const chat = useChat.getState();
chat.addMessage({ role: 'user', content: text, source, status: 'done' });
const result = await runLocalIntent(intent);
chat.addMessage({ role: 'assistant', content: result.message, source: 'local', status: result.ok ? 'done' : 'error' });
chat.setHud(result.ok ? 'idle' : 'error');
if (settings.speech.autoSpeak) speech.say(result.message, { interrupt: true });
return { handled: true };
}
export async function sendMessage(text: string, images: string[] = [], source = 'eveflow'): Promise<void> {
const trimmed = text.trim();
let trimmed = text.trim();
if ((!trimmed && images.length === 0) || active) return;
if (trimmed && images.length === 0) {
const local = await tryLocalIntent(trimmed, source);
if (local.handled) return;
if (local.images) {
images = local.images;
trimmed = (local.text ?? trimmed).trim();
}
}
const chat = useChat.getState();
const hermes = useHermes.getState();
const settings = useSettings.getState().settings;
@@ -189,7 +239,9 @@ export async function sendMessage(text: string, images: string[] = [], source =
const timing = { firstToken: null as number | null, startedAt };
const runIdRef = { id: null as string | null };
const client = hermes.client();
const mission = chat.missionMode && settings.hermes.missionModel.trim();
const client = hermes.client(mission ? settings.hermes.missionModel : undefined);
if (mission) Log.info('hermes', `mission mode → model ${settings.hermes.missionModel}`);
const handle = client.send(
{
text: trimmed || 'Analyse cette image.',
@@ -321,11 +373,17 @@ export function handlePush(event: HermesPushEvent): void {
chat.pushActivity({ kind: 'job', name: event.jobName || 'cron', status: event.status?.includes('fail') ? 'error' : 'done', detail: previewText(event.text, 160) });
void useHermes.getState().refreshJobs();
}
if (event.role !== 'user' && settings.speech.speakIncoming) {
speech.say(event.jobName ? `Résultat de ${event.jobName}. ${event.text}` : event.text);
const n = settings.notifications;
const priority = isPriority(event.text, n.priorityKeywords, event.jobName) || !!event.status?.includes('fail');
const silenced = chat.quiet && !priority;
if (document.hidden || silenced) chat.incUnread();
if (event.role !== 'user' && settings.speech.speakIncoming && !silenced) {
const body = n.summarizeIncoming ? summarize(event.text, n.summarySentences) : event.text;
speech.say(event.jobName ? `Résultat de ${event.jobName}. ${body}` : body);
}
if (silenced) Log.info('webhook', 'quiet hours: push shown silently');
chat.setHud(event.status?.includes('fail') ? 'alert' : 'success');
pingCore();
if (!silenced) pingCore();
setTimeout(() => {
const s = useChat.getState();
if (s.hud === 'success' || s.hud === 'alert') s.setHud(speech.isSpeaking() ? 'speaking' : 'idle');
+2
View File
@@ -467,6 +467,8 @@ export class HermesClient {
throw new HttpError(handle.start.status, detail);
}
ready = true;
// Chunks that raced ahead of the start event were buffered as potential error bodies: replay them.
for (const chunk of errorChunks.splice(0)) parser.feed(chunk);
const rotated = handle.start.headers['x-hermes-session-id'];
if (rotated && rotated !== options.sessionId) onEvent({ kind: 'session', sessionId: rotated });
+42
View File
@@ -3,16 +3,35 @@
* With the runs/sessions transports Hermes uses its own server-side toolsets instead.
*/
import { bridge } from '../../lib/bridge';
import type { SystemAction } from '../../../shared/bridge';
export interface LocalToolContext {
setEmotion: (emotion: string) => void;
showMessage?: (text: string, title?: string) => void;
speak: (text: string) => void;
getStatus: () => Record<string, unknown>;
getHistory: (n: number) => Array<{ role: string; content: string }>;
notify: (title: string, body: string) => void;
}
const fn = (name: string, description: string, properties: Record<string, unknown> = {}, required: string[] = []) => ({
type: 'function',
function: { name, description, parameters: { type: 'object', properties, required } }
});
/** Tools executed on the user's PC through the Electron main process (allow-listed). */
export const SYSTEM_TOOL_DEFINITIONS = [
fn('lock_session', "Verrouille la session de l'utilisateur."),
fn('open_app', "Lance une application du PC de l'utilisateur (bloc-notes, calculatrice, chrome, spotify, vscode, terminal, explorateur…).", { name: { type: 'string' } }, ['name']),
fn('open_url', "Ouvre une URL http(s) dans le navigateur de l'utilisateur.", { url: { type: 'string' } }, ['url']),
fn('media_key', 'Touche média : volume-up, volume-down, mute, play-pause, next, previous.', { key: { type: 'string', enum: ['volume-up', 'volume-down', 'mute', 'play-pause', 'next', 'previous'] } }, ['key']),
fn('clipboard_get', 'Lit le texte du presse-papiers.'),
fn('clipboard_set', 'Place un texte dans le presse-papiers.', { text: { type: 'string' } }, ['text']),
fn('find_files', "Cherche des fichiers par nom dans Documents, Bureau, Téléchargements et Images (25 max).", { query: { type: 'string' } }, ['query'])
];
export const LOCAL_TOOL_DEFINITIONS = [
...SYSTEM_TOOL_DEFINITIONS,
{
type: 'function',
function: {
@@ -95,6 +114,29 @@ export async function executeLocalTool(name: string, rawArgs: string, ctx: Local
case 'notify_user':
ctx.notify(String(args.title ?? 'EveFlow'), String(args.body ?? ''));
return JSON.stringify({ ok: true });
case 'show_message':
ctx.showMessage?.(String(args.text ?? ''), typeof args.title === 'string' ? args.title : undefined);
return JSON.stringify({ ok: true });
case 'lock_session':
case 'open_app':
case 'open_url':
case 'media_key':
case 'clipboard_get':
case 'clipboard_set':
case 'find_files': {
const api = bridge();
if (!api) return JSON.stringify({ error: 'system actions unavailable outside Electron' });
const action: SystemAction =
name === 'lock_session' ? { type: 'lock' }
: name === 'open_app' ? { type: 'open-app', name: String(args.name ?? '') }
: name === 'open_url' ? { type: 'open-url', url: String(args.url ?? '') }
: name === 'media_key' ? { type: 'media', key: String(args.key ?? '') as 'mute' }
: name === 'clipboard_get' ? { type: 'clipboard-read' }
: name === 'clipboard_set' ? { type: 'clipboard-write', text: String(args.text ?? '') }
: { type: 'find-files', query: String(args.query ?? '') };
const result = await api.system.action(action);
return JSON.stringify(result.ok ? { ok: true, message: result.message, data: result.data } : { error: result.message ?? 'failed' });
}
case 'write_shared_file': {
const api = bridge();
if (!api) return JSON.stringify({ error: 'file system unavailable outside Electron' });
+2
View File
@@ -10,6 +10,8 @@ export interface HermesConfig {
reasoningEffort: '' | 'low' | 'medium' | 'high';
/** Extra instructions layered on top of the Hermes system prompt. */
instructions: string;
/** Model used in "mission" mode (long tasks); empty = same as `model`. */
missionModel: string;
/** Expose EveFlow client tools in chat-completions mode. */
localTools: boolean;
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Instant local commands: short French/English intents handled on the machine without a
* round trip to Hermes (lock, open app/url, volume, media, screenshot to Hermes).
* Anything unmatched goes to Hermes as usual.
*/
import type { SystemAction } from '../../shared/bridge';
import { bridge } from '../lib/bridge';
import { Log } from '../lib/log';
export interface LocalIntent {
kind: 'action' | 'screenshot';
action?: SystemAction;
/** Spoken confirmation. */
reply: string;
/** For screenshot: the question to send to Hermes with the image. */
question?: string;
}
const norm = (t: string) =>
t
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[’']/g, ' ')
.replace(/[^a-z0-9 :/._-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const LOCK = /^(verrouille|verrouiller|bloque|lock)( (la |ma )?(session|le pc|l ordinateur|the (pc|computer|screen)))?$/;
const VOL_UP = /^(monte|augmente|hausse|plus fort|volume plus|turn up|raise)( (le|the)? ?(son|volume))?( de \d+)?$/;
const VOL_DOWN = /^(baisse|diminue|moins fort|volume moins|turn down|lower)( (le|the)? ?(son|volume))?( de \d+)?$/;
const MUTE = /^(coupe|couper|mute|silence|desactive)( (le|the)? ?(son|volume|audio))?$/;
const PLAY = /^(pause|play|lecture|reprends|reprendre|mets en pause|met en pause|stop la musique|arrete la musique)$/;
const NEXT = /^(suivant|suivante|piste suivante|musique suivante|next|skip)$/;
const PREV = /^(precedent|precedente|piste precedente|previous)$/;
const OPEN_APP = /^(ouvre|ouvrir|lance|lancer|demarre|open|launch|start) ((l application|l appli|le logiciel|le programme|the app|le|la|les|l|un|une|the|moi) )*(.+)$/;
const OPEN_URL = /^(ouvre|ouvrir|va sur|open|go to) (https?:\/\/\S+|(www\.)?[a-z0-9-]+\.[a-z]{2,}(\/\S*)?)$/;
const SCREEN = /(regarde|regardes|analyse|decris|decrit|lis|explique|qu est ce qu il y a sur|que vois tu sur|what is on|look at|read) (mon |l |the )?(ecran|screen)|capture (d )?ecran|screenshot/;
export function parseLocalIntent(text: string): LocalIntent | null {
const t = norm(text).replace(/^(jarvis|hey jarvis|ok jarvis)[ ,]*/, '');
if (!t) return null;
if (LOCK.test(t)) return { kind: 'action', action: { type: 'lock' }, reply: 'Session verrouillée.' };
if (MUTE.test(t)) return { kind: 'action', action: { type: 'media', key: 'mute' }, reply: 'Son coupé.' };
if (VOL_UP.test(t)) return { kind: 'action', action: { type: 'media', key: 'volume-up' }, reply: 'Volume augmenté.' };
if (VOL_DOWN.test(t)) return { kind: 'action', action: { type: 'media', key: 'volume-down' }, reply: 'Volume baissé.' };
if (PLAY.test(t)) return { kind: 'action', action: { type: 'media', key: 'play-pause' }, reply: 'Lecture.' };
if (NEXT.test(t)) return { kind: 'action', action: { type: 'media', key: 'next' }, reply: 'Piste suivante.' };
if (PREV.test(t)) return { kind: 'action', action: { type: 'media', key: 'previous' }, reply: 'Piste précédente.' };
if (SCREEN.test(t)) return { kind: 'screenshot', reply: 'Je regarde votre écran.', question: text.trim() };
const url = OPEN_URL.exec(t);
if (url) {
const target = url[2].startsWith('http') ? url[2] : `https://${url[2]}`;
return { kind: 'action', action: { type: 'open-url', url: target }, reply: `J’ouvre ${url[2]}.` };
}
const app = OPEN_APP.exec(t);
if (app) {
const name = app[app.length - 1].trim();
if (name.length <= 40 && !/\s(et|puis|and)\s/.test(name)) return { kind: 'action', action: { type: 'open-app', name }, reply: `J’ouvre ${name}.` };
}
return null;
}
/** Execute an action intent through the bridge. Returns the spoken outcome. */
export async function runLocalIntent(intent: LocalIntent): Promise<{ ok: boolean; message: string }> {
const api = bridge();
if (!api || !intent.action) return { ok: false, message: 'Actions locales indisponibles hors Electron.' };
try {
const result = await api.system.action(intent.action);
Log.info('local', `${intent.action.type}: ${result.ok ? 'ok' : result.message}`);
return { ok: result.ok, message: result.ok ? intent.reply : result.message || 'Échec de la commande locale.' };
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Answers tool calls that Hermes sends through the local MCP endpoint and that need the renderer
* (voice, notifications, HUD, chat). System tools are executed in the main process directly.
*/
import type { McpToolRequest } from '../../shared/ipc';
import { bridge } from '../lib/bridge';
import { Log } from '../lib/log';
import { useChat } from '../state/chat';
import { executeLocalTool, type LocalToolContext } from './hermes/localTools';
let unsubscribe: (() => void) | null = null;
export function initMcpBridge(context: () => LocalToolContext): void {
const api = bridge();
if (!api || unsubscribe) return;
unsubscribe = api.hermes.onToolRequest((req: McpToolRequest) => {
void handle(req, context)
.then((result) => api.hermes.toolResponse({ id: req.id, ok: true, result }))
.catch((err: Error) => api.hermes.toolResponse({ id: req.id, ok: false, error: err.message }));
});
}
async function handle(req: McpToolRequest, context: () => LocalToolContext): Promise<unknown> {
Log.info('mcp', `tool ${req.name}`);
const chat = useChat.getState();
if (req.name === 'show_message') {
const text = String(req.args.text ?? '');
if (!text.trim()) throw new Error('texte vide');
chat.addMessage({ role: 'assistant', content: text, source: 'mcp', jobName: typeof req.args.title === 'string' ? req.args.title : undefined, status: 'done' });
chat.pushActivity({ kind: 'system', name: 'hermes → eveflow', status: 'done', detail: text.slice(0, 160) });
return { ok: true };
}
const raw = await executeLocalTool(req.name, JSON.stringify(req.args ?? {}), context());
try {
const parsed = JSON.parse(raw) as { error?: string };
if (parsed && typeof parsed === 'object' && parsed.error) throw new Error(parsed.error);
return parsed;
} catch (err) {
if ((err as Error).message && !(err instanceof SyntaxError)) throw err;
return raw;
}
}
+26 -4
View File
@@ -113,8 +113,21 @@ class VoiceController {
this.unsubscribeKws?.();
this.unsubscribeKws = api.voice.onKwsDetected((d) => this.onWakeDetected(d.keyword));
const sensitivity = Math.min(5, Math.max(1, Math.round(settings.sensitivity))) as 1 | 2 | 3 | 4 | 5;
let neuralVad = false;
if (settings.neuralVad) {
try {
const installed = (await api.voice.listModels()).some((m) => m.id === 'silero-vad' && m.installed);
if (installed) {
await api.voice.vadStart({ modelId: 'silero-vad', silenceMs: Math.max(300, Math.min(1500, settings.silenceMs - 200)), threshold: 0.5, maxUtteranceSec: 25 });
neuralVad = true;
}
} catch (err) {
Log.warn('voice', `silero unavailable, energy VAD fallback: ${(err as Error).message}`);
}
}
await this.wake.start({
deviceId: settings.micDeviceId || undefined,
neuralVad,
vad: { silenceMs: settings.silenceMs, speechRatio: SENSITIVITY_RATIO[sensitivity], minRms: SENSITIVITY_MIN_RMS[sensitivity] },
callbacks: {
onPhase: (phase) => {
@@ -141,7 +154,8 @@ class VoiceController {
}
});
voice.setWake('spotting', result.accepted.map((k) => k.replace(/_/g, ' ')));
Log.info('voice', `wake mode on: ${result.accepted.join(', ')}${result.rejected.length ? ` (rejetés : ${result.rejected.join(', ')})` : ''}`);
voice.setNeuralVad(neuralVad);
Log.info('voice', `wake mode on: ${result.accepted.join(', ')}${result.rejected.length ? ` (rejetés : ${result.rejected.join(', ')})` : ''}${neuralVad ? ' · Silero' : ''}`);
} catch (err) {
const message = (err as Error).message;
voice.setWake('error');
@@ -149,6 +163,7 @@ class VoiceController {
useChat.getState().setError(`Mot d’activation : ${message}`);
Log.error('voice', `wake mode failed: ${message}`);
await api.voice.kwsStop().catch(() => undefined);
await api.voice.vadStop().catch(() => undefined);
}
}
@@ -157,7 +172,10 @@ class VoiceController {
this.unsubscribeKws = null;
this.wake.stop();
useVoice.getState().setWake('off');
await bridge()?.voice.kwsStop().catch(() => undefined);
useVoice.getState().setNeuralVad(false);
const api = bridge();
await api?.voice.kwsStop().catch(() => undefined);
await api?.voice.vadStop().catch(() => undefined);
}
/** Restart spotting with the current settings (wake word, sensitivity, microphone). */
@@ -224,6 +242,8 @@ class VoiceController {
}
const sensitivity = Math.min(5, Math.max(1, Math.round(settings.sensitivity))) as 1 | 2 | 3 | 4 | 5;
// Barge-in: while the assistant talks, demand a clearly louder signal so the speaker echo cannot trigger.
const barging = auto && settings.bargeIn && speech.isSpeaking();
try {
await audioBus.resume();
await this.capture.start({
@@ -231,8 +251,8 @@ class VoiceController {
mode: settings.captureMode,
vad: {
silenceMs: settings.silenceMs,
speechRatio: SENSITIVITY_RATIO[sensitivity],
minRms: SENSITIVITY_MIN_RMS[sensitivity],
speechRatio: barging ? Math.min(0.9, SENSITIVITY_RATIO[sensitivity] + 0.15) : SENSITIVITY_RATIO[sensitivity],
minRms: barging ? SENSITIVITY_MIN_RMS[sensitivity] * 2.5 : SENSITIVITY_MIN_RMS[sensitivity],
noSpeechTimeoutMs: useVoice.getState().handsFree ? Number.POSITIVE_INFINITY : 9_000
},
callbacks: {
@@ -241,6 +261,8 @@ class VoiceController {
if (q !== useVoice.getState().inputLevel) useVoice.getState().setInputLevel(q);
},
onSpeechStart: () => {
// The user started talking over the assistant: cut the voice (barge-in).
if (speech.isSpeaking()) speech.stop();
useVoice.getState().setPhase('speech');
useChat.getState().ping();
},
+66 -9
View File
@@ -9,6 +9,7 @@ import { Log } from '../../lib/log';
import { audioBus } from './audioBus';
import { DEFAULT_VAD, EnergyVad, type VadOptions } from './vad';
import { buildWav16k, rms, type WavResult } from './wav';
import type { VadEvent } from '../../../shared/voice';
const WORKLET_SOURCE = `
class EveFlowWakeProcessor extends AudioWorkletProcessor {
@@ -58,6 +59,11 @@ export class WakeListener {
private callbacks: WakeCallbacks | null = null;
private vadOptions: Partial<VadOptions> = {};
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
/** Neural end-of-speech (Silero in the main process) instead of the energy VAD. */
private neural = false;
private unsubscribeVad: (() => void) | null = null;
private vadPending: Float32Array[] = [];
private vadPendingSamples = 0;
get isActive(): boolean {
return this.phase !== 'off';
@@ -67,10 +73,15 @@ export class WakeListener {
return this.phase;
}
async start(options: { deviceId?: string; vad?: Partial<VadOptions>; callbacks: WakeCallbacks }): Promise<void> {
async start(options: { deviceId?: string; vad?: Partial<VadOptions>; neuralVad?: boolean; callbacks: WakeCallbacks }): Promise<void> {
if (this.phase !== 'off') return;
this.callbacks = options.callbacks;
this.vadOptions = options.vad ?? {};
this.neural = !!options.neuralVad;
if (this.neural) {
this.unsubscribeVad?.();
this.unsubscribeVad = bridge()?.voice.onVadEvent((event) => this.onVadEvent(event)) ?? null;
}
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: options.deviceId ? { exact: options.deviceId } : undefined,
@@ -98,13 +109,34 @@ export class WakeListener {
this.node.port.onmessage = (event: MessageEvent<Float32Array>) => this.onSamples(event.data);
source.connect(this.node);
this.setPhase('spotting');
Log.info('wake', `listener started (${this.sampleRate} Hz)`);
Log.info('wake', `listener started (${this.sampleRate} Hz, fin de phrase ${this.neural ? 'Silero' : 'énergie'})`);
}
private onVadEvent(event: VadEvent): void {
if (!this.neural || (this.phase !== 'command' && this.phase !== 'speech')) return;
if (event.type === 'speech-start') {
if (this.phase === 'command') {
this.setPhase('speech');
if (this.noSpeechTimer) clearTimeout(this.noSpeechTimer);
this.noSpeechTimer = null;
}
} else if (event.type === 'segment') {
const wav: WavResult = { bytes: event.wav, sampleRate: 16_000, durationSec: event.durationSec };
this.resumeSpotting();
if (wav.durationSec >= 0.25) this.callbacks?.onUtterance(wav);
else this.callbacks?.onNoSpeech();
} else if (event.type === 'error') {
this.callbacks?.onError(event.message);
this.resumeSpotting();
}
}
/** Called by the controller when the main process reports the wake word (or on manual trigger). */
beginCommand(): void {
if (this.phase === 'off' || this.phase === 'command' || this.phase === 'speech') return;
this.vad = new EnergyVad({ ...DEFAULT_VAD, ...this.vadOptions, noSpeechTimeoutMs: Number.POSITIVE_INFINITY });
this.vad = this.neural ? null : new EnergyVad({ ...DEFAULT_VAD, ...this.vadOptions, noSpeechTimeoutMs: Number.POSITIVE_INFINITY });
this.vadPending = [];
this.vadPendingSamples = 0;
this.chunks = [];
this.totalSamples = 0;
this.setPhase('command');
@@ -126,6 +158,8 @@ export class WakeListener {
if (this.phase === 'off') return;
if (this.noSpeechTimer) clearTimeout(this.noSpeechTimer);
this.noSpeechTimer = null;
this.unsubscribeVad?.();
this.unsubscribeVad = null;
audioBus.setInputAnalyser(null);
if (this.node) {
this.node.port.onmessage = null;
@@ -139,6 +173,8 @@ export class WakeListener {
this.chunks = [];
this.pending = [];
this.pendingSamples = 0;
this.vadPending = [];
this.vadPendingSamples = 0;
this.setPhase('off');
Log.info('wake', 'listener stopped');
}
@@ -170,6 +206,14 @@ export class WakeListener {
return;
}
if (this.neural) {
// command / speech with Silero: stream ~128 ms frames to the main process, which returns the segment.
this.vadPending.push(samples);
this.vadPendingSamples += samples.length;
if (this.vadPendingSamples >= this.sampleRate * 0.128) this.flushToVad();
return;
}
// command / speech: collect the utterance
this.chunks.push(samples);
this.totalSamples += samples.length;
@@ -196,21 +240,34 @@ export class WakeListener {
}
}
private flushToSpotter(): void {
const api = bridge();
if (!api) return;
const total = this.pendingSamples;
private static toInt16(chunks: Float32Array[], total: number): Uint8Array {
const merged = new Int16Array(total);
let offset = 0;
for (const chunk of this.pending) {
for (const chunk of chunks) {
for (let i = 0; i < chunk.length; i++) {
const s = Math.max(-1, Math.min(1, chunk[i]));
merged[offset + i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
offset += chunk.length;
}
return new Uint8Array(merged.buffer);
}
private flushToSpotter(): void {
const api = bridge();
if (!api) return;
const bytes = WakeListener.toInt16(this.pending, this.pendingSamples);
this.pending = [];
this.pendingSamples = 0;
api.voice.kwsAudio(new Uint8Array(merged.buffer), this.sampleRate);
api.voice.kwsAudio(bytes, this.sampleRate);
}
private flushToVad(): void {
const api = bridge();
if (!api) return;
const bytes = WakeListener.toInt16(this.vadPending, this.vadPendingSamples);
this.vadPending = [];
this.vadPendingSamples = 0;
api.voice.vadAudio(bytes, this.sampleRate);
}
}
+17
View File
@@ -57,6 +57,16 @@ interface ChatStore {
pingCount: number;
/** Time to first token of the current reply, in ms. */
latencyMs: number | null;
/** Pushes received while the window was hidden / compact, cleared when the user looks. */
unread: number;
/** Mission mode routes the next messages to the "mission" model (long tasks). */
missionMode: boolean;
/** True while quiet hours apply (computed by App). */
quiet: boolean;
incUnread: () => void;
markRead: () => void;
setMissionMode: (on: boolean) => void;
setQuiet: (quiet: boolean) => void;
addMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'> & Partial<Pick<ChatMessage, 'id' | 'timestamp'>>) => string;
updateMessage: (id: string, patch: Partial<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => void;
@@ -91,6 +101,13 @@ export const useChat = create<ChatStore>((set, get) => ({
draft: '',
pingCount: 0,
latencyMs: null,
unread: 0,
missionMode: false,
quiet: false,
incUnread: () => set((s) => ({ unread: Math.min(99, s.unread + 1) })),
markRead: () => set((s) => (s.unread ? { unread: 0 } : {})),
setMissionMode: (missionMode) => set({ missionMode }),
setQuiet: (quiet) => set((s) => (s.quiet === quiet ? {} : { quiet })),
addMessage: (message) => {
const id = message.id ?? uid('msg');
+3 -3
View File
@@ -43,7 +43,7 @@ interface HermesStore {
webhook: WebhookStatus | null;
busy: boolean;
client: () => HermesClient;
client: (modelOverride?: string) => HermesClient;
connect: () => Promise<void>;
refreshJobs: () => Promise<void>;
refreshSessions: () => Promise<void>;
@@ -93,10 +93,10 @@ export const useHermes = create<HermesStore>((set, get) => ({
webhook: null,
busy: false,
client: () => {
client: (modelOverride) => {
const config = useSettings.getState().settings.hermes;
// Without an explicit model, use the alias advertised by /v1/models (Hermes rejects unknown names).
const model = config.model.trim() || get().models[0]?.id || '';
const model = (modelOverride ?? '').trim() || config.model.trim() || get().models[0]?.id || '';
return new HermesClient({ ...config, model });
},
+24 -1
View File
@@ -22,6 +22,10 @@ export interface VoiceSettings extends SttConfig {
/** off = push-to-talk / hands-free; transcript = filter after transcription; kws = always-on keyword spotting. */
wakeMode: 'off' | 'transcript' | 'kws';
kwsSensitivity: number; // 1..5
/** Use Silero VAD for end-of-speech in always-on mode when the model is installed. */
neuralVad: boolean;
/** Execute short system intents locally (lock, volume, open app…) instead of asking Hermes. */
localCommands: boolean;
}
export interface SpeechSettings extends TtsConfig {
@@ -35,6 +39,20 @@ export interface WebhookSettings {
secret: string;
}
export interface NotificationSettings {
/** Quiet hours: no spoken pushes, no chime, dimmed HUD (24 h "HH:MM"). */
quietEnabled: boolean;
quietStart: string;
quietEnd: string;
/** Pushes whose text or job name contains one of these words are spoken even during quiet hours. */
priorityKeywords: string;
/** Speak only the first sentences of incoming pushes (cron reports can be long). */
summarizeIncoming: boolean;
summarySentences: number;
/** Dim the HUD (night theme) during quiet hours. */
nightTheme: boolean;
}
export interface Settings {
version: 2;
assistantName: string;
@@ -45,6 +63,7 @@ export interface Settings {
voice: VoiceSettings;
speech: SpeechSettings;
webhook: WebhookSettings;
notifications: NotificationSettings;
ui: {
showTelemetry: boolean;
showReasoning: boolean;
@@ -69,7 +88,8 @@ export const DEFAULT_SETTINGS: Settings = {
reasoningEffort: '',
instructions:
"Tu es l'interface vocale EveFlow (style JARVIS). Réponds en français, de façon concise et orale quand la question est simple; utilise le Markdown uniquement pour le contenu structuré (code, listes, tableaux). Les images doivent être des URL http(s) ou des fichiers du dossier partagé.",
localTools: true
localTools: true,
missionModel: ''
},
voice: {
provider: 'openai-compatible',
@@ -88,6 +108,8 @@ export const DEFAULT_SETTINGS: Settings = {
wakeWord: 'jarvis',
wakeMode: 'off',
kwsSensitivity: 3,
neuralVad: true,
localCommands: true,
localModel: 'whisper-base'
},
speech: {
@@ -107,6 +129,7 @@ export const DEFAULT_SETTINGS: Settings = {
speakIncoming: true
},
webhook: { enabled: true, port: 7842, secret: '' },
notifications: { quietEnabled: false, quietStart: '22:30', quietEnd: '07:30', priorityKeywords: 'urgent, alerte, alarme, panne', summarizeIncoming: true, summarySentences: 2, nightTheme: true },
ui: { showTelemetry: true, showReasoning: false, reduceMotion: false, compactOpacity: 0.92 },
hermesSessionId: ''
};
+4
View File
@@ -15,6 +15,8 @@ interface VoiceStore {
micDevices: Array<{ deviceId: string; label: string }>;
wake: WakeState;
wakeKeywords: string[];
neuralVad: boolean;
setNeuralVad: (on: boolean) => void;
setWake: (state: WakeState, keywords?: string[]) => void;
setPhase: (phase: ListenPhase) => void;
setInputLevel: (level: number) => void;
@@ -37,6 +39,8 @@ export const useVoice = create<VoiceStore>((set) => ({
micDevices: [],
wake: 'off',
wakeKeywords: [],
neuralVad: false,
setNeuralVad: (neuralVad) => set({ neuralVad }),
setWake: (wake, wakeKeywords) => set(wakeKeywords ? { wake, wakeKeywords } : { wake }),
setPhase: (phase) => set({ phase }),
setInputLevel: (inputLevel) => set({ inputLevel }),
+51
View File
@@ -67,3 +67,54 @@
font-size: 13px;
padding: 8px 11px;
}
/* Glanceable strip: state, unread badge, last sentence. */
.compact-glance {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
padding: 4px 12px 8px;
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-2);
}
.compact-glance .state {
color: var(--accent);
}
.compact-glance .state.alert {
color: var(--danger);
}
.compact-glance .last {
font-family: var(--font-body);
font-size: 12.5px;
letter-spacing: 0;
text-transform: none;
color: var(--ink-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-glance .badges {
display: flex;
gap: 6px;
align-items: center;
}
.compact-glance .unread {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--accent);
color: #03101c;
font-weight: 700;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.compact-glance .mission {
color: var(--warn, #f5c451);
}
+12
View File
@@ -159,3 +159,15 @@ textarea {
.kv dd {
user-select: text;
}
/* Quiet hours: dimmer HUD, softer glow. */
:root[data-night] {
--accent-glow: rgba(var(--accent-rgb), 0.18);
}
:root[data-night] .hud-root,
:root[data-night] .compact-root {
filter: brightness(0.72) saturate(0.85);
}
:root[data-night] .core-stage canvas {
opacity: 0.8;
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { parseLocalIntent } from '../src/services/localCommands';
describe('parseLocalIntent', () => {
it('recognises system actions in French and English', () => {
expect(parseLocalIntent('Jarvis, verrouille la session')?.action).toEqual({ type: 'lock' });
expect(parseLocalIntent('monte le son')?.action).toEqual({ type: 'media', key: 'volume-up' });
expect(parseLocalIntent('Baisse le volume')?.action).toEqual({ type: 'media', key: 'volume-down' });
expect(parseLocalIntent('coupe le son')?.action).toEqual({ type: 'media', key: 'mute' });
expect(parseLocalIntent('piste suivante')?.action).toEqual({ type: 'media', key: 'next' });
expect(parseLocalIntent('ouvre le bloc-notes')?.action).toEqual({ type: 'open-app', name: 'bloc-notes' });
expect(parseLocalIntent('open spotify')?.action).toEqual({ type: 'open-app', name: 'spotify' });
expect(parseLocalIntent('ouvre github.com')?.action).toEqual({ type: 'open-url', url: 'https://github.com' });
});
it('detects screen questions', () => {
expect(parseLocalIntent('Jarvis, regarde mon écran et dis-moi ce que tu vois')?.kind).toBe('screenshot');
expect(parseLocalIntent('fais une capture d’écran')?.kind).toBe('screenshot');
});
it('leaves everything else to Hermes', () => {
expect(parseLocalIntent('Quelle est la météo à Paris demain ?')).toBeNull();
expect(parseLocalIntent('ouvre le fichier puis envoie-le à Marc')).toBeNull();
expect(parseLocalIntent('')).toBeNull();
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { isPriority, isQuietTime, summarize } from '../src/lib/quietHours';
const at = (h: number, m = 0) => new Date(2026, 0, 1, h, m);
describe('isQuietTime', () => {
it('handles ranges crossing midnight', () => {
expect(isQuietTime('22:30', '07:30', at(23))).toBe(true);
expect(isQuietTime('22:30', '07:30', at(3, 15))).toBe(true);
expect(isQuietTime('22:30', '07:30', at(7, 30))).toBe(false);
expect(isQuietTime('22:30', '07:30', at(12))).toBe(false);
});
it('handles same-day ranges and invalid input', () => {
expect(isQuietTime('13:00', '14:00', at(13, 30))).toBe(true);
expect(isQuietTime('13:00', '14:00', at(14))).toBe(false);
expect(isQuietTime('bad', '14:00', at(13))).toBe(false);
expect(isQuietTime('10:00', '10:00', at(10))).toBe(false);
});
});
describe('isPriority', () => {
it('matches keywords in text or job name', () => {
expect(isPriority('Serveur en panne depuis 5 min', 'urgent, panne')).toBe(true);
expect(isPriority('Rapport quotidien', 'urgent', 'Alerte disque')).toBe(false);
expect(isPriority('Rapport quotidien', 'alerte', 'Alerte disque')).toBe(true);
expect(isPriority('x', '')).toBe(false);
});
});
describe('summarize', () => {
it('keeps the first sentences and strips markdown', () => {
const text = '## Rapport\n\nTrois annonces **majeures** aujourd’hui. Le marché monte de 2 %. Détails ci-dessous :\n\n```\ncode\n```\n- point 1';
expect(summarize(text, 2)).toBe('Rapport Trois annonces majeures aujourd’hui. Le marché monte de 2 %.');
expect(summarize('Une seule phrase', 2)).toBe('Une seule phrase');
expect(summarize('', 2)).toBe('');
});
});