mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-14 10:46:44 +02:00
improve hermes visuals and tool monitor
This commit is contained in:
1 parent
9f03a47078
commit
1caa738dfb
11 files changed
+1246
-170
No files matched your search
@@ -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: .',
|
||||
'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
@@ -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)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in new issue
Block a user