From bf247e7e5f1b0f8f16c8341bb72bccf6e7d44fcb Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Wed, 20 May 2026 15:59:16 +0200 Subject: [PATCH] feat: implement file sharing, SVG image rendering, avatar emotional animations, live telemetry, and dynamic TTS description with cyberpunk playback controls --- main.js | 560 +++++++++++++++++++++++ preload.js | 31 ++ src/App.tsx | 637 ++++++++++++++++++++++++--- src/components/AvatarModelLoader.tsx | 2 +- src/components/EveModel.tsx | 176 +++++++- src/index.css | 188 ++++++++ src/services/agentService.ts | 65 ++- src/services/audioService.ts | 46 +- src/services/toolRegistry.ts | 70 ++- 9 files changed, 1676 insertions(+), 99 deletions(-) create mode 100644 main.js create mode 100644 preload.js diff --git a/main.js b/main.js new file mode 100644 index 0000000..41242b8 --- /dev/null +++ b/main.js @@ -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 = ` + + +
+

EveFlow n'a pas pu charger l'interface

+

${reason}

+

Lance npm run build, puis relance l'application.

+
+ + + `; + + 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()}`); +}); diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..8111aa7 --- /dev/null +++ b/preload.js @@ -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), +}); diff --git a/src/App.tsx b/src/App.tsx index 13e3748..823eebd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(''); + + 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 ( + {alt + ); +}; + + 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(null); + const [currentlyPlayingMsgId, setCurrentlyPlayingMsgId] = useState(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([]); + const fileInputRef = useRef(null); + + // Gestion de la sélection des fichiers joints (Zéro Trust validation) + const handleFileChange = (e: React.ChangeEvent) => { + 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('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 => { + 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 = () => { )}
- + ( + + ), + a: ({ node, ...props }) => { + const isLocal = props.href?.startsWith('file://') || /^[a-zA-Z]:/.test(props.href || ''); + if (isLocal) { + return ( + { + 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} + + ); + } + return {props.children}; + } + }} + > {msg.content} + + {msg.images && msg.images.length > 0 && ( +
+ {msg.images.map((imgUrl, i) => ( + + ))} +
+ )} + + {msg.role === 'assistant' && msg.content.length >= TTS_LONG_TEXT_THRESHOLD && ( +
+ +
+ )}
{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 */}
- >>> HOST: UNRAID_HERMES // LIVE - // FREQ: 742.8 MHz + >>> HOST: {hostname} // LIVE + // FREQ: {cpuFreq} MHz // TRANSCEIVER: ACTIVE - TEMP: 34.6°C // FPS: 60 + TEMP: {cpuTemp}°C // FPS: {realFps}
+ {/* Aperçu des pièces jointes */} + {attachments.length > 0 && ( +
+ {attachments.map((att, idx) => ( +
+ {att.type.startsWith('image/') ? ( + {att.name} + ) : ( + 📄 + )} +
+ {att.name} + {(att.size / 1024).toFixed(1)} KB +
+ +
+ ))} +
+ )} + {/* Input Bar */}
+ + { )} + +
+
+ + saveConfig({ ...agentConfig, hermesSessionKey: e.target.value })} + className="glow-input" + style={{ padding: '8px 12px', fontSize: '12px' }} + placeholder="Ex: eveflow-user-session" + /> + + ℹ️ Cette clé associe EveFlow à vos fichiers de mémoire persistante (USER.md et MEMORY.md) sur votre hôte UNRAID_HERMES. + +
)} diff --git a/src/components/AvatarModelLoader.tsx b/src/components/AvatarModelLoader.tsx index f1c1e53..bb704de 100644 --- a/src/components/AvatarModelLoader.tsx +++ b/src/components/AvatarModelLoader.tsx @@ -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) { diff --git a/src/components/EveModel.tsx b/src/components/EveModel.tsx index 0304bf3..594a38c 100644 --- a/src/components/EveModel.tsx +++ b/src/components/EveModel.tsx @@ -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 = ({ emotion, isSpeaking, avatarId = 'eve' }) => { const modelRef = useRef(null); const headRef = useRef(null); @@ -438,11 +552,11 @@ export const EveModel: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ emotion, isSpeaking, avatarI }); return ( - + ); diff --git a/src/index.css b/src/index.css index 0729830..183254c 100644 --- a/src/index.css +++ b/src/index.css @@ -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); +} diff --git a/src/services/agentService.ts b/src/services/agentService.ts index a5dc5aa..faa429e 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -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 { 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 { 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 { - 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 diff --git a/src/services/audioService.ts b/src/services/audioService.ts index 95ead18..0199050 100644 --- a/src/services/audioService.ts +++ b/src/services/audioService.ts @@ -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) diff --git a/src/services/toolRegistry.ts b/src/services/toolRegistry.ts index f536110..26b74ab 100644 --- a/src/services/toolRegistry.ts +++ b/src/services/toolRegistry.ts @@ -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 , 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 { 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}` }); }