diff --git a/README.md b/README.md index 54d816a..4f25841 100644 --- a/README.md +++ b/README.md @@ -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.3.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. @@ -26,6 +26,11 @@ 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://: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. @@ -92,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://:7842/mcp" + headers: + Authorization: "Bearer " +``` + +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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 44621b9..5aad28c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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,6 @@ 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 | +| 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. diff --git a/electron/mcp.ts b/electron/mcp.ts new file mode 100644 index 0000000..61a4565 --- /dev/null +++ b/electron/mcp.ts @@ -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; + /** 'main' = executed here; 'renderer' = forwarded to the UI process. */ + where: 'main' | 'renderer'; +} + +const obj = (properties: Record, 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; +interface RpcRequest { + jsonrpc?: string; + id?: number | string | null; + method?: string; + params?: Rec; +} + +const pending = new Map void; timer: ReturnType }>(); +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 { + 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; isError?: boolean } { + return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }] }; +} + +function failure(message: string): { content: Array; isError: boolean } { + return { content: [{ type: 'text', text: message }], isError: true }; +} + +async function callTool(win: BrowserWindow | null, name: string, args: Rec): Promise<{ content: Array; 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 { + 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 { + 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]); +} diff --git a/electron/preload.ts b/electron/preload.ts index dbc8c14..94b6dad 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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(channel: string, callback: (payload: T) => void): Unsubscribe { @@ -57,6 +58,8 @@ const api: EveFlowBridge = { hermes: { onPush: (cb: (event: HermesPushEvent) => void) => subscribe(IPC.hermesPush, cb), webhookStatus: () => ipcRenderer.invoke(IPC.webhookStatus) as Promise, + onToolRequest: (cb: (req: McpToolRequest) => void) => subscribe(IPC.mcpRequest, cb), + toolResponse: (res: McpToolResponse) => ipcRenderer.send(IPC.mcpResponse, res), webhookRestart: () => ipcRenderer.invoke(IPC.webhookRestart) as Promise }, hotkeys: { diff --git a/electron/webhook.ts b/electron/webhook.ts index e498773..974f2ee 100644 --- a/electron/webhook.ts +++ b/electron/webhook.ts @@ -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); diff --git a/package.json b/package.json index 2265949..e47627b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "eveflow", - "version": "2.3.0", - "releaseVersion": "2.3.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, diff --git a/shared/bridge.ts b/shared/bridge.ts index d25d7a8..ebc5c71 100644 --- a/shared/bridge.ts +++ b/shared/bridge.ts @@ -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; + /** 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; }; hotkeys: { diff --git a/shared/ipc.ts b/shared/ipc.ts index 2ac8ba1..8df3b52 100644 --- a/shared/ipc.ts +++ b/shared/ipc.ts @@ -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; +} + +export interface McpToolResponse { + id: string; + ok: boolean; + result?: unknown; + error?: string; +} diff --git a/src/App.tsx b/src/App.tsx index 9043db0..19ce752 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; diff --git a/src/components/chat/CommandBar.tsx b/src/components/chat/CommandBar.tsx index c08826c..e183157 100644 --- a/src/components/chat/CommandBar.tsx +++ b/src/components/chat/CommandBar.tsx @@ -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([]); + const missionMode = useChat((s) => s.missionMode); + const missionModel = useSettings((s) => s.settings.hermes.missionModel); const [steerMode, setSteerMode] = useState(false); const fileRef = useRef(null); const inputRef = useRef(null); @@ -198,6 +200,9 @@ export function CommandBar({ compact }: Props) { + diff --git a/src/components/compact/CompactWidget.tsx b/src/components/compact/CompactWidget.tsx index 75e242b..8ee6648 100644 --- a/src/components/compact/CompactWidget.tsx +++ b/src/components/compact/CompactWidget.tsx @@ -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 = { + 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 (
@@ -21,6 +45,15 @@ export function CompactWidget() { )}
+
useChat.getState().markRead()} title={sentence}> + {label} + {sentence || 'Aucun message pour l’instant.'} + + {missionMode && } + {quiet && } + {unread > 0 && {unread}} + +
diff --git a/src/components/settings/SettingsDrawer.tsx b/src/components/settings/SettingsDrawer.tsx index c8b4d86..75a664e 100644 --- a/src/components/settings/SettingsDrawer.tsx +++ b/src/components/settings/SettingsDrawer.tsx @@ -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) {
+ + update({ hermes: { missionModel: e.target.value } })} /> + Utilisé quand le mode Mission est activé dans la barre de commande. Laissez vide pour garder le modèle principal. +
+