mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-15 11:17:17 +02:00
2371 lines
95 KiB
TypeScript
2371 lines
95 KiB
TypeScript
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:<chemin> en syntaxe Markdown image
|
||
// Le bot Hermes envoie MEDIA:/tmp/cat.jpg au lieu de , 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\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<string, string>();
|
||
const _imgPending = new Map<string, Promise<string>>();
|
||
const _imgErrorCache = new Map<string, string>();
|
||
|
||
// 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<string>(() => _imgCache.get(src) || '');
|
||
const [error, setError] = useState<string>(() => _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<string, string>) => {
|
||
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<string, string> = {};
|
||
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 (
|
||
<img
|
||
src={imageSrc || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}
|
||
alt={alt || 'Visualisation'}
|
||
className="markdown-image"
|
||
{...props}
|
||
/>
|
||
);
|
||
};
|
||
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<string>(() => _imgCache.get(src) || '');
|
||
const [error, setError] = useState<string>(() => _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<string, string>) => {
|
||
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<string, string> = {};
|
||
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 (
|
||
<a href={src} target="_blank" rel="noopener noreferrer" className="markdown-image-error" title={src}>
|
||
Image impossible a charger
|
||
</a>
|
||
);
|
||
}
|
||
|
||
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;
|
||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||
});
|
||
}
|
||
|
||
const AVATAR_PROFILES: Record<EveAvatar, {
|
||
name: string;
|
||
role: string;
|
||
accent: string;
|
||
glow: string;
|
||
welcome: string;
|
||
assetPath?: string;
|
||
}> = {
|
||
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<string | null> => {
|
||
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<HermesCockpitTab>('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 = () => (
|
||
<div className="tool-screen-body hermes-cockpit-scroll">
|
||
{latestEvents.length === 0 ? (
|
||
<div className="tool-screen-empty">Aucun outil en cours</div>
|
||
) : (
|
||
latestEvents.map((event) => (
|
||
<div key={event.id} className={`tool-screen-line ${event.status}`}>
|
||
<span className="tool-screen-status">{event.status.toUpperCase()}</span>
|
||
<span className="tool-screen-text">{event.label}</span>
|
||
{event.detail && <span className="tool-screen-detail">{event.detail}</span>}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderCrons = () => (
|
||
<div className="hermes-cron-panel hermes-cockpit-scroll">
|
||
<div className="hermes-cron-editor">
|
||
<input
|
||
className="hermes-cockpit-input"
|
||
value={draft.name}
|
||
onChange={(e) => onDraftChange({ ...draft, name: e.target.value })}
|
||
placeholder="Nom du cron"
|
||
/>
|
||
<input
|
||
className="hermes-cockpit-input"
|
||
value={draft.schedule}
|
||
onChange={(e) => onDraftChange({ ...draft, schedule: e.target.value })}
|
||
placeholder="every 1h / 0 9 * * *"
|
||
/>
|
||
<textarea
|
||
className="hermes-cockpit-textarea"
|
||
value={draft.prompt}
|
||
onChange={(e) => onDraftChange({ ...draft, prompt: e.target.value })}
|
||
placeholder="Mission Hermes planifiee"
|
||
/>
|
||
<div className="hermes-cron-editor-actions">
|
||
{editingJobId && (
|
||
<button className="hermes-icon-btn" onClick={onCancelEdit} title="Annuler l'edition">
|
||
<X size={13} />
|
||
</button>
|
||
)}
|
||
<button
|
||
className="hermes-icon-btn primary"
|
||
onClick={editingJobId ? onUpdate : onCreate}
|
||
disabled={!canSaveDraft}
|
||
title={editingJobId ? 'Sauvegarder le cron' : 'Creer le cron'}
|
||
>
|
||
{editingJobId ? <Save size={13} /> : <Plus size={13} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hermes-jobs-list">
|
||
{jobs.length === 0 ? (
|
||
<div className="tool-screen-empty">Aucun cron Hermes synchronise</div>
|
||
) : jobs.map((job) => {
|
||
const jobId = getHermesJobId(job);
|
||
const status = getHermesJobStatus(job);
|
||
const paused = status === 'paused' || job.enabled === false;
|
||
const result = getHermesJobResult(job);
|
||
|
||
return (
|
||
<div key={jobId} className={`hermes-job-row ${status.toLowerCase()}`}>
|
||
<div className="hermes-job-main">
|
||
<span className="hermes-job-name">{job.name || jobId}</span>
|
||
<span className="hermes-job-meta">
|
||
{status.toUpperCase()} // {formatHermesSchedule(job.schedule)}
|
||
</span>
|
||
<span className="hermes-job-meta">
|
||
NEXT {job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'inconnu'}
|
||
</span>
|
||
{result && <span className="hermes-job-result">{previewText(result)}</span>}
|
||
</div>
|
||
<div className="hermes-job-actions">
|
||
<button className="hermes-icon-btn" onClick={() => onRun(job)} title="Lancer maintenant"><Play size={12} /></button>
|
||
<button className="hermes-icon-btn" onClick={() => paused ? onResume(job) : onPause(job)} title={paused ? 'Reprendre' : 'Mettre en pause'}>
|
||
{paused ? <Play size={12} /> : <Pause size={12} />}
|
||
</button>
|
||
<button className="hermes-icon-btn" onClick={() => onStartEdit(job)} title="Editer"><Edit3 size={12} /></button>
|
||
<button className="hermes-icon-btn danger" onClick={() => onDelete(job)} title="Supprimer"><Trash2 size={12} /></button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const renderHistory = () => (
|
||
<div className="hermes-history-list hermes-cockpit-scroll">
|
||
{runs.length === 0 ? (
|
||
<div className="tool-screen-empty">Aucun resultat de cron en cache</div>
|
||
) : runs.slice(0, 24).map((run) => (
|
||
<button key={run.id} className={`hermes-run-row ${run.status} ${run.read ? 'read' : 'unread'}`} onClick={() => onOpenRun(run)}>
|
||
<span className="hermes-run-status">{run.status === 'failed' ? 'ERR' : 'OK'}</span>
|
||
<span className="hermes-run-content">
|
||
<strong>{run.jobName}</strong>
|
||
<span>{previewText(run.result, 120)}</span>
|
||
</span>
|
||
<span className="hermes-run-time">{new Date(run.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className={`tool-screen-window hermes-cockpit-window ${isWorking || syncState === 'syncing' ? 'active' : ''}`}>
|
||
<div className="tool-screen-header">
|
||
<span className={`tool-screen-led ${syncState}`} />
|
||
<span>COCKPIT HERMES</span>
|
||
<div className="hermes-header-actions">
|
||
<button className="hermes-icon-btn" onClick={onRefresh} title="Synchroniser Hermes">
|
||
<RefreshCw size={12} />
|
||
</button>
|
||
<button className="hermes-icon-btn" onClick={onClose} title="Refermer le cockpit Hermes">
|
||
<X size={12} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="hermes-cockpit-status">
|
||
{syncState === 'offline' ? <WifiOff size={12} /> : <CheckCircle2 size={12} />}
|
||
<span>{syncState === 'offline' ? 'HERMES OFFLINE' : syncState === 'syncing' ? 'SYNC...' : 'HERMES ONLINE'}</span>
|
||
{lastSyncAt && <span>{new Date(lastSyncAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>}
|
||
</div>
|
||
{syncState === 'offline' && syncDetail && <div className="hermes-cockpit-error">{previewText(syncDetail, 120)}</div>}
|
||
<div className="hermes-cockpit-tabs">
|
||
<button className={activeTab === 'tools' ? 'active' : ''} onClick={() => setActiveTab('tools')} title="Outils Hermes"><Wrench size={13} /></button>
|
||
<button className={activeTab === 'crons' ? 'active' : ''} onClick={() => setActiveTab('crons')} title="Crons Hermes"><CalendarClock size={13} /></button>
|
||
<button className={activeTab === 'history' ? 'active' : ''} onClick={() => setActiveTab('history')} title="Historique des crons">
|
||
<History size={13} />
|
||
{unreadRuns > 0 && <span className="hermes-tab-badge">{unreadRuns}</span>}
|
||
</button>
|
||
</div>
|
||
{activeTab === 'tools' && renderTools()}
|
||
{activeTab === 'crons' && renderCrons()}
|
||
{activeTab === 'history' && renderHistory()}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const App: React.FC = () => {
|
||
// --- ÉTATS ---
|
||
const [avatarId, setAvatarId] = useState<EveAvatar>(() => getInitialAvatarId());
|
||
const [messages, setMessages] = useState<Message[]>(() => [createWelcomeMessage(avatarId)]);
|
||
const [inputText, setInputText] = useState('');
|
||
const [isSending, setIsSending] = useState(false);
|
||
const [toolEvents, setToolEvents] = useState<ToolEvent[]>([]);
|
||
const [hermesJobs, setHermesJobs] = useState<HermesJob[]>([]);
|
||
const [hermesRunHistory, setHermesRunHistory] = useState<HermesRunRecord[]>([]);
|
||
const [hermesLastSyncAt, setHermesLastSyncAt] = useState<string>('');
|
||
const [hermesSyncState, setHermesSyncState] = useState<HermesSyncState>('idle');
|
||
const [hermesSyncDetail, setHermesSyncDetail] = useState<string>('');
|
||
const [hermesCronDraft, setHermesCronDraft] = useState<HermesJobDraft>(() => createDefaultCronDraft());
|
||
const [editingHermesJobId, setEditingHermesJobId] = useState<string | null>(null);
|
||
const [isSavingHermesCron, setIsSavingHermesCron] = 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');
|
||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||
const [isListening, setIsListening] = useState(false);
|
||
const [isMuted] = useState(false);
|
||
|
||
// Fenêtre & Navigation
|
||
const [isFloatingMode, setIsFloatingMode] = useState(false);
|
||
const [showSettings, setShowSettings] = useState(false);
|
||
const [showHermesCockpit, setShowHermesCockpit] = useState(false);
|
||
|
||
// Paramètres d'Agents et de Voix (sauvegardés localement)
|
||
const [agentConfig, setAgentConfig] = useState<AgentConfig>({
|
||
provider: 'ollama',
|
||
ollamaUrl: 'http://localhost:11434',
|
||
ollamaModel: 'llama3',
|
||
hermesUrl: 'http://127.0.0.1:8642/v1',
|
||
hermesModel: 'hermes-agent',
|
||
hermesApiKey: '',
|
||
hermesSessionKey: 'eveflow-user-session'
|
||
});
|
||
|
||
const [availableVoices, setAvailableVoices] = useState<any[]>([]);
|
||
const [selectedVoice, setSelectedVoice] = useState<string>('');
|
||
const [ttsRate, setTtsRate] = useState<number>(1.15);
|
||
const [sttLang, setSttLang] = useState<string>('fr-FR');
|
||
const [ttsProvider, setTtsProvider] = useState<'system' | 'google-free'>('google-free');
|
||
const activeAvatar = AVATAR_PROFILES[avatarId];
|
||
const hermesUnreadRuns = hermesRunHistory.filter(run => !run.read).length;
|
||
const hermesSyncLabel = hermesSyncState === 'offline'
|
||
? 'Hermes offline'
|
||
: hermesSyncState === 'syncing'
|
||
? 'Hermes sync'
|
||
: 'Hermes OK';
|
||
const pushToolEvent = (event: Omit<ToolEvent, 'id' | 'ts'>) => {
|
||
setToolEvents(prev => [
|
||
...prev.slice(-11),
|
||
{
|
||
...event,
|
||
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||
ts: Date.now()
|
||
}
|
||
]);
|
||
};
|
||
|
||
|
||
|
||
// Session Hermes persistante — garantit la continuité mémoire/skills entre redémarrages
|
||
const persistHermesCronCache = (cache: HermesCronCache) => {
|
||
persistWrite('eveflow_hermes_cron_cache', JSON.stringify(cache));
|
||
};
|
||
|
||
const mergeHermesJobs = (jobs: HermesJob[], syncedAt: string) => {
|
||
setHermesJobs(jobs);
|
||
setHermesLastSyncAt(syncedAt);
|
||
setHermesRunHistory(prev => {
|
||
const previousById = new Map(prev.map(run => [run.id, run]));
|
||
const incoming = jobs
|
||
.map(buildRunRecordFromJob)
|
||
.filter((run): run is HermesRunRecord => !!run)
|
||
.map(run => ({ ...run, read: previousById.get(run.id)?.read ?? false }));
|
||
const merged = [...incoming, ...prev.filter(run => !incoming.some(next => next.id === run.id))]
|
||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||
.slice(0, 80);
|
||
|
||
persistHermesCronCache({ jobs, runs: merged, lastSyncAt: syncedAt });
|
||
|
||
const newRuns = incoming.filter(run => !previousById.has(run.id));
|
||
if (newRuns.length > 0) {
|
||
const newest = newRuns[0];
|
||
setMessages(messagesPrev => [...messagesPrev, {
|
||
id: newest.id,
|
||
role: 'assistant',
|
||
content: newest.result,
|
||
source: 'cron',
|
||
timestamp: new Date(newest.at)
|
||
}]);
|
||
setCurrentEmotion(newest.status === 'failed' ? 'sad' : 'happy');
|
||
if (!isMuted && audioServiceRef.current && newest.status === 'succeeded') {
|
||
audioServiceRef.current.speak(newest.result, selectedVoice, ttsRate, 1.1, ttsProvider);
|
||
}
|
||
}
|
||
|
||
return merged;
|
||
});
|
||
};
|
||
|
||
const refreshHermesJobs = async () => {
|
||
if (!pollingServiceRef.current || agentConfig.provider !== 'hermes') return;
|
||
try {
|
||
setHermesSyncState('syncing');
|
||
const snapshot = await pollingServiceRef.current.refresh();
|
||
mergeHermesJobs(snapshot.jobs, snapshot.syncedAt);
|
||
setHermesSyncState('online');
|
||
setHermesSyncDetail('');
|
||
} catch (e: any) {
|
||
setHermesSyncState('offline');
|
||
setHermesSyncDetail(e?.message || String(e));
|
||
}
|
||
};
|
||
|
||
const finishHermesCronMutation = async (successLabel: string) => {
|
||
pushToolEvent({ label: successLabel, status: 'done' });
|
||
await refreshHermesJobs();
|
||
};
|
||
|
||
const handleCreateHermesCron = async () => {
|
||
if (!pollingServiceRef.current) return;
|
||
setIsSavingHermesCron(true);
|
||
pushToolEvent({ label: 'Creation cron Hermes', status: 'running', detail: hermesCronDraft.schedule });
|
||
try {
|
||
await pollingServiceRef.current.createJob(hermesCronDraft);
|
||
setHermesCronDraft(createDefaultCronDraft());
|
||
await finishHermesCronMutation('Cron Hermes cree');
|
||
} catch (e: any) {
|
||
const message = e?.message || String(e);
|
||
setErrorMessage(`Cron Hermes: ${message}`);
|
||
pushToolEvent({ label: 'Creation cron Hermes', status: 'error', detail: message });
|
||
} finally {
|
||
setIsSavingHermesCron(false);
|
||
}
|
||
};
|
||
|
||
const handleStartEditHermesCron = (job: HermesJob) => {
|
||
setEditingHermesJobId(getHermesJobId(job));
|
||
setHermesCronDraft({
|
||
name: job.name || getHermesJobId(job),
|
||
schedule: formatHermesSchedule(job.schedule),
|
||
prompt: job.prompt || '',
|
||
deliver: typeof job.deliver === 'string' ? job.deliver : 'local'
|
||
});
|
||
};
|
||
|
||
const handleCancelEditHermesCron = () => {
|
||
setEditingHermesJobId(null);
|
||
setHermesCronDraft(createDefaultCronDraft());
|
||
};
|
||
|
||
const handleUpdateHermesCron = async () => {
|
||
if (!pollingServiceRef.current || !editingHermesJobId) return;
|
||
setIsSavingHermesCron(true);
|
||
pushToolEvent({ label: 'Mise a jour cron Hermes', status: 'running', detail: editingHermesJobId });
|
||
try {
|
||
await pollingServiceRef.current.updateJob(editingHermesJobId, hermesCronDraft);
|
||
handleCancelEditHermesCron();
|
||
await finishHermesCronMutation('Cron Hermes mis a jour');
|
||
} catch (e: any) {
|
||
const message = e?.message || String(e);
|
||
setErrorMessage(`Cron Hermes: ${message}`);
|
||
pushToolEvent({ label: 'Mise a jour cron Hermes', status: 'error', detail: message });
|
||
} finally {
|
||
setIsSavingHermesCron(false);
|
||
}
|
||
};
|
||
|
||
const runHermesCronAction = async (job: HermesJob, action: 'pause' | 'resume' | 'run' | 'delete') => {
|
||
if (!pollingServiceRef.current) return;
|
||
const jobId = getHermesJobId(job);
|
||
const labels = {
|
||
pause: 'Pause cron Hermes',
|
||
resume: 'Reprise cron Hermes',
|
||
run: 'Execution immediate Hermes',
|
||
delete: 'Suppression cron Hermes'
|
||
};
|
||
pushToolEvent({ label: labels[action], status: 'running', detail: job.name || jobId });
|
||
try {
|
||
if (action === 'pause') await pollingServiceRef.current.pauseJob(jobId);
|
||
if (action === 'resume') await pollingServiceRef.current.resumeJob(jobId);
|
||
if (action === 'run') await pollingServiceRef.current.runJob(jobId);
|
||
if (action === 'delete') await pollingServiceRef.current.deleteJob(jobId);
|
||
await finishHermesCronMutation(`${labels[action]} OK`);
|
||
} catch (e: any) {
|
||
const message = e?.message || String(e);
|
||
setErrorMessage(`Cron Hermes: ${message}`);
|
||
pushToolEvent({ label: labels[action], status: 'error', detail: message });
|
||
}
|
||
};
|
||
|
||
const openHermesRunInChat = (run: HermesRunRecord) => {
|
||
setHermesRunHistory(prev => {
|
||
const updated = prev.map(item => item.id === run.id ? { ...item, read: true } : item);
|
||
persistHermesCronCache({ jobs: hermesJobs, runs: updated, lastSyncAt: hermesLastSyncAt });
|
||
return updated;
|
||
});
|
||
setMessages(prev => [...prev, {
|
||
id: `cron-open-${run.id}`,
|
||
role: 'assistant',
|
||
content: run.result,
|
||
source: 'cron',
|
||
timestamp: new Date(run.at)
|
||
}]);
|
||
};
|
||
|
||
const [hermesSessionId, setHermesSessionId] = useState<string>('');
|
||
|
||
// Références de services
|
||
const audioServiceRef = useRef<AudioService | null>(null);
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const compactMessagesEndRef = useRef<HTMLDivElement>(null);
|
||
const pollingServiceRef = useRef<PollingService | null>(null);
|
||
|
||
// AppCallbacks for tools
|
||
const buildCallbacks = (): AppCallbacks => ({
|
||
setEmotion: (emotion) => setCurrentEmotion(emotion as EveEmotion),
|
||
getStatus: () => ({
|
||
provider: agentConfig.provider,
|
||
avatar: avatarId,
|
||
emotion: currentEmotion,
|
||
isSpeaking,
|
||
isMuted,
|
||
messageCount: messages.length
|
||
}),
|
||
getHistory: (n) => messages.slice(-n).map(m => ({ role: m.role, content: m.content })),
|
||
speak: (text) => {
|
||
if (!isMuted && audioServiceRef.current) {
|
||
setIsSpeaking(true);
|
||
audioServiceRef.current.speak(text, selectedVoice, ttsRate, 1.1, ttsProvider).then(() => setIsSpeaking(false));
|
||
}
|
||
}
|
||
});
|
||
|
||
// --- 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();
|
||
audioServiceRef.current = service;
|
||
|
||
// Charger la configuration depuis le store persistant (IPC fichier > localStorage)
|
||
persistRead('eveflow_agent_config').then(savedConfig => {
|
||
if (savedConfig) {
|
||
try {
|
||
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);
|
||
}
|
||
}
|
||
});
|
||
|
||
persistRead('eveflow_voice').then(savedVoice => {
|
||
if (savedVoice) setSelectedVoice(savedVoice);
|
||
});
|
||
|
||
persistRead('eveflow_tts_provider').then(savedProvider => {
|
||
if (savedProvider === 'system' || savedProvider === 'google-free') {
|
||
setTtsProvider(savedProvider as 'system' | 'google-free');
|
||
}
|
||
});
|
||
|
||
persistRead('eveflow_avatar_id').then(savedAvatar => {
|
||
if (savedAvatar && savedAvatar in AVATAR_PROFILES) {
|
||
setAvatarId(savedAvatar as EveAvatar);
|
||
}
|
||
});
|
||
|
||
persistRead('eveflow_hermes_session_id').then(savedId => {
|
||
if (savedId) {
|
||
setHermesSessionId(savedId);
|
||
} else {
|
||
const newId = generateUUID();
|
||
setHermesSessionId(newId);
|
||
persistWrite('eveflow_hermes_session_id', newId);
|
||
}
|
||
});
|
||
|
||
// Charger les voix système
|
||
persistRead('eveflow_hermes_cron_cache').then(savedCache => {
|
||
if (!savedCache) return;
|
||
try {
|
||
const parsed = JSON.parse(savedCache) as HermesCronCache;
|
||
if (Array.isArray(parsed.jobs)) setHermesJobs(parsed.jobs);
|
||
if (Array.isArray(parsed.runs)) setHermesRunHistory(parsed.runs);
|
||
if (parsed.lastSyncAt) setHermesLastSyncAt(parsed.lastSyncAt);
|
||
} catch (e) {
|
||
console.warn('Cache Hermes crons illisible', e);
|
||
}
|
||
});
|
||
|
||
const loadVoices = () => {
|
||
const voices = service.getVoices();
|
||
setAvailableVoices(voices);
|
||
persistRead('eveflow_voice').then(saved => {
|
||
if (!saved && voices.length > 0) {
|
||
const frVoice = voices.find(v => v.lang.startsWith('fr'));
|
||
if (frVoice) setSelectedVoice(frVoice.name);
|
||
}
|
||
});
|
||
};
|
||
|
||
loadVoices();
|
||
window.speechSynthesis.onvoiceschanged = loadVoices;
|
||
|
||
// Écouter le mode de fenêtre Electron
|
||
if (window.electronAPI) {
|
||
const unsubscribe = window.electronAPI.onWindowModeChanged((mode) => {
|
||
setIsFloatingMode(mode === 'floating');
|
||
if (mode === 'floating') {
|
||
setShowSettings(false);
|
||
}
|
||
});
|
||
return unsubscribe;
|
||
}
|
||
|
||
return undefined;
|
||
}, []);
|
||
|
||
// Défilement automatique
|
||
useEffect(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
compactMessagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, [messages, showSettings, isFloatingMode]);
|
||
|
||
|
||
|
||
// Polling Hermes Jobs
|
||
useEffect(() => {
|
||
if (agentConfig.provider !== 'hermes') {
|
||
pollingServiceRef.current?.stop();
|
||
setHermesSyncState('idle');
|
||
return;
|
||
}
|
||
|
||
if (!pollingServiceRef.current) {
|
||
pollingServiceRef.current = new PollingService(30000);
|
||
}
|
||
|
||
pollingServiceRef.current.start(
|
||
agentConfig.hermesUrl,
|
||
agentConfig.hermesApiKey,
|
||
(snapshot) => {
|
||
mergeHermesJobs(snapshot.jobs, snapshot.syncedAt);
|
||
setHermesSyncDetail('');
|
||
},
|
||
(status, detail) => {
|
||
setHermesSyncState(status);
|
||
setHermesSyncDetail(detail || '');
|
||
}
|
||
);
|
||
|
||
return () => pollingServiceRef.current?.stop();
|
||
}, [agentConfig.provider, agentConfig.hermesUrl, agentConfig.hermesApiKey, isMuted, selectedVoice, ttsRate, ttsProvider]);
|
||
|
||
// Webhook Events — mirrors all Hermes conversations (Telegram, etc.) into the chat
|
||
useEffect(() => {
|
||
if (window.electronAPI && window.electronAPI.onHermesPush) {
|
||
const unsubscribe = window.electronAPI.onHermesPush((event: any) => {
|
||
// Events are already normalized by main.js into { type, role, text, source }
|
||
if (event.type === 'message' && event.text) {
|
||
const role = event.role === 'user' ? 'user' : 'assistant';
|
||
const source = event.source || 'webhook';
|
||
setMessages(prev => [...prev, {
|
||
id: Math.random().toString(),
|
||
role,
|
||
content: event.text,
|
||
images: Array.isArray(event.images) ? event.images : undefined,
|
||
source: source as Message['source'],
|
||
timestamp: new Date()
|
||
}]);
|
||
if (role === 'assistant') {
|
||
setCurrentEmotion('happy');
|
||
if (!isMuted && audioServiceRef.current) {
|
||
audioServiceRef.current.speak(event.text, selectedVoice, ttsRate, 1.1, ttsProvider)
|
||
.then(() => setIsSpeaking(false))
|
||
.catch(() => {});
|
||
setIsSpeaking(true);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
return unsubscribe;
|
||
}
|
||
return undefined;
|
||
}, [isMuted, selectedVoice, ttsRate, ttsProvider]);
|
||
|
||
// --- ACTIONS ---
|
||
const saveConfig = (newConfig: AgentConfig) => {
|
||
setAgentConfig(newConfig);
|
||
persistWrite('eveflow_agent_config', JSON.stringify(newConfig));
|
||
};
|
||
|
||
|
||
|
||
const handleWindowControl = (action: 'minimize' | 'maximize' | 'close') => {
|
||
if (window.electronAPI) {
|
||
window.electronAPI.windowControl(action);
|
||
}
|
||
};
|
||
|
||
const toggleWindowMode = () => {
|
||
if (window.electronAPI) {
|
||
const targetMode = isFloatingMode ? 'normal' : 'floating';
|
||
window.electronAPI.setWindowMode(targetMode);
|
||
} else {
|
||
setIsFloatingMode(!isFloatingMode);
|
||
}
|
||
};
|
||
|
||
const toggleHermesCockpit = () => {
|
||
const nextOpen = !showHermesCockpit;
|
||
setShowHermesCockpit(nextOpen);
|
||
window.electronAPI?.setCockpitMode?.(nextOpen);
|
||
};
|
||
|
||
const closeHermesCockpit = () => {
|
||
setShowHermesCockpit(false);
|
||
window.electronAPI?.setCockpitMode?.(false);
|
||
};
|
||
|
||
const handleSendMessage = async (text: string) => {
|
||
if (!text.trim() && attachments.length === 0) return;
|
||
if (isSending) return;
|
||
|
||
// Arrêter immédiatement toute lecture vocale en cours
|
||
if (audioServiceRef.current) {
|
||
audioServiceRef.current.stopSpeaking();
|
||
}
|
||
setIsSpeaking(false);
|
||
|
||
setErrorMessage(null);
|
||
setIsSending(true);
|
||
pushToolEvent({
|
||
label: 'Nouvelle execution Hermes',
|
||
detail: agentConfig.provider,
|
||
status: 'running'
|
||
});
|
||
|
||
// 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: 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 {
|
||
const chatHistory = messages
|
||
.slice(-8)
|
||
.map(m => ({ role: m.role, content: m.content }));
|
||
|
||
// Create a placeholder message for streaming
|
||
const assistantId = Math.random().toString();
|
||
setMessages(prev => [...prev, {
|
||
id: assistantId,
|
||
role: 'assistant',
|
||
content: '',
|
||
emotion: 'thinking',
|
||
timestamp: new Date()
|
||
}]);
|
||
|
||
let fullReply = '';
|
||
// Batch token updates via rAF — évite N re-renders par seconde pendant le streaming
|
||
let pendingContent = '';
|
||
let rafHandle: number | null = null;
|
||
const flushTokens = () => {
|
||
rafHandle = null;
|
||
const snapshot = pendingContent;
|
||
setMessages(prev => prev.map(m =>
|
||
m.id === assistantId ? { ...m, content: snapshot } : m
|
||
));
|
||
};
|
||
|
||
// Index de suivi de lecture vocale pour le streaming en temps réel
|
||
let lastSpokenIndex = 0;
|
||
|
||
const reply = await AgentService.sendMessage(
|
||
finalPrompt,
|
||
chatHistory,
|
||
agentConfig,
|
||
avatarId,
|
||
buildCallbacks(),
|
||
// onToken — UI rAF (mise à jour fluide en temps réel)
|
||
(token) => {
|
||
fullReply += token;
|
||
pendingContent = fullReply;
|
||
if (rafHandle === null) rafHandle = requestAnimationFrame(flushTokens);
|
||
|
||
// Synthèse vocale fluide phrase par phrase en temps réel (streaming)
|
||
if (!isMuted && audioServiceRef.current) {
|
||
const textToParse = fullReply.substring(lastSpokenIndex);
|
||
// Regex robuste pour isoler les phrases se terminant par un point, exclamation, interrogation ou saut de ligne
|
||
const sentenceRegex = /[^.!?\n\r]+[.!?\n\r]+/g;
|
||
let match;
|
||
let lastMatchEnd = 0;
|
||
while ((match = sentenceRegex.exec(textToParse)) !== null) {
|
||
const sentence = match[0].trim();
|
||
// Ignorer les tokens MEDIA: pour ne pas les lire à voix haute (ex: MEDIA:/tmp/cat.jpg)
|
||
if (sentence && !/^MEDIA:\s*\S+$/.test(sentence)) {
|
||
setIsSpeaking(true);
|
||
audioServiceRef.current.queueSentence(sentence, selectedVoice, ttsRate, 1.1, ttsProvider);
|
||
}
|
||
lastMatchEnd = match.index + match[0].length;
|
||
}
|
||
if (lastMatchEnd > 0) {
|
||
lastSpokenIndex += lastMatchEnd;
|
||
}
|
||
}
|
||
},
|
||
// onToolCall
|
||
(toolName, detail, status = 'running') => {
|
||
pushToolEvent({
|
||
label: toolName,
|
||
detail: detail || 'progression Hermes',
|
||
status
|
||
});
|
||
setMessages(prev => prev.map(m =>
|
||
m.id === assistantId ? { ...m, content: m.content + `\n\n> ⚙️ *${toolName}*\n\n` } : m
|
||
));
|
||
},
|
||
hermesSessionId || undefined,
|
||
(newSessionId) => {
|
||
setHermesSessionId(newSessionId);
|
||
persistWrite('eveflow_hermes_session_id', newSessionId);
|
||
},
|
||
images
|
||
);
|
||
if (rafHandle !== null) cancelAnimationFrame(rafHandle);
|
||
|
||
const deducedEmotion = EmotionService.analyzeSentiment(reply);
|
||
setCurrentEmotion(deducedEmotion);
|
||
pushToolEvent({
|
||
label: 'Reponse finalisee',
|
||
detail: `${reply.length} caracteres`,
|
||
status: 'done'
|
||
});
|
||
|
||
// Mettre à jour le message final dans le chat
|
||
setMessages(prev => prev.map(m =>
|
||
m.id === assistantId ? { ...m, content: reply, emotion: deducedEmotion } : m
|
||
));
|
||
|
||
// Diction de la portion restante finale non encore prononcée
|
||
if (!isMuted && audioServiceRef.current) {
|
||
const remainingText = reply.substring(lastSpokenIndex).trim();
|
||
if (remainingText) {
|
||
setIsSpeaking(true);
|
||
audioServiceRef.current.queueSentence(remainingText, selectedVoice, ttsRate, 1.1, ttsProvider);
|
||
}
|
||
|
||
// Attendre la fin complète de la file d'écoute pour arrêter l'animation d'élocution (isSpeaking)
|
||
(async () => {
|
||
while (audioServiceRef.current?.isSpeakingActive) {
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
}
|
||
setIsSpeaking(false);
|
||
setCurrentEmotion('neutral');
|
||
})().catch(() => {});
|
||
}
|
||
|
||
} catch (e: any) {
|
||
setErrorMessage(e.message || "Une erreur s'est produite.");
|
||
setCurrentEmotion('sad');
|
||
pushToolEvent({
|
||
label: 'Execution interrompue',
|
||
detail: e.message || 'erreur agent',
|
||
status: 'error'
|
||
});
|
||
const errorMessageObj: Message = {
|
||
id: Math.random().toString(),
|
||
role: 'assistant',
|
||
content: `Erreur : Impossible de contacter l'agent. Veuillez vérifier votre connexion et les paramètres.`,
|
||
emotion: 'sad',
|
||
timestamp: new Date()
|
||
};
|
||
setMessages(prev => [...prev, errorMessageObj]);
|
||
} finally {
|
||
setIsSending(false);
|
||
}
|
||
};
|
||
|
||
const handleStartVocalRecord = () => {
|
||
if (!audioServiceRef.current) return;
|
||
|
||
audioServiceRef.current.stopSpeaking();
|
||
setIsSpeaking(false);
|
||
|
||
audioServiceRef.current.startListening(
|
||
(text) => {
|
||
handleSendMessage(text);
|
||
},
|
||
() => {
|
||
setIsListening(true);
|
||
setCurrentEmotion('thinking');
|
||
},
|
||
() => {
|
||
setIsListening(false);
|
||
},
|
||
(err) => {
|
||
setIsListening(false);
|
||
console.error(err);
|
||
},
|
||
sttLang
|
||
);
|
||
};
|
||
|
||
const handleStopListening = () => {
|
||
if (audioServiceRef.current) {
|
||
audioServiceRef.current.stopListening();
|
||
}
|
||
};
|
||
|
||
const handleStopTTS = () => {
|
||
if (audioServiceRef.current) {
|
||
audioServiceRef.current.flushQueue();
|
||
audioServiceRef.current.stopSpeaking();
|
||
}
|
||
setIsSpeaking(false);
|
||
setCurrentEmotion('neutral');
|
||
};
|
||
|
||
|
||
|
||
|
||
|
||
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, 1.1, ttsProvider)
|
||
.then(() => {
|
||
setCurrentlyPlayingMsgId(null);
|
||
setIsSpeaking(false);
|
||
setCurrentEmotion('neutral');
|
||
})
|
||
.catch(err => {
|
||
console.error("Long message audio speak error:", err);
|
||
setCurrentlyPlayingMsgId(null);
|
||
setIsSpeaking(false);
|
||
setCurrentEmotion('neutral');
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className={`app-container ${isFloatingMode ? 'transparent-bg' : ''}`}>
|
||
{/* 1. APP HEADER */}
|
||
{!isFloatingMode && (
|
||
<header className="app-header" style={{ WebkitAppRegion: 'drag' } as any}>
|
||
<div className="flex-row" style={{ display: 'flex', alignItems: 'center', gap: '6px', WebkitAppRegion: 'no-drag' } as any}>
|
||
<span className="logo-dot" />
|
||
<span className="logo-text">EVEFLOW</span>
|
||
</div>
|
||
|
||
{agentConfig.provider === 'hermes' && (
|
||
<div className="header-hermes-status" style={{
|
||
color: hermesSyncState === 'offline' ? '#ef4444' : '#10b981',
|
||
borderColor: hermesSyncState === 'offline' ? 'rgba(239, 68, 68, 0.2)' : 'rgba(0, 242, 255, 0.2)',
|
||
WebkitAppRegion: 'no-drag'
|
||
} as any}>
|
||
<span className="status-dot" style={{
|
||
backgroundColor: hermesSyncState === 'offline' ? '#ef4444' : '#10b981',
|
||
boxShadow: hermesSyncState === 'offline' ? '0 0 10px #ef4444' : '0 0 10px #10b981'
|
||
}} />
|
||
<span>{hermesSyncState === 'offline' ? 'Hermes OFFLINE' : 'Hermes OK'}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="window-controls" style={{ WebkitAppRegion: 'no-drag' } as any}>
|
||
<button
|
||
className="control-btn"
|
||
onClick={() => setShowSettings(!showSettings)}
|
||
title="Configuration globale"
|
||
>
|
||
<Settings size={16} />
|
||
</button>
|
||
{window.electronAPI && (
|
||
<>
|
||
<button
|
||
className="control-btn"
|
||
onClick={() => handleWindowControl('minimize')}
|
||
title="Réduire"
|
||
>
|
||
<Minimize2 size={16} />
|
||
</button>
|
||
<button
|
||
className="control-btn close"
|
||
onClick={() => handleWindowControl('close')}
|
||
title="Fermer"
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</header>
|
||
)}
|
||
|
||
{/* 2. MAIN LAYOUT CONTAINER */}
|
||
<div className="app-main">
|
||
{/* CHAT SECTION (De gauche) */}
|
||
{!isFloatingMode && !showSettings && (
|
||
<section className="chat-section">
|
||
{agentConfig.provider === 'hermes' && (
|
||
<div className="chat-status-bar">
|
||
<div className={`status-item ${hermesSyncState === 'offline' ? 'red' : 'green'}`}>
|
||
{hermesSyncState === 'offline' ? <WifiOff size={14} /> : <CheckCircle2 size={14} />}
|
||
<span>{hermesSyncLabel}</span>
|
||
</div>
|
||
<span className="status-separator">|</span>
|
||
<div className="status-item blue">
|
||
<BriefcaseBusiness size={14} />
|
||
<span>{hermesJobs.length} job{hermesJobs.length > 1 ? 's' : ''}</span>
|
||
</div>
|
||
<span className="status-separator">|</span>
|
||
<div className={`status-item ${hermesUnreadRuns > 0 ? 'red' : 'blue'}`}>
|
||
<AlertTriangle size={14} />
|
||
<span>{hermesUnreadRuns} alerte{hermesUnreadRuns > 1 ? 's' : ''}</span>
|
||
</div>
|
||
<button
|
||
className="cockpit-btn"
|
||
onClick={toggleHermesCockpit}
|
||
title={showHermesCockpit ? 'Refermer le cockpit Hermes' : 'Ouvrir le cockpit Hermes'}
|
||
>
|
||
<span>Cockpit</span>
|
||
<ChevronUp size={12} className={`chevron ${showHermesCockpit ? 'rotated' : ''}`} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="messages-container">
|
||
{messages.map((msg) => {
|
||
const isUser = msg.role === 'user';
|
||
|
||
const markdownAndMedia = (
|
||
<div className="markdown-content">
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkGfm]}
|
||
components={{
|
||
img: ({ node, ...props }) => (
|
||
<ResilientImage
|
||
src={props.src || ''}
|
||
alt={props.alt}
|
||
agentUrl={agentConfig.hermesUrl}
|
||
provider={agentConfig.provider}
|
||
hermesApiKey={agentConfig.hermesApiKey}
|
||
hermesSessionKey={agentConfig.hermesSessionKey}
|
||
/>
|
||
),
|
||
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>;
|
||
}
|
||
}}
|
||
>
|
||
{preprocessMessageContent(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) => (
|
||
<ResilientImage
|
||
key={i}
|
||
src={imgUrl}
|
||
alt="Image jointe"
|
||
agentUrl={agentConfig.hermesUrl}
|
||
provider={agentConfig.provider}
|
||
hermesApiKey={agentConfig.hermesApiKey}
|
||
hermesSessionKey={agentConfig.hermesSessionKey}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{!isUser && 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>
|
||
);
|
||
|
||
if (isUser) {
|
||
return (
|
||
<div key={msg.id} className="message-row user">
|
||
<div className="message-bubble user">
|
||
{markdownAndMedia}
|
||
<div className="message-time">
|
||
<span>{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||
<span className="double-check">✓✓</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
} else {
|
||
return (
|
||
<div key={msg.id} style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
|
||
<div className="transmission-tag">
|
||
# [TRANSMISSION: {activeAvatar.name.toUpperCase()} // TELEMETRY: {msg.emotion?.toUpperCase() || 'NEUTRAL'}]
|
||
{msg.source && msg.source !== 'eveflow' && (
|
||
<span style={{ marginLeft: '8px', fontSize: '9px', padding: '1px 6px', backgroundColor: 'rgba(0,212,245,0.15)', border: '1px solid rgba(0,212,245,0.35)', borderRadius: '10px', color: 'var(--accent-cyan)', fontWeight: 800, letterSpacing: '1px', textTransform: 'uppercase' }}>
|
||
{msg.source}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="message-row assistant">
|
||
<div className="avatar-wrapper">
|
||
<img src="./avatars/eve-round.png" alt={activeAvatar.name} className="avatar-img" />
|
||
</div>
|
||
<div className="message-bubble assistant">
|
||
{markdownAndMedia}
|
||
<div className="message-time" style={{ color: 'rgba(226, 240, 255, 0.4)', fontSize: '9px', marginTop: '6px', textAlign: 'right' }}>
|
||
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
})}
|
||
|
||
{errorMessage && (
|
||
<div className="settings-card" style={{ backgroundColor: 'rgba(239,68,68,0.1)', borderColor: 'rgba(239,68,68,0.4)', color: '#ff6b6b', padding: '16px', display: 'flex', flexDirection: 'row', gap: '12px', alignItems: 'flex-start' }}>
|
||
<AlertCircle style={{ width: '20px', height: '20px', flexShrink: '0' }} />
|
||
<div>
|
||
<strong style={{ fontSize: '13px', display: 'block', marginBottom: '4px' }}>DÉFAILLANCE ANALOGIQUE</strong>
|
||
<p style={{ fontSize: '12px' }}>{errorMessage}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{/* Diagnostic Console Bar Retro-Futuriste Space Age */}
|
||
<div className="diagnostic-bar" style={{ display: 'flex', gap: '16px', padding: '8px 16px', backgroundColor: 'var(--bg-secondary)', borderTop: '3px solid var(--text-primary)', fontSize: '10px', fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)', fontWeight: 'bold', letterSpacing: '1px' }}>
|
||
<span>>>> HOST: {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: {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}
|
||
onChange={(e) => setInputText(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage(inputText)}
|
||
placeholder="Message a Eve..."
|
||
className="glow-input"
|
||
disabled={isSending}
|
||
/>
|
||
|
||
{/* Stop TTS — visible uniquement pendant la lecture */}
|
||
{isSpeaking && (
|
||
<button
|
||
onClick={handleStopTTS}
|
||
className="input-circle-btn"
|
||
style={{
|
||
borderColor: '#ef4444',
|
||
backgroundColor: 'rgba(239, 68, 68, 0.15)',
|
||
}}
|
||
title="Arrêter la lecture vocale"
|
||
>
|
||
<StopCircle style={{ width: '18px', height: '18px', color: '#ef4444' }} />
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="input-circle-btn"
|
||
style={{
|
||
borderColor: attachments.length > 0 ? 'var(--accent-cyan)' : 'rgba(226, 240, 255, 0.12)',
|
||
backgroundColor: attachments.length > 0 ? 'rgba(0, 242, 255, 0.1)' : '#050914',
|
||
}}
|
||
title="Ajouter des pièces jointes (Images, Fichiers texte...)"
|
||
>
|
||
<Paperclip style={{ width: '18px', height: '18px', color: attachments.length > 0 ? 'var(--accent-cyan)' : 'var(--text-secondary)' }} />
|
||
</button>
|
||
|
||
<button
|
||
onClick={isListening ? handleStopListening : handleStartVocalRecord}
|
||
className="input-circle-btn"
|
||
style={{
|
||
borderColor: isListening ? '#ef4444' : 'rgba(226, 240, 255, 0.12)',
|
||
backgroundColor: isListening ? 'rgba(239, 68, 68, 0.15)' : '#050914'
|
||
}}
|
||
title={isListening ? "Arrêter l'écoute" : "Démarrer la saisie vocale"}
|
||
>
|
||
{isListening ? <MicOff style={{ width: '18px', height: '18px', color: '#ef4444' }} /> : <Mic style={{ width: '18px', height: '18px', color: 'var(--text-secondary)' }} />}
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => handleSendMessage(inputText)}
|
||
disabled={(!inputText.trim() && attachments.length === 0) || isSending}
|
||
className="input-send-btn"
|
||
title="Envoyer le message"
|
||
>
|
||
<Send style={{ width: '16px', height: '16px' }} />
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* PARAMÈTRES SECTION (De gauche, recouvre le chat) */}
|
||
{!isFloatingMode && showSettings && (
|
||
<section className="settings-section">
|
||
<div className="flex-between" style={{ paddingBottom: '16px', borderBottom: '3px solid var(--text-primary)' }}>
|
||
<h2 className="neon-title text-xl font-bold flex items-center gap-2" style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-primary)' }}>
|
||
<Settings style={{ width: '20px', height: '20px', color: 'var(--accent-cyan)' }} />
|
||
Configuration Globale
|
||
</h2>
|
||
<button
|
||
onClick={() => setShowSettings(false)}
|
||
className="neon-btn neon-btn-secondary"
|
||
style={{ padding: '6px 16px', fontSize: '12px' }}
|
||
>
|
||
Retour au Chat
|
||
</button>
|
||
</div>
|
||
|
||
|
||
|
||
{/* A. Agents */}
|
||
<div className="settings-card">
|
||
<h3 className="label-title" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||
<Cpu style={{ width: '14px', height: '14px' }} /> Connecteurs d'Agents
|
||
</h3>
|
||
|
||
<div className="grid-3">
|
||
{(['ollama', 'hermes'] as AgentProvider[]).map((prov) => (
|
||
<button
|
||
key={prov}
|
||
onClick={() => saveConfig({ ...agentConfig, provider: prov })}
|
||
className="neon-btn"
|
||
style={{
|
||
padding: '8px 12px',
|
||
fontSize: '11px',
|
||
background: agentConfig.provider === prov ? '' : 'transparent',
|
||
border: agentConfig.provider === prov ? 'none' : '1px solid #cbd5e1',
|
||
color: agentConfig.provider === prov ? 'white' : '#64748b',
|
||
boxShadow: agentConfig.provider === prov ? '' : 'none'
|
||
}}
|
||
>
|
||
{prov}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{agentConfig.provider === 'ollama' && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
<div>
|
||
<label className="label-title">URL Endpoint Ollama</label>
|
||
<input
|
||
type="text"
|
||
value={agentConfig.ollamaUrl}
|
||
onChange={(e) => saveConfig({ ...agentConfig, ollamaUrl: e.target.value })}
|
||
className="glow-input"
|
||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="label-title">Modèle (ex: llama3, mistral)</label>
|
||
<input
|
||
type="text"
|
||
value={agentConfig.ollamaModel}
|
||
onChange={(e) => saveConfig({ ...agentConfig, ollamaModel: e.target.value })}
|
||
className="glow-input"
|
||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{agentConfig.provider === 'hermes' && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
<div>
|
||
<label className="label-title">URL Endpoint Hermes Agent</label>
|
||
<input
|
||
type="text"
|
||
value={agentConfig.hermesUrl}
|
||
onChange={(e) => saveConfig({ ...agentConfig, hermesUrl: e.target.value })}
|
||
className="glow-input"
|
||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||
placeholder="Ex: http://hermes.monserveur.eu"
|
||
/>
|
||
<span style={{ display: 'block', marginTop: '5px', fontSize: '10px', fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||
ℹ️ Formats acceptés: <code>http://host</code> · <code>http://host/v1</code> · <code>http://host/v1/chat/completions</code>
|
||
</span>
|
||
</div>
|
||
<div className="grid-2">
|
||
<div>
|
||
<label className="label-title">Modèle (ex: hermes3)</label>
|
||
<input
|
||
type="text"
|
||
value={agentConfig.hermesModel}
|
||
onChange={(e) => saveConfig({ ...agentConfig, hermesModel: e.target.value })}
|
||
className="glow-input"
|
||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||
placeholder="hermes3"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="label-title">Clé API (Optionnel)</label>
|
||
<input
|
||
type="password"
|
||
value={agentConfig.hermesApiKey}
|
||
onChange={(e) => saveConfig({ ...agentConfig, hermesApiKey: e.target.value })}
|
||
className="glow-input"
|
||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||
placeholder="Bearer token (API_SERVER_KEY)"
|
||
/>
|
||
</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>
|
||
)}
|
||
|
||
|
||
</div>
|
||
|
||
{/* B. TTS / STT */}
|
||
<div className="settings-card">
|
||
<h3 className="label-title" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||
<Volume2 style={{ width: '14px', height: '14px' }} /> Voix de {activeAvatar.name} (Text-to-Speech)
|
||
</h3>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
<div>
|
||
<label className="label-title">Type de Synthèse Vocale</label>
|
||
<div className="grid-2" style={{ gap: '10px', marginTop: '4px' }}>
|
||
<button
|
||
onClick={() => {
|
||
setTtsProvider('google-free');
|
||
persistWrite('eveflow_tts_provider', 'google-free');
|
||
}}
|
||
className="neon-btn"
|
||
style={{
|
||
padding: '8px 12px',
|
||
fontSize: '11px',
|
||
background: ttsProvider === 'google-free' ? '' : 'transparent',
|
||
border: ttsProvider === 'google-free' ? 'none' : '1px solid #cbd5e1',
|
||
color: ttsProvider === 'google-free' ? 'white' : '#64748b',
|
||
boxShadow: ttsProvider === 'google-free' ? '' : 'none'
|
||
}}
|
||
>
|
||
🟢 Voix Naturelle (Ligne)
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setTtsProvider('system');
|
||
persistWrite('eveflow_tts_provider', 'system');
|
||
}}
|
||
className="neon-btn"
|
||
style={{
|
||
padding: '8px 12px',
|
||
fontSize: '11px',
|
||
background: ttsProvider === 'system' ? '' : 'transparent',
|
||
border: ttsProvider === 'system' ? 'none' : '1px solid #cbd5e1',
|
||
color: ttsProvider === 'system' ? 'white' : '#64748b',
|
||
boxShadow: ttsProvider === 'system' ? '' : 'none'
|
||
}}
|
||
>
|
||
🔌 Voix Système (Local)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{ttsProvider === 'system' && (
|
||
<div>
|
||
<label className="label-title">Voix Synthétique Locale</label>
|
||
<select
|
||
value={selectedVoice}
|
||
onChange={(e) => {
|
||
setSelectedVoice(e.target.value);
|
||
persistWrite('eveflow_voice', e.target.value);
|
||
}}
|
||
className="glow-input"
|
||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||
>
|
||
{availableVoices.map((voice) => (
|
||
<option key={voice.name} value={voice.name}>
|
||
{voice.name} ({voice.lang})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{ttsProvider === 'google-free' && (
|
||
<div style={{ padding: '8px 12px', backgroundColor: 'var(--accent-light)', border: '1px solid rgba(0,212,245,0.2)', borderRadius: '10px', fontSize: '11px', fontFamily: 'var(--font-mono)', color: 'var(--text-neon)', lineHeight: 1.5 }}>
|
||
ℹ️ **Mode Voix Naturelle Actif** : EveFlow utilise la voix neuronale ultra-fluide et humaine de Google. Aucun frais ni clé API requis. En cas de perte de connexion, elle basculera automatiquement sur votre voix locale hors-ligne.
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid-2">
|
||
<div>
|
||
<label className="label-title">Vitesse: {ttsRate.toFixed(2)}</label>
|
||
<input
|
||
type="range"
|
||
min="0.8"
|
||
max="1.5"
|
||
step="0.05"
|
||
value={ttsRate}
|
||
onChange={(e) => setTtsRate(parseFloat(e.target.value))}
|
||
style={{ width: '100%', marginTop: '6px' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="label-title">Langue de dictée (STT)</label>
|
||
<select
|
||
value={sttLang}
|
||
onChange={(e) => setSttLang(e.target.value)}
|
||
className="glow-input"
|
||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||
>
|
||
<option value="fr-FR">Français (France)</option>
|
||
<option value="en-US">English (USA)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => {
|
||
if (audioServiceRef.current) {
|
||
setIsSpeaking(true);
|
||
audioServiceRef.current.speak(activeAvatar.welcome, selectedVoice, ttsRate, 1.1, ttsProvider)
|
||
.then(() => setIsSpeaking(false));
|
||
}
|
||
}}
|
||
className="neon-btn neon-btn-secondary"
|
||
style={{ width: '100%', padding: '8px 16px', fontSize: '12px' }}
|
||
>
|
||
Tester la voix de {activeAvatar.name}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* COMPAGNON 3D SECTION (De droite) */}
|
||
<section className={`eve-section ${isFloatingMode ? 'floating-widget' : ''} ${isListening || isSpeaking || isSending ? 'eve-active' : ''}`}>
|
||
{!isFloatingMode && (
|
||
<div className="eve-status-pill">
|
||
<span className="status-led" />
|
||
<span>{isListening ? 'Eve ecoute' : isSpeaking ? 'Eve parle' : isSending ? 'Eve reflechit' : 'Eve ecoute'}</span>
|
||
</div>
|
||
)}
|
||
|
||
{!isFloatingMode && (
|
||
<button onClick={toggleWindowMode} className="eve-popout-btn" title="Mode Widget flottant">
|
||
<ExternalLink size={14} />
|
||
</button>
|
||
)}
|
||
|
||
|
||
|
||
{/* Header flottant discret pour contrôle en mode Widget */}
|
||
{isFloatingMode && (
|
||
<div className="flex-between" style={{ position: 'absolute', top: '12px', left: '12px', right: '12px', zIndex: 50, WebkitAppRegion: 'drag' } as any}>
|
||
<div className="flex-row" style={{ WebkitAppRegion: 'no-drag' } as any}>
|
||
<span className="h-3 w-3 rounded-full pulsing-led" style={{ width: '12px', height: '12px', borderRadius: '50%', backgroundColor: activeAvatar.accent, boxShadow: `0 0 12px ${activeAvatar.accent}` }}></span>
|
||
<span style={{ fontSize: '11px', fontWeight: '800', color: 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: '1px', fontFamily: 'var(--font-title)' }}>{activeAvatar.name}</span>
|
||
</div>
|
||
<div className="flex-row" style={{ WebkitAppRegion: 'no-drag' } as any}>
|
||
<button
|
||
onClick={toggleWindowMode}
|
||
className="glow-input"
|
||
style={{ padding: '6px 12px', width: 'auto', borderRadius: '20px', cursor: 'pointer', backgroundColor: 'var(--bg-secondary)', borderColor: 'rgba(0,212,245,0.3)' }}
|
||
title="Retourner au chat complet"
|
||
>
|
||
<MessageSquare style={{ width: '12px', height: '12px', color: 'var(--text-primary)' }} />
|
||
</button>
|
||
{window.electronAPI && (
|
||
<button
|
||
onClick={() => handleWindowControl('close')}
|
||
className="glow-input"
|
||
style={{ padding: '6px 12px', width: 'auto', borderRadius: '20px', cursor: 'pointer', backgroundColor: 'rgba(239,68,68,0.15)', borderColor: 'rgba(239,68,68,0.4)', color: '#ff6b6b' }}
|
||
>
|
||
<X style={{ width: '12px', height: '12px' }} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Rendu Canvas 3D */}
|
||
<div style={{ flex: isFloatingMode ? 'none' : 1, height: isFloatingMode ? '300px' : 'auto', width: '100%', position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: isFloatingMode ? '34px' : '0' }}>
|
||
<ThreeCanvas
|
||
emotion={currentEmotion}
|
||
isSpeaking={isSpeaking}
|
||
avatarId={avatarId}
|
||
/>
|
||
{avatarId !== 'eve' && activeAvatar.assetPath && (
|
||
<div style={{ position: 'absolute', top: '16px', left: '50%', transform: 'translateX(-50%)', padding: '7px 14px', borderRadius: '18px', backgroundColor: 'var(--bg-secondary)', border: '1px solid rgba(0,212,245,0.25)', boxShadow: '0 0 20px rgba(0,212,245,0.1)', fontSize: '9px', fontFamily: 'var(--font-mono)', fontWeight: 800, letterSpacing: '0.8px', color: 'var(--text-secondary)', textTransform: 'uppercase', pointerEvents: 'none' }}>
|
||
{activeAvatar.name}: asset premium attendu
|
||
</div>
|
||
)}
|
||
|
||
{/* Badge d'humeur en bas */}
|
||
{!isFloatingMode && false && (
|
||
<div style={{ position: 'absolute', bottom: '16px', left: '50%', transform: 'translateX(-50%)', padding: '8px 20px', borderRadius: '30px', backgroundColor: 'var(--bg-secondary)', border: '2px solid var(--text-primary)', display: 'flex', alignItems: 'center', gap: '8px', boxShadow: '0 4px 15px rgba(45, 30, 24, 0.08)', fontSize: '12px', fontWeight: '700' }}>
|
||
<span className="h-3 w-3 rounded-full pulsing-led" style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: activeAvatar.accent, boxShadow: `0 0 10px ${activeAvatar.accent}` }}></span>
|
||
<span style={{ color: 'var(--text-secondary)', textTransform: 'uppercase', fontSize: '9px', letterSpacing: '1px', fontFamily: 'var(--font-title)' }}>Humeur:</span>
|
||
<span style={{ color: 'var(--text-neon)', textTransform: 'uppercase', fontSize: '10.5px', fontWeight: '800', fontFamily: 'var(--font-title)' }}>{currentEmotion}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Chat compact en bas en mode Widget */}
|
||
{isFloatingMode && (
|
||
<div className="compact-chat-container" style={{ display: 'flex', flexDirection: 'column', flex: 1, width: '100%', minHeight: 0, gap: '8px', marginTop: '4px' }}>
|
||
{/* Petite zone de messages défilable */}
|
||
<div className="compact-messages-list" style={{ flex: 1, overflowY: 'auto', padding: '6px 10px', display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '11px', lineHeight: '1.4', border: '1px solid rgba(0, 242, 255, 0.12)', borderRadius: '8px', backgroundColor: 'rgba(5, 9, 20, 0.65)', minHeight: 0 }}>
|
||
{messages.slice(-5).map((msg) => {
|
||
const isUser = msg.role === 'user';
|
||
return (
|
||
<div key={msg.id} style={{ display: 'flex', flexDirection: 'column', alignSelf: isUser ? 'flex-end' : 'flex-start', maxWidth: '85%' }}>
|
||
<div style={{
|
||
padding: '6px 10px',
|
||
borderRadius: '10px',
|
||
border: isUser ? '1px solid rgba(0, 212, 245, 0.35)' : '1px solid rgba(226, 240, 255, 0.15)',
|
||
backgroundColor: isUser ? 'rgba(0, 212, 245, 0.12)' : 'rgba(10, 15, 30, 0.75)',
|
||
color: isUser ? 'var(--accent-cyan)' : 'var(--text-primary)',
|
||
wordBreak: 'break-word',
|
||
boxShadow: isUser ? '0 0 10px rgba(0, 212, 245, 0.05)' : 'none'
|
||
}}>
|
||
{msg.content}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
<div ref={compactMessagesEndRef} />
|
||
</div>
|
||
|
||
{/* Barre de saisie compacte */}
|
||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', width: '100%', paddingBottom: '2px' }}>
|
||
<input
|
||
type="text"
|
||
value={inputText}
|
||
onChange={(e) => setInputText(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage(inputText)}
|
||
placeholder="Message..."
|
||
className="glow-input"
|
||
disabled={isSending}
|
||
style={{ flex: 1, fontSize: '11px', padding: '6px 12px', height: '32px', borderRadius: '16px', border: '1px solid rgba(0, 242, 255, 0.2)', backgroundColor: '#050914', color: 'var(--text-primary)' }}
|
||
/>
|
||
|
||
<button
|
||
onClick={isListening ? handleStopListening : handleStartVocalRecord}
|
||
className="input-circle-btn"
|
||
style={{
|
||
width: '32px',
|
||
height: '32px',
|
||
minWidth: '32px',
|
||
borderRadius: '50%',
|
||
border: '1px solid',
|
||
borderColor: isListening ? '#ef4444' : 'rgba(226, 240, 255, 0.12)',
|
||
backgroundColor: isListening ? 'rgba(239, 68, 68, 0.15)' : '#050914',
|
||
padding: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
cursor: 'pointer'
|
||
}}
|
||
title={isListening ? "Arrêter l'écoute" : "Saisie vocale"}
|
||
>
|
||
{isListening ? <MicOff style={{ width: '13px', height: '13px', color: '#ef4444' }} /> : <Mic style={{ width: '13px', height: '13px', color: 'var(--text-secondary)' }} />}
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => handleSendMessage(inputText)}
|
||
disabled={(!inputText.trim() && attachments.length === 0) || isSending}
|
||
className="input-send-btn"
|
||
style={{ width: '32px', height: '32px', minWidth: '32px', borderRadius: '50%', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', backgroundColor: 'var(--accent-cyan)', color: '#000', cursor: 'pointer' }}
|
||
title="Envoyer"
|
||
>
|
||
<Send style={{ width: '11px', height: '11px' }} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
</section>
|
||
|
||
{!isFloatingMode && !showSettings && agentConfig.provider === 'hermes' && showHermesCockpit && (
|
||
<HermesCockpitWindow
|
||
events={toolEvents}
|
||
isWorking={isSending}
|
||
jobs={hermesJobs}
|
||
runs={hermesRunHistory}
|
||
syncState={hermesSyncState}
|
||
syncDetail={hermesSyncDetail}
|
||
lastSyncAt={hermesLastSyncAt}
|
||
draft={hermesCronDraft}
|
||
editingJobId={editingHermesJobId}
|
||
isSaving={isSavingHermesCron}
|
||
onDraftChange={setHermesCronDraft}
|
||
onCreate={handleCreateHermesCron}
|
||
onCancelEdit={handleCancelEditHermesCron}
|
||
onStartEdit={handleStartEditHermesCron}
|
||
onUpdate={handleUpdateHermesCron}
|
||
onRefresh={refreshHermesJobs}
|
||
onPause={(job) => runHermesCronAction(job, 'pause')}
|
||
onResume={(job) => runHermesCronAction(job, 'resume')}
|
||
onRun={(job) => runHermesCronAction(job, 'run')}
|
||
onDelete={(job) => runHermesCronAction(job, 'delete')}
|
||
onOpenRun={openHermesRunInChat}
|
||
onClose={closeHermesCockpit}
|
||
/>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default App;
|
||
|