feat: implement file sharing, SVG image rendering, avatar emotional animations, live telemetry, and dynamic TTS description with cyberpunk playback controls

This commit is contained in:
Michael committed 2026-05-20 15:59:16 +02:00
1 parent d77ace03cd
commit bf247e7e5f
9 files changed
+1676 -99

No files matched your search

+585 -52
View File
@@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
Send, Mic, MicOff, Settings, MessageSquare, Volume2, VolumeX,
Sparkles, Cpu, Minimize2, X, AlertCircle, StopCircle
Sparkles, Cpu, Minimize2, X, AlertCircle, StopCircle, Paperclip
} from 'lucide-react';
import { ThreeCanvas } from './components/ThreeCanvas';
import { AudioService } from './services/audioService';
@@ -19,8 +19,168 @@ interface Message {
emotion?: EveEmotion;
timestamp: Date;
source?: 'eveflow' | 'telegram' | 'webhook';
images?: string[]; // base64 Data URLs pour affichage local direct
}
interface Attachment {
name: string;
type: string;
dataUrl: string; // base64 Data URL
size: number;
}
export const TTS_LONG_TEXT_THRESHOLD = 450;
// Helper de compression d'image asynchrone via Canvas HTML5 (Zero Trust & Zero Dependency)
// Réduit les payloads gigantesques (ex: 8 Mo d'un appareil photo) à environ 100-150 Ko pour fluidifier React et l'API
const compressImage = (dataUrl: string, mimeType: string): Promise<{ dataUrl: string; size: number }> => {
return new Promise((resolve) => {
// Si ce n'est pas une image ou si c'est un SVG / GIF animé, on conserve l'original
if (!mimeType.startsWith('image/') || mimeType === 'image/svg+xml' || mimeType.includes('gif')) {
resolve({ dataUrl, size: Math.round((dataUrl.length * 3) / 4) });
return;
}
const img = new Image();
img.src = dataUrl;
img.onload = () => {
try {
const MAX_WIDTH = 1024;
const MAX_HEIGHT = 1024;
let width = img.width;
let height = img.height;
// Conserver le ratio hauteur/largeur
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
if (width > height) {
height = Math.round((height * MAX_WIDTH) / width);
width = MAX_WIDTH;
} else {
width = Math.round((width * MAX_HEIGHT) / height);
height = MAX_HEIGHT;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0, width, height);
// Compression en JPEG à 70% de qualité (idéal pour la vision par IA et parfait visuellement pour les miniatures)
const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.7);
const approxSize = Math.round((compressedDataUrl.length * 3) / 4);
resolve({ dataUrl: compressedDataUrl, size: approxSize });
} else {
resolve({ dataUrl, size: Math.round((dataUrl.length * 3) / 4) });
}
} catch (e) {
console.error("Échec de la compression de l'image sélectionnée:", e);
resolve({ dataUrl, size: Math.round((dataUrl.length * 3) / 4) });
}
};
img.onerror = () => {
resolve({ dataUrl, size: Math.round((dataUrl.length * 3) / 4) });
};
});
};
// 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
const LocalImage: React.FC<{
src: string;
alt?: string;
agentUrl?: string;
provider?: string;
[key: string]: any;
}> = ({ src, alt, agentUrl, provider, ...props }) => {
const [imageSrc, setImageSrc] = useState<string>('');
useEffect(() => {
// 1. Si c'est une image base64 en direct (Data URL), pas de traitement IPC requis
if (src.startsWith('data:')) {
setImageSrc(src);
return;
}
// 2. Si c'est une URL HTTP/HTTPS directe complète, on l'affiche directement
if (src.startsWith('http://') || src.startsWith('https://')) {
setImageSrc(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:
const isWindowsLocal = src.startsWith('file://') || /^[a-zA-Z]:/.test(src);
const resolveDistantFallback = () => {
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);
}
} else {
setImageSrc(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
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();
});
} 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();
}
}, [src, agentUrl, provider]);
return (
<img
src={imageSrc || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}
alt={alt || 'Visualisation'}
className="markdown-image"
{...props}
/>
);
};
function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
@@ -125,6 +285,97 @@ export const App: React.FC = () => {
const [inputText, setInputText] = useState('');
const [isSending, setIsSending] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [currentlyPlayingMsgId, setCurrentlyPlayingMsgId] = useState<string | null>(null);
// États de télémétrie réelle (dynamique et mesurée)
const [realFps, setRealFps] = useState(60);
const [hostname, setHostname] = useState('UNRAID_HERMES');
const [cpuFreq, setCpuFreq] = useState(2400);
const [cpuTemp, setCpuTemp] = useState(36.5);
// États pour la gestion des pièces jointes (Fichiers / Images)
const [attachments, setAttachments] = useState<Attachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
// Gestion de la sélection des fichiers joints (Zéro Trust validation)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (!selectedFiles) return;
const filesArray = Array.from(selectedFiles);
let processedCount = 0;
const checkReset = () => {
processedCount++;
if (processedCount === filesArray.length && fileInputRef.current) {
fileInputRef.current.value = '';
}
};
filesArray.forEach(file => {
const MAX_FILE_SIZE = 10 * 1024 * 1024;
if (file.size > MAX_FILE_SIZE) {
setErrorMessage(`Le fichier ${file.name} dépasse la limite autorisée de 10 Mo.`);
checkReset();
return;
}
// Déduction robuste du type MIME à partir de l'extension de fichier si l'OS renvoie une valeur vide
let detectedType = file.type;
const ext = file.name.split('.').pop()?.toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
if (ext && imageExtensions.includes(ext) && !detectedType.startsWith('image/')) {
detectedType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
}
const reader = new FileReader();
reader.onload = async (event) => {
const dataUrl = event.target?.result as string;
if (dataUrl) {
try {
let finalDataUrl = dataUrl;
let finalSize = file.size;
// Compresser l'image à la volée s'il s'agit d'un format d'image supporté
if (detectedType.startsWith('image/')) {
console.log(`[EveFlow] Compression à la volée de ${file.name}... Taille originale: ${(file.size / 1024).toFixed(1)} KB`);
const compressed = await compressImage(dataUrl, detectedType);
finalDataUrl = compressed.dataUrl;
finalSize = compressed.size;
console.log(`[EveFlow] Compression terminée pour ${file.name}. Nouvelle taille: ${(finalSize / 1024).toFixed(1)} KB`);
}
setAttachments(prev => [...prev, {
name: file.name,
type: detectedType,
dataUrl: finalDataUrl,
size: finalSize
}]);
} catch (compressErr) {
console.error(`[EveFlow] Échec de compression pour ${file.name}, utilisation de la version originale:`, compressErr);
setAttachments(prev => [...prev, {
name: file.name,
type: detectedType,
dataUrl: dataUrl,
size: file.size
}]);
}
}
};
reader.onloadend = () => {
checkReset();
};
reader.onerror = (err) => {
console.error(`Erreur de lecture du fichier ${file.name}:`, err);
checkReset();
};
reader.readAsDataURL(file);
});
};
const removeAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index));
};
// Moteur 3D & Audio
const [currentEmotion, setCurrentEmotion] = useState<EveEmotion>('neutral');
@@ -144,6 +395,7 @@ export const App: React.FC = () => {
hermesUrl: 'http://127.0.0.1:8642/v1',
hermesModel: 'hermes-agent',
hermesApiKey: '',
hermesSessionKey: 'eveflow-user-session',
minimaxApiKey: '',
minimaxGroupId: '',
minimaxModel: 'abab6.5-chat'
@@ -197,6 +449,52 @@ export const App: React.FC = () => {
}
});
// --- TÉLÉMÉTRIE RÉELLE SYSTÈME ET MESURE DU FPS ---
useEffect(() => {
// 1. Calcul du FPS de rendu client (via requestAnimationFrame)
let frameCount = 0;
let lastTime = performance.now();
let animFrameId: number;
const fpsLoop = () => {
frameCount++;
const now = performance.now();
if (now - lastTime >= 1000) {
setRealFps(Math.round((frameCount * 1000) / (now - lastTime)));
frameCount = 0;
lastTime = now;
}
animFrameId = requestAnimationFrame(fpsLoop);
};
animFrameId = requestAnimationFrame(fpsLoop);
// 2. Récupération asynchrone des métriques système réelles (IPC toutes les 2 secondes)
const fetchMetrics = () => {
if ((window as any).electronAPI?.getSystemMetrics) {
(window as any).electronAPI.getSystemMetrics()
.then((metrics: { hostname: string; cpuFreq: number; cpuTemp: number }) => {
if (metrics) {
if (metrics.hostname) setHostname(metrics.hostname);
if (metrics.cpuFreq) setCpuFreq(metrics.cpuFreq);
if (metrics.cpuTemp) setCpuTemp(metrics.cpuTemp);
}
})
.catch((err: any) => {
console.warn("[Telemetry] Échec de la récupération des métriques système:", err.message);
});
}
};
// Premier appel immédiat, puis polling régulier
fetchMetrics();
const intervalId = setInterval(fetchMetrics, 2000);
return () => {
cancelAnimationFrame(animFrameId);
clearInterval(intervalId);
};
}, []);
// --- INITIALISATION ---
useEffect(() => {
const service = new AudioService();
@@ -206,7 +504,12 @@ export const App: React.FC = () => {
persistRead('eveflow_agent_config').then(savedConfig => {
if (savedConfig) {
try {
setAgentConfig(prev => ({ ...prev, ...JSON.parse(savedConfig) }));
const parsed = JSON.parse(savedConfig);
// Si la clé de session Hermes n'existe pas encore dans la config sauvegardée, on met une valeur par défaut
if (!parsed.hermesSessionKey) {
parsed.hermesSessionKey = 'eveflow-user-session';
}
setAgentConfig(prev => ({ ...prev, ...parsed }));
} catch (e) {
console.error("Erreur de chargement de la configuration", e);
}
@@ -378,20 +681,59 @@ export const App: React.FC = () => {
};
const handleSendMessage = async (text: string) => {
if (!text.trim() || isSending) return;
if (!text.trim() && attachments.length === 0) return;
if (isSending) return;
setErrorMessage(null);
setIsSending(true);
// Séparer les attachements : images vs documents texte
const images = attachments.filter(a => a.type.startsWith('image/')).map(a => a.dataUrl);
const textDocs = attachments.filter(a => !a.type.startsWith('image/'));
// 1. Construire le prompt textuel final (si documents texte, on extrait leur contenu)
let finalPrompt = text;
let textDocsContext = '';
textDocs.forEach(doc => {
try {
const base64Content = doc.dataUrl.split(',')[1];
// Décoder le base64 en UTF-8 proprement
const textContent = decodeURIComponent(
atob(base64Content)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
const ext = doc.name.split('.').pop() || 'txt';
textDocsContext += `\n\n[Fichier joint : ${doc.name}]\n\`\`\`${ext}\n${textContent}\n\`\`\`\n[Fin du fichier joint : ${doc.name}]`;
} catch (err: any) {
console.error(`Échec du décodage du document ${doc.name}:`, err.message);
}
});
if (textDocsContext) {
finalPrompt = textDocsContext + "\n\n" + (text.trim() || "Analyse le document ci-dessus.");
}
// 2. Construire le contenu à afficher localement dans le chat pour l'utilisateur
let displayContent = text;
attachments.forEach(att => {
if (!att.type.startsWith('image/')) {
displayContent += `\n\n📄 **[Fichier joint : ${att.name}]**`;
}
});
const userMessage: Message = {
id: Math.random().toString(),
role: 'user',
content: text,
content: displayContent || "Analyse des documents joints.",
images: images.length > 0 ? images : undefined,
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
setInputText('');
setAttachments([]); // Vider les pièces jointes après l'envoi
setCurrentEmotion('thinking');
try {
@@ -421,39 +763,19 @@ export const App: React.FC = () => {
));
};
// TTS streaming : buffer de phrase en cours de construction
let sentenceBuffer = '';
let ttsStarted = false;
// Détecte fin de phrase : point, !, ?, newline — suivi d'espace ou fin de token
const SENTENCE_END = /([.!?…][\s"»]|[.!?…]$|\n)/;
// TTS streaming : désactivé au fil de l'eau pour gérer intelligemment les messages longs en fin de génération
const reply = await AgentService.sendMessage(
text,
finalPrompt,
chatHistory,
agentConfig,
avatarId,
buildCallbacks(),
// onToken — UI rAF + TTS par phrase
// onToken — UI rAF (mise à jour fluide en temps réel)
(token) => {
fullReply += token;
pendingContent = fullReply;
if (rafHandle === null) rafHandle = requestAnimationFrame(flushTokens);
if (!isMuted && audioServiceRef.current) {
sentenceBuffer += token;
if (SENTENCE_END.test(sentenceBuffer)) {
const parts = sentenceBuffer.split(SENTENCE_END);
// Envoyer toutes les phrases complètes sauf le dernier fragment en cours
for (let i = 0; i < parts.length - 1; i++) {
const s = parts[i].trim();
if (s.length > 2) {
audioServiceRef.current.queueSentence(s, selectedVoice, ttsRate);
if (!ttsStarted) { setIsSpeaking(true); ttsStarted = true; }
}
}
sentenceBuffer = parts[parts.length - 1];
}
}
},
// onToolCall
(toolName) => {
@@ -461,7 +783,12 @@ export const App: React.FC = () => {
m.id === assistantId ? { ...m, content: m.content + `\n\n> ⚙️ *${toolName}*\n\n` } : m
));
},
hermesSessionId || undefined
hermesSessionId || undefined,
(newSessionId) => {
setHermesSessionId(newSessionId);
persistWrite('eveflow_hermes_session_id', newSessionId);
},
images
);
if (rafHandle !== null) cancelAnimationFrame(rafHandle);
@@ -473,28 +800,37 @@ export const App: React.FC = () => {
m.id === assistantId ? { ...m, content: reply, emotion: deducedEmotion } : m
));
// Lire le fragment restant après la dernière ponctuation (si non muet)
// Synthèse vocale de fin de message intelligente (Zero Trust & asynchrone)
if (!isMuted && audioServiceRef.current) {
const tail = sentenceBuffer.trim();
if (tail.length > 2) {
audioServiceRef.current.queueSentence(tail, selectedVoice, ttsRate);
if (!ttsStarted) { setIsSpeaking(true); ttsStarted = true; }
}
// Si pas de streaming (réponse non-streamed), lecture normale
if (!ttsStarted) {
setIsSpeaking(true);
audioServiceRef.current.speak(reply, selectedVoice, ttsRate)
.then(() => { setIsSpeaking(false); setCurrentEmotion('neutral'); })
.catch(() => setIsSpeaking(false));
} else {
// Attendre la fin de la file TTS via polling léger
const waitTTS = setInterval(() => {
if (!audioServiceRef.current?.ttsSynth.speaking) {
clearInterval(waitTTS);
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');
}
}, 300);
})
.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');
});
}
}
@@ -554,6 +890,63 @@ 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;
if (currentlyPlayingMsgId === messageId) {
// Arrêt si on reclique sur le bouton de lecture en cours
audioServiceRef.current.flushQueue();
audioServiceRef.current.stopSpeaking();
setCurrentlyPlayingMsgId(null);
setIsSpeaking(false);
setCurrentEmotion('neutral');
} else {
// Arrêt de toute lecture en cours et démarrage de la lecture intégrale
audioServiceRef.current.flushQueue();
audioServiceRef.current.stopSpeaking();
setCurrentlyPlayingMsgId(messageId);
setIsSpeaking(true);
const deducedEmotion = EmotionService.analyzeSentiment(content);
setCurrentEmotion(deducedEmotion);
audioServiceRef.current.speak(content, selectedVoice, ttsRate)
.then(() => {
setCurrentlyPlayingMsgId(null);
setIsSpeaking(false);
setCurrentEmotion('neutral');
})
.catch((err) => {
console.error("[TTS] Échec de la lecture audio complète :", err);
setCurrentlyPlayingMsgId(null);
setIsSpeaking(false);
setCurrentEmotion('neutral');
});
}
};
const handleLoadMinimaxModels = async () => {
if (!agentConfig.minimaxApiKey) {
setMinimaxModelsError("Veuillez saisir votre clé API Minimax.");
@@ -704,9 +1097,85 @@ export const App: React.FC = () => {
</div>
)}
<div className="markdown-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: ({ node, ...props }) => (
<LocalImage
src={props.src || ''}
alt={props.alt}
agentUrl={agentConfig.hermesUrl}
provider={agentConfig.provider}
/>
),
a: ({ node, ...props }) => {
const isLocal = props.href?.startsWith('file://') || /^[a-zA-Z]:/.test(props.href || '');
if (isLocal) {
return (
<a
href="#"
onClick={(e) => {
e.preventDefault();
let cleanPath = props.href || '';
if (cleanPath.startsWith('file:///')) {
cleanPath = cleanPath.substring(8);
} else if (cleanPath.startsWith('file://')) {
cleanPath = cleanPath.substring(7);
}
cleanPath = cleanPath.replace(/\//g, '\\');
(window as any).electronAPI?.openLocalFile?.(cleanPath)
.catch((err: any) => console.error("Impossible d'ouvrir le fichier :", err));
}}
className="markdown-link-local"
style={{ color: 'var(--text-neon)', textDecoration: 'underline', fontWeight: 'bold' }}
title={`Ouvrir le fichier local : ${props.href}`}
>
{props.children}
</a>
);
}
return <a href={props.href} target="_blank" rel="noopener noreferrer">{props.children}</a>;
}
}}
>
{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
key={i}
src={imgUrl}
alt="Image jointe"
agentUrl={agentConfig.hermesUrl}
provider={agentConfig.provider}
/>
))}
</div>
)}
{msg.role === 'assistant' && msg.content.length >= TTS_LONG_TEXT_THRESHOLD && (
<div className="long-message-audio-controls">
<button
onClick={() => handlePlayLongMessage(msg.id, msg.content)}
className={`audio-control-btn ${currentlyPlayingMsgId === msg.id ? 'playing' : ''}`}
title={currentlyPlayingMsgId === msg.id ? "Arrêter la lecture vocale complète" : "Écouter la lecture vocale complète"}
>
{currentlyPlayingMsgId === msg.id ? (
<>
<VolumeX style={{ width: '13px', height: '13px' }} />
<span>[ STOP_AUDIO ]</span>
</>
) : (
<>
<Volume2 style={{ width: '13px', height: '13px' }} />
<span>[ LIRE_MESSAGE_COMPLET ]</span>
</>
)}
</button>
</div>
)}
</div>
<span className="message-time">
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
@@ -732,14 +1201,48 @@ export const App: React.FC = () => {
{/* Diagnostic Console Bar Retro-Futuriste Space Age */}
<div className="diagnostic-bar" style={{ display: 'flex', gap: '16px', padding: '8px 16px', backgroundColor: 'var(--bg-secondary)', borderTop: '3px solid var(--text-primary)', fontSize: '10px', fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)', fontWeight: 'bold', letterSpacing: '1px' }}>
<span>&gt;&gt;&gt; HOST: UNRAID_HERMES // LIVE</span>
<span className="diag-item-secondary">// FREQ: 742.8 MHz</span>
<span>&gt;&gt;&gt; HOST: {hostname} // LIVE</span>
<span className="diag-item-secondary">// FREQ: {cpuFreq} MHz</span>
<span className="diag-item-secondary">// TRANSCEIVER: ACTIVE</span>
<span className="diag-item-temp" style={{ marginLeft: 'auto' }}>TEMP: 34.6°C // FPS: 60</span>
<span className="diag-item-temp" style={{ marginLeft: 'auto' }}>TEMP: {cpuTemp}°C // FPS: {realFps}</span>
</div>
{/* Aperçu des pièces jointes */}
{attachments.length > 0 && (
<div className="attachments-preview">
{attachments.map((att, idx) => (
<div key={idx} className="attachment-preview-card">
{att.type.startsWith('image/') ? (
<img src={att.dataUrl} alt={att.name} className="attachment-preview-thumbnail" />
) : (
<span className="attachment-preview-icon">📄</span>
)}
<div className="attachment-preview-details">
<span className="attachment-preview-name">{att.name}</span>
<span className="attachment-preview-size">{(att.size / 1024).toFixed(1)} KB</span>
</div>
<button
onClick={() => removeAttachment(idx)}
className="attachment-preview-remove"
title="Supprimer la pièce jointe"
>
<X style={{ width: '12px', height: '12px' }} />
</button>
</div>
))}
</div>
)}
{/* Input Bar */}
<div className="input-container">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
multiple
/>
<input
type="text"
value={inputText}
@@ -769,6 +1272,22 @@ export const App: React.FC = () => {
</button>
)}
<button
onClick={() => fileInputRef.current?.click()}
className="glow-input"
style={{
padding: '12px',
width: 'auto',
cursor: 'pointer',
borderColor: attachments.length > 0 ? 'var(--accent-cyan)' : 'var(--text-primary)',
backgroundColor: attachments.length > 0 ? 'var(--accent-light)' : 'var(--bg-secondary)',
flexShrink: 0
}}
title="Ajouter des pièces jointes (Images, Fichiers texte...)"
>
<Paperclip style={{ width: '18px', height: '18px', color: attachments.length > 0 ? 'var(--accent-cyan)' : 'var(--text-primary)' }} />
</button>
<button
onClick={isListening ? handleStopListening : handleStartVocalRecord}
className="glow-input"
@@ -786,7 +1305,7 @@ export const App: React.FC = () => {
<button
onClick={() => handleSendMessage(inputText)}
disabled={!inputText.trim() || isSending}
disabled={(!inputText.trim() && attachments.length === 0) || isSending}
className="neon-btn"
>
<Send style={{ width: '14px', height: '14px' }} />
@@ -951,6 +1470,20 @@ export const App: React.FC = () => {
/>
</div>
</div>
<div>
<label className="label-title">Clé de Session (Mémoire stable à long terme)</label>
<input
type="text"
value={agentConfig.hermesSessionKey}
onChange={(e) => saveConfig({ ...agentConfig, hermesSessionKey: e.target.value })}
className="glow-input"
style={{ padding: '8px 12px', fontSize: '12px' }}
placeholder="Ex: eveflow-user-session"
/>
<span style={{ display: 'block', marginTop: '5px', fontSize: '10px', fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
ℹ️ Cette clé associe EveFlow à vos fichiers de mémoire persistante (<code>USER.md</code> et <code>MEMORY.md</code>) sur votre hôte <code>UNRAID_HERMES</code>.
</span>
</div>
</div>
)}
+1 -1
View File
@@ -48,7 +48,7 @@ const LoadedGlbAvatar: React.FC<{
useFrame((state) => {
const t = state.clock.getElapsedTime();
scene.position.y = Math.sin(t * 1.4) * 0.05 - 0.28;
scene.position.y = Math.sin(t * 1.4) * 0.05 + 0.22;
scene.rotation.y = Math.sin(t * 0.45) * 0.05;
if (isSpeaking) {
+152 -24
View File
@@ -138,6 +138,120 @@ const createVisorShaderMaterial = () => {
});
};
/**
* Calculates local 3D vertex deformation offsets for Eve's arms based on current emotion,
* time, and side/height falloff coordinates.
*/
const getEmotionalArmOffset = (
side: number,
shoulderFalloff: number,
t: number,
emotion: EveEmotion,
isSpeaking: boolean
): { dx: number; dy: number; dz: number } => {
let dx = 0;
let dy = 0;
let dz = 0;
// 1. Base speaking gesticulation wave
const talkAmp = isSpeaking ? 1.0 : 0.0;
// Dynamic talking wave: faster and wider
const talkX = talkAmp * side * Math.sin(t * 12) * 0.12 * shoulderFalloff;
const talkY = talkAmp * Math.abs(Math.cos(t * 12)) * 0.12 * shoulderFalloff;
const talkZ = talkAmp * Math.sin(t * 10 + side) * 0.08 * shoulderFalloff;
// 2. Emotional posture offset and secondary oscillations
switch (emotion) {
case 'happy': {
// Fast, excited bouncing and outward waving
const bounceX = side * (Math.sin(t * 8.5) * 0.18 + 0.1) * shoulderFalloff;
const bounceY = Math.abs(Math.sin(t * 13)) * 0.26 * shoulderFalloff;
const bounceZ = Math.sin(t * 11 + side) * 0.08 * shoulderFalloff;
// Joyous gesticulation is fast
dx = bounceX + talkX * 1.3;
dy = bounceY + talkY * 1.3;
dz = bounceZ + talkZ * 1.3;
break;
}
case 'sad': {
// Heavy, lifeless drooping arms, slightly pulled in close to the body
const droopX = -side * 0.16 * shoulderFalloff;
const droopY = -0.32 * shoulderFalloff; // Sags downwards
const droopZ = Math.sin(t * 0.6) * 0.015 * shoulderFalloff; // Barely breathing
// When sad, talking gesticulation is extremely muted and slow
dx = droopX + talkX * 0.25;
dy = droopY + talkY * 0.25;
dz = droopZ + talkZ * 0.25;
break;
}
case 'thinking': {
// Asymmetric pose: Right arm (side > 0) raised high to think/touch helmet,
// Left arm (side < 0) hanging restfully.
if (side > 0) {
// Right arm raised towards helmet
const thinkX = -side * 0.18 * shoulderFalloff; // Brought inward
const thinkY = (1.18 + Math.sin(t * 2.2) * 0.04) * shoulderFalloff; // High vertical reach
const thinkZ = (0.38 + Math.cos(t * 2.2) * 0.04) * shoulderFalloff; // Pushed forward
dx = thinkX + talkX * 0.15; // Raised arm barely moves during speech
dy = thinkY + talkY * 0.15;
dz = thinkZ + talkZ * 0.15;
} else {
// Left arm relaxed, drifting slowly
const restX = -side * 0.04 * shoulderFalloff;
const restY = -0.12 * shoulderFalloff;
const restZ = Math.sin(t * 1.4) * 0.035 * shoulderFalloff;
dx = restX + talkX * 0.8;
dy = restY + talkY * 0.8;
dz = restZ + talkZ * 0.8;
}
break;
}
case 'angry': {
// Rigid, tensed arms, pushed slightly outwards, high-freq anger trembling
const stiffX = side * 0.14 * shoulderFalloff;
const stiffY = -0.08 * shoulderFalloff;
const tremble = Math.sin(t * 28) * 0.028 * shoulderFalloff; // Furious shaking
const stiffZ = tremble;
// Anger talking is abrupt, stiff and aggressive
dx = stiffX + talkX * 0.6;
dy = stiffY + talkY * 0.6;
dz = stiffZ + talkZ * 0.6;
break;
}
case 'surprised': {
// Arms immediately thrown upwards and wide (shock reflex)
const shockX = side * 0.32 * shoulderFalloff;
const shockY = (0.42 + Math.sin(t * 16) * 0.08) * shoulderFalloff; // Fast jitter
const shockZ = -0.16 * shoulderFalloff; // Recoil
// Speech gesticulation is fast but overwhelmed by shock
dx = shockX + talkX * 0.8;
dy = shockY + talkY * 0.8;
dz = shockZ + talkZ * 0.8;
break;
}
case 'neutral':
default: {
// Gentle breathing floating cycle
const breatheX = side * Math.sin(t * 1.5) * 0.02 * shoulderFalloff;
const breatheY = Math.abs(Math.sin(t * 1.5)) * 0.03 * shoulderFalloff;
const breatheZ = Math.sin(t * 1.5 + side) * 0.03 * shoulderFalloff;
dx = breatheX + talkX;
dy = breatheY + talkY;
dz = breatheZ + talkZ;
break;
}
}
return { dx, dy, dz };
};
export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarId = 'eve' }) => {
const modelRef = useRef<THREE.Group>(null);
const headRef = useRef<THREE.Object3D | null>(null);
@@ -438,11 +552,11 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
// Style émotionnel actuel
const emoStyle = EmotionService.getEmotionStyle(emotion, avatarId);
// 1. Animation globale de flottaison (Lévitation magnétique)
if (modelRef.current) {
// Oscillation en hauteur stabilisée autour de y = -0.3
modelRef.current.position.y = Math.sin(t * 1.5) * 0.06 - 0.3;
// Oscillation en hauteur stabilisée autour de y = 0.22 (remonté pour un centrage élégant)
modelRef.current.position.y = Math.sin(t * 1.5) * 0.06 + 0.22;
// Légère rotation sur elle-même (mouvement de veille)
modelRef.current.rotation.y = Math.sin(t * 0.5) * 0.05;
}
@@ -458,25 +572,39 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
const mouseX = state.pointer.x * 0.25;
const mouseY = state.pointer.y * 0.2;
const targetRotY = THREE.MathUtils.lerp(headGroupRef.current.rotation.y, mouseX, 0.1);
let targetRotY = THREE.MathUtils.lerp(headGroupRef.current.rotation.y, mouseX, 0.1);
let targetRotX = THREE.MathUtils.lerp(headGroupRef.current.rotation.x, -mouseY + 0.05, 0.1);
let targetRotZ = 0;
let headZOffset = 0;
// Inclinaisons émotionnelles
// Inclinaisons émotionnelles ultra-expressives
if (emotion === 'sad') {
// Tête baissée et légèrement inclinée
targetRotX = THREE.MathUtils.lerp(targetRotX, 0.2, 0.1);
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, 0.05, 0.1);
// Tête très baissée et abattue
targetRotX = THREE.MathUtils.lerp(targetRotX, 0.35, 0.1);
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, 0.12, 0.1);
headY -= 0.02; // Tête enfoncée dans les épaules
} else if (emotion === 'thinking') {
// Tête inclinée sur le côté (curieuse) ET léger balancement pendulaire (swaying)
// L'inclinaison de base est à -0.15, et on fait osciller doucement autour de ça
const sway = Math.sin(t * 3.5) * 0.04;
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, -0.16 + sway, 0.1);
// Micro-mouvements de tête (hochement pensif vertical léger)
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, -0.22 + sway, 0.1);
// Micro-mouvements de tête (hochement pensif vertical et balayage horizontal lent)
headY += Math.sin(t * 4.5) * 0.005;
} else if (emotion === 'angry') {
// Tête penchée en avant (boudeuse)
targetRotX = THREE.MathUtils.lerp(targetRotX, -0.1, 0.1);
// Tête très penchée en avant (froncée/agacée) avec micro-tremblements horizontaux
targetRotX = THREE.MathUtils.lerp(targetRotX, 0.22, 0.1);
const angerTremble = Math.sin(t * 22) * 0.015;
targetRotY = THREE.MathUtils.lerp(headGroupRef.current.rotation.y, mouseX + angerTremble, 0.1);
} else if (emotion === 'happy') {
// Excitation joyeuse : petits hochements de tête rapides (bobbing)
const joyBob = Math.sin(t * 6.5) * 0.03;
targetRotX = THREE.MathUtils.lerp(targetRotX, joyBob - mouseY + 0.02, 0.1);
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, Math.cos(t * 4.0) * 0.025, 0.1);
} else if (emotion === 'surprised') {
// Recul brusque de surprise de la tête et inclinaison vers le haut
targetRotX = THREE.MathUtils.lerp(targetRotX, -0.25 - mouseY, 0.1);
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, Math.sin(t * 10) * 0.01, 0.1);
headY += 0.025; // Tête étirée
headZOffset -= 0.05; // Recul
} else {
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, 0, 0.1);
}
@@ -485,7 +613,7 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
headGroupRef.current.position.set(
headCenter.x,
headCenter.y + headY,
headCenter.z
headCenter.z + headZOffset
);
headGroupRef.current.rotation.set(targetRotX, targetRotY, targetRotZ);
}
@@ -558,12 +686,11 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
);
}
// 4. Animation dynamique des bras (gesticulation en parlant, flottaison magnétique subtile au repos)
// 4. Animation dynamique des bras (déformation vertex organique selon l'émotion et la parole)
if (mainBodyMeshRef.current && mainBodyBasePositionsRef.current) {
const geometry = mainBodyMeshRef.current.geometry;
const position = geometry.getAttribute('position') as THREE.BufferAttribute;
const base = mainBodyBasePositionsRef.current;
const talkWave = isSpeaking ? Math.sin(t * 10) * 0.18 : Math.sin(t * 1.4) * 0.035;
for (let i = 0; i < position.count; i += 1) {
const index = i * 3;
@@ -575,11 +702,12 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
if (isArmRegion) {
const side = Math.sign(baseX);
const shoulderFalloff = THREE.MathUtils.clamp((6.25 - baseY) / 4.8, 0, 1);
const offset = getEmotionalArmOffset(side, shoulderFalloff, t, emotion, isSpeaking);
position.setXYZ(
i,
baseX + side * talkWave * 0.18 * shoulderFalloff,
baseY + Math.abs(talkWave) * 0.1 * shoulderFalloff,
baseZ + Math.sin(t * 8 + side) * 0.045 * shoulderFalloff
baseX + offset.dx,
baseY + offset.dy,
baseZ + offset.dz
);
} else {
position.setXYZ(i, baseX, baseY, baseZ);
@@ -601,7 +729,6 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
const geometry = mesh.geometry;
const position = geometry.getAttribute('position') as THREE.BufferAttribute;
const talkWave = isSpeaking ? Math.sin(t * 10) * 0.18 : Math.sin(t * 1.4) * 0.035;
for (let i = 0; i < position.count; i += 1) {
const index = i * 3;
@@ -613,11 +740,12 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
if (isArmRegion) {
const side = Math.sign(baseX);
const shoulderFalloff = THREE.MathUtils.clamp((6.25 - baseY) / 4.8, 0, 1);
const offset = getEmotionalArmOffset(side, shoulderFalloff, t, emotion, isSpeaking);
position.setXYZ(
i,
baseX + side * talkWave * 0.18 * shoulderFalloff,
baseY + Math.abs(talkWave) * 0.1 * shoulderFalloff,
baseZ + Math.sin(t * 8 + side) * 0.045 * shoulderFalloff
baseX + offset.dx,
baseY + offset.dy,
baseZ + offset.dz
);
} else {
position.setXYZ(i, baseX, baseY, baseZ);
@@ -632,7 +760,7 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
});
return (
<group ref={modelRef} scale={1.0} position={[0, -0.3, 0]}>
<group ref={modelRef} scale={1.0} position={[0, 0.22, 0]}>
<primitive object={clonedObj} />
</group>
);
+188
View File
@@ -649,3 +649,191 @@ select.glow-input {
}
}
/* Rendu d'images Markdown rétro-futuriste avec effet glow */
.markdown-image {
max-width: 100%;
max-height: 380px;
border-radius: var(--radius-sm);
margin-top: 10px;
border: 2px solid var(--text-primary);
box-shadow: 0 0 15px rgba(0, 212, 245, 0.25);
display: block;
animation: fadeIn 0.4s ease-out;
transition: var(--transition-smooth);
}
.markdown-image:hover {
transform: scale(1.02);
box-shadow: 0 0 25px rgba(0, 212, 245, 0.45);
border-color: var(--accent-cyan);
}
/* --- PIÈCES JOINTES ET APERÇUS --- */
.attachments-preview {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
padding: 12px 24px;
border-top: 3px solid var(--text-primary);
background-color: var(--bg-primary);
animation: fadeIn 0.3s ease-out;
}
.attachment-preview-card {
display: flex;
align-items: center;
gap: 10px;
background-color: var(--bg-secondary);
border: 1px solid rgba(0, 212, 245, 0.3);
padding: 6px 12px;
border-radius: var(--radius-sm);
max-width: 260px;
min-width: 140px;
position: relative;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
animation: fadeIn 0.2s ease-out;
}
.attachment-preview-card:hover {
border-color: var(--accent-cyan);
box-shadow: 0 0 10px rgba(0, 212, 245, 0.2);
}
.attachment-preview-thumbnail {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--text-primary);
}
.attachment-preview-icon {
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background-color: var(--bg-primary);
border-radius: 6px;
border: 1px solid rgba(0, 212, 245, 0.2);
}
.attachment-preview-details {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.attachment-preview-name {
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-body);
}
.attachment-preview-size {
font-size: 9px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.attachment-preview-remove {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: var(--transition-smooth);
}
.attachment-preview-remove:hover {
background-color: rgba(239, 68, 68, 0.2);
color: #ff6b6b;
}
/* --- GALERIE D'IMAGES DANS LES BULLES DE CHAT (SPACE AGE GLOW GRID) --- */
.message-images-gallery {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)) !important;
gap: 12px !important;
margin-top: 12px !important;
width: 100% !important;
}
.message-images-gallery .markdown-image {
width: 120px !important;
height: 120px !important;
object-fit: cover !important;
margin-top: 0 !important;
cursor: pointer !important;
border: 2px solid var(--text-primary) !important;
box-shadow: 0 0 10px rgba(0, 212, 245, 0.15) !important;
border-radius: var(--radius-sm) !important;
transition: var(--transition-smooth) !important;
}
.message-images-gallery .markdown-image:hover {
transform: scale(1.05) translateY(-2px) !important;
box-shadow: 0 0 20px rgba(0, 212, 245, 0.4) !important;
border-color: var(--accent-cyan) !important;
}
/* --- CONTROLES AUDIO MESSAGES LONGS (CYBERPUNK SPACE AGE) --- */
.long-message-audio-controls {
margin-top: 12px;
display: flex;
justify-content: flex-end;
animation: fadeIn 0.3s ease-out;
}
.audio-control-btn {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 800;
letter-spacing: 1.5px;
text-transform: uppercase;
background: var(--bg-primary);
color: var(--text-neon);
border: 1px solid rgba(0, 212, 245, 0.4);
padding: 6px 14px;
border-radius: var(--radius-sm);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: 0 0 10px rgba(0, 212, 245, 0.1);
transition: var(--transition-smooth);
}
.audio-control-btn:hover {
background: var(--accent-light);
border-color: var(--accent-cyan);
box-shadow: 0 0 15px rgba(0, 212, 245, 0.3);
transform: translateY(-1px);
}
.audio-control-btn:active {
transform: translateY(0);
}
.audio-control-btn.playing {
color: #ff6b6b;
border-color: rgba(239, 68, 68, 0.5);
box-shadow: 0 0 10px rgba(239, 68, 68, 0.15);
}
.audio-control-btn.playing:hover {
background: rgba(239, 68, 68, 0.12);
border-color: #ff6b6b;
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
}
+53 -12
View File
@@ -11,6 +11,7 @@ export interface AgentConfig {
hermesUrl: string;
hermesModel: string;
hermesApiKey: string;
hermesSessionKey: string;
minimaxApiKey: string;
minimaxGroupId: string;
minimaxModel: string;
@@ -50,11 +51,14 @@ export class AgentService {
callbacks?: AppCallbacks,
onToken?: (token: string) => void,
onToolCall?: (toolName: string) => void,
sessionId?: string
sessionId?: string,
onSessionIdChange?: (newSessionId: string) => void,
images?: string[]
): Promise<string> {
LogService.info('AgentService', `sendMessage → provider=${config.provider} avatar=${avatarId}`, {
messagePreview: message.slice(0, 120),
historyLength: history.length
historyLength: history.length,
imagesCount: images?.length || 0
});
// For Hermes: do NOT inject an avatar system prompt — let Hermes use its own
@@ -73,9 +77,9 @@ export class AgentService {
switch (config.provider) {
case 'ollama':
return this.sendToOllama(message, updatedHistory, config.ollamaUrl, config.ollamaModel);
return this.sendToOllama(message, updatedHistory, config.ollamaUrl, config.ollamaModel, images);
case 'hermes':
return this.sendToHermes(message, updatedHistory, config, callbacks, onToken, onToolCall, sessionId);
return this.sendToHermes(message, updatedHistory, config, callbacks, onToken, onToolCall, sessionId, onSessionIdChange, images);
case 'minimax':
return this.sendToMinimax(message, history, config, avatarId);
default:
@@ -88,10 +92,23 @@ export class AgentService {
message: string,
history: { role: 'user' | 'assistant' | 'system'; content: string }[],
url: string,
model: string
model: string,
images?: string[]
): Promise<string> {
const endpoint = `${url.replace(/\/$/, '')}/api/chat`;
const body = { model, messages: [...history, { role: 'user', content: message }], stream: false };
// Pour Ollama local, extraire uniquement le bloc base64 de la Data URL
const formattedImages = images?.map(img => {
const parts = img.split(',');
return parts.length > 1 ? parts[1] : parts[0];
});
const userMessage: any = { role: 'user', content: message };
if (formattedImages && formattedImages.length > 0) {
userMessage.images = formattedImages;
}
const body = { model, messages: [...history, userMessage], stream: false };
LogService.debug('Ollama', `POST ${endpoint}`, { model, historyLength: history.length });
@@ -127,9 +144,11 @@ export class AgentService {
callbacks?: AppCallbacks,
onToken?: (token: string) => void,
onToolCall?: (toolName: string) => void,
sessionId?: string
sessionId?: string,
onSessionIdChange?: (newSessionId: string) => void,
images?: string[]
): Promise<string> {
const { hermesUrl, hermesModel, hermesApiKey } = config;
const { hermesUrl, hermesModel, hermesApiKey, hermesSessionKey } = config;
// Smart endpoint builder
const base = hermesUrl.replace(/\/$/, '');
@@ -146,9 +165,23 @@ export class AgentService {
if (hermesApiKey) headers['Authorization'] = `Bearer ${hermesApiKey.trim()}`;
// Session continuity: Hermes uses this to retrieve memory and context across turns
if (sessionId) headers['X-Hermes-Session-Id'] = sessionId;
// Stable memory scoping: link this request to long-term memory (USER.md / MEMORY.md)
if (hermesSessionKey) headers['X-Hermes-Session-Key'] = hermesSessionKey.trim();
// Construction du format de message multimodal d'OpenAI pour Hermes Agent
let userContent: any = message;
if (images && images.length > 0) {
userContent = [
{ type: 'text', text: message },
...images.map(img => ({
type: 'image_url',
image_url: { url: img }
}))
];
}
// Build mutable message list for tool-calling loop
const messages: any[] = [...history, { role: 'user', content: message }];
const messages: any[] = [...history, { role: 'user', content: userContent }];
const useStreaming = !!onToken;
const useTools = !!callbacks;
@@ -156,7 +189,8 @@ export class AgentService {
model: hermesModel,
streaming: useStreaming,
toolsEnabled: useTools,
historyLength: history.length
historyLength: history.length,
hasSessionKey: !!hermesSessionKey
});
// ── Tool-calling loop (max 5 iterations to prevent infinite loops) ────────
@@ -194,6 +228,13 @@ export class AgentService {
LogService.info('HermesAgent', `HTTP ${response.status} [iter=${iteration}]`);
// Capture session rotation from response headers (context compression or new session)
const returnedSessionId = response.headers.get('x-hermes-session-id') || response.headers.get('X-Hermes-Session-Id');
if (returnedSessionId && onSessionIdChange && returnedSessionId !== sessionId) {
LogService.info('HermesAgent', `Session rotated: ${sessionId} -> ${returnedSessionId}`);
onSessionIdChange(returnedSessionId);
}
if (!response.ok) {
const rawErr = await response.text().catch(() => '(no body)');
LogService.error('HermesAgent', `Réponse non-OK`, { status: response.status, body: rawErr });
@@ -227,7 +268,7 @@ export class AgentService {
LogService.info('HermesAgent', `Tool call: ${toolName}`, tc.function?.arguments);
onToolCall?.(toolName);
const result = executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks!);
const result = await executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks!);
messages.push(result);
}
@@ -313,7 +354,7 @@ export class AgentService {
const toolName = tc.function?.name;
LogService.info('HermesAgent', `[stream] Tool call: ${toolName}`);
onToolCall?.(toolName);
const result = executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks);
const result = await executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks);
messages.push(result);
}
// Re-call non-streaming to get final answer after tools
+38 -8
View File
@@ -45,18 +45,46 @@ export class AudioService {
}
private _cleanForTTS(raw: string): string {
return raw
// 1. Extraire le texte des liens Markdown [Texte](url). Gère aussi les parenthèses dans les URLs
let cleaned = raw.replace(/\[([^\]]+)\]\((?:[^)(]+|\([^)(]*\))*\)/g, '$1');
// 2. Supprimer toutes les URLs nues (http, https, ftp, file) complexes ou simples
cleaned = cleaned.replace(/(?:https?|ftp|file):\/\/\S+/gi, '');
// 3. Supprimer les adresses commençant par www.
cleaned = cleaned.replace(/www\.\S+/gi, '');
// 4. Supprimer tout mot technique contenant des chemins de fichiers, adresses IP ou segments techniques
// (ex: C:\chemin, localhost:8080, v1/chat/completions)
const words = cleaned.split(/\s+/);
cleaned = words
.filter(word => {
// Exclure les mots qui ressemblent à des chemins ou contiennent des slashes / backslashes
if (word.includes('/') || word.includes('\\')) {
return false;
}
// Exclure les hôtes avec port (ex: localhost:8080, 127.0.0.1:8642)
if (/(?:[a-zA-Z0-9-]+\.[a-zA-Z]{2,6}|localhost|[\d.]+):\d+/.test(word)) {
return false;
}
// Exclure les adresses IP nues
if (/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/.test(word)) {
return false;
}
return true;
})
.join(' ');
return cleaned
// Emojis et symboles Unicode
.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FEFF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA9F}]/gu, '')
// Markdown : **, *, _, __, #, `, ~
// Markdown restant : **, *, _, __, #, `, ~
.replace(/(\*\*|__|~~|[*_#`~])/g, '')
// Liens markdown [texte](url) → garder le texte
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// Balises techniques [⚙️ Exécution: ...] et similaires
// Balises techniques résiduelles [⚙️ Exécution...]
.replace(/\[[^\]]*\]/g, '')
// Ponctuation répétée ou décorative : ---, ===, ~~~, ...
.replace(/(-{2,}|={2,}|~{2,}|\.{2,})/g, ' ')
// Caractères non lisibles restants
// Caractères techniques restants non lisibles
.replace(/[<>{}|\\^]/g, '')
// Espaces multiples
.replace(/\s{2,}/g, ' ')
@@ -90,10 +118,12 @@ export class AudioService {
// 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;
const clean = text.replace(/[*_#`]/g, '').replace(/\[.*?\]/g, '').trim();
const clean = text.trim();
if (!clean) return;
const u = this._makeUtterance(clean, voiceName, rate, pitch);
this.ttsSynth.speak(u); // s'enchaîne automatiquement à la file SpeechSynthesis
if (u.text.trim()) {
this.ttsSynth.speak(u); // s'enchaîne automatiquement à la file SpeechSynthesis
}
}
// Vide la file TTS (fin du streaming ou interruption utilisateur)
+68 -2
View File
@@ -113,6 +113,48 @@ export const HERMES_TOOLS: ToolDefinition[] = [
required: ['text']
}
}
},
{
type: 'function',
function: {
name: 'write_shared_file',
description: "Sauvegarde un fichier (rapport, script python, javascript, données JSON, HTML) dans le dossier partagé EveFlow de l'utilisateur Windows et lui partage un lien d'accès. Utilise cet outil chaque fois que l'utilisateur te demande de générer, d'écrire ou de sauvegarder un fichier sur son ordinateur.",
parameters: {
type: 'object',
properties: {
filename: {
type: 'string',
description: "Le nom du fichier avec son extension (ex: rapport.txt, script.py, data.json, index.html)"
},
content: {
type: 'string',
description: "Le contenu textuel intégral à écrire dans le fichier"
}
},
required: ['filename', 'content']
}
}
},
{
type: 'function',
function: {
name: 'generate_shared_image',
description: "Génère un schéma technique, un dessin vectoriel, ou une illustration de haute qualité au format SVG, l'enregistre sur le disque et l'affiche dans le chat. Utilise cet outil dès que l'utilisateur te demande d'afficher, de dessiner ou de générer un graphique, un diagramme ou une image.",
parameters: {
type: 'object',
properties: {
filename: {
type: 'string',
description: "Le nom du fichier SVG (ex: architecture.svg, graphique.svg)"
},
svgContent: {
type: 'string',
description: "Le code source SVG complet et valide (incluant les balises <svg>, viewBox, styles et éléments géométriques)"
}
},
required: ['filename', 'svgContent']
}
}
}
];
@@ -127,12 +169,12 @@ export const HERMES_TOOLS: ToolDefinition[] = [
* @param callbacks - App state accessors injected from the React layer
* @returns - ToolResult to append to the messages array
*/
export function executeTool(
export async function executeTool(
toolCallId: string,
name: string,
rawArgs: string,
callbacks: AppCallbacks
): ToolResult {
): Promise<ToolResult> {
let result: string;
try {
@@ -166,6 +208,30 @@ export function executeTool(
break;
}
case 'write_shared_file': {
const filename = String(args.filename || '');
const content = String(args.content || '');
if ((window as any).electronAPI?.writeSharedFile) {
const fileUrl = await (window as any).electronAPI.writeSharedFile(filename, content, false);
result = JSON.stringify({ success: true, url: fileUrl, filename });
} else {
result = JSON.stringify({ error: "L'API d'écriture de fichier local n'est pas disponible (hors Electron)." });
}
break;
}
case 'generate_shared_image': {
const filename = String(args.filename || '');
const svgContent = String(args.svgContent || '');
if ((window as any).electronAPI?.writeSharedFile) {
const fileUrl = await (window as any).electronAPI.writeSharedFile(filename, svgContent, false);
result = JSON.stringify({ success: true, url: fileUrl, filename });
} else {
result = JSON.stringify({ error: "L'API de génération d'image locale n'est pas disponible." });
}
break;
}
default:
result = JSON.stringify({ error: `Unknown tool: ${name}` });
}