mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-15 11:17:17 +02:00
feat: serveur MCP, heures calmes, mode mission, widget glanceable, barge-in (v2.4.0) (#14)
Serveur MCP local - Endpoint /mcp (JSON-RPC 2.0, Streamable HTTP, réponses JSON) sur le serveur webhook : initialize, tools/list, tools/call, ping. Même secret que le webhook. 14 outils : capture_screen (image MCP), lock_session, open_app, open_url, media_key, clipboard_get/set, find_files, speak_text, notify_user, set_hud_state, get_app_status, get_conversation_history, show_message. Les outils UI transitent par IPC vers le renderer. - Les mêmes actions système sont proposées au modèle en chat completions. Notifications - Heures calmes (plage horaire, franchissement de minuit), mots prioritaires, échecs de crons toujours lus, résumé vocal des messages entrants (n premières phrases), thème nuit automatique, compteur de non-lus. Conversation - Mode mission : bouton dans la barre de commande, second modèle Hermes (hermes.missionModel) pour les tâches longues. - Barge-in : la parole de l'utilisateur coupe la voix ; seuil d'énergie et ratio relevés pendant la synthèse pour ignorer l'écho. Widget compact - Bandeau glanceable : état, dernière phrase, non-lus, indicateurs heures calmes et mission. Tests : 28 tests unitaires (heures calmes, priorité, résumé), e2e Electron sous Xvfb : MCP initialize/tools/list/tools/call (presse-papiers, capture en image, état et message via le renderer), bandeau compact, aucune erreur. Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
af8bdc5e9d
commit
6fc38d335b
24 files changed
+668
-31
No files matched your search
@@ -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>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useVoice } from '../../state/voice';
|
||||
import { installedModels, useVoiceModels } from '../../state/voiceModels';
|
||||
import { ModelsSection } from './ModelsSection';
|
||||
|
||||
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'ui';
|
||||
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -151,6 +151,7 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
['speech', 'Voix / TTS'],
|
||||
['models', 'Modèles locaux'],
|
||||
['webhook', 'Webhook'],
|
||||
['notifications', 'Notifications'],
|
||||
['general', 'Général'],
|
||||
['ui', 'Interface']
|
||||
];
|
||||
@@ -215,6 +216,11 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Modèle « mission » (tâches longues)</label>
|
||||
<input className="input" list="hermes-models" value={settings.hermes.missionModel} placeholder="(même modèle)" onChange={(e) => update({ hermes: { missionModel: e.target.value } })} />
|
||||
<span className="hint">Utilisé quand le mode Mission est activé dans la barre de commande. Laissez vide pour garder le modèle principal.</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Instructions EveFlow (superposées au prompt Hermes)</label>
|
||||
<textarea className="textarea" value={settings.hermes.instructions} onChange={(e) => update({ hermes: { instructions: e.target.value } })} />
|
||||
</div>
|
||||
@@ -471,6 +477,14 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
-d '{"role":"assistant","text":"Rapport terminé","source":"telegram"}'`}</pre>
|
||||
<span className="hint">Formats acceptés : {'{role,text}'}, {'{event:"run.completed",input,output}'}, {'{event:"job.completed",job:{name},output,status}'}, {'{type:"message",payload:{text}}'}.</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Serveur MCP pour Hermes (outils du PC : écran, applications, volume, presse-papiers, voix)</label>
|
||||
<pre className="input" style={{ whiteSpace: 'pre-wrap', fontSize: 11.5, userSelect: 'text' }}>{`# ~/.hermes/config.yaml côté Hermes
|
||||
mcp_servers:
|
||||
eveflow:
|
||||
url: "http://<ip-de-ce-pc>:${settings.webhook.port}/mcp"${settings.webhook.secret ? '\n headers:\n Authorization: "Bearer <secret>"' : ''}`}</pre>
|
||||
<span className="hint">Même port et même secret que le webhook. Sans secret, EveFlow n’écoute qu’en local (127.0.0.1) : définissez un secret pour un Hermes distant.</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="btn primary small" onClick={() => void applyWebhook()} disabled={!bridge()}>Appliquer et redémarrer</button>
|
||||
{hermesWebhook && <span className={`status-pill ${hermesWebhook.listening ? 'online' : 'offline'}`}>{hermesWebhook.listening ? `port ${hermesWebhook.port}` : hermesWebhook.error ?? 'inactif'}</span>}
|
||||
@@ -478,6 +492,35 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'notifications' && (
|
||||
<div className="card">
|
||||
<Toggle on={settings.notifications.quietEnabled} onChange={(v) => update({ notifications: { quietEnabled: v } })} label="Heures calmes" hint="Les messages poussés (crons, Telegram) s’affichent sans être lus à voix haute ni faire clignoter le noyau ; le HUD passe en mode nuit." />
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Début</label>
|
||||
<input className="input" type="time" value={settings.notifications.quietStart} onChange={(e) => update({ notifications: { quietStart: e.target.value } })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Fin</label>
|
||||
<input className="input" type="time" value={settings.notifications.quietEnd} onChange={(e) => update({ notifications: { quietEnd: e.target.value } })} />
|
||||
</div>
|
||||
</div>
|
||||
<Toggle on={settings.notifications.nightTheme} onChange={(v) => update({ notifications: { nightTheme: v } })} label="Thème nuit pendant les heures calmes" />
|
||||
<div className="field" style={{ marginTop: 10 }}>
|
||||
<label>Mots prioritaires (lus même en heures calmes)</label>
|
||||
<input className="input" value={settings.notifications.priorityKeywords} onChange={(e) => update({ notifications: { priorityKeywords: e.target.value } })} placeholder="urgent, alerte, panne" />
|
||||
<span className="hint">Séparés par des virgules ; comparés au texte et au nom du cron. Les échecs de crons sont toujours prioritaires.</span>
|
||||
</div>
|
||||
<Toggle on={settings.notifications.summarizeIncoming} onChange={(v) => update({ notifications: { summarizeIncoming: v } })} label="Résumé vocal des messages entrants" hint="Seules les premières phrases sont lues ; le message complet reste dans le fil." />
|
||||
{settings.notifications.summarizeIncoming && (
|
||||
<div className="field">
|
||||
<label>Phrases lues : {settings.notifications.summarySentences}</label>
|
||||
<input className="range" type="range" min={1} max={5} step={1} value={settings.notifications.summarySentences} onChange={(e) => update({ notifications: { summarySentences: Number(e.target.value) } })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'general' && (
|
||||
<div className="card">
|
||||
<div className="grid-2">
|
||||
|
||||
Reference in new issue
Block a user