mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-16 19:58:07 +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
+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
|
||||
|
||||
Reference in new issue
Block a user