mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +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
@@ -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();
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
+467
-125
@@ -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:<chemin> en syntaxe Markdown image
|
||||
// Le bot Hermes envoie MEDIA:/tmp/cat.jpg au lieu de , 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\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<string, string>();
|
||||
const _imgPending = new Map<string, Promise<string>>();
|
||||
const _imgErrorCache = new Map<string, string>();
|
||||
|
||||
// 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<string>('');
|
||||
}> = ({ src, alt, agentUrl, provider, hermesApiKey, hermesSessionKey, ...props }) => {
|
||||
// Initialisation lazy : si déjà en cache, pas de flash du GIF transparent
|
||||
const [imageSrc, setImageSrc] = useState<string>(() => _imgCache.get(src) || '');
|
||||
const [error, setError] = useState<string>(() => _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<string, string>) => {
|
||||
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<string, string> = {};
|
||||
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<string>(() => _imgCache.get(src) || '');
|
||||
const [error, setError] = useState<string>(() => _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<string, string>) => {
|
||||
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<string, string> = {};
|
||||
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 (
|
||||
<a href={src} target="_blank" rel="noopener noreferrer" className="markdown-image-error" title={src}>
|
||||
Image impossible a charger
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageSrc || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}
|
||||
alt={alt || 'Visualisation'}
|
||||
className="markdown-image"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
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 (
|
||||
<div className={`tool-screen-window ${isWorking ? 'active' : ''}`}>
|
||||
<div className="tool-screen-header">
|
||||
<span className="tool-screen-led" />
|
||||
<span>OUTILS HERMES</span>
|
||||
<span>{isWorking ? 'ACTIF' : 'VEILLE'}</span>
|
||||
</div>
|
||||
<div className="tool-screen-body">
|
||||
{latestEvents.length === 0 ? (
|
||||
<div className="tool-screen-empty">Aucun outil en cours</div>
|
||||
) : (
|
||||
latestEvents.map((event) => (
|
||||
<div key={event.id} className={`tool-screen-line ${event.status}`}>
|
||||
<span className="tool-screen-status">{event.status.toUpperCase()}</span>
|
||||
<span className="tool-screen-text">{event.label}</span>
|
||||
{event.detail && <span className="tool-screen-detail">{event.detail}</span>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const App: React.FC = () => {
|
||||
// --- ÉTATS ---
|
||||
const [avatarId, setAvatarId] = useState<EveAvatar>(() => getInitialAvatarId());
|
||||
const [messages, setMessages] = useState<Message[]>(() => [createWelcomeMessage(avatarId)]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [toolEvents, setToolEvents] = useState<ToolEvent[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [currentlyPlayingMsgId, setCurrentlyPlayingMsgId] = useState<string | null>(null);
|
||||
|
||||
@@ -401,9 +640,20 @@ export const App: React.FC = () => {
|
||||
|
||||
const [availableVoices, setAvailableVoices] = useState<any[]>([]);
|
||||
const [selectedVoice, setSelectedVoice] = useState<string>('');
|
||||
const [ttsRate, setTtsRate] = useState<number>(1.05);
|
||||
const [ttsRate, setTtsRate] = useState<number>(1.15);
|
||||
const [sttLang, setSttLang] = useState<string>('fr-FR');
|
||||
const [ttsProvider, setTtsProvider] = useState<'system' | 'google-free'>('google-free');
|
||||
const activeAvatar = AVATAR_PROFILES[avatarId];
|
||||
const pushToolEvent = (event: Omit<ToolEvent, 'id' | 'ts'>) => {
|
||||
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<string> => {
|
||||
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 }) => (
|
||||
<LocalImage
|
||||
<ResilientImage
|
||||
src={props.src || ''}
|
||||
alt={props.alt}
|
||||
agentUrl={agentConfig.hermesUrl}
|
||||
provider={agentConfig.provider}
|
||||
provider={agentConfig.provider}
|
||||
hermesApiKey={agentConfig.hermesApiKey}
|
||||
hermesSessionKey={agentConfig.hermesSessionKey}
|
||||
/>
|
||||
),
|
||||
a: ({ node, ...props }) => {
|
||||
@@ -1067,18 +1339,20 @@ export const App: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
{preprocessMessageContent(msg.content)}
|
||||
</ReactMarkdown>
|
||||
|
||||
{msg.images && msg.images.length > 0 && (
|
||||
<div className="message-images-gallery" style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{msg.images.map((imgUrl, i) => (
|
||||
<LocalImage
|
||||
<ResilientImage
|
||||
key={i}
|
||||
src={imgUrl}
|
||||
alt="Image jointe"
|
||||
agentUrl={agentConfig.hermesUrl}
|
||||
provider={agentConfig.provider}
|
||||
provider={agentConfig.provider}
|
||||
hermesApiKey={agentConfig.hermesApiKey}
|
||||
hermesSessionKey={agentConfig.hermesSessionKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1382,24 +1656,72 @@ export const App: React.FC = () => {
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<label className="label-title">Voix Synthétique Locale</label>
|
||||
<select
|
||||
value={selectedVoice}
|
||||
onChange={(e) => {
|
||||
setSelectedVoice(e.target.value);
|
||||
persistWrite('eveflow_voice', e.target.value);
|
||||
}}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||||
>
|
||||
{availableVoices.map((voice) => (
|
||||
<option key={voice.name} value={voice.name}>
|
||||
{voice.name} ({voice.lang})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="label-title">Type de Synthèse Vocale</label>
|
||||
<div className="grid-2" style={{ gap: '10px', marginTop: '4px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTtsProvider('google-free');
|
||||
persistWrite('eveflow_tts_provider', 'google-free');
|
||||
}}
|
||||
className="neon-btn"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: '11px',
|
||||
background: ttsProvider === 'google-free' ? '' : 'transparent',
|
||||
border: ttsProvider === 'google-free' ? 'none' : '1px solid #cbd5e1',
|
||||
color: ttsProvider === 'google-free' ? 'white' : '#64748b',
|
||||
boxShadow: ttsProvider === 'google-free' ? '' : 'none'
|
||||
}}
|
||||
>
|
||||
🟢 Voix Naturelle (Ligne)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTtsProvider('system');
|
||||
persistWrite('eveflow_tts_provider', 'system');
|
||||
}}
|
||||
className="neon-btn"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: '11px',
|
||||
background: ttsProvider === 'system' ? '' : 'transparent',
|
||||
border: ttsProvider === 'system' ? 'none' : '1px solid #cbd5e1',
|
||||
color: ttsProvider === 'system' ? 'white' : '#64748b',
|
||||
boxShadow: ttsProvider === 'system' ? '' : 'none'
|
||||
}}
|
||||
>
|
||||
🔌 Voix Système (Local)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ttsProvider === 'system' && (
|
||||
<div>
|
||||
<label className="label-title">Voix Synthétique Locale</label>
|
||||
<select
|
||||
value={selectedVoice}
|
||||
onChange={(e) => {
|
||||
setSelectedVoice(e.target.value);
|
||||
persistWrite('eveflow_voice', e.target.value);
|
||||
}}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||||
>
|
||||
{availableVoices.map((voice) => (
|
||||
<option key={voice.name} value={voice.name}>
|
||||
{voice.name} ({voice.lang})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ttsProvider === 'google-free' && (
|
||||
<div style={{ padding: '8px 12px', backgroundColor: 'var(--accent-light)', border: '1px solid rgba(0,212,245,0.2)', borderRadius: '10px', fontSize: '11px', fontFamily: 'var(--font-mono)', color: 'var(--text-neon)', lineHeight: 1.5 }}>
|
||||
ℹ️ **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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
<label className="label-title">Vitesse: {ttsRate.toFixed(2)}</label>
|
||||
@@ -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) */}
|
||||
<section className={`eve-section ${isFloatingMode ? 'floating-widget' : ''}`}>
|
||||
<div className={`avatar-ambient-field ${currentEmotion === 'thinking' || isSending ? 'thinking' : 'idle'}`}>
|
||||
<span className="ambient-scanline scanline-a" />
|
||||
<span className="ambient-scanline scanline-b" />
|
||||
<span className="ambient-data data-a" />
|
||||
<span className="ambient-data data-b" />
|
||||
<span className="ambient-data data-c" />
|
||||
<span className="ambient-node node-a" />
|
||||
<span className="ambient-node node-b" />
|
||||
<span className="ambient-node node-c" />
|
||||
</div>
|
||||
|
||||
{/* Header flottant discret pour contrôle en mode Widget */}
|
||||
{isFloatingMode && (
|
||||
@@ -1479,7 +1811,17 @@ export const App: React.FC = () => {
|
||||
|
||||
{/* Rendu Canvas 3D */}
|
||||
<div style={{ flex: 1, width: '100%', position: 'relative' }}>
|
||||
<ThreeCanvas emotion={currentEmotion} isSpeaking={isSpeaking} avatarId={avatarId} />
|
||||
<ThreeCanvas
|
||||
emotion={currentEmotion}
|
||||
isSpeaking={isSpeaking}
|
||||
avatarId={avatarId}
|
||||
/>
|
||||
{agentConfig.provider === 'hermes' && (
|
||||
<ToolMonitorWindow
|
||||
events={toolEvents}
|
||||
isWorking={isSending}
|
||||
/>
|
||||
)}
|
||||
{avatarId !== 'eve' && activeAvatar.assetPath && (
|
||||
<div style={{ position: 'absolute', top: '16px', left: '50%', transform: 'translateX(-50%)', padding: '7px 14px', borderRadius: '18px', backgroundColor: 'var(--bg-secondary)', border: '1px solid rgba(0,212,245,0.25)', boxShadow: '0 0 20px rgba(0,212,245,0.1)', fontSize: '9px', fontFamily: 'var(--font-mono)', fontWeight: 800, letterSpacing: '0.8px', color: 'var(--text-secondary)', textTransform: 'uppercase', pointerEvents: 'none' }}>
|
||||
{activeAvatar.name}: asset premium attendu
|
||||
|
||||
@@ -58,7 +58,7 @@ const LoadedGlbAvatar: React.FC<{
|
||||
}
|
||||
});
|
||||
|
||||
return <primitive object={scene} scale={1} />;
|
||||
return <primitive object={scene} scale={0.48} />;
|
||||
};
|
||||
|
||||
export const AvatarModelLoader: React.FC<AvatarModelLoaderProps> = ({ avatarId, emotion, isSpeaking }) => {
|
||||
|
||||
@@ -452,8 +452,8 @@ export const EveModel: React.FC<EveModelProps> = ({ 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<EveModelProps> = ({ emotion, isSpeaking, avatarI
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={modelRef} scale={1.0} position={[0, 0.22, 0]}>
|
||||
<group ref={modelRef} scale={1.0} position={[0, -0.28, 0]}>
|
||||
<primitive object={clonedObj} />
|
||||
</group>
|
||||
);
|
||||
|
||||
@@ -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<ThreeCanvasProps> = ({ emotion, isSpeaking, a
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 4.6], fov: 45 }}
|
||||
gl={{ alpha: true, antialias: true }}
|
||||
camera={{ position: [0, 0.05, 6.2], fov: 36 }}
|
||||
gl={{ alpha: true, antialias: true, premultipliedAlpha: false }}
|
||||
onCreated={({ gl }) => {
|
||||
gl.setClearColor(0x000000, 0);
|
||||
}}
|
||||
dpr={[1, 2]}
|
||||
style={{ background: 'transparent' }}
|
||||
style={{ background: 'transparent', backgroundColor: 'transparent' }}
|
||||
>
|
||||
{/* Soft ambient — dark space base */}
|
||||
<ambientLight intensity={0.6} color="#b0c8e8" />
|
||||
@@ -46,8 +57,8 @@ export const ThreeCanvas: React.FC<ThreeCanvasProps> = ({ emotion, isSpeaking, a
|
||||
{/* OrbitControls calibrated to prevent clipping or losing focus */}
|
||||
<OrbitControls
|
||||
enableZoom={true}
|
||||
minDistance={2.2}
|
||||
maxDistance={5.5}
|
||||
minDistance={5.2}
|
||||
maxDistance={7.2}
|
||||
enablePan={false}
|
||||
autoRotate={false}
|
||||
maxPolarAngle={Math.PI / 1.7}
|
||||
|
||||
+335
@@ -19,6 +19,7 @@
|
||||
|
||||
--accent-cyan: #00d4f5; /* Bright Eve Cyan */
|
||||
--accent-blue: #2979ff; /* Electric cobalt */
|
||||
--accent-orange: #ffa000;
|
||||
--accent-light: rgba(0, 212, 245, 0.12); /* Subtle glow tint */
|
||||
|
||||
--font-title: 'Outfit', sans-serif;
|
||||
@@ -32,6 +33,27 @@
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.markdown-image-error {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 2px dashed var(--accent-orange);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent-orange);
|
||||
background: rgba(255, 160, 0, 0.08);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-image-error:hover {
|
||||
background: rgba(255, 160, 0, 0.14);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -183,6 +205,7 @@ body {
|
||||
align-items: center;
|
||||
padding: 32px 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--transition-smooth);
|
||||
background: radial-gradient(ellipse at 50% 40%, rgba(0, 212, 245, 0.07) 0%, transparent 70%), var(--bg-primary);
|
||||
}
|
||||
@@ -197,6 +220,255 @@ body {
|
||||
box-shadow: var(--glow-cyan-active), 0 10px 25px rgba(255, 85, 0, 0.1);
|
||||
}
|
||||
|
||||
.avatar-ambient-field {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(0, 229, 255, 0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 229, 255, 0.018) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.avatar-ambient-field::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
background: radial-gradient(circle at 50% 42%, rgba(0, 229, 255, 0.12), transparent 44%);
|
||||
animation: ambient-breathe 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.avatar-ambient-field.thinking::before {
|
||||
animation-duration: 3.4s;
|
||||
}
|
||||
|
||||
.ambient-scanline {
|
||||
position: absolute;
|
||||
left: -15%;
|
||||
width: 130%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 229, 255, 0.75), transparent);
|
||||
box-shadow: 0 0 18px rgba(0, 229, 255, 0.55);
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.avatar-ambient-field.thinking .ambient-scanline {
|
||||
opacity: 0.55;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.scanline-a {
|
||||
top: 24%;
|
||||
animation: scanline-drift-a 5.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.scanline-b {
|
||||
top: 64%;
|
||||
animation: scanline-drift-b 7.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ambient-data {
|
||||
position: absolute;
|
||||
height: 1px;
|
||||
width: 120px;
|
||||
background: linear-gradient(90deg, transparent, rgba(138, 247, 255, 0.72), transparent);
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.data-a {
|
||||
top: 18%;
|
||||
left: 12%;
|
||||
animation: data-slide 9s linear infinite;
|
||||
}
|
||||
|
||||
.data-b {
|
||||
top: 48%;
|
||||
right: 10%;
|
||||
animation: data-slide-reverse 11s linear infinite;
|
||||
}
|
||||
|
||||
.data-c {
|
||||
bottom: 18%;
|
||||
left: 28%;
|
||||
width: 80px;
|
||||
animation: data-slide 13s linear infinite;
|
||||
}
|
||||
|
||||
.ambient-node {
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 229, 255, 0.72);
|
||||
box-shadow: 0 0 14px rgba(0, 229, 255, 0.62);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.node-a {
|
||||
top: 22%;
|
||||
right: 22%;
|
||||
animation: node-float 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.node-b {
|
||||
top: 58%;
|
||||
left: 14%;
|
||||
animation: node-float 10s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
.node-c {
|
||||
bottom: 22%;
|
||||
right: 34%;
|
||||
animation: node-float 12s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.avatar-ambient-field.thinking .ambient-data,
|
||||
.avatar-ambient-field.thinking .ambient-node {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@keyframes ambient-breathe {
|
||||
0%, 100% { transform: scale(0.96); opacity: 0.52; }
|
||||
50% { transform: scale(1.06); opacity: 0.9; }
|
||||
}
|
||||
|
||||
@keyframes scanline-drift-a {
|
||||
0%, 100% { transform: translateY(-35px); }
|
||||
50% { transform: translateY(72px); }
|
||||
}
|
||||
|
||||
@keyframes scanline-drift-b {
|
||||
0%, 100% { transform: translateY(48px); }
|
||||
50% { transform: translateY(-64px); }
|
||||
}
|
||||
|
||||
@keyframes data-slide {
|
||||
0% { transform: translateX(-80px); opacity: 0; }
|
||||
20%, 80% { opacity: 0.38; }
|
||||
100% { transform: translateX(180px); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes data-slide-reverse {
|
||||
0% { transform: translateX(120px); opacity: 0; }
|
||||
20%, 80% { opacity: 0.34; }
|
||||
100% { transform: translateX(-180px); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes node-float {
|
||||
0%, 100% { transform: translate3d(0, 0, 0); }
|
||||
50% { transform: translate3d(18px, -22px, 0); }
|
||||
}
|
||||
|
||||
.tool-screen-window {
|
||||
position: absolute;
|
||||
z-index: 35;
|
||||
left: clamp(14px, 12%, 90px);
|
||||
right: clamp(14px, 12%, 90px);
|
||||
bottom: clamp(50px, 11vh, 118px);
|
||||
height: clamp(58px, 20vh, 220px);
|
||||
min-width: 0;
|
||||
padding: clamp(5px, 1vw, 10px);
|
||||
border: 1px solid rgba(0, 229, 255, 0.62);
|
||||
border-radius: 6px;
|
||||
color: var(--text-neon);
|
||||
background:
|
||||
linear-gradient(rgba(0, 229, 255, 0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 229, 255, 0.05) 1px, transparent 1px),
|
||||
rgba(3, 11, 24, 0.9);
|
||||
background-size: 12px 12px;
|
||||
box-shadow: inset 0 0 22px rgba(0, 212, 245, 0.18), 0 0 24px rgba(0, 212, 245, 0.28);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tool-screen-window.active {
|
||||
border-color: rgba(138, 247, 255, 0.9);
|
||||
box-shadow: inset 0 0 28px rgba(0, 212, 245, 0.24), 0 0 34px rgba(0, 212, 245, 0.4);
|
||||
}
|
||||
|
||||
.tool-screen-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: clamp(4px, 1vw, 10px);
|
||||
padding-bottom: clamp(3px, 0.8vh, 7px);
|
||||
border-bottom: 1px solid rgba(0, 229, 255, 0.35);
|
||||
font-size: clamp(7px, 1.25vw, 10px);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tool-screen-led {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 12px var(--accent-cyan);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tool-screen-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding-top: clamp(3px, 0.8vh, 8px);
|
||||
height: calc(100% - clamp(18px, 4vh, 30px));
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.tool-screen-empty,
|
||||
.tool-screen-line {
|
||||
min-height: clamp(10px, 2.4vh, 16px);
|
||||
font-size: clamp(7px, 1.1vw, 10px);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.tool-screen-empty {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.tool-screen-line {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(34px, 9vw, 48px) 1fr;
|
||||
column-gap: clamp(4px, 1vw, 8px);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.tool-screen-status {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tool-screen-line.done .tool-screen-status {
|
||||
color: #70ffa8;
|
||||
}
|
||||
|
||||
.tool-screen-line.error .tool-screen-status {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.tool-screen-text {
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-screen-detail {
|
||||
grid-column: 2;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Message bubbles: Capsule-shaped pilot styling */
|
||||
.message-row {
|
||||
display: flex;
|
||||
@@ -574,6 +846,36 @@ select.glow-input {
|
||||
.eve-section.floating-widget {
|
||||
height: 100vh !important;
|
||||
}
|
||||
|
||||
.tool-screen-window {
|
||||
left: clamp(10px, 18%, 120px);
|
||||
right: clamp(10px, 18%, 120px);
|
||||
bottom: clamp(46px, 9vh, 64px);
|
||||
height: clamp(54px, 12vh, 104px);
|
||||
min-width: 0;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.tool-screen-header {
|
||||
font-size: clamp(7px, 1.2vw, 8px);
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.tool-screen-body {
|
||||
gap: 3px;
|
||||
padding-top: 5px;
|
||||
height: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.tool-screen-line {
|
||||
grid-template-columns: clamp(34px, 8vw, 42px) 1fr;
|
||||
}
|
||||
|
||||
.tool-screen-empty,
|
||||
.tool-screen-line {
|
||||
min-height: 11px;
|
||||
font-size: clamp(7px, 1.1vw, 8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Intermediate breakpoint: hide secondary header items before full collapse */
|
||||
@@ -641,12 +943,45 @@ select.glow-input {
|
||||
.diag-item-secondary {
|
||||
display: none !important; /* Hide frequency/transceiver metrics on narrow windows */
|
||||
}
|
||||
|
||||
.tool-screen-window {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: clamp(42px, 8vh, 56px);
|
||||
height: clamp(52px, 11vh, 72px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.neon-title {
|
||||
display: none !important; /* Hide title completely to maximize space */
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-height: 780px) {
|
||||
.tool-screen-window {
|
||||
bottom: clamp(44px, 9vh, 62px);
|
||||
height: clamp(58px, 14vh, 112px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 660px) {
|
||||
.tool-screen-window {
|
||||
bottom: clamp(38px, 8vh, 52px);
|
||||
height: clamp(48px, 13vh, 76px);
|
||||
}
|
||||
|
||||
.tool-screen-header {
|
||||
font-size: 8px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.tool-screen-body {
|
||||
padding-top: 4px;
|
||||
height: calc(100% - 22px);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Rendu d'images Markdown rétro-futuriste avec effet glow */
|
||||
|
||||
@@ -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