improve hermes visuals and tool monitor

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

No files matched your search

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