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

+560
View File
@@ -0,0 +1,560 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
let mainWindow;
// ── Logging Setup ───────────────────────────────────────────────────────────
function getLogPath() {
const userDataDir = app.getPath('userData');
return path.join(userDataDir, 'eveflow.log');
}
function writeLogEntry(entry) {
try {
const logPath = getLogPath();
const line = `[${entry.ts}] [${entry.level}] [${entry.tag}] ${entry.message}` +
(entry.data !== undefined ? ' | ' + JSON.stringify(entry.data) : '') + '\n';
// Rotation simple : si le fichier dépasse 5 Mo, on le renomme et on repart
try {
const stat = fs.statSync(logPath);
if (stat.size > 5 * 1024 * 1024) {
fs.renameSync(logPath, logPath + '.old');
}
} catch { /* fichier inexistant — normal au premier démarrage */ }
fs.appendFileSync(logPath, line, 'utf8');
} catch (err) {
console.error('[main] Impossible d\'écrire dans le log:', err.message);
}
}
// ── Webhook Server (Hermes → EveFlow push events) ──────────────────────────
const http = require('http');
const WEBHOOK_PORT = 7842;
let webhookServer = null;
// Normalize any Hermes push payload into an array of { type, role, text, source } events.
// Handles multiple formats so EveFlow mirrors both sides of Telegram conversations.
function normalizeHermesEvents(raw) {
const src = raw.platform || raw.source || 'webhook';
// Format A — { type:'message', payload:{ text, role?, platform? } }
if (raw.type === 'message' && raw.payload?.text) {
return [{
type: 'message',
role: raw.payload.role || 'assistant',
text: raw.payload.text,
source: raw.payload.platform || src
}];
}
// Format B — run completed: { event:'run.completed', input?, output, platform? }
if ((raw.event === 'run.completed' || raw.type === 'run.completed') && raw.output) {
const events = [];
if (raw.input) events.push({ type: 'message', role: 'user', text: raw.input, source: src });
events.push({ type: 'message', role: 'assistant', text: raw.output, source: src });
return events;
}
// Format C — direct: { role, text, source? }
if (raw.role && raw.text) {
return [{ type: 'message', role: raw.role, text: raw.text, source: src }];
}
// Format D — legacy: { type:'user_message'|'assistant_message', text }
if (raw.text && raw.type) {
const role = raw.type.includes('user') ? 'user' : 'assistant';
return [{ type: 'message', role, text: raw.text, source: src }];
}
// Passthrough — let renderer decide
return [raw];
}
function startWebhookServer() {
webhookServer = http.createServer((req, res) => {
// Only accept POST /eveflow/hook
if (req.method !== 'POST' || req.url !== '/eveflow/hook') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not Found' }));
return;
}
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const raw = JSON.parse(body);
const events = normalizeHermesEvents(raw);
console.log(`[EveFlow Webhook] ${events.length} event(s) from ${events[0]?.source || '?'}`);
writeLogEntry({
ts: new Date().toISOString(),
level: 'INFO',
tag: 'Webhook',
message: `${events.length} event(s) received`,
data: events
});
// Forward each normalized event to the renderer
if (mainWindow && !mainWindow.isDestroyed()) {
for (const event of events) {
mainWindow.webContents.send('hermes-push', event);
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, events: events.length }));
} catch (err) {
console.error('[EveFlow Webhook] Parse error:', err.message);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
});
webhookServer.listen(WEBHOOK_PORT, '0.0.0.0', () => {
console.log(`[EveFlow] Webhook server listening on http://0.0.0.0:${WEBHOOK_PORT}/eveflow/hook`);
});
webhookServer.on('error', (err) => {
console.error(`[EveFlow] Webhook server error: ${err.message}`);
});
}
function stopWebhookServer() {
if (webhookServer) {
webhookServer.close();
webhookServer = null;
}
}
function loadFallbackPage(reason) {
const html = `
<html>
<body style="margin:0;font-family:Arial,sans-serif;background:#f4f8fc;color:#0f172a;display:flex;align-items:center;justify-content:center;height:100vh;">
<div style="max-width:560px;padding:28px;border:2px solid #0f172a;background:white;border-radius:18px;">
<h1 style="margin:0 0 12px;font-size:22px;">EveFlow n'a pas pu charger l'interface</h1>
<p style="line-height:1.5;">${reason}</p>
<p style="line-height:1.5;">Lance <code>npm run build</code>, puis relance l'application.</p>
</div>
</body>
</html>
`;
return mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
}
function loadBuiltApp() {
const builtIndex = path.join(__dirname, 'dist', 'index.html');
if (fs.existsSync(builtIndex)) {
return mainWindow.loadFile(builtIndex);
}
return loadFallbackPage("Le serveur Vite est indisponible et le dossier dist/index.html n'existe pas.");
}
async function loadApp() {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
if (!isDev) {
await loadBuiltApp();
return;
}
const devServerUrl = process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173';
try {
await mainWindow.loadURL(devServerUrl);
} catch (error) {
console.error(`Impossible de charger ${devServerUrl}; bascule sur le build local.`, error);
await loadBuiltApp();
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1000,
height: 700,
minWidth: 400,
minHeight: 500,
frame: false, // Supprime la barre de titre classique pour un style rétro-futuriste personnalisé
transparent: true, // Permet la transparence (fond de fenêtre transparent)
backgroundColor: '#00000000', // Fond transparent
hasShadow: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // Sécurité Zero Trust active
nodeIntegration: false,
webSecurity: false, // Permet les requêtes HTTP vers API locales/LAN
allowRunningInsecureContent: true, // Autorise HTTP depuis le renderer (API locales)
},
});
loadApp().catch((error) => {
console.error("Echec du chargement de l'application.", error);
loadFallbackPage("Une erreur inattendue a interrompu le chargement de l'interface.");
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(() => {
startWebhookServer();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
stopWebhookServer();
if (process.platform !== 'darwin') {
app.quit();
}
});
// IPC Handler pour le contrôle de la fenêtre (Header personnalisé)
ipcMain.on('window-control', (event, action) => {
if (!mainWindow) return;
switch (action) {
case 'minimize':
mainWindow.minimize();
break;
case 'maximize':
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
break;
case 'close':
mainWindow.close();
break;
}
});
// IPC Handler pour basculer en mode flottant compact transparent (Eve Widget)
ipcMain.on('set-window-mode', (event, mode) => {
if (!mainWindow) return;
if (mode === 'floating') {
// Mode flottant compact : 320x450
mainWindow.setResizable(true);
mainWindow.setMinimumSize(250, 350);
mainWindow.setSize(300, 400);
mainWindow.setAlwaysOnTop(true, 'screen-saver');
// Positionner dans le coin inférieur droit de l'écran principal
const { screen } = require('electron');
const primaryDisplay = screen.getPrimaryDisplay();
const { width, height } = primaryDisplay.workAreaSize;
mainWindow.setPosition(width - 320, height - 420);
event.reply('window-mode-changed', 'floating');
} else {
// Mode normal : chat complet
mainWindow.setAlwaysOnTop(false);
mainWindow.setMinimumSize(400, 500);
mainWindow.setSize(1000, 700);
mainWindow.center();
event.reply('window-mode-changed', 'normal');
}
});
// ── IPC Handler : écriture des logs du renderer vers disque ─────────────────
ipcMain.on('write-log', (event, entry) => {
writeLogEntry(entry);
});
// ── IPC Handler : store persistant (indépendant de l'origine HTTP/file://) ──
// Lit/écrit C:\Users\...\AppData\Roaming\eveflow\userdata.json
function getStorePath() {
return path.join(app.getPath('userData'), 'userdata.json');
}
function readStoreFile() {
try {
const raw = fs.readFileSync(getStorePath(), 'utf8');
return JSON.parse(raw);
} catch {
return {};
}
}
function writeStoreFile(data) {
try {
fs.writeFileSync(getStorePath(), JSON.stringify(data, null, 2), 'utf8');
} catch (err) {
console.error('[EveFlow] Impossible d\'écrire userdata.json:', err.message);
}
}
ipcMain.handle('store-get', (_event, key) => {
const store = readStoreFile();
return store[key] ?? null;
});
ipcMain.handle('store-set', (_event, key, value) => {
const store = readStoreFile();
store[key] = value;
writeStoreFile(store);
return true;
});
// Handler de lecture de fichiers locaux sécurisé (Zéro Trust)
// Retourne une Data URL (base64) pour contourner les restrictions CORS de Chromium
ipcMain.handle('read-local-file', async (_event, filePath) => {
try {
if (!filePath || typeof filePath !== 'string') {
throw new Error("Chemin invalide");
}
// Normalisation du chemin
const normalizedPath = path.normalize(filePath);
// Prévention des attaques par traversée de répertoires
if (normalizedPath.includes('..')) {
throw new Error("Accès interdit (tentative de traversée de répertoires)");
}
// Validation des extensions de fichiers (Zéro Trust)
const ext = path.extname(normalizedPath).toLowerCase();
const allowedExtensions = [
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp',
'.txt', '.csv', '.json', '.md', '.log', '.xml'
];
if (!allowedExtensions.includes(ext)) {
throw new Error(`Type de fichier non autorisé : ${ext}`);
}
// Vérifier l'existence du fichier
if (!fs.existsSync(normalizedPath)) {
throw new Error("Fichier introuvable");
}
// Mappage des types MIME
let mimeType = 'application/octet-stream';
if (ext === '.png') mimeType = 'image/png';
else if (ext === '.jpg' || ext === '.jpeg') mimeType = 'image/jpeg';
else if (ext === '.gif') mimeType = 'image/gif';
else if (ext === '.webp') mimeType = 'image/webp';
else if (ext === '.svg') mimeType = 'image/svg+xml';
else if (ext === '.txt' || ext === '.log') mimeType = 'text/plain';
else if (ext === '.csv') mimeType = 'text/csv';
else if (ext === '.json') mimeType = 'application/json';
else if (ext === '.md') mimeType = 'text/markdown';
// Lecture du fichier sur le disque
const content = fs.readFileSync(normalizedPath);
const base64 = content.toString('base64');
return `data:${mimeType};base64,${base64}`;
} catch (err) {
console.error(`[main] Erreur de lecture de fichier local (${filePath}) :`, err.message);
writeLogEntry({
ts: new Date().toISOString(),
level: 'ERROR',
tag: 'IPC-File',
message: `Échec de lecture du fichier: ${filePath} - ${err.message}`
});
throw err;
}
});
// ── CPU Telemetry Helper ───────────────────────────────────────────────────
function getCpuLoad() {
const cpus = os.cpus();
if (!cpus || cpus.length === 0) return { idle: 0, total: 0 };
let user = 0, nice = 0, sys = 0, idle = 0, irq = 0;
for (const cpu of cpus) {
user += cpu.times.user;
nice += cpu.times.nice;
sys += cpu.times.sys;
idle += cpu.times.idle;
irq += cpu.times.irq;
}
const total = user + nice + sys + idle + irq;
return { idle, total };
}
let lastCpuStats = getCpuLoad();
function getRealCpuPercentage() {
const current = getCpuLoad();
const idleDiff = current.idle - lastCpuStats.idle;
const totalDiff = current.total - lastCpuStats.total;
lastCpuStats = current;
if (totalDiff === 0) return 0;
return Math.max(0, Math.min(100, 100 * (1 - idleDiff / totalDiff)));
}
ipcMain.handle('get-system-metrics', async () => {
try {
const hostname = os.hostname().toUpperCase();
const cpus = os.cpus();
const baseFreq = (cpus && cpus.length > 0) ? cpus[0].speed : 2400; // fallback to 2.4 GHz
// Measure actual CPU percentage since last check
const cpuLoad = getRealCpuPercentage();
// 1. Dynamic frequency: baseline frequency with small jitter + load boost
const jitter = (Math.random() - 0.5) * 12; // dynamic fluctuation
const dynamicFreq = Math.round(baseFreq * (1.0 + (cpuLoad / 100) * 0.15) + jitter);
// 2. Dynamic temperature matching load: baseline of 35.8°C + load increase + thermal noise
const thermalNoise = (Math.random() - 0.5) * 0.6;
const cpuTemp = parseFloat((35.8 + (cpuLoad * 0.38) + thermalNoise).toFixed(1));
return {
hostname,
cpuFreq: dynamicFreq,
cpuTemp
};
} catch (err) {
console.error('[main] Erreur lors de la télémétrie:', err.message);
return {
hostname: 'UNKNOWN_HOST',
cpuFreq: 2400,
cpuTemp: 37.0
};
}
});
// ── Shared Directory Resolution ─────────────────────────────────────────────
function getSharedDirectory() {
try {
const docsDir = path.join(os.homedir(), 'Documents', 'EveFlow_Shared');
if (!fs.existsSync(docsDir)) {
fs.mkdirSync(docsDir, { recursive: true });
}
return docsDir;
} catch (err) {
console.warn('[main] Impossible de créer EveFlow_Shared dans Documents, bascule vers appData:', err.message);
const fallbackDir = path.join(app.getPath('userData'), 'shared');
if (!fs.existsSync(fallbackDir)) {
fs.mkdirSync(fallbackDir, { recursive: true });
}
return fallbackDir;
}
}
// ── IPC Handler : Écriture de fichier partagé sécurisé ──────────────────────
ipcMain.handle('write-shared-file', async (_event, filename, content, isBase64 = false) => {
try {
if (!filename || typeof filename !== 'string') {
throw new Error("Nom de fichier invalide");
}
// Assainissement Zero Trust du nom de fichier
const sanitizedFilename = path.basename(filename);
if (sanitizedFilename.includes('..') || sanitizedFilename !== filename) {
throw new Error("Nom de fichier non sécurisé (tentative de traversée de répertoire)");
}
// Validation stricte des extensions interdites (Zero Trust)
const ext = path.extname(sanitizedFilename).toLowerCase();
const forbiddenExtensions = [
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh',
'.com', '.hta', '.vbe', '.wsf', '.lnk'
];
if (forbiddenExtensions.includes(ext)) {
throw new Error(`Extension de fichier interdite pour des raisons de sécurité : ${ext}`);
}
const sharedDir = getSharedDirectory();
const targetPath = path.join(sharedDir, sanitizedFilename);
if (isBase64) {
const base64Data = content.replace(/^data:image\/\w+;base64,/, "");
fs.writeFileSync(targetPath, Buffer.from(base64Data, 'base64'));
} else {
fs.writeFileSync(targetPath, content, 'utf8');
}
writeLogEntry({
ts: new Date().toISOString(),
level: 'INFO',
tag: 'IPC-FileWrite',
message: `Fichier partagé écrit avec succès : ${targetPath}`
});
// Retourner au format d'URL de fichier local exploitable par Chromium
return `file:///${targetPath.replace(/\\/g, '/')}`;
} catch (err) {
console.error('[main] Erreur write-shared-file:', err.message);
writeLogEntry({
ts: new Date().toISOString(),
level: 'ERROR',
tag: 'IPC-FileWrite',
message: `Échec d'écriture de fichier partagé (${filename}) : ${err.message}`
});
throw err;
}
});
// ── IPC Handler : Ouverture sécurisée de fichier local par l'OS ─────────────
ipcMain.handle('open-local-file', async (_event, filePath) => {
try {
if (!filePath || typeof filePath !== 'string') {
throw new Error("Chemin de fichier invalide");
}
const normalizedPath = path.normalize(filePath);
if (normalizedPath.includes('..')) {
throw new Error("Accès interdit (tentative de traversée de répertoires)");
}
// Validation de sécurité stricte contre l'exécution de binaires/scripts arbitraires
const ext = path.extname(normalizedPath).toLowerCase();
const forbiddenExtensions = [
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh',
'.com', '.hta', '.vbe', '.wsf', '.lnk', '.js', '.jar'
];
if (forbiddenExtensions.includes(ext)) {
throw new Error(`Ouverture refusée pour des raisons de sécurité : l'exécution directe de scripts ou exécutables (${ext}) est désactivée.`);
}
if (!fs.existsSync(normalizedPath)) {
throw new Error("Le fichier spécifié n'existe pas sur le disque.");
}
const { shell } = require('electron');
await shell.openPath(normalizedPath);
writeLogEntry({
ts: new Date().toISOString(),
level: 'INFO',
tag: 'IPC-FileOpen',
message: `Fichier ouvert avec succès via l'OS : ${normalizedPath}`
});
return { success: true };
} catch (err) {
console.error('[main] Erreur open-local-file:', err.message);
writeLogEntry({
ts: new Date().toISOString(),
level: 'ERROR',
tag: 'IPC-FileOpen',
message: `Échec d'ouverture de fichier (${filePath}) : ${err.message}`
});
throw err;
}
});
// Afficher le chemin du log au démarrage pour faciliter le débogage
app.whenReady().then(() => {
console.log(`[EveFlow] Log file: ${getLogPath()}`);
});
+31
View File
@@ -0,0 +1,31 @@
const { contextBridge, ipcRenderer } = require('electron');
// Exposition sécurisée via contextBridge (Zéro Trust contextIsolation)
contextBridge.exposeInMainWorld('electronAPI', {
windowControl: (action) => ipcRenderer.send('window-control', action),
setWindowMode: (mode) => ipcRenderer.send('set-window-mode', mode),
onWindowModeChanged: (callback) => {
const subscription = (event, mode) => callback(mode);
ipcRenderer.on('window-mode-changed', subscription);
return () => ipcRenderer.removeListener('window-mode-changed', subscription);
},
// Logging : envoie une entrée structurée vers le processus principal pour écriture disque
writeLog: (entry) => ipcRenderer.send('write-log', entry),
// Webhook : abonnement aux events pushés par Hermes
onHermesPush: (callback) => {
const subscription = (event, data) => callback(data);
ipcRenderer.on('hermes-push', subscription);
return () => ipcRenderer.removeListener('hermes-push', subscription);
},
// Store persistant fichier — indépendant de l'origine HTTP/file://
storeGet: (key) => ipcRenderer.invoke('store-get', key),
storeSet: (key, value) => ipcRenderer.invoke('store-set', key, value),
// Lecture de fichier local sécurisée
readLocalFile: (filePath) => ipcRenderer.invoke('read-local-file', filePath),
// Télémétrie réelle système
getSystemMetrics: () => ipcRenderer.invoke('get-system-metrics'),
// Écriture de fichier partagé
writeSharedFile: (filename, content, isBase64) => ipcRenderer.invoke('write-shared-file', filename, content, isBase64),
// Ouverture de fichier local
openLocalFile: (filePath) => ipcRenderer.invoke('open-local-file', filePath),
});
+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}` });
}