mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c75c1d674c | ||
|
|
6d3073b083 | ||
|
|
31659500ab | ||
|
|
6fc38d335b |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# EveFlow 2 — Interface vocale JARVIS pour Hermes Agent
|
||||
|
||||
[](https://github.com/R0m1k3/EveFlow/actions)
|
||||
[](https://github.com/R0m1k3/EveFlow/releases)
|
||||
[](https://github.com/R0m1k3/EveFlow/releases)
|
||||
[](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.
|
||||
@@ -26,6 +26,12 @@ La version 2 est une réécriture complète : plus de robot 3D, un pipeline voca
|
||||
* **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.
|
||||
* **Voix masculine ou féminine** : un réglage unique (Paramètres → Voix) appliqué à tous les moteurs. En local, Piper Tom ou Pierre (UPMC) pour le masculin, téléchargé automatiquement si aucune voix masculine n'est installée ; onyx / nova pour les API compatibles OpenAI ; Paul / Hortense pour les voix Windows.
|
||||
* **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.
|
||||
@@ -92,6 +98,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
|
||||
|
||||
+20
-9
@@ -13,6 +13,11 @@
|
||||
| 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 (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 |
|
||||
@@ -44,17 +49,21 @@ Sources : [jarvis-desktop-ai](https://github.com/ccarloshenri/jarvis-desktop-ai)
|
||||
- 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.
|
||||
- Reste à faire : exposer ces actions à Hermes lui-même (serveur MCP local) pour qu'un run puisse les enchaîner.
|
||||
- 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)
|
||||
|
||||
@@ -68,5 +77,7 @@ Sources : [jarvis-desktop-ai](https://github.com/ccarloshenri/jarvis-desktop-ai)
|
||||
| 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 |
|
||||
| Correctifs 2.4.0.1 | réponse vide en chat completions désormais expliquée (JSON non streamé ou erreur HTTP 200), la liaison ne passe plus en « dégradé » quand seule l'API des crons échoue, transcriptions parasites (« (cliquant) », « *Claire* ») ignorées, préférence de voix masculine/féminine |
|
||||
| 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.
|
||||
+191
@@ -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]);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type WindowMode
|
||||
} from '../shared/ipc';
|
||||
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 {
|
||||
@@ -57,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: {
|
||||
|
||||
@@ -164,3 +164,12 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [
|
||||
export function findModel(id: string): VoiceModelSpec | undefined {
|
||||
return VOICE_CATALOG.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
// Speaker gender from the label ("(homme, …)", "(femme, …)", known first names) so the UI and the
|
||||
// voice preference can pick a masculine or feminine voice without a lookup table per model.
|
||||
const MALE = /\b(homme|tom|pierre|adam|michael|eric|liam|george|lewis|daniel|fenrir|puck|onyx|echo|santa)\b/i;
|
||||
for (const spec of VOICE_CATALOG) {
|
||||
for (const sp of spec.speakers ?? []) {
|
||||
if (!sp.gender) sp.gender = /\b(femme|female)\b/i.test(sp.name) ? 'f' : MALE.test(sp.name) ? 'm' : /\bfemme\b/i.test(spec.name) ? 'f' : /\bhomme\b/i.test(spec.name) ? 'm' : undefined;
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "2.3.0",
|
||||
"releaseVersion": "2.3.0",
|
||||
"version": "2.4.0",
|
||||
"releaseVersion": "2.4.0.1",
|
||||
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
||||
"main": "dist-electron/main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
WebhookStatus,
|
||||
WindowMode
|
||||
} from './ipc';
|
||||
import type { McpToolRequest, McpToolResponse } from './ipc';
|
||||
import type {
|
||||
KwsDetection,
|
||||
KwsStartRequest,
|
||||
@@ -57,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: {
|
||||
|
||||
@@ -113,6 +113,8 @@ export const IPC = {
|
||||
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',
|
||||
@@ -124,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;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export interface VoiceSpeaker {
|
||||
id: number;
|
||||
name: string;
|
||||
lang: string;
|
||||
/** m = masculine, f = feminine (from the catalog label). */
|
||||
gender?: 'm' | 'f';
|
||||
}
|
||||
|
||||
export interface VoiceModelSpec {
|
||||
|
||||
+24
-2
@@ -3,7 +3,10 @@ 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 { ensurePreferredVoice } from './services/voice/voicePreference';
|
||||
import { isQuietTime } from './lib/quietHours';
|
||||
import { speech } from './services/voice/speech';
|
||||
import { voiceController } from './services/voice/voiceController';
|
||||
import { useChat } from './state/chat';
|
||||
@@ -43,7 +46,7 @@ function useBoot(): boolean {
|
||||
}
|
||||
if (api) {
|
||||
disposers.push(useVoiceModels.getState().subscribe());
|
||||
void useVoiceModels.getState().refresh();
|
||||
void useVoiceModels.getState().refresh().then(() => ensurePreferredVoice());
|
||||
if (settings.voice.wakeMode === 'kws') void voiceController.startWakeMode();
|
||||
let lastWake = JSON.stringify([settings.voice.wakeMode, settings.voice.wakeWord, settings.voice.kwsSensitivity, settings.voice.micDeviceId]);
|
||||
disposers.push(
|
||||
@@ -57,6 +60,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 +99,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';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Mic, Paperclip, Send, Square, X, Radio, Loader2, Volume2, VolumeX, Navigation, ScanEye } from 'lucide-react';
|
||||
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';
|
||||
@@ -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);
|
||||
@@ -198,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Wrench, CalendarClock, Sparkles, Layers, History, Play, Pause, Trash2, Pencil, Plus, Save, X, RefreshCw,
|
||||
CheckCircle2, XCircle, Loader2, Bot, Cpu, GitBranch, Webhook, FolderOpen
|
||||
} from 'lucide-react';
|
||||
@@ -41,6 +42,7 @@ function ActivityTab() {
|
||||
|
||||
function JobsTab() {
|
||||
const jobs = useHermes((s) => s.jobs);
|
||||
const jobsError = useHermes((s) => s.jobsError);
|
||||
const busy = useHermes((s) => s.busy);
|
||||
const lastSyncAt = useHermes((s) => s.lastSyncAt);
|
||||
const refreshJobs = useHermes((s) => s.refreshJobs);
|
||||
@@ -95,8 +97,9 @@ function JobsTab() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{jobsError && <div className="test-result fail" style={{ marginBottom: 8 }}><AlertTriangle size={13} /> Crons indisponibles : {jobsError}</div>}
|
||||
{jobs.length === 0 ? (
|
||||
<div className="empty">Aucun cron Hermes. Créez une mission planifiée en langage naturel.</div>
|
||||
<div className="empty">{jobsError ? 'La liaison Hermes reste utilisable ; seule la planification est indisponible.' : 'Aucun cron Hermes. Créez une mission planifiée en langage naturel.'}</div>
|
||||
) : (
|
||||
<div className="list">
|
||||
{jobs.map((job) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { bridge } from '../../lib/bridge';
|
||||
import { HermesClient } from '../../services/hermes/client';
|
||||
import { listSystemVoices } from '../../services/voice/tts';
|
||||
import { speech } from '../../services/voice/speech';
|
||||
import { ensurePreferredVoice } from '../../services/voice/voicePreference';
|
||||
import { listMicrophones } from '../../services/voice/capture';
|
||||
import { transcribeWav } from '../../services/voice/stt';
|
||||
import { encodeWav } from '../../services/voice/wav';
|
||||
@@ -13,7 +14,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 +152,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 +217,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>
|
||||
@@ -351,6 +358,14 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
|
||||
{section === 'speech' && (
|
||||
<div className="card">
|
||||
<div className="field">
|
||||
<label>Voix</label>
|
||||
<div className="segmented">
|
||||
<button className={(settings.speech.voiceGender ?? 'male') === 'male' ? 'active' : ''} onClick={() => { update({ speech: { voiceGender: 'male' } }); void ensurePreferredVoice().then((m) => m && setTtsTest({ status: 'ok', message: m })); }}>Masculine</button>
|
||||
<button className={settings.speech.voiceGender === 'female' ? 'active' : ''} onClick={() => { update({ speech: { voiceGender: 'female' } }); void ensurePreferredVoice().then((m) => m && setTtsTest({ status: 'ok', message: m })); }}>Féminine</button>
|
||||
</div>
|
||||
<span className="hint">S’applique à tous les moteurs : voix locale (Piper Tom ou Pierre pour le masculin, téléchargée automatiquement si besoin), voix OpenAI (onyx / nova), voix système Windows (Paul / Hortense).</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Moteur de synthèse</label>
|
||||
<select className="select" value={settings.speech.provider} onChange={(e) => update({ speech: { provider: e.target.value as typeof settings.speech.provider } })}>
|
||||
@@ -471,6 +486,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>}
|
||||
@@ -478,6 +501,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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -154,3 +154,27 @@ export function formatDuration(seconds: number): string {
|
||||
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`;
|
||||
return `${m}m ${(s % 60).toString().padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
const NOISE_PHRASES = [
|
||||
'sous-titres réalisés par la communauté d\'amara.org',
|
||||
'sous-titrage société radio-canada',
|
||||
'merci d\'avoir regardé',
|
||||
'abonnez-vous',
|
||||
'thank you for watching',
|
||||
'thanks for watching',
|
||||
'...'
|
||||
];
|
||||
|
||||
/**
|
||||
* Whisper-style hallucinations on silence or clicks: "(cliquant)", "*Claire*", "[Musique]",
|
||||
* "Sous-titres réalisés par…". Such transcripts must not be sent to Hermes.
|
||||
*/
|
||||
export function isTranscriptNoise(text: string): boolean {
|
||||
const t = text.trim();
|
||||
if (!t) return true;
|
||||
if (/^[\s\p{P}\p{S}]*$/u.test(t)) return true;
|
||||
// whole transcript wrapped in brackets/asterisks: a sound description
|
||||
if (/^[(\[*«"'\s]+[^()\[\]*]{0,60}[)\]*»"'\s]+$/u.test(t) && !/[a-zà-ÿ]{3,}\s+[a-zà-ÿ]{3,}\s+[a-zà-ÿ]{3,}/i.test(t)) return true;
|
||||
const lower = t.toLowerCase().replace(/[.!?…\s]+$/u, '');
|
||||
return NOISE_PHRASES.some((p) => lower === p.replace(/[.!?…\s]+$/u, ''));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Voice gender preference applied to every TTS provider (pure helpers, unit tested). */
|
||||
import type { VoiceModelStatus } from '../../shared/voice';
|
||||
|
||||
export type VoiceGender = 'male' | 'female';
|
||||
|
||||
const MALE_HINTS = /\b(homme|male|masculin|paul|thomas|claude|henri|guillaume|mathieu|antoine|nicolas|denis|pierre|tom|adam|michael|eric|liam|george|lewis|daniel|fenrir|puck|onyx|echo|david|mark|richard|james|ryan|guy)\b/i;
|
||||
const FEMALE_HINTS = /\b(femme|female|f[ée]minin|hortense|julie|denise|eloise|am[ée]lie|audrey|siwis|jessica|heart|bella|sarah|nicole|sky|alloy|nova|shimmer|zira|aria|jenny|emma|isabella|sophie|charlotte|vivienne|coral|sage)\b/i;
|
||||
|
||||
/** Best-effort gender from a voice or speaker label ("Piper Tom (homme, français)", "Microsoft Paul", "am_adam"). */
|
||||
export function inferGender(name: string): VoiceGender | undefined {
|
||||
const n = name.replace(/_/g, ' ');
|
||||
if (/^(am|bm|em|hm|im|jm|pm|zm)\b/i.test(n) || MALE_HINTS.test(n)) return 'male';
|
||||
if (/^(af|bf|ef|ff|hf|if|jf|pf|zf)\b/i.test(n) || FEMALE_HINTS.test(n)) return 'female';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** OpenAI-compatible default voice for the preferred gender (used when the user left the field empty). */
|
||||
export function defaultOpenAiVoice(gender: VoiceGender): string {
|
||||
return gender === 'male' ? 'onyx' : 'nova';
|
||||
}
|
||||
|
||||
/** Rank system voices: language first, then gender, then quality hints. */
|
||||
export function rankSystemVoice(v: { name: string; lang: string; localService?: boolean }, lang: string, gender: VoiceGender): number {
|
||||
const name = v.name.toLowerCase();
|
||||
let score = v.lang.toLowerCase().startsWith(lang) ? 100 : 0;
|
||||
const g = inferGender(v.name);
|
||||
if (g === gender) score += 50;
|
||||
else if (g && g !== gender) score -= 30;
|
||||
if (name.includes('natural') || name.includes('neural') || name.includes('online')) score += 20;
|
||||
if (name.includes('google')) score += 10;
|
||||
if (name.includes('microsoft')) score += 5;
|
||||
if (v.localService) score += 2;
|
||||
return score;
|
||||
}
|
||||
|
||||
export interface LocalVoiceChoice {
|
||||
modelId: string;
|
||||
speaker: number;
|
||||
}
|
||||
|
||||
/** Installed local voice matching language + gender, or null. */
|
||||
export function findLocalVoice(models: VoiceModelStatus[], lang: string, gender: VoiceGender): LocalVoiceChoice | null {
|
||||
const l = lang.toLowerCase().split('-')[0];
|
||||
const installed = models.filter((m) => m.kind === 'tts' && m.installed);
|
||||
for (const model of installed) {
|
||||
for (const sp of model.speakers ?? []) {
|
||||
const spLang = (sp.lang || '').toLowerCase();
|
||||
if (spLang && spLang !== l && spLang !== 'multi') continue;
|
||||
const g = (sp as { gender?: string }).gender === 'm' ? 'male' : (sp as { gender?: string }).gender === 'f' ? 'female' : inferGender(sp.name);
|
||||
if (g === gender) return { modelId: model.id, speaker: sp.id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Catalog model to download when nothing installed offers the preferred gender (French). */
|
||||
export function suggestedDownload(models: VoiceModelStatus[], lang: string, gender: VoiceGender): string | null {
|
||||
const l = lang.toLowerCase().split('-')[0];
|
||||
if (l !== 'fr') return null;
|
||||
const wanted = gender === 'male' ? ['piper-fr-tom', 'piper-fr-upmc'] : ['kokoro-v1', 'piper-fr-siwis'];
|
||||
for (const id of wanted) {
|
||||
const m = models.find((x) => x.id === id);
|
||||
if (m && !m.installed) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -9,7 +9,8 @@ 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';
|
||||
@@ -48,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 {
|
||||
@@ -60,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) => {
|
||||
@@ -231,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.',
|
||||
@@ -363,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');
|
||||
|
||||
@@ -430,6 +430,7 @@ export class HermesClient {
|
||||
let iterationText = '';
|
||||
const errorChunks: string[] = [];
|
||||
let ready = false;
|
||||
let rawBody = '';
|
||||
const parser = new SseParser((message) => {
|
||||
if (message.data === '[DONE]') return;
|
||||
const data = tryParseJson<unknown>(message.data);
|
||||
@@ -451,7 +452,13 @@ export class HermesClient {
|
||||
if (sessionId) headers['X-Hermes-Session-Id'] = sessionId;
|
||||
const handle = await httpStream(
|
||||
{ url: `${this.base}/v1/chat/completions`, method: 'POST', headers, body: JSON.stringify(payload), timeoutMs: 10 * 60_000 },
|
||||
{ onChunk: (text) => (ready ? parser.feed(text) : errorChunks.push(text)) }
|
||||
{
|
||||
onChunk: (text) => {
|
||||
if (rawBody.length < 512_000) rawBody += text;
|
||||
if (ready) parser.feed(text);
|
||||
else errorChunks.push(text);
|
||||
}
|
||||
}
|
||||
);
|
||||
currentHandle = handle;
|
||||
if (!handle.start.ok) {
|
||||
@@ -474,6 +481,17 @@ export class HermesClient {
|
||||
|
||||
await handle.done;
|
||||
parser.end();
|
||||
if (!iterationText && toolCalls.size === 0 && !aborted()) {
|
||||
// Nothing streamed: the server may have answered with a plain JSON completion (stream ignored)
|
||||
// or with a 200 carrying an error object. Surface it instead of an empty bubble.
|
||||
const recovered = recoverCompletion(rawBody);
|
||||
if (recovered.text) {
|
||||
iterationText = recovered.text;
|
||||
onEvent({ kind: 'delta', text: recovered.text });
|
||||
} else {
|
||||
throw new Error(recovered.error ?? `Réponse vide de Hermes (${rawBody.length} octets reçus${rawBody ? ` : ${rawBody.slice(0, 160).replace(/\s+/g, ' ')}` : ''})`);
|
||||
}
|
||||
}
|
||||
fullText += iterationText;
|
||||
|
||||
if (finishReason === 'tool_calls' && toolsAllowed && toolCalls.size > 0 && !aborted()) {
|
||||
@@ -498,6 +516,30 @@ export class HermesClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract text or an error message from a non-streamed chat completion body. */
|
||||
export function recoverCompletion(raw: string): { text?: string; error?: string } {
|
||||
const body = raw.trim();
|
||||
if (!body) return {};
|
||||
const candidates = body.startsWith('data:') || body.startsWith('event:')
|
||||
? body.split(/\n+/).filter((l) => l.startsWith('data:')).map((l) => l.replace(/^data:\s*/, '')).filter((l) => l && l !== '[DONE]')
|
||||
: [body];
|
||||
let text = '';
|
||||
for (const c of candidates) {
|
||||
const data = tryParseJson<Rec>(c);
|
||||
if (!data || !isRec(data)) continue;
|
||||
if (data.error) {
|
||||
const e = data.error as Rec | string;
|
||||
return { error: `Hermes : ${typeof e === 'string' ? e : String((e as Rec).message ?? JSON.stringify(e))}` };
|
||||
}
|
||||
const choice = Array.isArray(data.choices) && isRec(data.choices[0]) ? (data.choices[0] as Rec) : null;
|
||||
const msg = choice && isRec(choice.message) ? (choice.message as Rec) : choice && isRec(choice.delta) ? (choice.delta as Rec) : null;
|
||||
if (msg && typeof msg.content === 'string') text += msg.content;
|
||||
else if (typeof data.output === 'string') text += data.output;
|
||||
else if (typeof data.text === 'string') text += data.text;
|
||||
}
|
||||
return text ? { text } : {};
|
||||
}
|
||||
|
||||
/** Session ids created by the sessions transport carry an `hs:` prefix; other endpoints get the bare id. */
|
||||
export function plainSession(id: string): string {
|
||||
return id.startsWith('hs:') ? id.slice(3) : id;
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
* OpenAI-compatible /v1/audio/speech, system voices, and the legacy Google Translate endpoint.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { defaultOpenAiVoice, rankSystemVoice } from '../../lib/voicePreference';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { chunkForSpeech, cleanForSpeech, extractSentences } from '../../lib/text';
|
||||
import { httpFetch } from '../../lib/transport';
|
||||
@@ -26,6 +27,8 @@ export interface TtsConfig {
|
||||
localModel: string;
|
||||
/** Speaker id inside the local model. */
|
||||
localSpeaker: number;
|
||||
/** Preferred voice gender, applied to every provider's default voice. */
|
||||
voiceGender?: 'male' | 'female';
|
||||
}
|
||||
|
||||
export type TtsState = 'idle' | 'loading' | 'speaking';
|
||||
@@ -201,7 +204,7 @@ export class TtsEngine {
|
||||
body: JSON.stringify({
|
||||
model: model || 'tts-1',
|
||||
input: text,
|
||||
voice: voice || 'alloy',
|
||||
voice: voice || defaultOpenAiVoice(this.config.voiceGender ?? 'male'),
|
||||
speed: Math.max(0.5, Math.min(2, speed || 1)),
|
||||
response_format: format || 'mp3'
|
||||
}),
|
||||
@@ -312,8 +315,8 @@ export class TtsEngine {
|
||||
if (exact) return exact;
|
||||
}
|
||||
const lang = (this.config.language || 'fr').toLowerCase().split('-')[0];
|
||||
const candidates = voices.filter((v) => v.lang.toLowerCase().startsWith(lang));
|
||||
return candidates.sort((a, b) => scoreVoice(b) - scoreVoice(a))[0] ?? voices[0];
|
||||
const gender = this.config.voiceGender ?? 'male';
|
||||
return [...voices].sort((a, b) => rankSystemVoice(b, lang, gender) - rankSystemVoice(a, lang, gender))[0];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Voice controller: microphone → VAD → STT → conversation, plus hands-free loop and barge-in.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { isTranscriptNoise } from '../../lib/text';
|
||||
import { useChat } from '../../state/chat';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { useVoice } from '../../state/voice';
|
||||
@@ -242,6 +243,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({
|
||||
@@ -249,8 +252,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: {
|
||||
@@ -259,6 +262,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();
|
||||
},
|
||||
@@ -340,6 +345,10 @@ class VoiceController {
|
||||
const settings = useSettings.getState().settings.voice;
|
||||
try {
|
||||
let text = await transcribeWav(wav.bytes, settings);
|
||||
if (isTranscriptNoise(text)) {
|
||||
Log.debug('voice', `transcript ignored as noise: ${text}`);
|
||||
text = '';
|
||||
}
|
||||
voice.setTranscript(text);
|
||||
voice.setPhase('off');
|
||||
Log.info('voice', `transcript (${wav.durationSec.toFixed(1)}s): ${text}`);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Applies the preferred voice gender to the active TTS provider: switches the local model/speaker,
|
||||
* downloads a matching French voice when none is installed, and logs what it did.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { findLocalVoice, inferGender, suggestedDownload } from '../../lib/voicePreference';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { useVoiceModels } from '../../state/voiceModels';
|
||||
|
||||
let downloading: string | null = null;
|
||||
|
||||
/** Make the local voice match `speech.voiceGender`. Returns a short status message. */
|
||||
export async function ensurePreferredVoice(): Promise<string> {
|
||||
const { settings, update } = useSettings.getState();
|
||||
const { speech } = settings;
|
||||
const gender = speech.voiceGender ?? 'male';
|
||||
if (speech.provider !== 'local') return '';
|
||||
const models = useVoiceModels.getState().models;
|
||||
if (!models.length) return '';
|
||||
const lang = speech.language || 'fr-FR';
|
||||
const current = models.find((m) => m.id === speech.localModel);
|
||||
const currentSpeaker = current?.speakers?.find((s) => s.id === speech.localSpeaker);
|
||||
const currentGender = currentSpeaker ? (currentSpeaker as { gender?: string }).gender === 'm' ? 'male' : (currentSpeaker as { gender?: string }).gender === 'f' ? 'female' : inferGender(currentSpeaker.name) : undefined;
|
||||
if (current?.installed && currentGender === gender) return '';
|
||||
|
||||
const choice = findLocalVoice(models, lang, gender);
|
||||
if (choice) {
|
||||
update({ speech: { localModel: choice.modelId, localSpeaker: choice.speaker } });
|
||||
const name = models.find((m) => m.id === choice.modelId)?.speakers?.find((s) => s.id === choice.speaker)?.name ?? choice.modelId;
|
||||
Log.info('tts', `voice preference ${gender}: ${name}`);
|
||||
return `Voix ${gender === 'male' ? 'masculine' : 'féminine'} : ${name}`;
|
||||
}
|
||||
const download = suggestedDownload(models, lang, gender);
|
||||
if (!download || downloading === download) return download ? 'Téléchargement de la voix en cours…' : '';
|
||||
downloading = download;
|
||||
Log.info('tts', `no ${gender} voice installed, downloading ${download}`);
|
||||
try {
|
||||
await useVoiceModels.getState().download(download);
|
||||
const after = findLocalVoice(useVoiceModels.getState().models, lang, gender);
|
||||
if (after) update({ speech: { localModel: after.modelId, localSpeaker: after.speaker } });
|
||||
return after ? `Voix ${gender === 'male' ? 'masculine' : 'féminine'} installée.` : 'Voix téléchargée.';
|
||||
} catch (err) {
|
||||
Log.warn('tts', `voice download failed: ${(err as Error).message}`);
|
||||
return `Téléchargement impossible : ${(err as Error).message}`;
|
||||
} finally {
|
||||
downloading = null;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
+39
-9
@@ -38,12 +38,14 @@ interface HermesStore {
|
||||
sessions: HermesSession[];
|
||||
jobs: HermesJob[];
|
||||
jobRuns: JobRun[];
|
||||
/** Last cron sync failure (the chat link stays independent of it). */
|
||||
jobsError: string | null;
|
||||
transport: ResolvedTransport;
|
||||
lastSyncAt: number | null;
|
||||
webhook: WebhookStatus | null;
|
||||
busy: boolean;
|
||||
|
||||
client: () => HermesClient;
|
||||
client: (modelOverride?: string) => HermesClient;
|
||||
connect: () => Promise<void>;
|
||||
refreshJobs: () => Promise<void>;
|
||||
refreshSessions: () => Promise<void>;
|
||||
@@ -88,15 +90,16 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
sessions: [],
|
||||
jobs: [],
|
||||
jobRuns: [],
|
||||
jobsError: null,
|
||||
transport: 'completions',
|
||||
lastSyncAt: null,
|
||||
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 });
|
||||
},
|
||||
|
||||
@@ -119,8 +122,8 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
Log.warn('hermes', `capabilities unavailable: ${(err as Error).message}`);
|
||||
}
|
||||
const transport = resolveTransport(config, capabilities);
|
||||
const degraded = String(health.status ?? 'ok').toLowerCase() !== 'ok';
|
||||
set({ health, capabilities, transport, link: degraded ? 'degraded' : 'online', linkDetail: degraded ? `état ${health.status}` : '' });
|
||||
const degraded = !isHealthyStatus(health.status);
|
||||
set({ health, capabilities, transport, link: degraded ? 'degraded' : 'online', linkDetail: degraded ? describeHealth(health) : '' });
|
||||
Log.info('hermes', `connected (${transport})`, { status: health.status, model: capabilities?.model });
|
||||
void get().refreshCatalog();
|
||||
void get().refreshJobs();
|
||||
@@ -165,7 +168,7 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
const merged = [...incoming, ...previous.filter((r) => !incoming.some((i) => i.id === r.id))]
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
.slice(0, 100);
|
||||
set({ jobs, jobRuns: merged, lastSyncAt: Date.now(), link: get().link === 'offline' || get().link === 'degraded' ? 'online' : get().link });
|
||||
set({ jobs, jobRuns: merged, jobsError: null, lastSyncAt: Date.now(), link: get().link === 'offline' ? 'online' : get().link });
|
||||
const snapshot = JSON.stringify({ jobs, jobRuns: merged });
|
||||
if (snapshot !== lastCacheSnapshot) {
|
||||
lastCacheSnapshot = snapshot;
|
||||
@@ -174,9 +177,8 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
Log.warn('hermes', `jobs sync failed: ${message}`);
|
||||
if (/HTTP 404/.test(message)) return; // jobs API disabled on this server
|
||||
// A failing jobs poll does not mean chat is down: degrade, and let the next health probe decide.
|
||||
set({ linkDetail: message, link: get().link === 'online' ? 'degraded' : get().link });
|
||||
// The cron API can be absent or restricted on a given Hermes: the chat link is unaffected.
|
||||
set({ jobsError: /HTTP 404/.test(message) ? 'API des crons absente sur ce serveur Hermes' : message });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -233,3 +235,31 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
}));
|
||||
|
||||
export { jobStatus };
|
||||
|
||||
const HEALTHY = new Set(['ok', 'healthy', 'up', 'alive', 'pass', 'ready', 'running', 'online', 'true']);
|
||||
|
||||
/** Hermes /health reports "ok"; /health/detailed may report "healthy", "degraded" or "unhealthy". */
|
||||
export function isHealthyStatus(status: unknown): boolean {
|
||||
if (status === undefined || status === null || status === '') return true;
|
||||
return HEALTHY.has(String(status).toLowerCase());
|
||||
}
|
||||
|
||||
/** Human summary of a degraded health payload: failing checks by name. */
|
||||
export function describeHealth(health: Record<string, unknown>): string {
|
||||
const failing: string[] = [];
|
||||
const visit = (node: unknown, prefix: string, depth: number) => {
|
||||
if (!node || typeof node !== 'object' || depth > 3) return;
|
||||
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
||||
if (key === 'status') continue;
|
||||
if (value && typeof value === 'object') {
|
||||
const rec = value as Record<string, unknown>;
|
||||
const st = rec.status ?? rec.ok ?? rec.healthy;
|
||||
if (st !== undefined && !isHealthyStatus(st)) failing.push(prefix + key);
|
||||
else visit(value, `${prefix}${key}.`, depth + 1);
|
||||
} else if (typeof value === 'boolean' && !value && /ok|healthy|ready|connected|available/i.test(key)) failing.push(prefix + key);
|
||||
}
|
||||
};
|
||||
visit(health, '', 0);
|
||||
const base = `état ${String(health.status)}`;
|
||||
return failing.length ? `${base} · ${failing.slice(0, 4).join(', ')}` : base;
|
||||
}
|
||||
+19
-1
@@ -39,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;
|
||||
@@ -49,6 +63,7 @@ export interface Settings {
|
||||
voice: VoiceSettings;
|
||||
speech: SpeechSettings;
|
||||
webhook: WebhookSettings;
|
||||
notifications: NotificationSettings;
|
||||
ui: {
|
||||
showTelemetry: boolean;
|
||||
showReasoning: boolean;
|
||||
@@ -73,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',
|
||||
@@ -109,10 +125,12 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
volume: 1,
|
||||
localModel: 'kokoro-v1',
|
||||
localSpeaker: 30,
|
||||
voiceGender: 'male',
|
||||
autoSpeak: true,
|
||||
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: ''
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { recoverCompletion } from '../src/services/hermes/client';
|
||||
import { describeHealth, isHealthyStatus } from '../src/state/hermes';
|
||||
|
||||
describe('health status', () => {
|
||||
it('accepts the usual healthy words', () => {
|
||||
for (const s of ['ok', 'OK', 'healthy', 'ready', undefined]) expect(isHealthyStatus(s)).toBe(true);
|
||||
for (const s of ['degraded', 'unhealthy', 'error']) expect(isHealthyStatus(s)).toBe(false);
|
||||
});
|
||||
it('names the failing checks', () => {
|
||||
expect(describeHealth({ status: 'degraded', checks: { memory: { status: 'ok' }, sessions_db: { status: 'error' } } })).toBe('état degraded · checks.sessions_db');
|
||||
expect(describeHealth({ status: 'degraded' })).toBe('état degraded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recoverCompletion', () => {
|
||||
it('reads a plain JSON completion when the server ignored streaming', () => {
|
||||
expect(recoverCompletion(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'Bonjour.' } }] }))).toEqual({ text: 'Bonjour.' });
|
||||
});
|
||||
it('surfaces an error object returned with HTTP 200', () => {
|
||||
expect(recoverCompletion(JSON.stringify({ error: { message: 'model not found' } })).error).toContain('model not found');
|
||||
});
|
||||
it('handles SSE bodies and empty input', () => {
|
||||
expect(recoverCompletion('data: {"choices":[{"delta":{"content":"A"}}]}\n\ndata: {"choices":[{"delta":{"content":"B"}}]}\n\ndata: [DONE]\n\n')).toEqual({ text: 'AB' });
|
||||
expect(recoverCompletion('')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
+16
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { chunkForSpeech, cleanForSpeech, extractSentences, preprocessMedia } from '../src/lib/text';
|
||||
import { chunkForSpeech, cleanForSpeech, extractSentences, isTranscriptNoise, preprocessMedia } from '../src/lib/text';
|
||||
|
||||
describe('text', () => {
|
||||
it('cleans markdown for speech', () => {
|
||||
@@ -25,3 +25,18 @@ describe('text', () => {
|
||||
expect(preprocessMedia('MEDIA:/tmp/a.png')).toContain('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTranscriptNoise', () => {
|
||||
it('drops Whisper hallucinations on silence', () => {
|
||||
expect(isTranscriptNoise('(cliquant)')).toBe(true);
|
||||
expect(isTranscriptNoise('*Claire*')).toBe(true);
|
||||
expect(isTranscriptNoise('[Musique]')).toBe(true);
|
||||
expect(isTranscriptNoise('...')).toBe(true);
|
||||
expect(isTranscriptNoise("Sous-titres réalisés par la communauté d'Amara.org")).toBe(true);
|
||||
});
|
||||
it('keeps real sentences', () => {
|
||||
expect(isTranscriptNoise('Jarvis, allume la lumière du salon.')).toBe(false);
|
||||
expect(isTranscriptNoise('(Jarvis) quelle heure est-il maintenant ?')).toBe(false);
|
||||
expect(isTranscriptNoise('Oui')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultOpenAiVoice, findLocalVoice, inferGender, rankSystemVoice, suggestedDownload } from '../src/lib/voicePreference';
|
||||
import type { VoiceModelStatus } from '../shared/voice';
|
||||
|
||||
const model = (id: string, installed: boolean, speakers: Array<{ id: number; name: string; lang: string }>): VoiceModelStatus =>
|
||||
({ id, kind: 'tts', engine: 'piper', name: id, description: '', languages: ['fr'], sizeMb: 1, url: '', dir: id, files: [], speakers, installed, installedBytes: 0 }) as unknown as VoiceModelStatus;
|
||||
|
||||
describe('inferGender', () => {
|
||||
it('reads catalog labels, system voices and kokoro ids', () => {
|
||||
expect(inferGender('Piper Tom (homme, français)')).toBe('male');
|
||||
expect(inferGender('Siwis (femme, français)')).toBe('female');
|
||||
expect(inferGender('Microsoft Paul - French (France)')).toBe('male');
|
||||
expect(inferGender('Microsoft Hortense - French (France)')).toBe('female');
|
||||
expect(inferGender('am_adam')).toBe('male');
|
||||
expect(inferGender('Voix 3')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('local voice selection', () => {
|
||||
const models = [
|
||||
model('kokoro-v1', true, [{ id: 30, name: 'Siwis (femme, français)', lang: 'fr' }, { id: 4, name: 'Adam (homme, anglais US)', lang: 'en' }]),
|
||||
model('piper-fr-tom', false, [{ id: 0, name: 'Tom', lang: 'fr' }]),
|
||||
model('piper-fr-upmc', true, [{ id: 0, name: 'Jessica (femme)', lang: 'fr' }, { id: 1, name: 'Pierre (homme)', lang: 'fr' }])
|
||||
];
|
||||
it('prefers an installed speaker of the wanted gender in the right language', () => {
|
||||
expect(findLocalVoice(models, 'fr-FR', 'male')).toEqual({ modelId: 'piper-fr-upmc', speaker: 1 });
|
||||
expect(findLocalVoice(models, 'fr-FR', 'female')).toEqual({ modelId: 'kokoro-v1', speaker: 30 });
|
||||
expect(findLocalVoice(models.slice(0, 2), 'fr', 'male')).toBeNull();
|
||||
});
|
||||
it('suggests a French download when nothing matches', () => {
|
||||
expect(suggestedDownload(models, 'fr', 'male')).toBe('piper-fr-tom');
|
||||
expect(suggestedDownload(models, 'en', 'male')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider defaults', () => {
|
||||
it('picks onyx for male and nova for female on OpenAI-compatible APIs', () => {
|
||||
expect(defaultOpenAiVoice('male')).toBe('onyx');
|
||||
expect(defaultOpenAiVoice('female')).toBe('nova');
|
||||
});
|
||||
it('ranks system voices by language then gender', () => {
|
||||
const paul = { name: 'Microsoft Paul - French (France)', lang: 'fr-FR', localService: true };
|
||||
const hortense = { name: 'Microsoft Hortense - French (France)', lang: 'fr-FR', localService: true };
|
||||
const david = { name: 'Microsoft David - English (US)', lang: 'en-US', localService: true };
|
||||
expect(rankSystemVoice(paul, 'fr', 'male')).toBeGreaterThan(rankSystemVoice(hortense, 'fr', 'male'));
|
||||
expect(rankSystemVoice(hortense, 'fr', 'female')).toBeGreaterThan(rankSystemVoice(paul, 'fr', 'female'));
|
||||
expect(rankSystemVoice(paul, 'fr', 'male')).toBeGreaterThan(rankSystemVoice(david, 'fr', 'male'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user