improve hermes visuals and tool monitor

This commit is contained in:
Michael committed 2026-05-21 08:30:39 +02:00
1 parent 9f03a47078
commit 1caa738dfb
11 files changed
+1246 -170

No files matched your search

+41 -7
View File
@@ -47,7 +47,7 @@ export class AgentService {
avatarId: EveAvatar = 'eve',
callbacks?: AppCallbacks,
onToken?: (token: string) => void,
onToolCall?: (toolName: string) => void,
onToolCall?: (toolName: string, detail?: string, status?: 'running' | 'done' | 'error') => void,
sessionId?: string,
onSessionIdChange?: (newSessionId: string) => void,
images?: string[]
@@ -63,7 +63,18 @@ export class AgentService {
// inject the avatar character prompt as before.
let updatedHistory: { role: 'user' | 'assistant' | 'system'; content: string }[];
if (config.provider === 'hermes') {
updatedHistory = history.map(h => ({ role: h.role as 'user' | 'assistant' | 'system', content: h.content }));
updatedHistory = [
{
role: 'system' as const,
content: [
'Platform: EveFlow desktop chat, connected through the OpenAI-compatible API, not a Hermes messaging gateway.',
'Do not use MEDIA:/path tags for images or files here: those are only intercepted by Telegram/Discord/Slack gateways and local paths are not web-accessible in EveFlow.',
'When showing an internet image, return a normal Markdown image with a public http(s) URL: ![alt](https://...).',
'When creating a diagram or SVG for EveFlow, use the generate_shared_image tool so the app writes it locally and can display it.'
].join(' ')
},
...history.map(h => ({ role: h.role as 'user' | 'assistant' | 'system', content: h.content }))
];
} else {
const systemPrompt = this.getSystemPromptForAvatar(avatarId);
updatedHistory = [
@@ -138,7 +149,7 @@ export class AgentService {
config: AgentConfig,
callbacks?: AppCallbacks,
onToken?: (token: string) => void,
onToolCall?: (toolName: string) => void,
onToolCall?: (toolName: string, detail?: string, status?: 'running' | 'done' | 'error') => void,
sessionId?: string,
onSessionIdChange?: (newSessionId: string) => void,
images?: string[]
@@ -261,7 +272,7 @@ export class AgentService {
for (const tc of assistantMsg.tool_calls) {
const toolName: string = tc.function?.name || 'unknown';
LogService.info('HermesAgent', `Tool call: ${toolName}`, tc.function?.arguments);
onToolCall?.(toolName);
onToolCall?.(toolName, tc.function?.arguments, 'running');
const result = await executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks!);
messages.push(result);
@@ -295,7 +306,7 @@ export class AgentService {
onToken: (token: string) => void,
messages: any[],
callbacks: AppCallbacks | undefined,
onToolCall: ((name: string) => void) | undefined,
onToolCall: ((name: string, detail?: string, status?: 'running' | 'done' | 'error') => void) | undefined,
endpoint: string,
headers: Record<string, string>,
model: string
@@ -305,6 +316,7 @@ export class AgentService {
let fullContent = '';
let buffer = '';
const pendingToolCalls: Record<string, any> = {};
let sseEvent = 'message';
while (true) {
const { done, value } = await reader.read();
@@ -315,12 +327,34 @@ export class AgentService {
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('event: ')) {
sseEvent = line.slice(7).trim() || 'message';
continue;
}
if (!line.startsWith('data: ')) continue;
const jsonStr = line.slice(6).trim();
if (jsonStr === '[DONE]') break;
if (jsonStr === '[DONE]') {
sseEvent = 'message';
break;
}
try {
const chunk = JSON.parse(jsonStr);
if (sseEvent === 'hermes.tool.progress') {
const tool = String(chunk.tool || chunk.name || chunk.label || 'outil');
const label = String(chunk.label || chunk.preview || tool);
const emoji = chunk.emoji ? `${chunk.emoji} ` : '';
const detail = [chunk.preview, chunk.args, chunk.skill, chunk.toolset]
.filter(Boolean)
.map((value: any) => typeof value === 'string' ? value : JSON.stringify(value))
.join(' | ');
onToolCall?.(`${emoji}${label}`, detail || tool, 'running');
sseEvent = 'message';
continue;
}
const delta = chunk.choices?.[0]?.delta;
const finishReason = chunk.choices?.[0]?.finish_reason;
@@ -348,7 +382,7 @@ export class AgentService {
for (const tc of Object.values(pendingToolCalls) as any[]) {
const toolName = tc.function?.name;
LogService.info('HermesAgent', `[stream] Tool call: ${toolName}`);
onToolCall?.(toolName);
onToolCall?.(toolName, tc.function?.arguments, 'running');
const result = await executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks);
messages.push(result);
}
+229 -20
View File
@@ -54,6 +54,40 @@ export class AudioService {
public ttsSynth: SpeechSynthesis;
private recognition: any = null;
private isListeningActive = false;
private currentAudioElement: HTMLAudioElement | null = null;
private audioQueue: { text: string; voiceName?: string; rate: number; pitch: number; provider: 'system' | 'google-free' }[] = [];
private isPlayingQueue = false;
private activeReject: ((err: any) => void) | null = null;
public get isSpeakingActive(): boolean {
return this.isPlayingQueue || this.audioQueue.length > 0;
}
private logError(tag: string, message: string, data?: any) {
console.error(`[${tag}] ${message}`, data);
if ((window as any).electronAPI?.writeLog) {
(window as any).electronAPI.writeLog({
ts: new Date().toISOString(),
level: 'ERROR',
tag,
message,
data: data ? { message: data.message, stack: data.stack, ...data } : undefined
});
}
}
private logWarn(tag: string, message: string, data?: any) {
console.warn(`[${tag}] ${message}`, data);
if ((window as any).electronAPI?.writeLog) {
(window as any).electronAPI.writeLog({
ts: new Date().toISOString(),
level: 'WARN',
tag,
message,
data: data ? { message: data.message, stack: data.stack, ...data } : undefined
});
}
}
constructor() {
this.ttsSynth = window.speechSynthesis;
@@ -148,9 +182,15 @@ export class AudioService {
}
private _cleanForTTS(raw: string): string {
// 0a. Supprimer les tokens MEDIA: (chemins de fichiers envoyés par le bot) pour ne pas les lire à voix haute
let noMedia = raw.replace(/MEDIA:\s*\S+/g, '');
// 0b. Supprimer complètement les blocs de code Markdown (```code```) car ils ne sont pas prononçables oralement
let noCode = noMedia.replace(/```[\s\S]*?```/g, ' ');
// 1. Améliorer la ponctuation Markdown pour forcer des pauses de diction naturelles
// Remplacer les puces de liste (ex: - item, * item) par une pause douce (virgule)
let formatted = raw.replace(/(?:\r?\n)\s*[-*+]\s+/g, ', ');
let formatted = noCode.replace(/(?:\r?\n)\s*[-*+]\s+/g, ', ');
// Remplacer les deux-points suivis d'un saut de ligne par un point
formatted = formatted.replace(/:\s*(?:\r?\n)/g, '. ');
@@ -218,40 +258,209 @@ export class AudioService {
return u;
}
// Synthèse Vocale complète (annule et relit tout le texte)
public speak(text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
this.ttsSynth.cancel();
const u = this._makeUtterance(text, voiceName, rate, pitch);
u.onend = () => resolve();
u.onerror = (e) => reject(e);
this.ttsSynth.speak(u);
});
// Moteur Google TTS gratuit en ligne avec découpage intelligent
private async speakGoogleFree(text: string, rate: number): Promise<void> {
const cleanText = this._cleanForTTS(text);
if (!cleanText.trim()) return;
const chunks: string[] = [];
let currentChunk = '';
// Regex pour couper en phrases
const sentences = cleanText.match(/[^.!?]+[.!?]*|.+/g) || [cleanText];
for (const sentence of sentences) {
if ((currentChunk + sentence).length > 180) {
if (currentChunk) {
chunks.push(currentChunk.trim());
currentChunk = '';
}
if (sentence.length > 180) {
const words = sentence.split(/\s+/);
let temp = '';
for (const word of words) {
if ((temp + ' ' + word).length > 180) {
chunks.push(temp.trim());
temp = word;
} else {
temp += (temp ? ' ' : '') + word;
}
}
if (temp) currentChunk = temp;
} else {
currentChunk = sentence;
}
} else {
currentChunk += (currentChunk ? ' ' : '') + sentence;
}
}
if (currentChunk) {
chunks.push(currentChunk.trim());
}
for (const chunk of chunks) {
if (!chunk) continue;
const url = `https://translate.google.com/translate_tts?ie=UTF-8&tl=fr&client=tw-ob&q=${encodeURIComponent(chunk)}`;
await new Promise<void>((resolve, reject) => {
const audio = new Audio(url);
this.currentAudioElement = audio;
audio.playbackRate = rate;
this.activeReject = reject;
const timeout = setTimeout(() => {
if (this.currentAudioElement === audio) this.currentAudioElement = null;
this.activeReject = null;
audio.pause();
reject(new Error("playback_stopped"));
}, 15000);
audio.onended = () => {
clearTimeout(timeout);
if (this.currentAudioElement === audio) this.currentAudioElement = null;
this.activeReject = null;
resolve();
};
audio.onerror = () => {
clearTimeout(timeout);
if (this.currentAudioElement === audio) this.currentAudioElement = null;
this.activeReject = null;
const code = audio.error ? audio.error.code : 'UNKNOWN';
const msg = audio.error ? audio.error.message : 'No message';
reject(new Error(`Erreur de chargement Google TTS (code: ${code}, msg: ${msg})`));
};
audio.play().catch((err) => {
clearTimeout(timeout);
if (this.currentAudioElement === audio) this.currentAudioElement = null;
this.activeReject = null;
reject(err);
});
});
}
}
// Ajoute une phrase à la file TTS sans annuler la lecture en cours
// Utilisé pour le streaming phrase par phrase (démarre l'audio dès la 1ère phrase)
public queueSentence(text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1): void {
if (!this.ttsSynth) return;
// File de traitement asynchrone des phrases de synthèse vocale (Zero Trust & Zero Dependency)
private async processQueue(): Promise<void> {
if (this.isPlayingQueue) return;
this.isPlayingQueue = true;
while (this.audioQueue.length > 0) {
const item = this.audioQueue.shift();
if (!item) continue;
try {
if (item.provider === 'google-free') {
await this.speakGoogleFree(item.text, item.rate);
} else {
await new Promise<void>((resolve, reject) => {
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
const u = this._makeUtterance(item.text, item.voiceName, item.rate, item.pitch);
u.onend = () => resolve();
u.onerror = (e: any) => {
if (e && (e.error === 'interrupted' || e.error === 'canceled')) {
reject(new Error("playback_stopped"));
} else {
reject(new Error("Erreur de diction système"));
}
};
this.ttsSynth.speak(u);
});
}
} catch (err: any) {
if (err?.message === 'playback_stopped') {
break;
}
this.logWarn("AudioService", "Échec de la lecture de la phrase dans la file", err);
if (item.provider === 'google-free') {
// Repli automatique transparent sur la voix locale hors-ligne pour ce segment précis en cas de défaillance
try {
await new Promise<void>((resolve, reject) => {
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
const u = this._makeUtterance(item.text, item.voiceName, item.rate, item.pitch);
u.onend = () => resolve();
u.onerror = (e: any) => {
if (e && (e.error === 'interrupted' || e.error === 'canceled')) {
reject(new Error("playback_stopped"));
} else {
reject(new Error("Erreur de diction système"));
}
};
this.ttsSynth.speak(u);
});
} catch (fallbackErr: any) {
if (fallbackErr?.message === 'playback_stopped') {
break;
}
this.logError("AudioService", "Échec également du repli système", fallbackErr);
}
}
}
}
this.isPlayingQueue = false;
}
// Synthèse Vocale complète (annule et relit tout le texte)
public async speak(
text: string,
voiceName?: string,
rate: number = 1.0,
pitch: number = 1.1,
provider: 'system' | 'google-free' = 'google-free'
): Promise<void> {
this.stopSpeaking();
this.queueSentence(text, voiceName, rate, pitch, provider);
// Attendre la fin complète de la lecture pour résoudre la promesse
while (this.isPlayingQueue || this.audioQueue.length > 0) {
await new Promise(resolve => setTimeout(resolve, 50));
}
}
// Ajoute une phrase à la file de lecture vocale pour un enchaînement fluide en temps réel (streaming)
public queueSentence(
text: string,
voiceName?: string,
rate: number = 1.0,
pitch: number = 1.1,
provider: 'system' | 'google-free' = 'google-free'
): void {
const clean = text.trim();
if (!clean) return;
const u = this._makeUtterance(clean, voiceName, rate, pitch);
if (u.text.trim()) {
this.ttsSynth.speak(u); // s'enchaîne automatiquement à la file SpeechSynthesis
}
this.audioQueue.push({ text: clean, voiceName, rate, pitch, provider });
this.processQueue();
}
// Vide la file TTS (fin du streaming ou interruption utilisateur)
public flushQueue(): void {
this.audioQueue = [];
this.isPlayingQueue = false;
if (this.ttsSynth) this.ttsSynth.cancel();
if (this.currentAudioElement) {
this.currentAudioElement.pause();
this.currentAudioElement = null;
}
if (this.activeReject) {
this.activeReject(new Error("playback_stopped"));
this.activeReject = null;
}
}
// Arrêter immédiatement de parler
// Arrêter immédiatement de parler et vider la file
public stopSpeaking() {
this.audioQueue = [];
this.isPlayingQueue = false;
if (this.ttsSynth) {
this.ttsSynth.cancel();
}
if (this.currentAudioElement) {
this.currentAudioElement.pause();
this.currentAudioElement = null;
}
if (this.activeReject) {
this.activeReject(new Error("playback_stopped"));
this.activeReject = null;
}
}
// Lancer l'écoute vocale (STT - Speech-To-Text)
+11 -1
View File
@@ -85,7 +85,17 @@ export class PollingService {
return;
}
const jobs: HermesJob[] = await response.json();
const payload = await response.json();
const jobs: HermesJob[] = Array.isArray(payload)
? payload
: Array.isArray(payload?.jobs)
? payload.jobs
: [];
if (!Array.isArray(jobs)) {
LogService.warn('PollingService', 'Format /api/jobs inattendu', { preview: JSON.stringify(payload).slice(0, 200) });
return;
}
for (const job of jobs) {
if (job.status === 'completed' && job.id && !this.seenJobIds.has(job.id)) {