mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-16 19:58:07 +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
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,8 @@ class VoiceController {
|
||||
}
|
||||
|
||||
const sensitivity = Math.min(5, Math.max(1, Math.round(settings.sensitivity))) as 1 | 2 | 3 | 4 | 5;
|
||||
// Barge-in: while the assistant talks, demand a clearly louder signal so the speaker echo cannot trigger.
|
||||
const barging = auto && settings.bargeIn && speech.isSpeaking();
|
||||
try {
|
||||
await audioBus.resume();
|
||||
await this.capture.start({
|
||||
@@ -249,8 +251,8 @@ class VoiceController {
|
||||
mode: settings.captureMode,
|
||||
vad: {
|
||||
silenceMs: settings.silenceMs,
|
||||
speechRatio: SENSITIVITY_RATIO[sensitivity],
|
||||
minRms: SENSITIVITY_MIN_RMS[sensitivity],
|
||||
speechRatio: barging ? Math.min(0.9, SENSITIVITY_RATIO[sensitivity] + 0.15) : SENSITIVITY_RATIO[sensitivity],
|
||||
minRms: barging ? SENSITIVITY_MIN_RMS[sensitivity] * 2.5 : SENSITIVITY_MIN_RMS[sensitivity],
|
||||
noSpeechTimeoutMs: useVoice.getState().handsFree ? Number.POSITIVE_INFINITY : 9_000
|
||||
},
|
||||
callbacks: {
|
||||
@@ -259,6 +261,8 @@ class VoiceController {
|
||||
if (q !== useVoice.getState().inputLevel) useVoice.getState().setInputLevel(q);
|
||||
},
|
||||
onSpeechStart: () => {
|
||||
// The user started talking over the assistant: cut the voice (barge-in).
|
||||
if (speech.isSpeaking()) speech.stop();
|
||||
useVoice.getState().setPhase('speech');
|
||||
useChat.getState().ping();
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user