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

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

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

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

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


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 00:47:34 +02:00

392 lines
17 KiB
TypeScript

/**
* Conversation orchestrator: sends user input to Hermes through the resolved transport,
* streams the answer into the chat store (coalesced per animation frame), drives the HUD
* state, speaks the reply and surfaces approvals / clarifications as pending requests.
*/
import type { HermesPushEvent } from '../../shared/ipc';
import { Log } from '../lib/log';
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, type LocalToolContext } from './hermes/localTools';
import { isPriority, summarize } from '../lib/quietHours';
import type { HermesStreamEvent, SendHandle } from './hermes/types';
import { speech } from './voice/speech';
import { bridge } from '../lib/bridge';
import { parseLocalIntent, runLocalIntent } from './localCommands';
let active: SendHandle | null = null;
let activeMessageId: string | null = null;
const toolLabel = (name: string) => name.replace(/_/g, ' ');
// ── delta coalescing: one store update per animation frame ─────────────────
const pendingDelta = { id: '', content: '', reasoning: '', raf: 0 };
function flushDelta(): void {
if (pendingDelta.raf) cancelAnimationFrame(pendingDelta.raf);
const { id, content, reasoning } = pendingDelta;
pendingDelta.id = '';
pendingDelta.content = '';
pendingDelta.reasoning = '';
pendingDelta.raf = 0;
if (!id) return;
const chat = useChat.getState();
if (content) chat.appendToMessage(id, content);
if (reasoning) chat.appendToMessage(id, reasoning, 'reasoning');
}
function queueDelta(id: string, text: string, field: 'content' | 'reasoning'): void {
if (pendingDelta.id && pendingDelta.id !== id) flushDelta();
pendingDelta.id = id;
pendingDelta[field] += text;
if (!pendingDelta.raf) pendingDelta.raf = requestAnimationFrame(flushDelta);
}
/** Bumps a counter the core reads to flash briefly (send, tool start, speech start). */
export function pingCore(): void {
useChat.getState().ping();
}
export function localToolContext(): LocalToolContext {
const chat = useChat.getState();
const settings = useSettings.getState().settings;
return {
setEmotion: (state: string) => {
const map: Record<string, Parameters<typeof chat.setHudOverride>[0]> = {
happy: 'success', success: 'success', thinking: 'thinking', alert: 'alert', error: 'error', neutral: null
};
chat.setHudOverride(map[state] ?? null);
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,
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) => {
try {
if (Notification.permission === 'granted') new Notification(title, { body });
} catch {
/* notifications unavailable */
}
}
};
}
function handleEvent(event: HermesStreamEvent, messageId: string, runIdRef: { id: string | null }, timing: { firstToken: number | null; startedAt: number }): void {
const chat = useChat.getState();
switch (event.kind) {
case 'run.started':
runIdRef.id = event.runId;
chat.setSending(true, event.runId);
break;
case 'delta':
if (timing.firstToken === null) {
timing.firstToken = Date.now() - timing.startedAt;
chat.setLatency(timing.firstToken);
}
queueDelta(messageId, event.text, 'content');
speech.pushStream(event.text);
break;
case 'reasoning':
queueDelta(messageId, event.text, 'reasoning');
break;
case 'tool.start':
chat.pushActivity({ id: event.id, kind: 'tool', name: event.name, status: 'running', detail: event.args ? previewText(event.args, 160) : undefined });
chat.setHud('thinking');
pingCore();
break;
case 'tool.progress':
if (!chat.activity.some((a) => (event.id && a.id === event.id) || (a.name === event.name && a.status === 'running'))) {
chat.pushActivity({ id: event.id, kind: 'tool', name: event.name, status: 'running', detail: event.label ?? event.detail });
}
break;
case 'tool.end':
chat.finishActivity({ id: event.id || undefined, name: event.name }, {
status: event.ok ? 'done' : 'error',
output: event.output ? previewText(event.output, 400) : undefined
});
break;
case 'subagent.start':
chat.pushActivity({ id: event.id, kind: 'subagent', name: event.label ?? 'sous-agent', status: 'running', detail: event.childSessionId });
break;
case 'subagent.end':
chat.finishActivity({ id: event.id }, { status: event.status.includes('fail') ? 'error' : 'done', output: event.summary });
break;
case 'approval.request':
addPending({
requestId: event.requestId,
runId: runIdRef.id ?? '',
kind: 'approval',
title: 'Autorisation requise',
description: event.description,
options: event.options,
tool: event.tool,
args: event.args
});
break;
case 'clarify.request':
addPending({ requestId: event.requestId, runId: runIdRef.id ?? '', kind: 'clarify', title: 'Précision demandée', description: event.question, options: event.options });
break;
case 'input.request':
addPending({ requestId: event.requestId, runId: runIdRef.id ?? '', kind: 'input', title: event.variant === 'sudo' ? 'Mot de passe sudo' : 'Secret requis', description: event.prompt, secret: true });
break;
case 'request.expire':
chat.removePending(event.requestId);
break;
case 'session':
if (event.sessionId && event.sessionId !== useSettings.getState().settings.hermesSessionId) {
useSettings.getState().setHermesSessionId(event.sessionId);
Log.info('hermes', `session id updated: ${event.sessionId}`);
}
break;
case 'completed':
if (event.usage) chat.updateMessage(messageId, { usage: event.usage });
if (event.status === 'length') chat.updateMessage(messageId, { status: 'cancelled' });
break;
case 'error':
chat.setError(event.message);
break;
case 'raw':
Log.debug('hermes', `event ${event.event}`, event.data);
break;
}
}
function addPending(request: Omit<PendingRequest, 'id' | 'createdAt'>): void {
const chat = useChat.getState();
chat.addPending(request);
chat.setHud('alert');
// Spoken without interrupting the streamed answer.
speech.say(request.kind === 'approval' ? 'Autorisation requise.' : request.kind === 'clarify' ? request.description : 'Saisie requise.', { interrupt: false });
}
/** Screenshot of the primary display as a data URL (Electron only). */
export async function captureScreen(): Promise<string | null> {
const api = bridge();
if (!api) return null;
try {
return await api.system.captureScreen(1600);
} catch (err) {
useChat.getState().setError(`Capture d’écran : ${(err as Error).message}`);
return null;
}
}
/** Short system intents handled on the machine without a Hermes round trip. Returns true when consumed. */
async function tryLocalIntent(text: string, source: string): Promise<{ handled: boolean; images?: string[]; text?: string }> {
const settings = useSettings.getState().settings;
if (!settings.voice.localCommands || !bridge()) return { handled: false };
const intent = parseLocalIntent(text);
if (!intent) return { handled: false };
if (intent.kind === 'screenshot') {
const shot = await captureScreen();
if (!shot) return { handled: false };
return { handled: false, images: [shot], text: intent.question || text };
}
const chat = useChat.getState();
chat.addMessage({ role: 'user', content: text, source, status: 'done' });
const result = await runLocalIntent(intent);
chat.addMessage({ role: 'assistant', content: result.message, source: 'local', status: result.ok ? 'done' : 'error' });
chat.setHud(result.ok ? 'idle' : 'error');
if (settings.speech.autoSpeak) speech.say(result.message, { interrupt: true });
return { handled: true };
}
export async function sendMessage(text: string, images: string[] = [], source = 'eveflow'): Promise<void> {
let trimmed = text.trim();
if ((!trimmed && images.length === 0) || active) return;
if (trimmed && images.length === 0) {
const local = await tryLocalIntent(trimmed, source);
if (local.handled) return;
if (local.images) {
images = local.images;
trimmed = (local.text ?? trimmed).trim();
}
}
const chat = useChat.getState();
const hermes = useHermes.getState();
const settings = useSettings.getState().settings;
speech.stop();
chat.setError(null);
chat.setLatency(null);
chat.addMessage({ role: 'user', content: trimmed || '(image)', images: images.length ? images : undefined, source, status: 'done' });
const history = chat.messages
.filter((m) => m.role !== 'system' && m.status !== 'error')
.slice(-12)
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }));
const messageId = chat.addMessage({ role: 'assistant', content: '', status: 'streaming', transport: hermes.transport });
activeMessageId = messageId;
chat.setSending(true);
chat.setHud('thinking');
pingCore();
const startedAt = Date.now();
const timing = { firstToken: null as number | null, startedAt };
const runIdRef = { id: null as string | null };
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.',
images,
history,
sessionId: settings.hermesSessionId,
onEvent: (event) => handleEvent(event, messageId, runIdRef, timing),
localToolDefinitions: LOCAL_TOOL_DEFINITIONS,
localToolExecutor: (name, args) => executeLocalTool(name, args, localToolContext())
},
hermes.transport
);
active = handle;
chat.updateMessage(messageId, { transport: handle.transport });
try {
const reply = await handle.result;
flushDelta();
const state = useChat.getState();
const current = state.messages.find((m) => m.id === messageId);
const streamedText = current?.content ?? '';
const finalText = handle.aborted ? streamedText : reply || streamedText;
const truncated = current?.status === 'cancelled';
state.updateMessage(messageId, { content: finalText || (handle.aborted ? '(interrompu)' : ''), status: handle.aborted || truncated ? 'cancelled' : 'done' });
if (handle.aborted) {
speech.discardStream();
} else if (settings.speech.autoSpeak) {
if (!streamedText && finalText) speech.say(finalText);
else speech.endStream();
} else {
speech.discardStream();
}
state.setHud(speech.isSpeaking() ? 'speaking' : 'idle');
if (!handle.aborted && useHermes.getState().link === 'offline') useHermes.getState().connect().catch(() => undefined);
Log.info('hermes', `${handle.aborted ? 'aborted' : 'reply'} in ${Date.now() - startedAt} ms via ${handle.transport}`, { chars: finalText.length, firstToken: timing.firstToken });
} catch (err) {
flushDelta();
const message = (err as Error).message || String(err);
const partial = useChat.getState().messages.find((m) => m.id === messageId)?.content ?? '';
useChat.getState().updateMessage(messageId, {
content: partial || `Liaison Hermes interrompue : ${message}`,
status: partial ? 'cancelled' : 'error'
});
useChat.getState().setError(message);
useChat.getState().setHud('error');
speech.discardStream();
Log.error('hermes', `send failed: ${message}`);
if (/HTTP|ECONNREFUSED|fetch|réseau|network/i.test(message)) useHermes.getState().connect().catch(() => undefined);
setTimeout(() => {
if (useChat.getState().hud === 'error') useChat.getState().setHud('idle');
}, 4000);
} finally {
active = null;
activeMessageId = null;
const state = useChat.getState();
state.setSending(false);
state.setLatency(null);
for (const a of state.activity) {
if (a.status === 'running') state.finishActivity({ id: a.id }, { status: 'done' });
}
// Requests bound to this run cannot be answered once it is over.
for (const p of state.pending) if (!runIdRef.id || p.runId === runIdRef.id) state.removePending(p.id);
}
}
export function stopGeneration(): void {
if (active) {
active.abort();
Log.info('hermes', 'generation aborted by user');
}
speech.stop();
if (activeMessageId) useChat.getState().updateMessage(activeMessageId, { status: 'cancelled' });
useChat.getState().setHud('idle');
}
export async function steer(text: string): Promise<boolean> {
const runId = useChat.getState().currentRunId;
if (!runId || !text.trim()) return false;
try {
await useHermes.getState().client().steerRun(runId, text.trim());
useChat.getState().addMessage({ role: 'system', content: `Consigne transmise à Hermes : ${text.trim()}`, status: 'done' });
return true;
} catch (err) {
useChat.getState().setError(`Steer impossible : ${(err as Error).message}`);
return false;
}
}
export async function resolvePending(request: PendingRequest, decision: string): Promise<void> {
const chat = useChat.getState();
chat.removePending(request.id);
const client = useHermes.getState().client();
try {
if (!request.runId) throw new Error('run inconnu');
if (request.kind === 'approval') {
const choice = (['once', 'session', 'always', 'deny'].includes(decision) ? decision : decision === 'approve' ? 'once' : 'deny') as 'once' | 'session' | 'always' | 'deny';
await client.approveRun(request.runId, choice, request.requestId || undefined);
chat.addMessage({ role: 'system', content: `Autorisation ${choice === 'deny' ? 'refusée' : 'accordée'}${request.tool ? ` (${toolLabel(request.tool)})` : ''}.`, status: 'done' });
} else {
// Clarifications and secrets are answered by steering the run with the reply.
await client.steerRun(request.runId, decision);
chat.addMessage({ role: 'system', content: request.secret ? 'Saisie transmise à Hermes.' : `Réponse transmise : ${decision}`, status: 'done' });
}
chat.setHud(useChat.getState().isSending ? 'thinking' : 'idle');
} catch (err) {
chat.setError(`Réponse impossible : ${(err as Error).message}`);
}
}
/** Incoming webhook pushes (Telegram mirror, cron deliveries…). */
export function handlePush(event: HermesPushEvent): void {
const chat = useChat.getState();
const settings = useSettings.getState().settings;
if (event.type === 'raw') {
Log.debug('webhook', 'raw push', event.raw);
chat.pushActivity({ kind: 'system', name: `webhook ${event.source}`, status: 'done', detail: previewText(event.text, 200) });
return;
}
if (!event.text.trim() && !event.images?.length) return;
chat.addMessage({
role: event.role === 'user' ? 'user' : 'assistant',
content: event.text,
images: event.images,
source: event.source,
jobName: event.jobName,
status: 'done'
});
if (event.type === 'job') {
chat.pushActivity({ kind: 'job', name: event.jobName || 'cron', status: event.status?.includes('fail') ? 'error' : 'done', detail: previewText(event.text, 160) });
void useHermes.getState().refreshJobs();
}
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');
if (!silenced) pingCore();
setTimeout(() => {
const s = useChat.getState();
if (s.hud === 'success' || s.hud === 'alert') s.setHud(speech.isSpeaking() ? 'speaking' : 'idle');
}, 3000);
}