mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-12 01:39:13 +02:00
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:
1 parent
d77ace03cd
commit
bf247e7e5f
9 files changed
+1676
-99
No files matched your search
+585
-52
@@ -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>>>> HOST: UNRAID_HERMES // LIVE</span>
|
||||
<span className="diag-item-secondary">// FREQ: 742.8 MHz</span>
|
||||
<span>>>> 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user