import React, { useState, useEffect, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Send, Mic, MicOff, Settings, MessageSquare, Volume2, VolumeX, Cpu, Minimize2, X, AlertCircle, StopCircle, Paperclip, CalendarClock, History, Wrench, Play, Pause, RefreshCw, Trash2, Plus, CheckCircle2, WifiOff, Edit3, Save, ChevronUp, ExternalLink, BriefcaseBusiness, AlertTriangle } from 'lucide-react'; import { ThreeCanvas, ToolEvent } from './components/ThreeCanvas'; import { AudioService } from './services/audioService'; import { AgentService, AgentConfig, AgentProvider } from './services/agentService'; import { EmotionService, EveAvatar, EveEmotion } from './services/emotionService'; import { HermesJob, HermesJobDraft, PollingService } from './services/pollingService'; import { AppCallbacks } from './services/toolRegistry'; interface Message { id: string; role: 'user' | 'assistant'; content: string; emotion?: EveEmotion; timestamp: Date; source?: 'eveflow' | 'telegram' | 'webhook' | 'cron'; images?: string[]; // base64 Data URLs pour affichage local direct } interface Attachment { name: string; type: string; dataUrl: string; // base64 Data URL size: number; } type HermesCockpitTab = 'tools' | 'crons' | 'history'; type HermesSyncState = 'idle' | 'syncing' | 'online' | 'offline'; interface HermesRunRecord { id: string; jobId: string; jobName: string; status: 'succeeded' | 'failed'; result: string; at: string; read: boolean; } interface HermesCronCache { jobs: HermesJob[]; runs: HermesRunRecord[]; lastSyncAt?: string; } 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) }); }; }); }; // Prétraite le contenu d'un message pour convertir les tokens MEDIA: en syntaxe Markdown image // Le bot Hermes envoie MEDIA:/tmp/cat.jpg au lieu de ![img](/tmp/cat.jpg), on corrige ça côté renderer const preprocessMessageContent = (content: string): string => { if (!content) return content; // Entourer de \n\n pour que ReactMarkdown traite l'image comme un élément bloc (pas inline) return content.replace(/MEDIA:\s*(\S+)/g, (_match, filePath) => `\n\n![image](${filePath})\n\n`); }; // Cache module-level des images déjà chargées (src → dataUrl ou blobUrl) // Évite les re-fetches et le clignotement quand ReactMarkdown recrée les composants pendant le streaming const _imgCache = new Map(); const _imgPending = new Map>(); const _imgErrorCache = new Map(); // Composant de rendu asynchrone pour les images locales dans ReactMarkdown et la galerie // Gère de façon Zero Trust la distinction entre base64 local, fichier Windows, et chemin absolu du serveur distant Hermes // Le cache module-level _imgCache évite les refetch et le clignotement lors du streaming React const LocalImage: React.FC<{ src: string; alt?: string; agentUrl?: string; provider?: string; hermesApiKey?: string; hermesSessionKey?: string; [key: string]: any; }> = ({ src, alt, agentUrl, provider, hermesApiKey, hermesSessionKey, ...props }) => { // Initialisation lazy : si déjà en cache, pas de flash du GIF transparent const [imageSrc, setImageSrc] = useState(() => _imgCache.get(src) || ''); const [error, setError] = useState(() => _imgErrorCache.get(src) || ''); void error; useEffect(() => { if (!src) return; // Cache hit : image déjà chargée, on l'affiche immédiatement sans aucun fetch if (_imgCache.has(src)) { setImageSrc(_imgCache.get(src)!); return; } if (_imgErrorCache.has(src)) { setError(_imgErrorCache.get(src)!); return; } const setAndCache = (url: string) => { _imgCache.set(src, url); _imgErrorCache.delete(src); setError(''); setImageSrc(url); }; const setFailure = (message: string) => { _imgErrorCache.set(src, message); setError(message); setImageSrc(''); }; const loadRemote = (remoteUrl: string, headers?: Record) => { const api = (window as any).electronAPI; if (!api?.fetchRemoteImage) { setAndCache(remoteUrl); return; } if (!_imgPending.has(remoteUrl)) { const pending = api.fetchRemoteImage(remoteUrl, headers || {}) .then((dataUrl: string) => { _imgCache.set(remoteUrl, dataUrl); _imgErrorCache.delete(remoteUrl); return dataUrl; }) .catch((err: any) => { const message = `Image inaccessible (${err?.message || 'erreur reseau'})`; _imgErrorCache.set(remoteUrl, message); throw new Error(message); }) .finally(() => _imgPending.delete(remoteUrl)); _imgPending.set(remoteUrl, pending); } _imgPending.get(remoteUrl)! .then(setAndCache) .catch((err: any) => setFailure(err?.message || 'Image inaccessible')); }; void loadRemote; // 1. Data URL directe — rien à faire if (src.startsWith('data:')) { setAndCache(src); return; } // 2. URL HTTP/HTTPS : passer par le processus principal Electron (Node.js) pour // charger l'image sans restriction CORS ni problème de serveur statique if (src.startsWith('http://') || src.startsWith('https://')) { const api = (window as any).electronAPI; if (api?.fetchRemoteImage) { api.fetchRemoteImage(src) .then(setAndCache) .catch(() => setAndCache(src)); // fallback direct si IPC échoue } else { setAndCache(src); } return; } // 3. Chemin local Windows (file:// ou C:\...) const isWindowsLocal = src.startsWith('file://') || /^[a-zA-Z]:/.test(src); const resolveRemoteHermes = () => { if (provider === 'hermes' && agentUrl) { try { const parsed = new URL(agentUrl); const hostUrl = `${parsed.protocol}//${parsed.host}`; let relativePath = src; if (src.startsWith('file://')) { relativePath = src.replace(/^file:\/\/\/[a-zA-Z]:/, '').replace(/^file:\/\//, ''); } else if (/^[a-zA-Z]:/.test(src)) { relativePath = src.substring(2); } relativePath = relativePath.replace(/\\/g, '/'); const resolvedUrl = `${hostUrl.replace(/\/$/, '')}/${relativePath.replace(/^\//, '')}`; const authHeaders: Record = {}; if (hermesApiKey) authHeaders['Authorization'] = `Bearer ${hermesApiKey}`; if (hermesSessionKey) authHeaders['X-Hermes-Session-Key'] = hermesSessionKey; const api = (window as any).electronAPI; if (api?.fetchRemoteImage) { api.fetchRemoteImage(resolvedUrl, authHeaders) .then(setAndCache) .catch(() => setAndCache(resolvedUrl)); } else { setAndCache(resolvedUrl); } } catch { setAndCache(src); } } else { setAndCache(src); } }; if (isWindowsLocal && (window as any).electronAPI?.readLocalFile) { let cleanPath = src; if (src.startsWith('file:///')) cleanPath = src.substring(8); else if (src.startsWith('file://')) cleanPath = src.substring(7); cleanPath = cleanPath.replace(/\//g, '\\'); (window as any).electronAPI.readLocalFile(cleanPath) .then(setAndCache) .catch(() => resolveRemoteHermes()); } else { // Chemin Unix (/tmp/cat.jpg) → construire l'URL Hermes et charger via IPC resolveRemoteHermes(); } }, [src, agentUrl, provider]); return ( {alt ); }; void LocalImage; const ResilientImage: React.FC<{ src: string; alt?: string; agentUrl?: string; provider?: string; hermesApiKey?: string; hermesSessionKey?: string; [key: string]: any; }> = ({ src, alt, agentUrl, provider, hermesApiKey, hermesSessionKey, ...props }) => { const [imageSrc, setImageSrc] = useState(() => _imgCache.get(src) || ''); const [error, setError] = useState(() => _imgErrorCache.get(src) || ''); useEffect(() => { if (!src) return; if (_imgCache.has(src)) { setImageSrc(_imgCache.get(src)!); setError(''); return; } if (_imgErrorCache.has(src)) { setError(_imgErrorCache.get(src)!); return; } const setSuccess = (cacheKey: string, value: string) => { _imgCache.set(cacheKey, value); _imgErrorCache.delete(cacheKey); setImageSrc(value); setError(''); }; const setFailure = (cacheKey: string, message: string) => { _imgErrorCache.set(cacheKey, message); setImageSrc(''); setError(message); }; const loadRemote = (remoteUrl: string, headers?: Record) => { const api = (window as any).electronAPI; if (!api?.fetchRemoteImage) { setSuccess(src, remoteUrl); return; } if (!_imgPending.has(remoteUrl)) { const pending = api.fetchRemoteImage(remoteUrl, headers || {}) .then((dataUrl: string) => { _imgCache.set(remoteUrl, dataUrl); _imgErrorCache.delete(remoteUrl); return dataUrl; }) .catch((err: any) => { const message = `Image inaccessible (${err?.message || 'erreur reseau'})`; _imgErrorCache.set(remoteUrl, message); throw new Error(message); }) .finally(() => _imgPending.delete(remoteUrl)); _imgPending.set(remoteUrl, pending); } _imgPending.get(remoteUrl)! .then((dataUrl) => setSuccess(src, dataUrl)) .catch((err: any) => setFailure(src, err?.message || 'Image inaccessible')); }; if (src.startsWith('data:')) { setSuccess(src, src); return; } if (src.startsWith('http://') || src.startsWith('https://')) { loadRemote(src); return; } const isWindowsLocal = src.startsWith('file://') || /^[a-zA-Z]:/.test(src); const resolveRemoteHermes = () => { if (provider === 'hermes' && agentUrl) { try { const parsed = new URL(agentUrl); const hostUrl = `${parsed.protocol}//${parsed.host}`; let relativePath = src; if (src.startsWith('file://')) { relativePath = src.replace(/^file:\/\/\/[a-zA-Z]:/, '').replace(/^file:\/\//, ''); } else if (/^[a-zA-Z]:/.test(src)) { relativePath = src.substring(2); } relativePath = relativePath.replace(/\\/g, '/'); const resolvedUrl = `${hostUrl.replace(/\/$/, '')}/${relativePath.replace(/^\//, '')}`; const headers: Record = {}; if (hermesApiKey) headers.Authorization = `Bearer ${hermesApiKey}`; if (hermesSessionKey) headers['X-Hermes-Session-Key'] = hermesSessionKey; loadRemote(resolvedUrl, headers); } catch { setSuccess(src, src); } } else { setSuccess(src, src); } }; if (isWindowsLocal && (window as any).electronAPI?.readLocalFile) { let cleanPath = src; if (src.startsWith('file:///')) cleanPath = src.substring(8); else if (src.startsWith('file://')) cleanPath = src.substring(7); cleanPath = cleanPath.replace(/\//g, '\\'); (window as any).electronAPI.readLocalFile(cleanPath) .then((dataUrl: string) => setSuccess(src, dataUrl)) .catch(() => resolveRemoteHermes()); } else { resolveRemoteHermes(); } }, [src, agentUrl, provider, hermesApiKey, hermesSessionKey]); if (error && !imageSrc) { return ( Image impossible a charger ); } return ( {alt ); }; function generateUUID(): string { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); } const AVATAR_PROFILES: Record = { eve: { name: 'Eve', role: "Unite d'assistance generale", accent: '#00f2ff', glow: 'rgba(0, 242, 255, 0.28)', welcome: "Bonjour ! Je suis Eve, ton assistante retro-futuriste. Comment puis-je t'aider aujourd'hui ?" }, nova: { name: 'Nova', role: "Unite d'analyse et cybersecurite", accent: '#ff00ea', glow: 'rgba(255, 0, 234, 0.28)', welcome: "Systeme de calcul Nova operationnel. Initialisation des protocoles d'analyse. Quelle est votre requete, operateur ?", assetPath: 'public/avatars/retrobot-space-explorer.glb' }, aegis: { name: 'Aegis', role: 'Unite de garde et diagnostic', accent: '#ff6a00', glow: 'rgba(255, 106, 0, 0.28)', welcome: "Aegis en ligne. Bouclier thermique stabilise et securite reseau au niveau maximal. J'attends vos instructions de controle, commandant.", assetPath: 'public/avatars/aegis.glb' } }; const getInitialAvatarId = (): EveAvatar => { // Lecture synchrone localStorage pour l'état initial React (le store IPC est async) // L'avatar est ensuite re-synchronisé via persistRead dans useEffect si besoin try { if (typeof window === 'undefined') return 'eve'; const savedAvatar = window.localStorage.getItem('eveflow_avatar_id') as EveAvatar | null; return savedAvatar && savedAvatar in AVATAR_PROFILES ? savedAvatar : 'eve'; } catch { return 'eve'; } }; // Lecture persistante : store fichier Electron en priorité, localStorage en fallback const readLocalStorage = (key: string): string | null => { try { if (typeof window === 'undefined') return null; return window.localStorage.getItem(key); } catch { return null; } }; const writeLocalStorage = (key: string, value: string) => { try { if (typeof window !== 'undefined') { window.localStorage.setItem(key, value); } } catch { /* ignore */ } }; // Store persistant via IPC Electron (fichier userData/userdata.json) // Garantit la persistance indépendamment de l'origine (dev/prod) const persistRead = async (key: string): Promise => { try { if (window.electronAPI?.storeGet) { const val = await window.electronAPI.storeGet(key); return val !== null ? val : readLocalStorage(key); } } catch { /* ignore */ } return readLocalStorage(key); }; const persistWrite = (key: string, value: string) => { writeLocalStorage(key, value); try { window.electronAPI?.storeSet?.(key, value); } catch { /* ignore */ } }; const createWelcomeMessage = (avatarId: EveAvatar): Message => ({ id: 'welcome', role: 'assistant', content: AVATAR_PROFILES[avatarId].welcome, emotion: 'neutral', timestamp: new Date() }); const getHermesJobId = (job: HermesJob): string => job.id || String(job.job_id || job.name || Math.random()); const getHermesJobStatus = (job: HermesJob): string => { if (job.state) return String(job.state); if (job.status) return String(job.status); if (job.enabled === false) return 'paused'; if (job.last_status) return String(job.last_status); return 'scheduled'; }; const formatHermesSchedule = (schedule: HermesJob['schedule']): string => { if (!schedule) return 'non planifie'; if (typeof schedule === 'string') return schedule; return schedule.display || schedule.expr || schedule.kind || 'schedule'; }; const getHermesJobResult = (job: HermesJob): string => ( job.result || job.last_result || job.output || job.last_output || job.error || job.last_error || '' ); const getHermesJobTime = (job: HermesJob): string => ( job.completed_at || job.last_run_at || job.updated_at || job.created_at || new Date().toISOString() ); const buildRunRecordFromJob = (job: HermesJob): HermesRunRecord | null => { const result = getHermesJobResult(job); const status = getHermesJobStatus(job).toLowerCase(); if (!result || (!['completed', 'failed', 'ok'].includes(status) && !job.completed_at && !job.last_run_at)) { return null; } const at = getHermesJobTime(job); const jobId = getHermesJobId(job); return { id: `${jobId}:${at}:${result.slice(0, 24)}`, jobId, jobName: job.name || jobId, status: status === 'failed' || job.error || job.last_error ? 'failed' : 'succeeded', result, at, read: false }; }; const previewText = (value: string, max = 96): string => ( value.length > max ? `${value.slice(0, max - 1)}...` : value ); const createDefaultCronDraft = (): HermesJobDraft => ({ name: '', schedule: 'every 1h', prompt: '', deliver: 'local' }); const HermesCockpitWindow: React.FC<{ events: ToolEvent[]; isWorking: boolean; jobs: HermesJob[]; runs: HermesRunRecord[]; syncState: HermesSyncState; syncDetail?: string; lastSyncAt?: string; draft: HermesJobDraft; editingJobId: string | null; isSaving: boolean; onDraftChange: (draft: HermesJobDraft) => void; onCreate: () => void; onCancelEdit: () => void; onStartEdit: (job: HermesJob) => void; onUpdate: () => void; onRefresh: () => void; onPause: (job: HermesJob) => void; onResume: (job: HermesJob) => void; onRun: (job: HermesJob) => void; onDelete: (job: HermesJob) => void; onOpenRun: (run: HermesRunRecord) => void; onClose: () => void; }> = ({ events, isWorking, jobs, runs, syncState, syncDetail, lastSyncAt, draft, editingJobId, isSaving, onDraftChange, onCreate, onCancelEdit, onStartEdit, onUpdate, onRefresh, onPause, onResume, onRun, onDelete, onOpenRun, onClose }) => { const latestEvents = events.slice(-8).reverse(); const [activeTab, setActiveTab] = useState('tools'); const unreadRuns = runs.filter(run => !run.read).length; const hasDraft = draft.name.trim() && draft.schedule.trim() && draft.prompt.trim(); const canSaveDraft = !!hasDraft && !isSaving; const renderTools = () => (
{latestEvents.length === 0 ? (
Aucun outil en cours
) : ( latestEvents.map((event) => (
{event.status.toUpperCase()} {event.label} {event.detail && {event.detail}}
)) )}
); const renderCrons = () => (
onDraftChange({ ...draft, name: e.target.value })} placeholder="Nom du cron" /> onDraftChange({ ...draft, schedule: e.target.value })} placeholder="every 1h / 0 9 * * *" />