From 1caa738dfb1829bc72c0ced85dbdc258a3f98b09 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 21 May 2026 08:30:39 +0200 Subject: [PATCH] improve hermes visuals and tool monitor --- main.js | 145 ++++++- package-lock.json | 4 +- preload.js | 2 + src/App.tsx | 592 +++++++++++++++++++++------ src/components/AvatarModelLoader.tsx | 2 +- src/components/EveModel.tsx | 6 +- src/components/ThreeCanvas.tsx | 21 +- src/index.css | 335 +++++++++++++++ src/services/agentService.ts | 48 ++- src/services/audioService.ts | 249 ++++++++++- src/services/pollingService.ts | 12 +- 11 files changed, 1246 insertions(+), 170 deletions(-) diff --git a/main.js b/main.js index 41242b8..7e31e05 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain } = require('electron'); +const { app, BrowserWindow, ipcMain, session } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -42,34 +42,55 @@ let webhookServer = null; // Handles multiple formats so EveFlow mirrors both sides of Telegram conversations. function normalizeHermesEvents(raw) { const src = raw.platform || raw.source || 'webhook'; + const collectImages = (obj) => { + const images = []; + const push = (value) => { + if (!value) return; + if (typeof value === 'string') images.push(value); + else if (typeof value === 'object') push(value.url || value.href || value.path || value.file || value.file_url || value.media_url); + }; + for (const key of ['image', 'image_url', 'photo', 'media', 'url', 'file', 'file_url', 'media_url']) { + push(obj?.[key]); + } + for (const key of ['images', 'photos', 'media_urls', 'attachments', 'files']) { + const value = obj?.[key]; + if (Array.isArray(value)) value.forEach(push); + else push(value); + } + return [...new Set(images)]; + }; + const withImages = (event, obj) => { + const images = collectImages(obj); + return images.length ? { ...event, images } : event; + }; // Format A — { type:'message', payload:{ text, role?, platform? } } if (raw.type === 'message' && raw.payload?.text) { - return [{ + return [withImages({ type: 'message', role: raw.payload.role || 'assistant', text: raw.payload.text, source: raw.payload.platform || src - }]; + }, raw.payload)]; } // Format B — run completed: { event:'run.completed', input?, output, platform? } if ((raw.event === 'run.completed' || raw.type === 'run.completed') && raw.output) { const events = []; if (raw.input) events.push({ type: 'message', role: 'user', text: raw.input, source: src }); - events.push({ type: 'message', role: 'assistant', text: raw.output, source: src }); + events.push(withImages({ type: 'message', role: 'assistant', text: raw.output, source: src }, raw)); return events; } // Format C — direct: { role, text, source? } if (raw.role && raw.text) { - return [{ type: 'message', role: raw.role, text: raw.text, source: src }]; + return [withImages({ type: 'message', role: raw.role, text: raw.text, source: src }, raw)]; } // Format D — legacy: { type:'user_message'|'assistant_message', text } if (raw.text && raw.type) { const role = raw.type.includes('user') ? 'user' : 'assistant'; - return [{ type: 'message', role, text: raw.text, source: src }]; + return [withImages({ type: 'message', role, text: raw.text, source: src }, raw)]; } // Passthrough — let renderer decide @@ -206,6 +227,16 @@ function createWindow() { } app.whenReady().then(() => { + // Configurer l'intercepteur de requêtes pour Google TTS pour contourner le blocage HTTP 403 (User-Agent Electron) + session.defaultSession.webRequest.onBeforeSendHeaders( + { urls: ['https://translate.google.com/*'] }, + (details, callback) => { + details.requestHeaders['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'; + details.requestHeaders['Referer'] = 'https://translate.google.com/'; + callback({ cancel: false, requestHeaders: details.requestHeaders }); + } + ); + startWebhookServer(); createWindow(); @@ -373,6 +404,108 @@ ipcMain.handle('read-local-file', async (_event, filePath) => { } }); +// Handler pour charger une image distante via Node.js (contourne CORS et les restrictions du navigateur Electron) +// Retourne une Data URL base64 pour un affichage garanti sans flickering ni erreur CORS +ipcMain.handle('fetch-remote-image', async (_event, url, extraHeaders = {}) => { + const https = require('https'); + const http = require('http'); + + writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `fetch-remote-image: ${url}` }); + + return new Promise((resolve, reject) => { + try { + if (!url || typeof url !== 'string' || (!url.startsWith('http://') && !url.startsWith('https://'))) { + const err = `URL invalide : ${url}`; + writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: err }); + reject(new Error(err)); + return; + } + + const client = url.startsWith('https://') ? https : http; + let timeoutId; + let isSettled = false; + const settleReject = (err) => { + if (isSettled) return; + isSettled = true; + clearTimeout(timeoutId); + reject(err); + }; + const settleResolve = (value) => { + if (isSettled) return; + isSettled = true; + clearTimeout(timeoutId); + resolve(value); + }; + + const req = client.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'image/*, */*', + ...extraHeaders, + }, + }, (res) => { + writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `HTTP ${res.statusCode} — Content-Type: ${res.headers['content-type']} — URL: ${url}` }); + + // Gérer les redirects HTTP 301/302 + if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) { + writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `Redirect → ${res.headers.location}` }); + // Suivre le redirect récursivement + const newUrl = res.headers.location; + const newClient = newUrl.startsWith('https://') ? https : http; + newClient.get(newUrl, { headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'image/*' } }, (res2) => { + if (res2.statusCode !== 200) { reject(new Error(`HTTP ${res2.statusCode} après redirect`)); return; } + const chunks2 = []; + res2.on('data', c => chunks2.push(c)); + res2.on('end', () => { + const buf = Buffer.concat(chunks2); + const ct = ((res2.headers['content-type'] || 'image/jpeg').split(';')[0]).trim(); + settleResolve(`data:${ct};base64,${buf.toString('base64')}`); + }); + }).on('error', settleReject); + return; + } + + if (res.statusCode !== 200) { + const err = `HTTP ${res.statusCode} pour ${url}`; + writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: err }); + res.resume(); + settleReject(new Error(err)); + return; + } + + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => { + try { + const buf = Buffer.concat(chunks); + const ct = ((res.headers['content-type'] || 'image/jpeg').split(';')[0]).trim(); + const dataUrl = `data:${ct};base64,${buf.toString('base64')}`; + writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `Image chargée OK — ${buf.length} octets — ${ct}` }); + settleResolve(dataUrl); + } catch (e) { + settleReject(e); + } + }); + res.on('error', settleReject); + }); + + timeoutId = setTimeout(() => { + req.destroy(); + const err = `Timeout (12s) pour ${url}`; + writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: err }); + settleReject(new Error(err)); + }, 12000); + req.on('error', (err) => { + writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: `Erreur réseau: ${err.message} — ${url}` }); + settleReject(err); + }); + } catch (err) { + writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: `Exception: ${err.message}` }); + reject(err); + } + }); +}); + // ── CPU Telemetry Helper ─────────────────────────────────────────────────── function getCpuLoad() { const cpus = os.cpus(); diff --git a/package-lock.json b/package-lock.json index c6851e2..e0cf5a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "eveflow", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eveflow", - "version": "1.0.0", + "version": "1.0.2", "dependencies": { "@react-three/drei": "^9.105.0", "@react-three/fiber": "^8.16.0", diff --git a/preload.js b/preload.js index 8111aa7..d855c33 100644 --- a/preload.js +++ b/preload.js @@ -28,4 +28,6 @@ contextBridge.exposeInMainWorld('electronAPI', { writeSharedFile: (filename, content, isBase64) => ipcRenderer.invoke('write-shared-file', filename, content, isBase64), // Ouverture de fichier local openLocalFile: (filePath) => ipcRenderer.invoke('open-local-file', filePath), + // Chargement d'une image distante via Node.js (contourne CORS \u2014 retourne une Data URL base64) + fetchRemoteImage: (url, extraHeaders = {}) => ipcRenderer.invoke('fetch-remote-image', url, extraHeaders), }); diff --git a/src/App.tsx b/src/App.tsx index 6b34844..561f9ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { Send, Mic, MicOff, Settings, MessageSquare, Volume2, VolumeX, Sparkles, Cpu, Minimize2, X, AlertCircle, StopCircle, Paperclip } from 'lucide-react'; -import { ThreeCanvas } from './components/ThreeCanvas'; +import { ThreeCanvas, ToolEvent } from './components/ThreeCanvas'; import { AudioService } from './services/audioService'; import { AgentService, AgentConfig, AgentProvider } from './services/agentService'; import { EmotionService, EveAvatar, EveEmotion } from './services/emotionService'; @@ -86,88 +86,161 @@ const compressImage = (dataUrl: string, mimeType: string): Promise<{ dataUrl: st }); }; +// Prétraite le contenu d'un message pour convertir les tokens MEDIA: en syntaxe Markdown image +// Le bot Hermes envoie MEDIA:/tmp/cat.jpg au lieu de ![img](/tmp/cat.jpg), on corrige ça côté renderer +const preprocessMessageContent = (content: string): string => { + if (!content) return content; + // Entourer de \n\n pour que ReactMarkdown traite l'image comme un élément bloc (pas inline) + return content.replace(/MEDIA:\s*(\S+)/g, (_match, filePath) => `\n\n![image](${filePath})\n\n`); +}; + +// Cache module-level des images déjà chargées (src → dataUrl ou blobUrl) +// Évite les re-fetches et le clignotement quand ReactMarkdown recrée les composants pendant le streaming +const _imgCache = new Map(); +const _imgPending = new Map>(); +const _imgErrorCache = new Map(); + // Composant de rendu asynchrone pour les images locales dans ReactMarkdown et la galerie // Gère de façon Zero Trust la distinction entre base64 local, fichier Windows, et chemin absolu du serveur distant Hermes +// Le cache module-level _imgCache évite les refetch et le clignotement lors du streaming React const LocalImage: React.FC<{ src: string; alt?: string; agentUrl?: string; - provider?: string; + provider?: string; + hermesApiKey?: string; + hermesSessionKey?: string; [key: string]: any; -}> = ({ src, alt, agentUrl, provider, ...props }) => { - const [imageSrc, setImageSrc] = useState(''); +}> = ({ src, alt, agentUrl, provider, hermesApiKey, hermesSessionKey, ...props }) => { + // Initialisation lazy : si déjà en cache, pas de flash du GIF transparent + const [imageSrc, setImageSrc] = useState(() => _imgCache.get(src) || ''); + const [error, setError] = useState(() => _imgErrorCache.get(src) || ''); + void error; useEffect(() => { - // 1. Si c'est une image base64 en direct (Data URL), pas de traitement IPC requis + if (!src) return; + + // Cache hit : image déjà chargée, on l'affiche immédiatement sans aucun fetch + if (_imgCache.has(src)) { + setImageSrc(_imgCache.get(src)!); + return; + } + + if (_imgErrorCache.has(src)) { + setError(_imgErrorCache.get(src)!); + return; + } + + const setAndCache = (url: string) => { + _imgCache.set(src, url); + _imgErrorCache.delete(src); + setError(''); + setImageSrc(url); + }; + + const setFailure = (message: string) => { + _imgErrorCache.set(src, message); + setError(message); + setImageSrc(''); + }; + + const loadRemote = (remoteUrl: string, headers?: Record) => { + const api = (window as any).electronAPI; + if (!api?.fetchRemoteImage) { + setAndCache(remoteUrl); + return; + } + + if (!_imgPending.has(remoteUrl)) { + const pending = api.fetchRemoteImage(remoteUrl, headers || {}) + .then((dataUrl: string) => { + _imgCache.set(remoteUrl, dataUrl); + _imgErrorCache.delete(remoteUrl); + return dataUrl; + }) + .catch((err: any) => { + const message = `Image inaccessible (${err?.message || 'erreur reseau'})`; + _imgErrorCache.set(remoteUrl, message); + throw new Error(message); + }) + .finally(() => _imgPending.delete(remoteUrl)); + _imgPending.set(remoteUrl, pending); + } + + _imgPending.get(remoteUrl)! + .then(setAndCache) + .catch((err: any) => setFailure(err?.message || 'Image inaccessible')); + }; + void loadRemote; + + // 1. Data URL directe — rien à faire if (src.startsWith('data:')) { - setImageSrc(src); + setAndCache(src); return; } - // 2. Si c'est une URL HTTP/HTTPS directe complète, on l'affiche directement + // 2. URL HTTP/HTTPS : passer par le processus principal Electron (Node.js) pour + // charger l'image sans restriction CORS ni problème de serveur statique if (src.startsWith('http://') || src.startsWith('https://')) { - setImageSrc(src); + const api = (window as any).electronAPI; + if (api?.fetchRemoteImage) { + api.fetchRemoteImage(src) + .then(setAndCache) + .catch(() => setAndCache(src)); // fallback direct si IPC échoue + } else { + setAndCache(src); + } return; } - // 3. Détecter si c'est un chemin de fichier local réel sur la machine Windows de l'utilisateur - // Un vrai chemin local Windows commencera par file:// ou par une lettre de lecteur comme C: + // 3. Chemin local Windows (file:// ou C:\...) const isWindowsLocal = src.startsWith('file://') || /^[a-zA-Z]:/.test(src); - const resolveDistantFallback = () => { + const resolveRemoteHermes = () => { if (provider === 'hermes' && agentUrl) { try { - // Nettoyer l'URL de l'agent pour obtenir la racine du serveur (ex: http://host:port) const parsed = new URL(agentUrl); const hostUrl = `${parsed.protocol}//${parsed.host}`; - let relativePath = src; - // Nettoyer les reliquats de chemins de fichiers si présents if (src.startsWith('file://')) { relativePath = src.replace(/^file:\/\/\/[a-zA-Z]:/, '').replace(/^file:\/\//, ''); } else if (/^[a-zA-Z]:/.test(src)) { relativePath = src.substring(2); } - - // Remplacer les antislashs par des slashs pour l'URL HTTP relativePath = relativePath.replace(/\\/g, '/'); - const resolvedUrl = `${hostUrl.replace(/\/$/, '')}/${relativePath.replace(/^\//, '')}`; - console.log("[LocalImage] Chemin distant résolu pour Hermes:", resolvedUrl); - setImageSrc(resolvedUrl); - } catch (urlErr) { - console.error("[LocalImage] Impossible de résoudre l'URL de secours distante:", urlErr); - setImageSrc(src); + + const authHeaders: Record = {}; + if (hermesApiKey) authHeaders['Authorization'] = `Bearer ${hermesApiKey}`; + if (hermesSessionKey) authHeaders['X-Hermes-Session-Key'] = hermesSessionKey; + + const api = (window as any).electronAPI; + if (api?.fetchRemoteImage) { + api.fetchRemoteImage(resolvedUrl, authHeaders) + .then(setAndCache) + .catch(() => setAndCache(resolvedUrl)); + } else { + setAndCache(resolvedUrl); + } + } catch { + setAndCache(src); } } else { - setImageSrc(src); + setAndCache(src); } }; if (isWindowsLocal && (window as any).electronAPI?.readLocalFile) { let cleanPath = src; - // Retirer les préfixes de protocole file:/// ou file:// - if (src.startsWith('file:///')) { - cleanPath = src.substring(8); - } else if (src.startsWith('file://')) { - cleanPath = src.substring(7); - } - - // Convertir les slashes pour s'adapter au système de fichiers Windows + if (src.startsWith('file:///')) cleanPath = src.substring(8); + else if (src.startsWith('file://')) cleanPath = src.substring(7); cleanPath = cleanPath.replace(/\//g, '\\'); - (window as any).electronAPI.readLocalFile(cleanPath) - .then((dataUrl: string) => { - setImageSrc(dataUrl); - }) - .catch((err: any) => { - console.warn("[LocalImage] Échec de la lecture IPC locale, tentative de fallback distant:", err.message); - resolveDistantFallback(); - }); + .then(setAndCache) + .catch(() => resolveRemoteHermes()); } else { - // Si ce n'est pas un chemin local Windows (par exemple un chemin Unix absolu /data/jobs...), - // ou si l'API Electron n'est pas présente, on résout directement à distance sans faire d'appel IPC Windows coûteux qui échouera. - resolveDistantFallback(); + // Chemin Unix (/tmp/cat.jpg) → construire l'URL Hermes et charger via IPC + resolveRemoteHermes(); } }, [src, agentUrl, provider]); @@ -180,6 +253,141 @@ const LocalImage: React.FC<{ /> ); }; +void LocalImage; + +const ResilientImage: React.FC<{ + src: string; + alt?: string; + agentUrl?: string; + provider?: string; + hermesApiKey?: string; + hermesSessionKey?: string; + [key: string]: any; +}> = ({ src, alt, agentUrl, provider, hermesApiKey, hermesSessionKey, ...props }) => { + const [imageSrc, setImageSrc] = useState(() => _imgCache.get(src) || ''); + const [error, setError] = useState(() => _imgErrorCache.get(src) || ''); + + useEffect(() => { + if (!src) return; + + if (_imgCache.has(src)) { + setImageSrc(_imgCache.get(src)!); + setError(''); + return; + } + + if (_imgErrorCache.has(src)) { + setError(_imgErrorCache.get(src)!); + return; + } + + const setSuccess = (cacheKey: string, value: string) => { + _imgCache.set(cacheKey, value); + _imgErrorCache.delete(cacheKey); + setImageSrc(value); + setError(''); + }; + + const setFailure = (cacheKey: string, message: string) => { + _imgErrorCache.set(cacheKey, message); + setImageSrc(''); + setError(message); + }; + + const loadRemote = (remoteUrl: string, headers?: Record) => { + const api = (window as any).electronAPI; + if (!api?.fetchRemoteImage) { + setSuccess(src, remoteUrl); + return; + } + + if (!_imgPending.has(remoteUrl)) { + const pending = api.fetchRemoteImage(remoteUrl, headers || {}) + .then((dataUrl: string) => { + _imgCache.set(remoteUrl, dataUrl); + _imgErrorCache.delete(remoteUrl); + return dataUrl; + }) + .catch((err: any) => { + const message = `Image inaccessible (${err?.message || 'erreur reseau'})`; + _imgErrorCache.set(remoteUrl, message); + throw new Error(message); + }) + .finally(() => _imgPending.delete(remoteUrl)); + _imgPending.set(remoteUrl, pending); + } + + _imgPending.get(remoteUrl)! + .then((dataUrl) => setSuccess(src, dataUrl)) + .catch((err: any) => setFailure(src, err?.message || 'Image inaccessible')); + }; + + if (src.startsWith('data:')) { + setSuccess(src, src); + return; + } + + if (src.startsWith('http://') || src.startsWith('https://')) { + loadRemote(src); + return; + } + + const isWindowsLocal = src.startsWith('file://') || /^[a-zA-Z]:/.test(src); + const resolveRemoteHermes = () => { + if (provider === 'hermes' && agentUrl) { + try { + const parsed = new URL(agentUrl); + const hostUrl = `${parsed.protocol}//${parsed.host}`; + let relativePath = src; + if (src.startsWith('file://')) { + relativePath = src.replace(/^file:\/\/\/[a-zA-Z]:/, '').replace(/^file:\/\//, ''); + } else if (/^[a-zA-Z]:/.test(src)) { + relativePath = src.substring(2); + } + relativePath = relativePath.replace(/\\/g, '/'); + const resolvedUrl = `${hostUrl.replace(/\/$/, '')}/${relativePath.replace(/^\//, '')}`; + const headers: Record = {}; + if (hermesApiKey) headers.Authorization = `Bearer ${hermesApiKey}`; + if (hermesSessionKey) headers['X-Hermes-Session-Key'] = hermesSessionKey; + loadRemote(resolvedUrl, headers); + } catch { + setSuccess(src, src); + } + } else { + setSuccess(src, src); + } + }; + + if (isWindowsLocal && (window as any).electronAPI?.readLocalFile) { + let cleanPath = src; + if (src.startsWith('file:///')) cleanPath = src.substring(8); + else if (src.startsWith('file://')) cleanPath = src.substring(7); + cleanPath = cleanPath.replace(/\//g, '\\'); + (window as any).electronAPI.readLocalFile(cleanPath) + .then((dataUrl: string) => setSuccess(src, dataUrl)) + .catch(() => resolveRemoteHermes()); + } else { + resolveRemoteHermes(); + } + }, [src, agentUrl, provider, hermesApiKey, hermesSessionKey]); + + if (error && !imageSrc) { + return ( + + Image impossible a charger + + ); + } + + return ( + {alt + ); +}; function generateUUID(): string { @@ -279,12 +487,43 @@ const createWelcomeMessage = (avatarId: EveAvatar): Message => ({ timestamp: new Date() }); +const ToolMonitorWindow: React.FC<{ + events: ToolEvent[]; + isWorking: boolean; +}> = ({ events, isWorking }) => { + const latestEvents = events.slice(-8).reverse(); + + return ( +
+
+ + OUTILS HERMES + {isWorking ? 'ACTIF' : 'VEILLE'} +
+
+ {latestEvents.length === 0 ? ( +
Aucun outil en cours
+ ) : ( + latestEvents.map((event) => ( +
+ {event.status.toUpperCase()} + {event.label} + {event.detail && {event.detail}} +
+ )) + )} +
+
+ ); +}; + export const App: React.FC = () => { // --- ÉTATS --- const [avatarId, setAvatarId] = useState(() => getInitialAvatarId()); const [messages, setMessages] = useState(() => [createWelcomeMessage(avatarId)]); const [inputText, setInputText] = useState(''); const [isSending, setIsSending] = useState(false); + const [toolEvents, setToolEvents] = useState([]); const [errorMessage, setErrorMessage] = useState(null); const [currentlyPlayingMsgId, setCurrentlyPlayingMsgId] = useState(null); @@ -401,9 +640,20 @@ export const App: React.FC = () => { const [availableVoices, setAvailableVoices] = useState([]); const [selectedVoice, setSelectedVoice] = useState(''); - const [ttsRate, setTtsRate] = useState(1.05); + const [ttsRate, setTtsRate] = useState(1.15); const [sttLang, setSttLang] = useState('fr-FR'); + const [ttsProvider, setTtsProvider] = useState<'system' | 'google-free'>('google-free'); const activeAvatar = AVATAR_PROFILES[avatarId]; + const pushToolEvent = (event: Omit) => { + setToolEvents(prev => [ + ...prev.slice(-11), + { + ...event, + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + ts: Date.now() + } + ]); + }; @@ -430,7 +680,7 @@ export const App: React.FC = () => { speak: (text) => { if (!isMuted && audioServiceRef.current) { setIsSpeaking(true); - audioServiceRef.current.speak(text, selectedVoice, ttsRate).then(() => setIsSpeaking(false)); + audioServiceRef.current.speak(text, selectedVoice, ttsRate, 1.1, ttsProvider).then(() => setIsSpeaking(false)); } } }); @@ -506,6 +756,12 @@ export const App: React.FC = () => { if (savedVoice) setSelectedVoice(savedVoice); }); + persistRead('eveflow_tts_provider').then(savedProvider => { + if (savedProvider === 'system' || savedProvider === 'google-free') { + setTtsProvider(savedProvider as 'system' | 'google-free'); + } + }); + persistRead('eveflow_avatar_id').then(savedAvatar => { if (savedAvatar && savedAvatar in AVATAR_PROFILES) { setAvatarId(savedAvatar as EveAvatar); @@ -580,13 +836,13 @@ export const App: React.FC = () => { // Trigger generic emotion for a pushed job setCurrentEmotion('happy'); if (!isMuted && audioServiceRef.current) { - audioServiceRef.current.speak(job.result, selectedVoice, ttsRate); + audioServiceRef.current.speak(job.result, selectedVoice, ttsRate, 1.1, ttsProvider); } } }); return () => pollingServiceRef.current?.stop(); - }, [agentConfig.provider, agentConfig.hermesUrl, agentConfig.hermesApiKey, isMuted, selectedVoice, ttsRate]); + }, [agentConfig.provider, agentConfig.hermesUrl, agentConfig.hermesApiKey, isMuted, selectedVoice, ttsRate, ttsProvider]); // Webhook Events — mirrors all Hermes conversations (Telegram, etc.) into the chat useEffect(() => { @@ -600,13 +856,14 @@ export const App: React.FC = () => { id: Math.random().toString(), role, content: event.text, + images: Array.isArray(event.images) ? event.images : undefined, source: source as Message['source'], timestamp: new Date() }]); if (role === 'assistant') { setCurrentEmotion('happy'); if (!isMuted && audioServiceRef.current) { - audioServiceRef.current.speak(event.text, selectedVoice, ttsRate) + audioServiceRef.current.speak(event.text, selectedVoice, ttsRate, 1.1, ttsProvider) .then(() => setIsSpeaking(false)) .catch(() => {}); setIsSpeaking(true); @@ -617,7 +874,7 @@ export const App: React.FC = () => { return unsubscribe; } return undefined; - }, [isMuted, selectedVoice, ttsRate]); + }, [isMuted, selectedVoice, ttsRate, ttsProvider]); // --- ACTIONS --- const saveConfig = (newConfig: AgentConfig) => { @@ -646,8 +903,19 @@ export const App: React.FC = () => { if (!text.trim() && attachments.length === 0) return; if (isSending) return; + // Arrêter immédiatement toute lecture vocale en cours + if (audioServiceRef.current) { + audioServiceRef.current.stopSpeaking(); + } + setIsSpeaking(false); + setErrorMessage(null); setIsSending(true); + pushToolEvent({ + label: 'Nouvelle execution Hermes', + detail: agentConfig.provider, + status: 'running' + }); // Séparer les attachements : images vs documents texte const images = attachments.filter(a => a.type.startsWith('image/')).map(a => a.dataUrl); @@ -725,7 +993,8 @@ export const App: React.FC = () => { )); }; - // TTS streaming : désactivé au fil de l'eau pour gérer intelligemment les messages longs en fin de génération + // Index de suivi de lecture vocale pour le streaming en temps réel + let lastSpokenIndex = 0; const reply = await AgentService.sendMessage( finalPrompt, @@ -738,9 +1007,35 @@ export const App: React.FC = () => { fullReply += token; pendingContent = fullReply; if (rafHandle === null) rafHandle = requestAnimationFrame(flushTokens); + + // Synthèse vocale fluide phrase par phrase en temps réel (streaming) + if (!isMuted && audioServiceRef.current) { + const textToParse = fullReply.substring(lastSpokenIndex); + // Regex robuste pour isoler les phrases se terminant par un point, exclamation, interrogation ou saut de ligne + const sentenceRegex = /[^.!?\n\r]+[.!?\n\r]+/g; + let match; + let lastMatchEnd = 0; + while ((match = sentenceRegex.exec(textToParse)) !== null) { + const sentence = match[0].trim(); + // Ignorer les tokens MEDIA: pour ne pas les lire à voix haute (ex: MEDIA:/tmp/cat.jpg) + if (sentence && !/^MEDIA:\s*\S+$/.test(sentence)) { + setIsSpeaking(true); + audioServiceRef.current.queueSentence(sentence, selectedVoice, ttsRate, 1.1, ttsProvider); + } + lastMatchEnd = match.index + match[0].length; + } + if (lastMatchEnd > 0) { + lastSpokenIndex += lastMatchEnd; + } + } }, // onToolCall - (toolName) => { + (toolName, detail, status = 'running') => { + pushToolEvent({ + label: toolName, + detail: detail || 'progression Hermes', + status + }); setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: m.content + `\n\n> ⚙️ *${toolName}*\n\n` } : m )); @@ -756,49 +1051,43 @@ export const App: React.FC = () => { const deducedEmotion = EmotionService.analyzeSentiment(reply); setCurrentEmotion(deducedEmotion); + pushToolEvent({ + label: 'Reponse finalisee', + detail: `${reply.length} caracteres`, + status: 'done' + }); - // Update final message + // Mettre à jour le message final dans le chat setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: reply, emotion: deducedEmotion } : m )); - // Synthèse vocale de fin de message intelligente (Zero Trust & asynchrone) + // Diction de la portion restante finale non encore prononcée if (!isMuted && audioServiceRef.current) { - setIsSpeaking(true); - - if (reply.length >= TTS_LONG_TEXT_THRESHOLD) { - // Si le texte est long, l'agent génère une description courte par IA en arrière-plan - setCurrentEmotion('thinking'); - generateLongMessageSummary(reply) - .then((shortDescription) => { - setCurrentEmotion(deducedEmotion); - return audioServiceRef.current!.speak(shortDescription, selectedVoice, ttsRate); - }) - .then(() => { - setIsSpeaking(false); - setCurrentEmotion('neutral'); - }) - .catch(() => { - setIsSpeaking(false); - setCurrentEmotion('neutral'); - }); - } else { - // Si le texte est court, lecture intégrale directe - audioServiceRef.current.speak(reply, selectedVoice, ttsRate) - .then(() => { - setIsSpeaking(false); - setCurrentEmotion('neutral'); - }) - .catch(() => { - setIsSpeaking(false); - setCurrentEmotion('neutral'); - }); + const remainingText = reply.substring(lastSpokenIndex).trim(); + if (remainingText) { + setIsSpeaking(true); + audioServiceRef.current.queueSentence(remainingText, selectedVoice, ttsRate, 1.1, ttsProvider); } + + // Attendre la fin complète de la file d'écoute pour arrêter l'animation d'élocution (isSpeaking) + (async () => { + while (audioServiceRef.current?.isSpeakingActive) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + setIsSpeaking(false); + setCurrentEmotion('neutral'); + })().catch(() => {}); } } catch (e: any) { setErrorMessage(e.message || "Une erreur s'est produite."); setCurrentEmotion('sad'); + pushToolEvent({ + label: 'Execution interrompue', + detail: e.message || 'erreur agent', + status: 'error' + }); const errorMessageObj: Message = { id: Math.random().toString(), role: 'assistant', @@ -852,26 +1141,7 @@ export const App: React.FC = () => { setCurrentEmotion('neutral'); }; - const generateLongMessageSummary = async (text: string): Promise => { - const prompt = `Fais une description orale très courte (une seule phrase simple de maximum 12 mots) pour annoncer et résumer le texte suivant de manière agréable. Parle obligatoirement à la première personne en tant qu'assistant de l'utilisateur (ex: "Voici mon rapport de sécurité..." ou "Voici mon analyse géométrique...").\n\nTexte :\n${text}`; - try { - // Appel asynchrone direct et ultra-rapide sans historique pour le résumé - const result = await AgentService.sendMessage( - prompt, - [], - agentConfig, - avatarId, - buildCallbacks() - ); - - // Nettoyer la réponse des guillemets superflus - return result.replace(/^["']|["']$/g, '').trim(); - } catch (err) { - console.warn("[TTS] Échec de la description vocale IA, utilisation du fallback statique:", err); - const wordCount = text.split(/\s+/).filter(Boolean).length; - return `Voici ma transmission contenant environ ${wordCount} mots. Vous pouvez lancer son écoute audio complète à l'aide du bouton de lecture au bas du message.`; - } - }; + const handlePlayLongMessage = (messageId: string, content: string) => { if (!audioServiceRef.current) return; @@ -894,7 +1164,7 @@ export const App: React.FC = () => { const deducedEmotion = EmotionService.analyzeSentiment(content); setCurrentEmotion(deducedEmotion); - audioServiceRef.current.speak(content, selectedVoice, ttsRate) + audioServiceRef.current.speak(content, selectedVoice, ttsRate, 1.1, ttsProvider) .then(() => { setCurrentlyPlayingMsgId(null); setIsSpeaking(false); @@ -1030,11 +1300,13 @@ export const App: React.FC = () => { remarkPlugins={[remarkGfm]} components={{ img: ({ node, ...props }) => ( - ), a: ({ node, ...props }) => { @@ -1067,18 +1339,20 @@ export const App: React.FC = () => { } }} > - {msg.content} + {preprocessMessageContent(msg.content)} {msg.images && msg.images.length > 0 && (
{msg.images.map((imgUrl, i) => ( - ))}
@@ -1382,24 +1656,72 @@ export const App: React.FC = () => {
- - + +
+ + +
+ {ttsProvider === 'system' && ( +
+ + +
+ )} + + {ttsProvider === 'google-free' && ( +
+ ℹ️ **Mode Voix Naturelle Actif** : EveFlow utilise la voix neuronale ultra-fluide et humaine de Google. Aucun frais ni clé API requis. En cas de perte de connexion, elle basculera automatiquement sur votre voix locale hors-ligne. +
+ )} +
@@ -1431,7 +1753,7 @@ export const App: React.FC = () => { onClick={() => { if (audioServiceRef.current) { setIsSpeaking(true); - audioServiceRef.current.speak(activeAvatar.welcome, selectedVoice, ttsRate) + audioServiceRef.current.speak(activeAvatar.welcome, selectedVoice, ttsRate, 1.1, ttsProvider) .then(() => setIsSpeaking(false)); } }} @@ -1447,6 +1769,16 @@ export const App: React.FC = () => { {/* COMPAGNON 3D SECTION (De droite) */}
+
+ + + + + + + + +
{/* Header flottant discret pour contrôle en mode Widget */} {isFloatingMode && ( @@ -1479,7 +1811,17 @@ export const App: React.FC = () => { {/* Rendu Canvas 3D */}
- + + {agentConfig.provider === 'hermes' && ( + + )} {avatarId !== 'eve' && activeAvatar.assetPath && (
{activeAvatar.name}: asset premium attendu diff --git a/src/components/AvatarModelLoader.tsx b/src/components/AvatarModelLoader.tsx index bb704de..e3f397a 100644 --- a/src/components/AvatarModelLoader.tsx +++ b/src/components/AvatarModelLoader.tsx @@ -58,7 +58,7 @@ const LoadedGlbAvatar: React.FC<{ } }); - return ; + return ; }; export const AvatarModelLoader: React.FC = ({ avatarId, emotion, isSpeaking }) => { diff --git a/src/components/EveModel.tsx b/src/components/EveModel.tsx index 594a38c..bacdb15 100644 --- a/src/components/EveModel.tsx +++ b/src/components/EveModel.tsx @@ -452,8 +452,8 @@ export const EveModel: React.FC = ({ emotion, isSpeaking, avatarI const center = new THREE.Vector3(); localBox.getCenter(center); - // Normalize height to exactly 2.4 units in scene space - const targetHeight = 2.4; + // Normalize height conservatively so the avatar stays inside the right panel. + const targetHeight = 1.18; const scaleFactor = targetHeight / (size.y || 1); clonedObj.scale.setScalar(scaleFactor); @@ -760,7 +760,7 @@ export const EveModel: React.FC = ({ emotion, isSpeaking, avatarI }); return ( - + ); diff --git a/src/components/ThreeCanvas.tsx b/src/components/ThreeCanvas.tsx index caf2c66..eb95ab9 100644 --- a/src/components/ThreeCanvas.tsx +++ b/src/components/ThreeCanvas.tsx @@ -4,6 +4,14 @@ import { OrbitControls } from '@react-three/drei'; import { AvatarModelLoader } from './AvatarModelLoader'; import { EveEmotion, EveAvatar } from '../services/emotionService'; +export interface ToolEvent { + id: string; + label: string; + detail?: string; + status: 'idle' | 'running' | 'done' | 'error'; + ts: number; +} + interface ThreeCanvasProps { emotion: EveEmotion; isSpeaking: boolean; @@ -14,10 +22,13 @@ export const ThreeCanvas: React.FC = ({ emotion, isSpeaking, a return (
{ + gl.setClearColor(0x000000, 0); + }} dpr={[1, 2]} - style={{ background: 'transparent' }} + style={{ background: 'transparent', backgroundColor: 'transparent' }} > {/* Soft ambient — dark space base */} @@ -46,8 +57,8 @@ export const ThreeCanvas: React.FC = ({ emotion, isSpeaking, a {/* OrbitControls calibrated to prevent clipping or losing focus */} 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, model: string @@ -305,6 +316,7 @@ export class AgentService { let fullContent = ''; let buffer = ''; const pendingToolCalls: Record = {}; + 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); } diff --git a/src/services/audioService.ts b/src/services/audioService.ts index 5a78a32..f3100c4 100644 --- a/src/services/audioService.ts +++ b/src/services/audioService.ts @@ -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 { - 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 { + 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((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 { + 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((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((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 { + 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) diff --git a/src/services/pollingService.ts b/src/services/pollingService.ts index 1c9765c..d40a430 100644 --- a/src/services/pollingService.ts +++ b/src/services/pollingService.ts @@ -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)) {