feat!: rebuild EveFlow as a JARVIS-style voice HUD for Hermes Agent (v2.0.0)

Complete rewrite of the application:

- Toolchain: Electron 44, Vite 8, React 19, TypeScript 5.9, Zustand, Vitest;
  main process rewritten in TypeScript (electron/), typed IPC contract (shared/),
  sandboxed renderer with webSecurity on and an HTTP/SSE proxy in the main process.
- Voice: AudioWorklet microphone capture at 16 kHz with adaptive energy VAD and
  auto-stop, WAV encoder, OpenAI-compatible STT, TTS queue with sentence-level
  streaming, prefetch and Web Audio playback feeding an analyser; hands-free mode,
  global push-to-talk hotkey, system-voice fallback.
- Hermes: full API client (capabilities, health, models, skills, toolsets,
  sessions, jobs) with automatic transport selection: runs API (SSE lifecycle,
  approvals, steer, stop) > sessions stream > chat completions with local tools;
  tolerant event normalisation; webhook receiver hardened (secret, size limit).
- UI: JARVIS arc-reactor core on Canvas 2D reacting to the real audio signal,
  holographic HUD layout (transcript, core, Hermes ops panel with tools/crons/
  skills/sessions/link tabs, telemetry), settings drawer, approval modals,
  compact floating widget, four themes, tray icon and global shortcuts.
- Removed the 3D Eve robot, three.js and legacy assets; migrated 1.x settings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
This commit is contained in:
Claude committed 2026-09-03 13:40:59 +00:00
1 parent 5217eee53a
commit 127a2083a1
239 files changed
+11351 -86495

No files matched your search

-109
View File
@@ -1,109 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useFrame, useLoader } from '@react-three/fiber';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import * as THREE from 'three';
import { EveModel } from './EveModel';
import { EveAvatar, EveEmotion, EmotionService } from '../services/emotionService';
interface AvatarModelLoaderProps {
avatarId: EveAvatar;
emotion: EveEmotion;
isSpeaking: boolean;
}
type AvatarManifest = Partial<Record<Exclude<EveAvatar, 'eve'>, string | null>>;
const AVATAR_MANIFEST_PATH = './avatars/avatar-manifest.json';
const LoadedGlbAvatar: React.FC<{
path: string;
avatarId: EveAvatar;
emotion: EveEmotion;
isSpeaking: boolean;
}> = ({ path, avatarId, emotion, isSpeaking }) => {
const gltf = useLoader(GLTFLoader, path);
const scene = useMemo(() => {
const cloned = gltf.scene.clone(true);
cloned.position.y = 0.27;
return cloned;
}, [gltf.scene]);
useEffect(() => {
const style = EmotionService.getEmotionStyle(emotion, avatarId);
scene.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
child.castShadow = false;
child.receiveShadow = false;
const materialName = Array.isArray(child.material)
? child.material.map((material) => material.name).join(' ')
: child.material?.name || '';
if (/eye|led|visor|screen|emissive/i.test(`${child.name} ${materialName}`)) {
const eyeMaterial = new THREE.MeshBasicMaterial({
color: new THREE.Color(style.eyeColor),
transparent: true,
opacity: 0.96
});
child.material = eyeMaterial;
}
});
}, [avatarId, emotion, scene]);
useFrame((state) => {
const t = state.clock.getElapsedTime();
scene.position.y = Math.sin(t * 1.4) * 0.05 + 0.27;
scene.rotation.y = Math.sin(t * 0.45) * 0.05;
if (isSpeaking) {
scene.rotation.z = Math.sin(t * 8) * 0.025;
} else {
scene.rotation.z = THREE.MathUtils.lerp(scene.rotation.z, 0, 0.12);
}
});
return <primitive object={scene} scale={0.48} />;
};
export const AvatarModelLoader: React.FC<AvatarModelLoaderProps> = ({ avatarId, emotion, isSpeaking }) => {
const [assetPath, setAssetPath] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setAssetPath(null);
if (avatarId === 'eve') return;
fetch(AVATAR_MANIFEST_PATH, { cache: 'no-store' })
.then((response) => {
if (!response.ok) return null;
return response.json() as Promise<AvatarManifest>;
})
.then((manifest) => {
if (cancelled || !manifest) return;
let nextAssetPath = manifest[avatarId];
if (typeof nextAssetPath === 'string' && nextAssetPath.trim()) {
// Si le chemin commence par un slash, on le rend relatif pour fonctionner sous le protocole file:// en production
if (nextAssetPath.startsWith('/')) {
nextAssetPath = '.' + nextAssetPath;
}
setAssetPath(nextAssetPath);
} else {
setAssetPath(null);
}
})
.catch(() => {
if (!cancelled) setAssetPath(null);
});
return () => {
cancelled = true;
};
}, [avatarId]);
if (avatarId !== 'eve' && assetPath) {
return <LoadedGlbAvatar path={assetPath} avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />;
}
return <EveModel emotion={emotion} isSpeaking={isSpeaking} avatarId="eve" />;
};
-767
View File
@@ -1,767 +0,0 @@
import React, { useRef, useEffect } from 'react';
import { useFrame, useLoader } from '@react-three/fiber';
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js';
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import * as THREE from 'three';
import { EveEmotion, EveAvatar, EmotionService } from '../services/emotionService';
interface EveModelProps {
emotion: EveEmotion;
isSpeaking: boolean;
avatarId?: EveAvatar;
}
const createSmoothGeometry = (geometry: THREE.BufferGeometry, tolerance = 0.01) => {
const smoothGeometry = geometry.clone();
smoothGeometry.deleteAttribute('normal');
smoothGeometry.deleteAttribute('uv');
if (smoothGeometry.hasAttribute('color')) {
smoothGeometry.deleteAttribute('color');
}
if (smoothGeometry.hasAttribute('uv2')) {
smoothGeometry.deleteAttribute('uv2');
}
const mergedGeometry = mergeVertices(smoothGeometry, tolerance);
mergedGeometry.computeVertexNormals();
mergedGeometry.computeBoundingBox();
mergedGeometry.computeBoundingSphere();
return mergedGeometry;
};
const createVisorShaderMaterial = () => {
return new THREE.ShaderMaterial({
uniforms: {
eyeColor: { value: new THREE.Color('#00f2ff') },
eyeScaleY: { value: 0.75 },
eyeRotation: { value: 0 },
eyeOffsetX: { value: 0 },
eyeOffsetY: { value: 0 },
speakingPulse: { value: 0 },
thinkingVal: { value: 0 },
uTime: { value: 0 }
},
vertexShader: `
varying vec3 vLocalPosition;
varying vec3 vNormal;
void main() {
vLocalPosition = position;
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 eyeColor;
uniform float eyeScaleY;
uniform float eyeRotation;
uniform float eyeOffsetX;
uniform float eyeOffsetY;
uniform float speakingPulse;
uniform float thinkingVal;
uniform float uTime;
varying vec3 vLocalPosition;
varying vec3 vNormal;
vec2 rotateAround(vec2 point, vec2 center, float angle) {
float c = cos(angle);
float s = sin(angle);
vec2 localPoint = point - center;
return vec2(
localPoint.x * c - localPoint.y * s,
localPoint.x * s + localPoint.y * c
) + center;
}
float eyeMask(vec2 point, vec2 center, float width, float height, float angle) {
vec2 rotatedPoint = rotateAround(point, center, angle);
vec2 normalizedPoint = (rotatedPoint - center) / vec2(width, height);
float distanceFromCenter = dot(normalizedPoint, normalizedPoint);
return 1.0 - smoothstep(0.64, 0.92, distanceFromCenter);
}
float eyeGlow(vec2 point, vec2 center, float width, float height, float angle) {
vec2 rotatedPoint = rotateAround(point, center, angle);
vec2 normalizedPoint = (rotatedPoint - center) / vec2(width, height);
float distanceFromCenter = dot(normalizedPoint, normalizedPoint);
return 1.0 - smoothstep(0.82, 1.85, distanceFromCenter);
}
void main() {
vec2 facePoint = vLocalPosition.xy;
float eyeHeight = max(0.11, 0.18 * eyeScaleY + speakingPulse);
vec2 leftCenter = vec2(-0.72 + eyeOffsetX, 8.56 + eyeOffsetY);
vec2 rightCenter = vec2(0.72 + eyeOffsetX, 8.56 + eyeOffsetY);
float leftEye = eyeMask(facePoint, leftCenter, 0.43, eyeHeight, eyeRotation);
float rightEye = eyeMask(facePoint, rightCenter, 0.43, eyeHeight, -eyeRotation);
float leftGlow = eyeGlow(facePoint, leftCenter, 0.49, eyeHeight * 1.35, eyeRotation);
float rightGlow = eyeGlow(facePoint, rightCenter, 0.49, eyeHeight * 1.35, -eyeRotation);
float eye = max(leftEye, rightEye);
float glow = max(leftGlow, rightGlow);
float core = eyeMask(facePoint, leftCenter + vec2(0.0, 0.01), 0.25, eyeHeight * 0.42, eyeRotation)
+ eyeMask(facePoint, rightCenter + vec2(0.0, 0.01), 0.25, eyeHeight * 0.42, -eyeRotation);
vec3 viewLight = normalize(vec3(-0.35, 0.42, 1.0));
float highlight = pow(max(dot(normalize(vNormal), viewLight), 0.0), 16.0) * 0.23;
float rim = pow(1.0 - abs(vNormal.z), 2.0) * 0.08;
// Lueur de calcul douce pendant la réflexion (remplace les bandes dures)
float scanline = 0.0;
if (thinkingVal > 0.05) {
// Bandes très espacées et très graduelles — effet data-pulse discret
float wave = sin(vLocalPosition.y * 2.5 - uTime * 4.0) * 0.5 + 0.5;
scanline = smoothstep(0.55, 0.95, wave) * thinkingVal * 0.45;
// Respiration lente (pas de scintillement haute fréquence agressif)
scanline *= (0.85 + 0.15 * sin(uTime * 2.2));
}
vec3 color = vec3(0.002, 0.004, 0.006);
color += vec3(highlight + rim);
color += eyeColor * glow * 0.13;
color += eyeColor * eye * 1.85;
color += vec3(0.88, 1.0, 1.0) * clamp(core, 0.0, 1.0) * 0.55;
color += eyeColor * scanline * 0.85;
gl_FragColor = vec4(color, 1.0);
}
`,
side: THREE.DoubleSide,
depthWrite: true,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -1
});
};
/**
* Calculates local 3D vertex deformation offsets for Eve's arms based on current emotion,
* time, and side/height falloff coordinates.
*/
const getEmotionalArmOffset = (
side: number,
shoulderFalloff: number,
t: number,
emotion: EveEmotion,
isSpeaking: boolean
): { dx: number; dy: number; dz: number } => {
let dx = 0;
let dy = 0;
let dz = 0;
// 1. Base speaking gesticulation wave
const talkAmp = isSpeaking ? 1.0 : 0.0;
// Dynamic talking wave: faster and wider
const talkX = talkAmp * side * Math.sin(t * 12) * 0.12 * shoulderFalloff;
const talkY = talkAmp * Math.abs(Math.cos(t * 12)) * 0.12 * shoulderFalloff;
const talkZ = talkAmp * Math.sin(t * 10 + side) * 0.08 * shoulderFalloff;
// 2. Emotional posture offset and secondary oscillations
switch (emotion) {
case 'happy': {
// Fast, excited bouncing and outward waving
const bounceX = side * (Math.sin(t * 8.5) * 0.18 + 0.1) * shoulderFalloff;
const bounceY = Math.abs(Math.sin(t * 13)) * 0.26 * shoulderFalloff;
const bounceZ = Math.sin(t * 11 + side) * 0.08 * shoulderFalloff;
// Joyous gesticulation is fast
dx = bounceX + talkX * 1.3;
dy = bounceY + talkY * 1.3;
dz = bounceZ + talkZ * 1.3;
break;
}
case 'sad': {
// Heavy, lifeless drooping arms, slightly pulled in close to the body
const droopX = -side * 0.16 * shoulderFalloff;
const droopY = -0.32 * shoulderFalloff; // Sags downwards
const droopZ = Math.sin(t * 0.6) * 0.015 * shoulderFalloff; // Barely breathing
// When sad, talking gesticulation is extremely muted and slow
dx = droopX + talkX * 0.25;
dy = droopY + talkY * 0.25;
dz = droopZ + talkZ * 0.25;
break;
}
case 'thinking': {
// Asymmetric pose: Right arm (side > 0) raised high to think/touch helmet,
// Left arm (side < 0) hanging restfully.
if (side > 0) {
// Right arm raised towards helmet
const thinkX = -side * 0.18 * shoulderFalloff; // Brought inward
const thinkY = (1.18 + Math.sin(t * 2.2) * 0.04) * shoulderFalloff; // High vertical reach
const thinkZ = (0.38 + Math.cos(t * 2.2) * 0.04) * shoulderFalloff; // Pushed forward
dx = thinkX + talkX * 0.15; // Raised arm barely moves during speech
dy = thinkY + talkY * 0.15;
dz = thinkZ + talkZ * 0.15;
} else {
// Left arm relaxed, drifting slowly
const restX = -side * 0.04 * shoulderFalloff;
const restY = -0.12 * shoulderFalloff;
const restZ = Math.sin(t * 1.4) * 0.035 * shoulderFalloff;
dx = restX + talkX * 0.8;
dy = restY + talkY * 0.8;
dz = restZ + talkZ * 0.8;
}
break;
}
case 'angry': {
// Rigid, tensed arms, pushed slightly outwards, high-freq anger trembling
const stiffX = side * 0.14 * shoulderFalloff;
const stiffY = -0.08 * shoulderFalloff;
const tremble = Math.sin(t * 28) * 0.028 * shoulderFalloff; // Furious shaking
const stiffZ = tremble;
// Anger talking is abrupt, stiff and aggressive
dx = stiffX + talkX * 0.6;
dy = stiffY + talkY * 0.6;
dz = stiffZ + talkZ * 0.6;
break;
}
case 'surprised': {
// Arms immediately thrown upwards and wide (shock reflex)
const shockX = side * 0.32 * shoulderFalloff;
const shockY = (0.42 + Math.sin(t * 16) * 0.08) * shoulderFalloff; // Fast jitter
const shockZ = -0.16 * shoulderFalloff; // Recoil
// Speech gesticulation is fast but overwhelmed by shock
dx = shockX + talkX * 0.8;
dy = shockY + talkY * 0.8;
dz = shockZ + talkZ * 0.8;
break;
}
case 'neutral':
default: {
// Gentle breathing floating cycle
const breatheX = side * Math.sin(t * 1.5) * 0.02 * shoulderFalloff;
const breatheY = Math.abs(Math.sin(t * 1.5)) * 0.03 * shoulderFalloff;
const breatheZ = Math.sin(t * 1.5 + side) * 0.03 * shoulderFalloff;
dx = breatheX + talkX;
dy = breatheY + talkY;
dz = breatheZ + talkZ;
break;
}
}
return { dx, dy, dz };
};
export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarId = 'eve' }) => {
const modelRef = useRef<THREE.Group>(null);
const headRef = useRef<THREE.Object3D | null>(null);
const visorRef = useRef<THREE.Object3D | null>(null);
const eyesRef = useRef<THREE.Object3D | null>(null);
const visorMaterialRef = useRef<THREE.ShaderMaterial | null>(null);
const mainBodyMeshRef = useRef<THREE.Mesh | null>(null);
const mainBodyBasePositionsRef = useRef<Float32Array | null>(null);
const armsRef = useRef<THREE.Mesh[]>([]);
// Perfect pivot reference and center memory for neck-like head rotation
const headGroupRef = useRef<THREE.Group | null>(null);
const headCenterRef = useRef<THREE.Vector3>(new THREE.Vector3());
// Charger les matériaux et le modèle OBJ depuis le répertoire public
const materials = useLoader(MTLLoader, './Eve/EVE.mtl');
const obj = useLoader(OBJLoader, './Eve/EVE.obj.bin', (loader) => {
materials.preload();
loader.setMaterials(materials);
});
// Cloner le modèle pour éviter les interférences si réutilisé
const clonedObj = React.useMemo(() => obj.clone(), [obj]);
useEffect(() => {
if (!clonedObj) return;
// Reset references to avoid old instances
const headMeshes: THREE.Mesh[] = [];
const visorMeshes: THREE.Mesh[] = [];
const eyesMeshes: THREE.Mesh[] = [];
const armsMeshes: THREE.Mesh[] = [];
const bodyMeshes: THREE.Mesh[] = [];
headRef.current = null;
visorRef.current = null;
eyesRef.current = null;
armsRef.current = [];
headGroupRef.current = null;
visorMaterialRef.current = null;
mainBodyMeshRef.current = null;
mainBodyBasePositionsRef.current = null;
// Remove helper covers from previous dev/StrictMode effect passes.
[
'eveBodyCollarSeamCover',
'eveNeckSeamCover',
'eveLeftShoulderSeamCover',
'eveRightShoulderSeamCover'
].forEach((helperName) => {
const helper = clonedObj.getObjectByName(helperName);
if (helper?.parent) {
helper.parent.remove(helper);
}
});
// 1. Classify meshes to assign control references and extract original textures
clonedObj.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
const lowerName = (child.name || '').toLowerCase();
// Hide weapons accessories completely (as requested: only the character)
if (lowerName.includes('gun')) {
child.visible = false;
return;
}
if (lowerName.includes('head')) {
headMeshes.push(child);
} else if (lowerName.includes('visor') || lowerName.includes('visiere') || lowerName.includes('screen') || lowerName.includes('ecran')) {
visorMeshes.push(child);
} else if (lowerName.includes('eye') || lowerName.includes('yeux')) {
eyesMeshes.push(child);
} else if (lowerName.includes('upperbody')) {
bodyMeshes.push(child);
} else if (lowerName.includes('arm')) {
armsMeshes.push(child);
} else {
bodyMeshes.push(child);
}
}
});
const resolvedHeadMesh = headMeshes[0] || null;
const resolvedVisorMesh = visorMeshes[0] || null;
const existingGroup = clonedObj.getObjectByName('headGroup');
// 2. Build High-Fidelity Retro-Futuristic space-age materials
// A. White lacquer glossy material for body parts, limbs and helmet shell.
// Specular dispersion values (higher roughness and low metalness) are optimized
// alongside a light emissive fill to diffuse self-shadows and mask assembly lines.
const whiteLacquerMaterial = new THREE.MeshPhysicalMaterial({
color: new THREE.Color('#ffffff'),
roughness: 0.14,
metalness: 0.14,
clearcoat: 1.0,
clearcoatRoughness: 0.04,
emissive: new THREE.Color('#ffffff'),
emissiveIntensity: 0.16,
reflectivity: 0.92,
depthWrite: true,
transparent: false
});
const seamCoverMaterial = new THREE.MeshPhysicalMaterial({
color: new THREE.Color('#ffffff'),
roughness: 0.14,
metalness: 0.14,
clearcoat: 1.0,
clearcoatRoughness: 0.04,
emissive: new THREE.Color('#ffffff'),
emissiveIntensity: 0.16,
reflectivity: 0.92,
depthWrite: true,
transparent: false
});
bodyMeshes.forEach((mesh) => {
// Weld nearby vertices to resolve seams perfectly
mesh.geometry = createSmoothGeometry(mesh.geometry, 0.08);
mesh.material = whiteLacquerMaterial;
mesh.castShadow = false;
mesh.receiveShadow = false;
if ((mesh.name || '').toLowerCase() === 'eve') {
mainBodyMeshRef.current = mesh;
mainBodyBasePositionsRef.current = new Float32Array(mesh.geometry.getAttribute('position').array);
}
});
if (resolvedHeadMesh) {
// Smooth out head geometry specifically to erase forehead seams and curves
resolvedHeadMesh.geometry = createSmoothGeometry(resolvedHeadMesh.geometry, 0.08);
// Dedicated head material: higher emissive to fill shadow gaps at seam edges
resolvedHeadMesh.material = new THREE.MeshPhysicalMaterial({
color: new THREE.Color('#ffffff'),
roughness: 0.12,
metalness: 0.12,
clearcoat: 1.0,
clearcoatRoughness: 0.03,
emissive: new THREE.Color('#ffffff'),
emissiveIntensity: 0.28,
reflectivity: 0.95,
depthWrite: true,
transparent: false,
});
resolvedHeadMesh.castShadow = false;
resolvedHeadMesh.receiveShadow = false;
}
armsRef.current = [];
armsMeshes.forEach((mesh) => {
// Apply smooth geometry and white lacquer material to the floating arms
mesh.geometry = createSmoothGeometry(mesh.geometry, 0.07);
mesh.material = whiteLacquerMaterial;
mesh.castShadow = false;
mesh.receiveShadow = false;
// Store base coordinates for dynamic relative offset animations
mesh.userData.initialPosition = mesh.position.clone();
mesh.userData.initialRotation = mesh.rotation.clone();
mesh.userData.basePositions = new Float32Array(mesh.geometry.getAttribute('position').array);
armsRef.current.push(mesh);
});
// B. Glossy black acrylic plastic cosmonaut visor (fully opaque black shield)
if (resolvedVisorMesh) {
resolvedVisorMesh.geometry.computeVertexNormals();
const visorMaterial = createVisorShaderMaterial();
visorMaterialRef.current = visorMaterial;
resolvedVisorMesh.material = visorMaterial;
resolvedVisorMesh.renderOrder = 2; // Render after the head shell
}
// C. Hide the low-resolution texture eyes. Clean Eve-style eyes are generated below.
eyesMeshes.forEach((mesh) => {
mesh.visible = false;
});
// 3. Compute local bounding box of the whole model to standardize height
const localBox = new THREE.Box3();
clonedObj.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
if (!child.geometry.boundingBox) {
child.geometry.computeBoundingBox();
}
if (child.geometry.boundingBox) {
localBox.union(child.geometry.boundingBox);
}
}
});
const size = new THREE.Vector3();
localBox.getSize(size);
const center = new THREE.Vector3();
localBox.getCenter(center);
// Normalize height conservatively so the avatar stays inside the right panel.
const targetHeight = 1.18;
const scaleFactor = targetHeight / (size.y || 1);
clonedObj.scale.setScalar(scaleFactor);
// Center model pivot perfectly at [0, 0, 0] in global workspace
clonedObj.position.x = -center.x * scaleFactor;
clonedObj.position.y = -center.y * scaleFactor;
clonedObj.position.z = -center.z * scaleFactor;
// Collar ring — wider tube to fully bridge body-to-neck gap
const bodyCollar = new THREE.Mesh(
new THREE.TorusGeometry(1.66, 0.14, 28, 160),
seamCoverMaterial
);
bodyCollar.name = 'eveBodyCollarSeamCover';
bodyCollar.position.set(0, 6.43, -0.04);
bodyCollar.rotation.x = Math.PI / 2;
bodyCollar.scale.z = 0.88;
bodyCollar.renderOrder = 1;
bodyCollar.castShadow = false;
bodyCollar.receiveShadow = false;
clonedObj.add(bodyCollar);
// Neck cylinder — fills the gap between collar ring and helmet base
const neckCover = new THREE.Mesh(
new THREE.CylinderGeometry(0.56, 0.70, 0.52, 36, 1, false),
seamCoverMaterial
);
neckCover.name = 'eveNeckSeamCover';
neckCover.position.set(0, 7.05, -0.04);
neckCover.renderOrder = 1;
neckCover.castShadow = false;
neckCover.receiveShadow = false;
clonedObj.add(neckCover);
// Shoulder caps — larger and deeper to fully cover arm-body junction
const shoulderGeometry = new THREE.SphereGeometry(0.42, 40, 24);
const leftShoulderCover = new THREE.Mesh(shoulderGeometry, seamCoverMaterial);
const rightShoulderCover = new THREE.Mesh(shoulderGeometry.clone(), seamCoverMaterial);
leftShoulderCover.name = 'eveLeftShoulderSeamCover';
rightShoulderCover.name = 'eveRightShoulderSeamCover';
leftShoulderCover.position.set(-2.58, 5.9, 0.03);
rightShoulderCover.position.set(2.58, 5.9, 0.03);
leftShoulderCover.scale.set(0.72, 0.88, 0.56);
rightShoulderCover.scale.set(0.72, 0.88, 0.56);
leftShoulderCover.renderOrder = 1;
rightShoulderCover.renderOrder = 1;
leftShoulderCover.castShadow = false;
rightShoulderCover.castShadow = false;
leftShoulderCover.receiveShadow = false;
rightShoulderCover.receiveShadow = false;
clonedObj.add(leftShoulderCover, rightShoulderCover);
// 4. Construct the synchronized Head Group with perfect pivots and local translations
if (resolvedHeadMesh) {
// A. Calculate local center of head geometry
const headGeom = resolvedHeadMesh.geometry;
if (!headGeom.boundingBox) {
headGeom.computeBoundingBox();
}
const headCenter = new THREE.Vector3();
if (headGeom.boundingBox) {
headGeom.boundingBox.getCenter(headCenter);
}
headCenterRef.current.copy(headCenter);
// B. Create head group and place it at headCenter (in clonedObj local space)
const headGroup = new THREE.Group();
headGroup.name = "headGroup";
headGroup.position.copy(headCenter);
clonedObj.add(headGroup);
headGroupRef.current = headGroup;
// C. Position Head: Keep geometry intact to maintain perfect Blender-defined coordinates, position relative to headGroup
headGroup.add(resolvedHeadMesh);
resolvedHeadMesh.position.set(-headCenter.x, -headCenter.y, -headCenter.z);
// D. Position Visor: Keep geometry intact for absolute precision, and position at headCenter
if (resolvedVisorMesh) {
headGroup.add(resolvedVisorMesh);
resolvedVisorMesh.position.set(-headCenter.x, -headCenter.y, -headCenter.z);
}
headRef.current = resolvedHeadMesh;
visorRef.current = resolvedVisorMesh;
eyesRef.current = null;
if (existingGroup?.parent && existingGroup !== headGroup) {
existingGroup.parent.remove(existingGroup);
}
}
}, [clonedObj, avatarId]);
// Boucle de rendu pour animer Eve en temps réel (Animations fluides R3F)
useFrame((state) => {
const t = state.clock.getElapsedTime();
// Style émotionnel actuel
const emoStyle = EmotionService.getEmotionStyle(emotion, avatarId);
// 1. Animation globale de flottaison (Lévitation magnétique)
if (modelRef.current) {
// Oscillation en hauteur stabilisée autour de y = 0.27 pour mieux occuper la fenetre avatar.
modelRef.current.position.y = Math.sin(t * 1.5) * 0.06 + 0.27;
// Légère rotation sur elle-même (mouvement de veille)
modelRef.current.rotation.y = Math.sin(t * 0.5) * 0.05;
}
// 2. Animation de la tête flottante autonome (incluant la visière et les yeux)
if (headGroupRef.current && headCenterRef.current) {
const headCenter = headCenterRef.current;
// Flottaison déphasée par rapport au corps pour accentuer l'effet magnétique
let headY = Math.sin(t * 1.8) * 0.02 + 0.01;
// Orientation naturelle vers la souris pour donner vie au robot
const mouseX = state.pointer.x * 0.25;
const mouseY = state.pointer.y * 0.2;
let targetRotY = THREE.MathUtils.lerp(headGroupRef.current.rotation.y, mouseX, 0.1);
let targetRotX = THREE.MathUtils.lerp(headGroupRef.current.rotation.x, -mouseY + 0.05, 0.1);
let targetRotZ = 0;
let headZOffset = 0;
// Inclinaisons émotionnelles ultra-expressives
if (emotion === 'sad') {
// Tête très baissée et abattue
targetRotX = THREE.MathUtils.lerp(targetRotX, 0.35, 0.1);
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, 0.12, 0.1);
headY -= 0.02; // Tête enfoncée dans les épaules
} else if (emotion === 'thinking') {
// Tête inclinée sur le côté (curieuse) ET léger balancement pendulaire (swaying)
const sway = Math.sin(t * 3.5) * 0.04;
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, -0.22 + sway, 0.1);
// Micro-mouvements de tête (hochement pensif vertical et balayage horizontal lent)
headY += Math.sin(t * 4.5) * 0.005;
} else if (emotion === 'angry') {
// Tête très penchée en avant (froncée/agacée) avec micro-tremblements horizontaux
targetRotX = THREE.MathUtils.lerp(targetRotX, 0.22, 0.1);
const angerTremble = Math.sin(t * 22) * 0.015;
targetRotY = THREE.MathUtils.lerp(headGroupRef.current.rotation.y, mouseX + angerTremble, 0.1);
} else if (emotion === 'happy') {
// Excitation joyeuse : petits hochements de tête rapides (bobbing)
const joyBob = Math.sin(t * 6.5) * 0.03;
targetRotX = THREE.MathUtils.lerp(targetRotX, joyBob - mouseY + 0.02, 0.1);
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, Math.cos(t * 4.0) * 0.025, 0.1);
} else if (emotion === 'surprised') {
// Recul brusque de surprise de la tête et inclinaison vers le haut
targetRotX = THREE.MathUtils.lerp(targetRotX, -0.25 - mouseY, 0.1);
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, Math.sin(t * 10) * 0.01, 0.1);
headY += 0.025; // Tête étirée
headZOffset -= 0.05; // Recul
} else {
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, 0, 0.1);
}
// Appliquer les transformations au Groupe Tête (tout bouge et pivote en parfait accord autour du pivot naturel)
headGroupRef.current.position.set(
headCenter.x,
headCenter.y + headY,
headCenter.z + headZOffset
);
headGroupRef.current.rotation.set(targetRotX, targetRotY, targetRotZ);
}
// 3. Animation des Yeux LED (Expressions d'émotion + Clignotement + Parole + Micro-saccades de veille)
if (visorMaterialRef.current) {
// Clignotement naturel toutes les 4.2 secondes
const isBlinking = t % 4.2 < 0.15;
// A. Taille verticale des yeux
let targetScaleY = isBlinking ? 0.02 : emoStyle.eyeScaleY;
// Respiration des yeux (micro-pulsation de veille) : oscillation subtile à ~0.38Hz
if (!isBlinking) {
targetScaleY += Math.sin(t * 2.4) * 0.018;
}
// Effet de parole : si Eve parle, ses yeux oscillent légèrement en hauteur au rythme de sa voix
if (isSpeaking && !isBlinking) {
targetScaleY += Math.sin(t * 18) * 0.08;
}
// B. Position des yeux (micro-saccades autonomes de veille + émotions)
let targetEyeOffsetY = 0;
let targetEyeOffsetX = 0;
if (emotion === 'surprised') {
targetEyeOffsetY = 0.05;
} else if (emotion === 'sad') {
targetEyeOffsetY = -0.04;
} else if (emotion === 'thinking') {
// Balayage lent et pensif
targetEyeOffsetY = Math.sin(t * 2.6) * 0.025;
targetEyeOffsetX = Math.cos(t * 1.8) * 0.04;
// Pulsation LED de calcul intense : les yeux vibrent légèrement en taille à haute fréquence (15 rad/s)
if (!isBlinking) {
targetScaleY += Math.sin(t * 15.0) * 0.05;
}
} else {
// Mode veille standard : génération de micro-saccades oculaires pseudo-aléatoires toutes les 2.8 secondes
const saccadeCycle = Math.floor(t * 0.35); // change toutes les ~2.8s
// Micro-saccades horizontales et verticales basées sur des fréquences non harmoniques
const rawSaccadeX = Math.sin(saccadeCycle * 17.31 + 2.5) * Math.cos(saccadeCycle * 7.89);
const rawSaccadeY = Math.cos(saccadeCycle * 13.17 - 1.2) * Math.sin(saccadeCycle * 5.43);
// Limitation des décalages pour garder un aspect centré et naturel
targetEyeOffsetX = rawSaccadeX * 0.045; // max ±0.045
targetEyeOffsetY = rawSaccadeY * 0.025; // max ±0.025
// Ajout d'une très légère dérive continue (bruit oculaire de veille)
targetEyeOffsetX += Math.sin(t * 0.8) * 0.008;
targetEyeOffsetY += Math.cos(t * 0.5) * 0.005;
}
visorMaterialRef.current.uniforms.eyeColor.value.set(emoStyle.eyeColor);
visorMaterialRef.current.uniforms.eyeScaleY.value = targetScaleY;
visorMaterialRef.current.uniforms.eyeRotation.value = emoStyle.eyeRotation;
visorMaterialRef.current.uniforms.eyeOffsetX.value = targetEyeOffsetX;
visorMaterialRef.current.uniforms.eyeOffsetY.value = targetEyeOffsetY;
visorMaterialRef.current.uniforms.speakingPulse.value = isSpeaking ? 0.025 : 0;
// Mise à jour des uniforms uTime et de la valeur de transition de réflexion
visorMaterialRef.current.uniforms.uTime.value = t;
let targetThinkingVal = emotion === 'thinking' ? 1.0 : 0.0;
visorMaterialRef.current.uniforms.thinkingVal.value = THREE.MathUtils.lerp(
visorMaterialRef.current.uniforms.thinkingVal.value,
targetThinkingVal,
0.08
);
}
// 4. Animation dynamique des bras (déformation vertex organique selon l'émotion et la parole)
if (mainBodyMeshRef.current && mainBodyBasePositionsRef.current) {
const geometry = mainBodyMeshRef.current.geometry;
const position = geometry.getAttribute('position') as THREE.BufferAttribute;
const base = mainBodyBasePositionsRef.current;
for (let i = 0; i < position.count; i += 1) {
const index = i * 3;
const baseX = base[index];
const baseY = base[index + 1];
const baseZ = base[index + 2];
const isArmRegion = Math.abs(baseX) > 2.05 && baseY > 1.0 && baseY < 6.25;
if (isArmRegion) {
const side = Math.sign(baseX);
const shoulderFalloff = THREE.MathUtils.clamp((6.25 - baseY) / 4.8, 0, 1);
const offset = getEmotionalArmOffset(side, shoulderFalloff, t, emotion, isSpeaking);
position.setXYZ(
i,
baseX + offset.dx,
baseY + offset.dy,
baseZ + offset.dz
);
} else {
position.setXYZ(i, baseX, baseY, baseZ);
}
}
position.needsUpdate = true;
geometry.computeVertexNormals();
}
if (armsRef.current.length > 0) {
armsRef.current.forEach((mesh) => {
// Neutralize global transformation to let vertex deformation do the work perfectly in the same coordinate space
mesh.position.set(0, 0, 0);
mesh.rotation.set(0, 0, 0);
const base = mesh.userData.basePositions as Float32Array;
if (!base) return;
const geometry = mesh.geometry;
const position = geometry.getAttribute('position') as THREE.BufferAttribute;
for (let i = 0; i < position.count; i += 1) {
const index = i * 3;
const baseX = base[index];
const baseY = base[index + 1];
const baseZ = base[index + 2];
const isArmRegion = Math.abs(baseX) > 2.05 && baseY > 1.0 && baseY < 6.25;
if (isArmRegion) {
const side = Math.sign(baseX);
const shoulderFalloff = THREE.MathUtils.clamp((6.25 - baseY) / 4.8, 0, 1);
const offset = getEmotionalArmOffset(side, shoulderFalloff, t, emotion, isSpeaking);
position.setXYZ(
i,
baseX + offset.dx,
baseY + offset.dy,
baseZ + offset.dz
);
} else {
position.setXYZ(i, baseX, baseY, baseZ);
}
}
position.needsUpdate = true;
geometry.computeVertexNormals();
});
}
});
return (
<group ref={modelRef} scale={1.0} position={[0, 0.27, 0]}>
<primitive object={clonedObj} />
</group>
);
};
-629
View File
@@ -1,629 +0,0 @@
import React, { Suspense, useRef } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import * as THREE from 'three';
import { AvatarModelLoader } from './AvatarModelLoader';
import { EveEmotion, EveAvatar } from '../services/emotionService';
export interface ToolEvent {
id: string;
label: string;
detail?: string;
status: 'idle' | 'running' | 'done' | 'error';
ts: number;
}
interface ThreeCanvasProps {
emotion: EveEmotion;
isSpeaking: boolean;
avatarId?: EveAvatar;
isFloatingMode?: boolean;
}
interface HologramProjectionProps {
emotion: EveEmotion;
isSpeaking: boolean;
avatarId?: EveAvatar;
}
interface ParticleInstance {
x: number;
y: number;
z: number;
speed: number;
scale: number;
phase: number;
}
/**
* 3D Holographic rising particles representing active data streams.
* Flowing upwards in an expanding spiral fashion, strictly confined within the restricted projection space (below the avatar).
* Restrained dynamically to the avatar's real-time bottom baseline to prevent overlapping with the avatar's body.
*/
const HologramParticles: React.FC<{ color: THREE.Color; avatarId?: EveAvatar }> = ({ color, avatarId = 'eve' }) => {
const count = 54; // restrained density so the projector stays clean and readable
const particlesRef = useRef<THREE.Group>(null);
// Persist random initial coordinates within the cone volume, speed modifiers, and scaling factors
const particleData = React.useMemo<ParticleInstance[]>(() => {
const data: ParticleInstance[] = [];
for (let i = 0; i < count; i++) {
const y = -0.8 + Math.random() * 0.6;
const ratio = (y + 0.8) / 0.6;
const radiusAtY = 0.17 + 0.17 * ratio;
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * radiusAtY;
data.push({
x: Math.cos(angle) * radius,
y: y,
z: Math.sin(angle) * radius,
speed: 0.002 + Math.random() * 0.004, // Elegant floating speed below EVE
scale: 0.16 + Math.random() * 0.4,
phase: Math.random() * Math.PI * 2
});
}
return data;
}, []);
useFrame((state) => {
if (!particlesRef.current) return;
const children = particlesRef.current.children;
const t = state.clock.getElapsedTime();
// Compute dynamic avatar bottom in real-time
const isEve = avatarId === 'eve';
const avatarY = isEve
? Math.sin(t * 1.5) * 0.06 + 0.27
: Math.sin(t * 1.4) * 0.05 + 0.27;
const yBottomLocal = (avatarY + 0.1) - 0.59;
for (let i = 0; i < children.length; i++) {
const child = children[i] as THREE.Mesh;
const data = particleData[i];
// Rise animation
data.y += data.speed;
// Spiral micro-rotation effect
const currentRadius = Math.sqrt(data.x * data.x + data.z * data.z);
const angle = Math.atan2(data.z, data.x) + 0.014;
data.x = Math.cos(angle) * currentRadius;
data.z = Math.sin(angle) * currentRadius;
// Radial constraints: inverted cone is narrower at the bottom (0.17 radius) than top (0.34 radius)
const ratio = (data.y + 0.8) / 0.6;
const radiusAtY = 0.17 + 0.17 * ratio;
// Reset position if exceeding top or radial boundaries
if (data.y > yBottomLocal || currentRadius > radiusAtY) {
data.y = -0.8;
const newAngle = Math.random() * Math.PI * 2;
const newRadius = Math.random() * 0.11; // Start close to the lens focal point
data.x = Math.cos(newAngle) * newRadius;
data.z = Math.sin(newAngle) * newRadius;
}
child.position.set(data.x, data.y, data.z);
// Sinuous individual flickering
if (child.material) {
const mat = child.material as THREE.MeshBasicMaterial;
mat.opacity = 0.45 + Math.sin(t * 5.0 + data.phase) * 0.15;
}
}
});
return (
<group ref={particlesRef}>
{particleData.map((data, i) => (
<mesh key={i} scale={data.scale}>
{/* Energy crystals shape using Dodecahedron for premium specular feel */}
<dodecahedronGeometry args={[0.018, 0]} />
<meshBasicMaterial
color={color}
transparent
opacity={0.5}
blending={THREE.AdditiveBlending}
/>
</mesh>
))}
</group>
);
};
/**
* Animated holographic chamber behind Eve.
* Strong enough to be visible, but abstract enough to avoid the old planet/star look.
*/
const TechnicalBackdrop: React.FC = () => {
const haloRef = useRef<THREE.Group>(null);
const sweepRef = useRef<THREE.Group>(null);
const gateRef = useRef<THREE.Group>(null);
const nodeMat = React.useMemo(() => (
new THREE.MeshBasicMaterial({
color: '#00f2ff',
transparent: true,
opacity: 0.72,
depthWrite: false,
blending: THREE.AdditiveBlending
})
), []);
const dataLines = React.useMemo(() => ([
{ y: 1.02, width: 2.15, phase: 0.1, speed: 0.36, opacity: 0.34 },
{ y: 0.68, width: 2.55, phase: 1.6, speed: 0.28, opacity: 0.26 },
{ y: 0.24, width: 2.18, phase: 2.8, speed: 0.42, opacity: 0.32 },
{ y: -0.22, width: 2.44, phase: 4.2, speed: 0.31, opacity: 0.24 },
{ y: -0.68, width: 1.92, phase: 5.4, speed: 0.48, opacity: 0.31 }
]), []);
const orbitNodes = React.useMemo(() => ([
{ radius: 1.04, angle: 0.18, size: 0.034 },
{ radius: 1.36, angle: 1.7, size: 0.024 },
{ radius: 1.68, angle: 2.78, size: 0.028 },
{ radius: 1.28, angle: 4.35, size: 0.022 },
{ radius: 1.52, angle: 5.52, size: 0.026 }
]), []);
useFrame((state) => {
const t = state.clock.getElapsedTime();
if (haloRef.current) {
haloRef.current.rotation.z = t * 0.035;
haloRef.current.scale.setScalar(1 + Math.sin(t * 0.7) * 0.025);
}
if (gateRef.current) {
gateRef.current.rotation.z = Math.sin(t * 0.22) * 0.035;
}
if (sweepRef.current) {
sweepRef.current.children.forEach((child, index) => {
const line = dataLines[index];
child.position.x = Math.sin(t * line.speed + line.phase) * 0.82;
const mesh = child as THREE.Mesh;
const material = mesh.material as THREE.MeshBasicMaterial;
material.opacity = line.opacity + (Math.sin(t * 1.4 + line.phase) + 1) * 0.08;
});
}
});
return (
<group position={[0, 0.02, -1.72]}>
<mesh position={[0, 0, -0.12]}>
<planeGeometry args={[3.5, 3.85]} />
<meshBasicMaterial
color="#005e78"
transparent
opacity={0.16}
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</mesh>
<group ref={sweepRef} position={[0, 0, -0.01]}>
{dataLines.map((line, index) => (
<mesh key={index} position={[0, line.y, 0]} rotation={[0, 0, index % 2 === 0 ? 0.05 : -0.06]}>
<planeGeometry args={[line.width, 0.018]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={line.opacity}
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</mesh>
))}
</group>
<group ref={gateRef}>
{[-1.28, -0.86, 0.86, 1.28].map((x, index) => (
<mesh key={x} position={[x, 0, -0.03]}>
<planeGeometry args={[0.028, 3.35 - index % 2 * 0.34]} />
<meshBasicMaterial
color={index < 2 ? '#00f2ff' : '#8af7ff'}
transparent
opacity={index % 2 === 0 ? 0.19 : 0.13}
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</mesh>
))}
</group>
<group ref={haloRef}>
{[0.86, 1.14, 1.48, 1.78].map((radius, index) => (
<mesh key={radius} rotation={[0, 0, index * 0.18]}>
<ringGeometry args={[radius, radius + 0.012, 128]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={0.28 - index * 0.045}
side={THREE.DoubleSide}
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</mesh>
))}
<mesh>
<ringGeometry args={[0.58, 0.592, 96]} />
<meshBasicMaterial
color="#8af7ff"
transparent
opacity={0.27}
side={THREE.DoubleSide}
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</mesh>
{orbitNodes.map((node, index) => (
<mesh
key={index}
position={[
Math.cos(node.angle) * node.radius,
Math.sin(node.angle) * node.radius,
0.01
]}
material={nodeMat}
>
<circleGeometry args={[node.size, 24]} />
</mesh>
))}
</group>
<mesh position={[0, 0, -0.08]}>
<circleGeometry args={[1.28, 96]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={0.07}
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</mesh>
{[
[0, -0.94, 0]
].map((position, index) => (
<mesh key={index} position={position as [number, number, number]}>
<planeGeometry args={[1.55, 0.014]} />
<meshBasicMaterial
color="#8af7ff"
transparent
opacity={0.28}
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</mesh>
))}
</group>
);
};
/**
* 3D Hologram Projection platform and light cone visualizer.
* Fully interactive and reactive to speech and emotional state.
* Fixed to a premium cyan hue (#00f2ff) to align perfectly with the EVEFLOW brand.
* Confines the light cone strictly below the avatar's body, stretching to touch her base dynamically.
*/
const HologramProjection: React.FC<HologramProjectionProps> = ({ emotion, isSpeaking, avatarId = 'eve' }) => {
const beamRef = useRef<THREE.Mesh>(null);
const glowRef = useRef<THREE.Mesh>(null);
const baseRef = useRef<THREE.Group>(null);
// Cybernetic rotative diagnostic ring groups
const ringGroup1Ref = useRef<THREE.Group>(null);
const ringGroup2Ref = useRef<THREE.Group>(null);
const ringGroup3Ref = useRef<THREE.Group>(null);
// Permanently fixed to signature premium cyan to preserve brand identity and theme consistency
const stateColor = React.useMemo(() => new THREE.Color('#00f2ff'), []);
const isThinking = emotion === 'thinking';
// Custom ShaderMaterial to generate a smooth, high-fidelity opacity gradient fading towards the top
const coneMaterial = React.useMemo(() => {
return new THREE.ShaderMaterial({
uniforms: {
color: { value: stateColor },
opacity: { value: 0.42 },
},
vertexShader: `
varying vec3 vLocalPosition;
void main() {
vLocalPosition = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 color;
uniform float opacity;
varying vec3 vLocalPosition;
void main() {
// Normalizing local Y from -0.3 (bottom) to 0.3 (top) for a total height of 0.6
float factor = (vLocalPosition.y + 0.3) / 0.6;
// Smooth progressive fade out to absolute transparency at the top
float grad = clamp(1.0 - factor, 0.0, 1.0);
// Cubic curve for ultra-soft, premium cinematic light scattering
grad = pow(grad, 1.6);
gl_FragColor = vec4(color, grad * opacity);
}
`,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
}, [stateColor]);
useFrame((state) => {
const t = state.clock.getElapsedTime();
// Dynamic tracking of avatar's float baseline to keep perfect visual contact
const isEve = avatarId === 'eve';
const avatarY = isEve
? Math.sin(t * 1.5) * 0.06 + 0.27
: Math.sin(t * 1.4) * 0.05 + 0.27;
// The bottom of the avatar shell is approximately Y_avatar - 0.59
const yBottomLocal = (avatarY + 0.1) - 0.59;
// Lens source is at Y = -0.80 local (aligned to the new flat holographic emitter lens)
const yLensLocal = -0.80;
const currentHeight = yBottomLocal - yLensLocal;
const currentCenterY = yLensLocal + currentHeight / 2;
// Scale vertically relative to the default geometry height of 0.6
if (beamRef.current) {
beamRef.current.scale.y = currentHeight / 0.6;
beamRef.current.position.y = currentCenterY;
}
// Volumetric base flicker intensity simulation (high-fidelity holographic noise) - Boosted for pronounced cyan look
let baseFlicker = 0.42 + Math.sin(t * 7.5) * 0.05 + Math.cos(t * 3.2) * 0.025;
if (isThinking) {
baseFlicker += Math.sin(t * 22.0) * 0.035;
}
if (isSpeaking) {
const voiceFrequencyOscillation = Math.sin(t * 18.0) * 0.08 + Math.abs(Math.cos(t * 12.0)) * 0.05;
baseFlicker += voiceFrequencyOscillation;
}
// Volumetric soft cone opacity adjustment through custom shader uniforms
if (beamRef.current && beamRef.current.material) {
const mat = beamRef.current.material as THREE.ShaderMaterial;
if (mat.uniforms && mat.uniforms.opacity && mat.uniforms.color) {
mat.uniforms.opacity.value = baseFlicker;
mat.uniforms.color.value.lerp(stateColor, 0.08);
}
}
// Smooth cybernetic diagnostics rotations
if (ringGroup1Ref.current) ringGroup1Ref.current.rotation.z = t * 0.16;
if (ringGroup2Ref.current) ringGroup2Ref.current.rotation.z = -t * 0.10;
if (ringGroup3Ref.current) ringGroup3Ref.current.rotation.z = t * 0.06;
if (baseRef.current) baseRef.current.scale.setScalar(1 + Math.sin(t * 2.2) * 0.018);
// Glow plate adjustment - Pulsing emission intensity simulated via opacity for BasicMaterial
if (glowRef.current && glowRef.current.material) {
const mat = glowRef.current.material as THREE.MeshBasicMaterial;
mat.opacity = 0.70 + Math.sin(t * 5.0) * 0.15;
}
});
return (
<group position={[0, -0.1, 0]}>
{/* 1. Volumetric hologram light cone - Dynamically scaled and positioned to touch EVE's bottom */}
<mesh ref={beamRef} position={[0, -0.50, 0]}>
<cylinderGeometry args={[0.34, 0.15, 0.6, 48, 1, true]} />
<primitive object={coneMaterial} attach="material" />
</mesh>
{/* High-fidelity upward particle flow synchronized to avatar bottom limit */}
<HologramParticles color={stateColor} avatarId={avatarId} />
{/* 2. Émetteur Holographique Pur (100% Lumineux & Intégré) */}
<group position={[0, -0.05, 0]}>
<group ref={baseRef}>
<mesh position={[0, -0.93, 0]}>
<cylinderGeometry args={[0.32, 0.38, 0.08, 72]} />
<meshPhysicalMaterial
color="#dff8ff"
roughness={0.18}
metalness={0.18}
clearcoat={1}
clearcoatRoughness={0.08}
emissive="#4defff"
emissiveIntensity={0.25}
/>
</mesh>
<mesh position={[0, -0.835, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[0.28, 72]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={0.48}
blending={THREE.AdditiveBlending}
side={THREE.DoubleSide}
/>
</mesh>
<mesh position={[0, -0.828, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.25, 0.27, 72]} />
<meshBasicMaterial
color="#ffffff"
transparent
opacity={0.58}
blending={THREE.AdditiveBlending}
side={THREE.DoubleSide}
/>
</mesh>
<mesh position={[0, -1.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[0.44, 72]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={0.13}
blending={THREE.AdditiveBlending}
side={THREE.DoubleSide}
/>
</mesh>
</group>
<pointLight position={[0, -0.76, 0.35]} intensity={0.95} color="#00f2ff" distance={2.4} />
{/* A. Lentille émissive centrale - Source principale de lumière */}
<mesh
ref={glowRef}
position={[0, -0.80, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<ringGeometry args={[0, 0.14, 32]} />
<meshBasicMaterial
color="#ffffff"
transparent
opacity={0.88}
blending={THREE.AdditiveBlending}
side={THREE.DoubleSide}
/>
</mesh>
{/* B. Premier anneau concentrique de confinement d'énergie */}
<mesh position={[0, -0.798, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.155, 0.16, 64]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={0.45}
blending={THREE.AdditiveBlending}
side={THREE.DoubleSide}
/>
</mesh>
{/* C. Anneau segmenté rotatif interne (Sens horaire) */}
<group ref={ringGroup1Ref} position={[0, -0.796, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<mesh>
<ringGeometry args={[0.18, 0.19, 32, 1, 0, Math.PI * 0.45]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={0.65}
blending={THREE.AdditiveBlending}
side={THREE.DoubleSide}
/>
</mesh>
<mesh rotation={[0, 0, Math.PI]}>
<ringGeometry args={[0.18, 0.19, 32, 1, 0, Math.PI * 0.45]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={0.65}
blending={THREE.AdditiveBlending}
side={THREE.DoubleSide}
/>
</mesh>
</group>
{/* D. Anneau segmenté rotatif externe (Sens anti-horaire) */}
<group ref={ringGroup2Ref} position={[0, -0.794, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<mesh>
<ringGeometry args={[0.22, 0.23, 32, 1, Math.PI * 0.2, Math.PI * 0.5]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={0.40}
blending={THREE.AdditiveBlending}
side={THREE.DoubleSide}
/>
</mesh>
<mesh rotation={[0, 0, Math.PI]}>
<ringGeometry args={[0.22, 0.23, 32, 1, Math.PI * 0.2, Math.PI * 0.5]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={0.40}
blending={THREE.AdditiveBlending}
side={THREE.DoubleSide}
/>
</mesh>
</group>
{/* E. Anneau de ciblage tech externe avec micro-graduations */}
<group ref={ringGroup3Ref} position={[0, -0.792, 0]} rotation={[-Math.PI / 2, 0, 0]}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<mesh key={i} rotation={[0, 0, (i * Math.PI) / 3]}>
<ringGeometry args={[0.26, 0.264, 16, 1, 0, Math.PI * 0.04]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={0.28}
blending={THREE.AdditiveBlending}
side={THREE.DoubleSide}
/>
</mesh>
))}
</group>
{/* F. Grand anneau de balayage de diagnostic extérieur pulsant */}
<mesh position={[0, -0.798, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.31, 0.313, 64]} />
<meshBasicMaterial
color="#00f2ff"
transparent
opacity={0.18}
blending={THREE.AdditiveBlending}
side={THREE.DoubleSide}
/>
</mesh>
</group>
</group>
);
};
export const ThreeCanvas: React.FC<ThreeCanvasProps> = ({ emotion, isSpeaking, avatarId = 'eve', isFloatingMode = false }) => {
return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<Canvas
camera={{ position: isFloatingMode ? [0, 0.72, 3.7] : [0, 1.1, 5.8], fov: isFloatingMode ? 30 : 36 }}
gl={{ alpha: true, antialias: true, premultipliedAlpha: false }}
onCreated={({ gl }) => {
gl.setClearColor(0x000000, 0);
}}
dpr={[1, 2]}
style={{ background: 'transparent', backgroundColor: 'transparent' }}
>
{/* Soft ambient — dark space base */}
<ambientLight intensity={0.6} color="#b0c8e8" />
<hemisphereLight args={['#1a2a4a', '#050810', 0.8]} />
{/* Main key light — warm white rim from top-right */}
<directionalLight
position={[5, 8, 5]}
intensity={2.0}
color="#e8f4ff"
/>
{/* Cyan atmosphere rim */}
<pointLight position={[-5, 3, 2]} intensity={1.0} color="#60c8e8" />
{/* Desaturated blue fill */}
<pointLight position={[4, -2, 2]} intensity={0.45} color="#8ab4d8" />
{/* Soft white back fill to keep geometry readable */}
<pointLight position={[0, 3, -4]} intensity={1.0} color="#ffffff" />
<Suspense fallback={null}>
<TechnicalBackdrop />
<HologramProjection emotion={emotion} isSpeaking={isSpeaking} avatarId={avatarId} />
<AvatarModelLoader avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />
</Suspense>
{/* OrbitControls calibrated to prevent clipping or losing focus */}
<OrbitControls
enableZoom={true}
minDistance={isFloatingMode ? 2.4 : 4.8}
maxDistance={isFloatingMode ? 4.7 : 6.8}
enablePan={false}
autoRotate={false}
maxPolarAngle={Math.PI / 1.7}
minPolarAngle={Math.PI / 2.8}
/>
</Canvas>
</div>
);
};
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
import { MessageSquare, Trash2, X } from 'lucide-react';
import { useChat } from '../../state/chat';
import { useSettings } from '../../state/settings';
import { MessageBubble } from './MessageBubble';
import { CommandBar } from './CommandBar';
interface Props {
compact?: boolean;
}
export function ChatPanel({ compact }: Props) {
const messages = useChat((s) => s.messages);
const clear = useChat((s) => s.clear);
const settings = useSettings((s) => s.settings);
const listRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState<string | null>(null);
const [stick, setStick] = useState(true);
useEffect(() => {
if (!stick) return;
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, stick]);
const onScroll = () => {
const el = listRef.current;
if (!el) return;
setStick(el.scrollHeight - el.scrollTop - el.clientHeight < 80);
};
const visible = compact ? messages.slice(-6) : messages;
return (
<section className={`panel bracket chat-panel${compact ? ' compact' : ''}`}>
{!compact && (
<header className="panel-head">
<MessageSquare size={14} />
<span>Transmission</span>
<span className="spacer" />
<span className="chip">{messages.length} msg</span>
<button className="icon-btn" title="Effacer la conversation" onClick={clear} disabled={messages.length === 0}>
<Trash2 size={14} />
</button>
</header>
)}
<div ref={listRef} className={`chat-list${compact ? ' compact' : ''}`} onScroll={onScroll}>
{visible.length === 0 && (
<div className="empty">
{compact ? 'En attente…' : `Canal ouvert. Parlez ou écrivez à ${settings.assistantName}.`}
{!compact && (
<div className="muted" style={{ marginTop: 8, fontSize: 12 }}>
<span className="kbd">Ctrl</span> + <span className="kbd">Shift</span> + <span className="kbd">Espace</span> active le micro depuis n’importe où.
</div>
)}
</div>
)}
{visible.map((m) => (
<MessageBubble
key={m.id}
message={m}
assistantName={settings.assistantName}
userName={settings.userName}
showReasoning={settings.ui.showReasoning}
compact={compact}
onOpenImage={setZoom}
/>
))}
</div>
<CommandBar compact={compact} />
{zoom && (
<div className="overlay" onClick={() => setZoom(null)}>
<img src={zoom} alt="aperçu" style={{ maxWidth: '92%', maxHeight: '90%', borderRadius: 10, border: '1px solid var(--line-strong)' }} />
<button className="icon-btn" style={{ position: 'absolute', top: 12, right: 12 }} onClick={() => setZoom(null)}>
<X size={16} />
</button>
</div>
)}
</section>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useRef, useState } from 'react';
import { Mic, Paperclip, Send, Square, X, Radio, Loader2, Volume2, VolumeX, Navigation } from 'lucide-react';
import { sendMessage, steer, stopGeneration } from '../../services/conversation';
import { speech } from '../../services/voice/speech';
import { voiceController } from '../../services/voice/voiceController';
import { useChat } from '../../state/chat';
import { useSettings } from '../../state/settings';
import { useVoice } from '../../state/voice';
interface Props {
compact?: boolean;
}
const MAX_IMAGE_EDGE = 1280;
async function fileToDataUrl(file: File): Promise<string> {
const raw = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
if (!file.type.startsWith('image/') || file.type === 'image/svg+xml' || file.type === 'image/gif') return raw;
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const scale = Math.min(1, MAX_IMAGE_EDGE / Math.max(img.width, img.height));
if (scale === 1 && file.size < 900_000) return resolve(raw);
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d')?.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', 0.82));
};
img.onerror = () => resolve(raw);
img.src = raw;
});
}
export function CommandBar({ compact }: Props) {
const draft = useChat((s) => s.draft);
const setDraft = useChat((s) => s.setDraft);
const isSending = useChat((s) => s.isSending);
const currentRunId = useChat((s) => s.currentRunId);
const phase = useVoice((s) => s.phase);
const level = useVoice((s) => s.inputLevel);
const handsFree = useVoice((s) => s.handsFree);
const ttsState = useVoice((s) => s.tts);
const speechProvider = useSettings((s) => s.settings.speech.provider);
const autoSpeak = useSettings((s) => s.settings.speech.autoSpeak);
const update = useSettings((s) => s.update);
const [images, setImages] = useState<string[]>([]);
const [steerMode, setSteerMode] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = inputRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(160, el.scrollHeight)}px`;
}, [draft]);
useEffect(() => {
if (!isSending) setSteerMode(false);
}, [isSending]);
const submit = async () => {
const text = draft.trim();
if (steerMode && currentRunId) {
if (await steer(text)) setDraft('');
return;
}
if (!text && images.length === 0) return;
const attached = images;
setDraft('');
setImages([]);
await sendMessage(text, attached);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void submit();
}
if (e.key === 'Escape' && isSending) stopGeneration();
};
const onFiles = async (files: FileList | null) => {
if (!files) return;
const next: string[] = [];
for (const file of Array.from(files).slice(0, 4)) {
if (!file.type.startsWith('image/')) continue;
if (file.size > 12 * 1024 * 1024) continue;
next.push(await fileToDataUrl(file));
}
setImages((prev) => [...prev, ...next].slice(0, 4));
if (fileRef.current) fileRef.current.value = '';
};
const onPaste = (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items).filter((i) => i.type.startsWith('image/'));
if (items.length === 0) return;
const list = new DataTransfer();
for (const item of items) {
const f = item.getAsFile();
if (f) list.items.add(f);
}
void onFiles(list.files);
};
const listening = phase !== 'off';
const micClass = phase === 'speech' ? 'speech' : phase === 'transcribing' ? 'transcribing' : listening ? 'listening' : '';
const micTitle = phase === 'transcribing' ? 'Transcription…' : listening ? 'Arrêter l’écoute' : 'Parler (Ctrl+Shift+Espace)';
const placeholder = steerMode ? 'Consigne à injecter dans le run en cours…' : compact ? 'Message…' : 'Commande, question ou mission pour Hermes…';
return (
<div className="command-bar">
{images.length > 0 && (
<div className="attachments">
{images.map((img, i) => (
<div key={i} className="attachment">
<img src={img} alt="" />
<button onClick={() => setImages((prev) => prev.filter((_, j) => j !== i))} title="Retirer"><X size={11} /></button>
</div>
))}
</div>
)}
<div className="command-row">
<button
className={`mic-btn ${micClass}`}
style={{ ['--level' as string]: level }}
title={micTitle}
onClick={() => voiceController.toggle()}
disabled={phase === 'transcribing'}
>
{phase === 'transcribing' ? <Loader2 size={20} className="spin" /> : listening ? <Square size={18} /> : <Mic size={20} />}
</button>
<textarea
ref={inputRef}
className={`command-input${steerMode ? ' steer' : ''}`}
rows={1}
value={draft}
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
spellCheck={false}
/>
{!compact && (
<button className="icon-btn" title="Joindre une image" onClick={() => fileRef.current?.click()} style={{ height: 46, width: 40 }}>
<Paperclip size={18} />
</button>
)}
<input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void onFiles(e.target.files)} />
{isSending && !steerMode ? (
<button className="send-btn stop" title="Interrompre (Échap)" onClick={stopGeneration}>
<Square size={18} />
</button>
) : (
<button className="send-btn primary" title={steerMode ? 'Envoyer la consigne' : 'Envoyer (Entrée)'} onClick={() => void submit()} disabled={!draft.trim() && images.length === 0}>
{steerMode ? <Navigation size={18} /> : <Send size={18} />}
</button>
)}
</div>
{!compact && (
<div className="command-foot">
<button className={`toggle-btn${handsFree ? ' on' : ''}`} onClick={() => voiceController.setHandsFree(!handsFree)} title="Écoute continue : le micro se réactive après chaque réponse">
<Radio size={12} /> mains libres
</button>
<button className={`toggle-btn${autoSpeak && speechProvider !== 'off' ? ' on' : ''}`} onClick={() => update({ speech: { autoSpeak: !autoSpeak } })} title="Lire les réponses à voix haute">
{autoSpeak && speechProvider !== 'off' ? <Volume2 size={12} /> : <VolumeX size={12} />} voix
</button>
{ttsState !== 'idle' && (
<button className="toggle-btn on" onClick={() => speech.stop()} title="Couper la voix">
<Square size={10} /> {ttsState === 'loading' ? 'synthèse…' : 'parle'}
</button>
)}
{isSending && currentRunId && (
<button className={`toggle-btn${steerMode ? ' on' : ''}`} onClick={() => setSteerMode((v) => !v)} title="Injecter une consigne dans le run en cours">
<Navigation size={12} /> steer
</button>
)}
<span className="spacer" />
<span>{isSending ? 'Hermes travaille…' : listening ? (phase === 'speech' ? 'parole détectée' : 'écoute…') : 'Entrée ↵ envoyer · Shift+Entrée ligne'}</span>
</div>
)}
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { memo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Copy, Volume2, Check } from 'lucide-react';
import { bridge } from '../../lib/bridge';
import { formatTime, preprocessMedia } from '../../lib/text';
import { speech } from '../../services/voice/speech';
import type { ChatMessage } from '../../state/chat';
import { ResilientImage } from './ResilientImage';
interface Props {
message: ChatMessage;
assistantName: string;
userName: string;
showReasoning: boolean;
compact?: boolean;
onOpenImage?: (src: string) => void;
}
function openLink(href: string): void {
const api = bridge();
if (/^(file:\/\/|[a-zA-Z]:[\\/])/.test(href) && api) {
api.files.openPath(href).catch(() => undefined);
return;
}
window.open(href, '_blank', 'noopener');
}
export const MessageBubble = memo(function MessageBubble({ message, assistantName, userName, showReasoning, compact, onOpenImage }: Props) {
const [copied, setCopied] = useState(false);
const isUser = message.role === 'user';
const who = isUser ? userName || 'VOUS' : message.role === 'system' ? 'SYSTÈME' : assistantName;
const status = message.status ?? 'done';
const copy = () => {
navigator.clipboard.writeText(message.content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}).catch(() => undefined);
};
return (
<div className={`msg ${message.role}`}>
{!compact && (
<div className="msg-meta">
<span className="who">{who}</span>
<span>{formatTime(message.timestamp)}</span>
{message.source && message.source !== 'eveflow' && <span className={`src ${message.source}`}>{message.jobName ? `cron · ${message.jobName}` : message.source}</span>}
{message.transport && message.role === 'assistant' && <span className="src">{message.transport}</span>}
{message.usage?.total_tokens ? <span>{message.usage.total_tokens} tok</span> : null}
{message.role === 'assistant' && status === 'done' && (
<span className="msg-actions">
<button className="icon-btn" title="Copier" onClick={copy}>{copied ? <Check size={13} /> : <Copy size={13} />}</button>
<button className="icon-btn" title="Lire à voix haute" onClick={() => speech.say(message.content)}><Volume2 size={13} /></button>
</span>
)}
</div>
)}
<div className={`bubble ${status}`}>
{message.images && message.images.length > 0 && (
<div className="msg-images">
{message.images.map((img, i) => (
<ResilientImage key={i} src={img} alt={`image ${i + 1}`} onOpen={onOpenImage} />
))}
</div>
)}
{showReasoning && message.reasoning && (
<details className="reasoning">
<summary>raisonnement</summary>
{message.reasoning}
</details>
)}
{message.role === 'user' ? (
<span style={{ whiteSpace: 'pre-wrap' }}>{message.content}</span>
) : (
<div className="md">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: ({ src, alt }) => <ResilientImage src={typeof src === 'string' ? src : ''} alt={alt} onOpen={onOpenImage} />,
a: ({ href, children }) => (
<a
href={href}
onClick={(e) => {
e.preventDefault();
if (href) openLink(href);
}}
>
{children}
</a>
)
}}
>
{preprocessMedia(message.content) || (status === 'streaming' ? '' : '…')}
</ReactMarkdown>
</div>
)}
</div>
</div>
);
});
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { ShieldAlert, HelpCircle, KeyRound } from 'lucide-react';
import { resolvePending } from '../../services/conversation';
import { useChat, type PendingRequest } from '../../state/chat';
function RequestCard({ request }: { request: PendingRequest }) {
const [answer, setAnswer] = useState('');
const icon = request.kind === 'approval' ? <ShieldAlert size={16} /> : request.kind === 'clarify' ? <HelpCircle size={16} /> : <KeyRound size={16} />;
return (
<div className="modal">
<div className="request-card">
<div className="title">
{icon}
{request.title}
{request.tool && <span className="chip warn">{request.tool}</span>}
</div>
<div className="desc">{request.description}</div>
{request.args && <pre>{request.args}</pre>}
{request.kind === 'approval' ? (
<div className="actions">
<button className="btn danger" onClick={() => void resolvePending(request, 'deny')}>Refuser</button>
<button className="btn" onClick={() => void resolvePending(request, 'once')}>Autoriser une fois</button>
<button className="btn" onClick={() => void resolvePending(request, 'session')}>Pour la session</button>
<button className="btn primary" onClick={() => void resolvePending(request, 'always')}>Toujours</button>
</div>
) : (
<>
{request.options && request.options.length > 0 && (
<div className="actions" style={{ justifyContent: 'flex-start' }}>
{request.options.map((opt) => (
<button key={opt} className="btn small" onClick={() => void resolvePending(request, opt)}>{opt}</button>
))}
</div>
)}
<input
className="input"
type={request.secret ? 'password' : 'text'}
placeholder={request.secret ? 'Saisie confidentielle' : 'Votre réponse…'}
value={answer}
autoFocus
onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && answer.trim()) void resolvePending(request, answer.trim());
}}
/>
<div className="actions">
<button className="btn ghost" onClick={() => useChat.getState().removePending(request.id)}>Ignorer</button>
<button className="btn primary" disabled={!answer.trim()} onClick={() => void resolvePending(request, answer.trim())}>Envoyer</button>
</div>
</>
)}
</div>
</div>
);
}
export function PendingRequests() {
const pending = useChat((s) => s.pending);
if (pending.length === 0) return null;
return (
<div className="overlay">
<RequestCard key={pending[0].id} request={pending[0]} />
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useState } from 'react';
import { bridge } from '../../lib/bridge';
import { httpFetch } from '../../lib/transport';
import { useSettings } from '../../state/settings';
import { hermesBaseUrl } from '../../services/hermes/client';
const cache = new Map<string, string>();
const failures = new Map<string, string>();
interface Props {
src?: string;
alt?: string;
onOpen?: (src: string) => void;
}
/**
* Renders images coming from Hermes: data URLs, http(s), Windows/file paths (read through the
* main process) and server-side absolute paths (fetched from the Hermes host with auth).
*/
export function ResilientImage({ src = '', alt, onOpen }: Props) {
const [resolved, setResolved] = useState<string>(() => cache.get(src) ?? '');
const [error, setError] = useState<string>(() => failures.get(src) ?? '');
const hermes = useSettings((s) => s.settings.hermes);
useEffect(() => {
if (!src) return;
let cancelled = false;
const ok = (value: string) => {
cache.set(src, value);
if (!cancelled) {
setResolved(value);
setError('');
}
};
const fail = (message: string) => {
failures.set(src, message);
if (!cancelled) setError(message);
};
if (cache.has(src)) {
setResolved(cache.get(src)!);
return;
}
if (src.startsWith('data:') || src.startsWith('blob:') || /^https?:\/\//i.test(src)) {
ok(src);
return;
}
const api = bridge();
const isLocal = src.startsWith('file://') || /^[a-zA-Z]:[\\/]/.test(src);
const fromHermes = async () => {
if (!hermes.url) throw new Error('chemin local inaccessible');
const base = hermesBaseUrl(hermes.url);
const path = src.replace(/^file:\/\//, '').replace(/\\/g, '/');
const headers: Record<string, string> = {};
if (hermes.apiKey) headers.Authorization = `Bearer ${hermes.apiKey}`;
const res = await httpFetch({ url: `${base}/${path.replace(/^\//, '')}`, headers, responseType: 'binary', timeoutMs: 15_000 });
if (!res.ok || !res.binary) throw new Error(`HTTP ${res.status}`);
const type = res.headers['content-type'] || 'image/png';
const bytes = new Uint8Array(res.binary);
return URL.createObjectURL(new Blob([bytes], { type }));
};
const task = isLocal && api ? api.files.readLocal(src).catch(fromHermes) : fromHermes();
task.then(ok).catch((err: Error) => fail(err.message || 'image inaccessible'));
return () => {
cancelled = true;
};
}, [src, hermes.url, hermes.apiKey]);
if (error && !resolved) {
return (
<span className="img-error" title={src}>
Image inaccessible ({error})
</span>
);
}
if (!resolved) return <span className="img-error pulse">chargement…</span>;
return <img src={resolved} alt={alt ?? 'image'} loading="lazy" onClick={() => onOpen?.(resolved)} />;
}
+28
View File
@@ -0,0 +1,28 @@
import { Maximize2, X, Minus } from 'lucide-react';
import { bridge } from '../../lib/bridge';
import { useSettings } from '../../state/settings';
import { CoreStage } from '../hud/CoreStage';
import { ChatPanel } from '../chat/ChatPanel';
export function CompactWidget() {
const settings = useSettings((s) => s.settings);
const api = bridge();
return (
<div className="compact-root" style={{ ['--compact-opacity' as string]: settings.ui.compactOpacity }}>
<div className="compact-head">
<span className="title">{settings.assistantName}</span>
{api && (
<>
<button className="icon-btn" title="Mode HUD complet" onClick={() => api.window.setMode('hud')}><Maximize2 size={14} /></button>
<button className="icon-btn" title="Masquer (Ctrl+Shift+J)" onClick={() => api.window.control('hide')}><Minus size={14} /></button>
<button className="icon-btn close" title="Fermer" onClick={() => api.window.control('close')}><X size={14} /></button>
</>
)}
</div>
<CoreStage compact />
<div className="compact-body">
<ChatPanel compact />
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useChat } from '../../state/chat';
import { useHermes } from '../../state/hermes';
import { useSettings } from '../../state/settings';
import { useVoice } from '../../state/voice';
import { previewText } from '../../lib/text';
import { JarvisCore } from './JarvisCore';
const STATE_LABEL: Record<string, string> = {
idle: 'En veille',
listening: 'À l’écoute',
thinking: 'Analyse',
speaking: 'Transmission',
alert: 'Attention',
error: 'Anomalie',
success: 'Terminé'
};
interface Props {
compact?: boolean;
}
export function CoreStage({ compact }: Props) {
const hud = useChat((s) => s.hudOverride ?? s.hud);
const activity = useChat((s) => s.activity);
const isSending = useChat((s) => s.isSending);
const error = useChat((s) => s.error);
const phase = useVoice((s) => s.phase);
const level = useVoice((s) => s.inputLevel);
const transcript = useVoice((s) => s.lastTranscript);
const voiceError = useVoice((s) => s.error);
const settings = useSettings((s) => s.settings);
const transport = useHermes((s) => s.transport);
const link = useHermes((s) => s.link);
const running = activity.filter((a) => a.status === 'running').slice(-1)[0];
let sub = '';
if (hud === 'listening') sub = phase === 'speech' ? 'parole détectée…' : phase === 'transcribing' ? 'transcription…' : 'parlez maintenant';
else if (hud === 'thinking') sub = running ? `${running.name}${running.detail ? ` · ${previewText(running.detail, 60)}` : ''}` : isSending ? `Hermes · ${transport}` : '';
else if (hud === 'error') sub = previewText(error ?? voiceError ?? 'erreur', 90);
else if (hud === 'speaking') sub = 'synthèse vocale';
else if (transcript) sub = `« ${previewText(transcript, 80)} »`;
else sub = link === 'online' ? `liaison Hermes · ${transport}` : link === 'offline' ? 'Hermes hors ligne' : '';
return (
<div className={compact ? 'compact-core' : 'core-stage'}>
{!compact && (
<div className="core-name">
{settings.assistantName}
<small>EVEFLOW · HERMES INTERFACE</small>
</div>
)}
<div className={compact ? undefined : 'core-canvas-wrap'} style={compact ? { position: 'absolute', inset: '0 0 36px 0' } : undefined}>
<JarvisCore state={hud} inputLevel={level} reduceMotion={settings.ui.reduceMotion} />
</div>
<div className="core-caption">
<div className={`core-state ${hud}`}>{STATE_LABEL[hud] ?? hud}</div>
{sub && <div className="core-sub">{sub}</div>}
</div>
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
/**
* JARVIS-style arc reactor core rendered on a 2D canvas.
* Rotating tick rings, segmented arcs and an audio-reactive radial spectrum that reads the
* real TTS output (or the microphone while listening).
*/
import { useEffect, useRef } from 'react';
import { audioBus } from '../../services/voice/audioBus';
import type { HudState } from '../../state/chat';
interface Props {
state: HudState;
/** 0..1 override for the microphone level (VAD), used while listening. */
inputLevel?: number;
reduceMotion?: boolean;
className?: string;
}
interface Palette {
main: [number, number, number];
glow: [number, number, number];
}
const PALETTES: Record<HudState, Palette> = {
idle: { main: [52, 228, 255], glow: [52, 228, 255] },
listening: { main: [90, 240, 255], glow: [52, 228, 255] },
thinking: { main: [255, 195, 90], glow: [255, 170, 60] },
speaking: { main: [140, 245, 255], glow: [52, 228, 255] },
alert: { main: [255, 171, 64], glow: [255, 120, 40] },
error: { main: [255, 92, 108], glow: [255, 60, 80] },
success: { main: [78, 240, 168], glow: [60, 220, 150] }
};
const BARS = 72;
function readThemeAccent(): [number, number, number] | null {
const raw = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim();
if (!raw) return null;
const parts = raw.split(',').map((p) => Number(p.trim()));
return parts.length === 3 && parts.every((n) => Number.isFinite(n)) ? (parts as [number, number, number]) : null;
}
export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, className }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const stateRef = useRef(state);
const inputRef = useRef(inputLevel);
const motionRef = useRef(reduceMotion);
stateRef.current = state;
inputRef.current = inputLevel;
motionRef.current = reduceMotion;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let raf = 0;
let width = 0;
let height = 0;
let dpr = 1;
const bars = new Float32Array(BARS);
const color = { main: [52, 228, 255] as [number, number, number], glow: [52, 228, 255] as [number, number, number] };
let energy = 0;
let spin = 0;
let spin2 = 0;
let spin3 = 0;
let last = performance.now();
let visible = true;
const resize = () => {
const rect = canvas.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
width = Math.max(1, Math.floor(rect.width));
height = Math.max(1, Math.floor(rect.height));
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
};
const observer = new ResizeObserver(resize);
observer.observe(canvas);
resize();
const onVisibility = () => {
visible = document.visibilityState === 'visible';
};
document.addEventListener('visibilitychange', onVisibility);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const rgba = (c: [number, number, number], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const draw = (now: number) => {
raf = requestAnimationFrame(draw);
if (!visible) return;
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const st = stateRef.current;
const motion = motionRef.current ? 0.25 : 1;
// ── target colours & energy
const target = PALETTES[st];
const themeAccent = st === 'idle' || st === 'listening' || st === 'speaking' ? readThemeAccent() : null;
const mainTarget = themeAccent ?? target.main;
for (let i = 0; i < 3; i++) {
color.main[i] = lerp(color.main[i], mainTarget[i], 0.08);
color.glow[i] = lerp(color.glow[i], (themeAccent ?? target.glow)[i], 0.08);
}
const speaking = st === 'speaking';
const listening = st === 'listening';
const spectrum = speaking ? audioBus.spectrum(BARS, 'output') : listening ? audioBus.spectrum(BARS, 'input') : null;
const level = speaking ? audioBus.outputLevel() : listening ? Math.max(inputRef.current, audioBus.inputLevel()) : 0;
const targetEnergy = st === 'thinking' ? 0.45 : st === 'alert' || st === 'error' ? 0.6 : level;
energy = lerp(energy, targetEnergy, speaking || listening ? 0.35 : 0.08);
for (let i = 0; i < BARS; i++) {
let t = 0;
if (spectrum) {
const v = spectrum[i];
t = Math.pow(v, 1.4);
if (listening) t = Math.max(t, inputRef.current * (0.5 + 0.5 * Math.sin(now / 90 + i)));
} else if (st === 'thinking') {
t = 0.15 + 0.12 * Math.sin(now / 260 + i * 0.35);
} else if (st === 'idle') {
t = 0.05 + 0.04 * Math.sin(now / 900 + i * 0.5);
} else {
t = 0.12 + 0.08 * Math.sin(now / 140 + i * 0.7);
}
bars[i] = lerp(bars[i], t, t > bars[i] ? 0.5 : 0.12);
}
// ── rotation
const speed = (st === 'thinking' ? 1.6 : st === 'alert' || st === 'error' ? 1.2 : speaking ? 0.7 : 0.35) * motion;
spin += dt * 0.35 * speed;
spin2 -= dt * 0.55 * speed;
spin3 += dt * 0.9 * speed;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2;
const R = Math.min(width, height) * 0.46;
const main = color.main;
const glow = color.glow;
// ── ambient glow
const halo = ctx.createRadialGradient(cx, cy, R * 0.1, cx, cy, R * 1.05);
halo.addColorStop(0, rgba(glow, 0.18 + energy * 0.25));
halo.addColorStop(0.5, rgba(glow, 0.05));
halo.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = halo;
ctx.fillRect(0, 0, width, height);
// ── outer tick ring
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(spin);
ctx.strokeStyle = rgba(main, 0.55);
ctx.lineWidth = 1;
for (let i = 0; i < 120; i++) {
const a = (i / 120) * Math.PI * 2;
const long = i % 10 === 0;
const mid = i % 5 === 0;
const r0 = R * (long ? 0.93 : mid ? 0.955 : 0.97);
ctx.globalAlpha = long ? 0.9 : mid ? 0.6 : 0.3;
ctx.beginPath();
ctx.moveTo(Math.cos(a) * r0, Math.sin(a) * r0);
ctx.lineTo(Math.cos(a) * R, Math.sin(a) * R);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(0, 0, R, 0, Math.PI * 2);
ctx.strokeStyle = rgba(main, 0.35);
ctx.stroke();
ctx.restore();
// ── segmented arc ring
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(spin2);
ctx.lineCap = 'butt';
const segments = 6;
for (let i = 0; i < segments; i++) {
const start = (i / segments) * Math.PI * 2;
const span = (Math.PI * 2) / segments - 0.22;
ctx.beginPath();
ctx.arc(0, 0, R * 0.86, start, start + span);
ctx.lineWidth = 6;
ctx.strokeStyle = rgba(main, 0.16 + (i % 2 === 0 ? 0.18 : 0.05) + energy * 0.2);
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, R * 0.82, start + 0.08, start + span * 0.45);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(main, 0.75);
ctx.stroke();
}
ctx.restore();
// ── dashed ring
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(spin3);
ctx.setLineDash([2, 6]);
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(main, 0.45);
ctx.beginPath();
ctx.arc(0, 0, R * 0.7, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// ── audio spectrum bars
ctx.save();
ctx.translate(cx, cy);
ctx.lineCap = 'round';
const inner = R * 0.5;
const maxLen = R * 0.16;
for (let i = 0; i < BARS; i++) {
const a = (i / BARS) * Math.PI * 2 - Math.PI / 2;
const len = 2 + bars[i] * maxLen;
const x0 = Math.cos(a) * inner;
const y0 = Math.sin(a) * inner;
const x1 = Math.cos(a) * (inner + len);
const y1 = Math.sin(a) * (inner + len);
ctx.lineWidth = 2.4;
ctx.strokeStyle = rgba(main, 0.35 + bars[i] * 0.65);
ctx.shadowBlur = 8 * bars[i];
ctx.shadowColor = rgba(glow, 0.8);
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}
ctx.shadowBlur = 0;
ctx.restore();
// ── reactor core
const coreR = R * 0.36;
const breath = 1 + Math.sin(now / 700) * 0.02 + energy * 0.08;
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR * breath);
core.addColorStop(0, `rgba(255,255,255,${0.75 + energy * 0.25})`);
core.addColorStop(0.25, rgba(main, 0.55 + energy * 0.3));
core.addColorStop(0.7, rgba(glow, 0.12));
core.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = core;
ctx.beginPath();
ctx.arc(cx, cy, coreR * breath, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.translate(cx, cy);
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.arc(0, 0, coreR * (0.42 + i * 0.16) * breath, 0, Math.PI * 2);
ctx.lineWidth = i === 0 ? 2 : 1;
ctx.strokeStyle = rgba(main, 0.7 - i * 0.2);
ctx.stroke();
}
// reactor triangle
ctx.rotate(-spin2 * 0.5);
ctx.beginPath();
for (let i = 0; i < 3; i++) {
const a = (i / 3) * Math.PI * 2 - Math.PI / 2;
const r = coreR * 0.5;
if (i === 0) ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r);
else ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
}
ctx.closePath();
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(main, 0.55);
ctx.stroke();
ctx.restore();
// ── thinking orbiters
if (st === 'thinking' || st === 'alert') {
ctx.save();
ctx.translate(cx, cy);
for (let i = 0; i < 3; i++) {
const a = spin3 * 2 + (i / 3) * Math.PI * 2;
const r = R * 0.78;
ctx.beginPath();
ctx.arc(Math.cos(a) * r, Math.sin(a) * r, 3.5, 0, Math.PI * 2);
ctx.fillStyle = rgba(main, 0.95);
ctx.shadowBlur = 14;
ctx.shadowColor = rgba(glow, 1);
ctx.fill();
}
ctx.restore();
}
// ── listening ring (input level)
if (listening) {
ctx.save();
ctx.translate(cx, cy);
ctx.beginPath();
ctx.arc(0, 0, R * (0.6 + level * 0.12), 0, Math.PI * 2);
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(main, 0.25 + level * 0.6);
ctx.stroke();
ctx.restore();
}
};
raf = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
return <canvas ref={canvasRef} className={className} aria-hidden="true" />;
}
+71
View File
@@ -0,0 +1,71 @@
import { Minus, X, Settings, PictureInPicture2, RefreshCw, Webhook } from 'lucide-react';
import { bridge } from '../../lib/bridge';
import { useClock } from '../../hooks/useMetrics';
import { useHermes } from '../../state/hermes';
import { useSettings } from '../../state/settings';
interface Props {
onOpenSettings: () => void;
}
export function TopBar({ onOpenSettings }: Props) {
const now = useClock();
const link = useHermes((s) => s.link);
const linkDetail = useHermes((s) => s.linkDetail);
const transport = useHermes((s) => s.transport);
const capabilities = useHermes((s) => s.capabilities);
const webhook = useHermes((s) => s.webhook);
const connect = useHermes((s) => s.connect);
const hermesUrl = useSettings((s) => s.settings.hermes.url);
const model = useSettings((s) => s.settings.hermes.model);
const api = bridge();
const host = (() => {
try {
return new URL(hermesUrl).host;
} catch {
return hermesUrl || '—';
}
})();
const linkLabel = link === 'online' ? 'HERMES LINK' : link === 'checking' ? 'CONNEXION' : link === 'degraded' ? 'DÉGRADÉ' : link === 'offline' ? 'HORS LIGNE' : 'HERMES';
return (
<header className="topbar">
<div className="brand">
<span className="core-dot" />
EVEFLOW
<small>v2</small>
</div>
<div className="status-strip">
<span className={`chip ${link}`} title={linkDetail || host}>
<span className="dot" /> {linkLabel} · {host}
</span>
{link === 'online' && (
<span className="chip accent" title="Transport de conversation">
{transport}
</span>
)}
{(model || capabilities?.model) && <span className="chip" title="Modèle">{model || String(capabilities?.model)}</span>}
{webhook && (
<span className={`chip ${webhook.listening ? 'online' : 'offline'}`} title={webhook.error ?? `POST ${webhook.path}`}>
<Webhook size={11} /> :{webhook.port}
</span>
)}
<button className="icon-btn" title="Reconnecter Hermes" onClick={() => void connect()}>
<RefreshCw size={14} className={link === 'checking' ? 'spin' : undefined} />
</button>
</div>
<span className="clock">{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
<div className="controls">
<button className="icon-btn" title="Paramètres" onClick={onOpenSettings}><Settings size={16} /></button>
{api && (
<>
<button className="icon-btn" title="Mode compact flottant" onClick={() => api.window.setMode('compact')}><PictureInPicture2 size={16} /></button>
<button className="icon-btn" title="Réduire" onClick={() => api.window.control('minimize')}><Minus size={16} /></button>
<button className="icon-btn close" title="Fermer" onClick={() => api.window.control('close')}><X size={16} /></button>
</>
)}
</div>
</header>
);
}
+376
View File
@@ -0,0 +1,376 @@
import { useEffect, useState } from 'react';
import {
Wrench, CalendarClock, Sparkles, Layers, History, Play, Pause, Trash2, Pencil, Plus, Save, X, RefreshCw,
CheckCircle2, XCircle, Loader2, Bot, Cpu, GitBranch, Webhook, FolderOpen
} from 'lucide-react';
import { formatDateTime, formatTime, previewText } from '../../lib/text';
import { bridge } from '../../lib/bridge';
import { jobSchedule, jobOutput } from '../../services/hermes/client';
import type { HermesJob, HermesJobDraft } from '../../services/hermes/types';
import { useChat } from '../../state/chat';
import { jobStatus, useHermes } from '../../state/hermes';
import { useSettings } from '../../state/settings';
type Tab = 'activity' | 'jobs' | 'skills' | 'sessions' | 'link';
const emptyDraft = (): HermesJobDraft => ({ name: '', schedule: 'every 1h', prompt: '', deliver: 'local' });
function ActivityTab() {
const activity = useChat((s) => s.activity);
const items = [...activity].reverse();
if (items.length === 0) return <div className="empty">Aucune activité outil pour l’instant.</div>;
return (
<div>
{items.map((a) => (
<div key={a.id} className={`activity-item ${a.status}`}>
<span className="icon">
{a.status === 'running' ? <Loader2 size={13} className="spin" /> : a.status === 'done' ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
</span>
<div>
<div className="name">{a.kind === 'subagent' ? <><Bot size={11} /> </> : null}{a.name}</div>
{a.detail && <div className="detail">{previewText(a.detail, 220)}</div>}
{a.output && <div className="detail muted">{previewText(a.output, 220)}</div>}
</div>
<span className="time">{formatTime(a.startedAt)}{a.endedAt ? ` · ${((a.endedAt - a.startedAt) / 1000).toFixed(1)}s` : ''}</span>
</div>
))}
</div>
);
}
function JobsTab() {
const jobs = useHermes((s) => s.jobs);
const busy = useHermes((s) => s.busy);
const lastSyncAt = useHermes((s) => s.lastSyncAt);
const refreshJobs = useHermes((s) => s.refreshJobs);
const createJob = useHermes((s) => s.createJob);
const updateJob = useHermes((s) => s.updateJob);
const jobAction = useHermes((s) => s.jobAction);
const setError = useChat((s) => s.setError);
const [draft, setDraft] = useState<HermesJobDraft>(emptyDraft);
const [editing, setEditing] = useState<string | null>(null);
const [showEditor, setShowEditor] = useState(false);
const run = async (task: Promise<void>) => {
try {
await task;
} catch (err) {
setError(`Cron Hermes : ${(err as Error).message}`);
}
};
const startEdit = (job: HermesJob) => {
setEditing(job.id);
setDraft({ name: job.name ?? job.id, schedule: jobSchedule(job), prompt: job.prompt ?? '', deliver: job.deliver ?? 'local' });
setShowEditor(true);
};
const save = async () => {
if (editing) await run(updateJob(editing, draft));
else await run(createJob(draft));
setEditing(null);
setDraft(emptyDraft());
setShowEditor(false);
};
const valid = draft.name.trim() && draft.schedule.trim() && draft.prompt.trim();
return (
<div>
<div className="row" style={{ marginBottom: 10, justifyContent: 'space-between' }}>
<span className="muted mono" style={{ fontSize: 11 }}>{lastSyncAt ? `sync ${formatTime(lastSyncAt)}` : 'non synchronisé'}</span>
<span className="row">
<button className="icon-btn" title="Synchroniser" onClick={() => void run(refreshJobs())}><RefreshCw size={14} className={busy ? 'spin' : undefined} /></button>
<button className="btn small" onClick={() => { setEditing(null); setDraft(emptyDraft()); setShowEditor((v) => !v); }}><Plus size={13} /> Cron</button>
</span>
</div>
{showEditor && (
<div className="job-editor">
<input className="input" placeholder="Nom de la mission" value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
<input className="input" placeholder="every 1h · 0 9 * * MON-FRI · in 30m · every monday 9am" value={draft.schedule} onChange={(e) => setDraft({ ...draft, schedule: e.target.value })} />
<textarea className="textarea" placeholder="Instruction à exécuter par Hermes" value={draft.prompt} onChange={(e) => setDraft({ ...draft, prompt: e.target.value })} />
<input className="input" placeholder="deliver : local · origin · telegram · all" value={draft.deliver ?? ''} onChange={(e) => setDraft({ ...draft, deliver: e.target.value })} />
<div className="row">
<button className="btn ghost small" onClick={() => { setShowEditor(false); setEditing(null); }}><X size={13} /> Annuler</button>
<button className="btn primary small" disabled={!valid || busy} onClick={() => void save()}><Save size={13} /> {editing ? 'Mettre à jour' : 'Créer'}</button>
</div>
</div>
)}
{jobs.length === 0 ? (
<div className="empty">Aucun cron Hermes. Créez une mission planifiée en langage naturel.</div>
) : (
<div className="list">
{jobs.map((job) => {
const status = jobStatus(job).toLowerCase();
const paused = status === 'paused' || job.enabled === false;
const output = jobOutput(job);
return (
<div key={job.id} className="row-item">
<div className="main">
<div className="row wrap" style={{ gap: 6 }}>
<span className="name">{job.name || job.id}</span>
<span className={`status-pill ${status}`}>{status}</span>
{job.last_status && <span className={`status-pill ${String(job.last_status).toLowerCase()}`}>{String(job.last_status)}</span>}
</div>
<span className="meta">{jobSchedule(job)} · prochain {formatDateTime(job.next_run_at)}</span>
{job.prompt && <span className="desc">{previewText(job.prompt, 140)}</span>}
{output && <span className="meta">↳ {previewText(output, 120)}</span>}
</div>
<div className="actions">
<button className="icon-btn" title="Exécuter maintenant" onClick={() => void run(jobAction(job.id, 'run'))}><Play size={13} /></button>
<button className="icon-btn" title={paused ? 'Reprendre' : 'Mettre en pause'} onClick={() => void run(jobAction(job.id, paused ? 'resume' : 'pause'))}>{paused ? <Play size={13} /> : <Pause size={13} />}</button>
<button className="icon-btn" title="Modifier" onClick={() => startEdit(job)}><Pencil size={13} /></button>
<button className="icon-btn danger" title="Supprimer" onClick={() => { if (confirm(`Supprimer le cron « ${job.name || job.id} » ?`)) void run(jobAction(job.id, 'delete')); }}><Trash2 size={13} /></button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function HistoryList() {
const runs = useHermes((s) => s.jobRuns);
const markRead = useHermes((s) => s.markRunRead);
const addMessage = useChat((s) => s.addMessage);
if (runs.length === 0) return null;
return (
<>
<div className="section-title"><History size={12} /> Résultats récents</div>
<div className="list">
{runs.slice(0, 20).map((r) => (
<div
key={r.id}
className="row-item clickable"
style={{ opacity: r.read ? 0.7 : 1 }}
onClick={() => {
markRead(r.id);
addMessage({ role: 'assistant', content: r.output, source: 'cron', jobName: r.jobName, status: 'done', timestamp: new Date(r.at).getTime() || Date.now() });
}}
>
<div className="main">
<div className="row wrap" style={{ gap: 6 }}>
<span className="name">{r.jobName}</span>
<span className={`status-pill ${r.status}`}>{r.status}</span>
</div>
<span className="desc">{previewText(r.output, 160)}</span>
<span className="meta">{formatDateTime(r.at)}</span>
</div>
</div>
))}
</div>
</>
);
}
function SkillsTab() {
const skills = useHermes((s) => s.skills);
const toolsets = useHermes((s) => s.toolsets);
const refresh = useHermes((s) => s.refreshCatalog);
const [filter, setFilter] = useState('');
const q = filter.toLowerCase();
const visibleSkills = skills.filter((s) => !q || `${s.name} ${s.description ?? ''} ${s.category ?? ''}`.toLowerCase().includes(q));
const visibleToolsets = toolsets.filter((t) => !q || `${t.name} ${t.label ?? ''} ${t.description ?? ''} ${(t.tools ?? []).join(' ')}`.toLowerCase().includes(q));
return (
<div>
<div className="row" style={{ marginBottom: 10 }}>
<input className="input" placeholder="Filtrer les skills et toolsets…" value={filter} onChange={(e) => setFilter(e.target.value)} />
<button className="icon-btn" title="Recharger" onClick={() => void refresh()}><RefreshCw size={14} /></button>
</div>
<div className="section-title"><Sparkles size={12} /> Skills ({skills.length})</div>
{visibleSkills.length === 0 ? <div className="empty">Aucun skill exposé par ce serveur.</div> : (
<div className="list">
{visibleSkills.map((s) => (
<div key={s.name} className="row-item">
<div className="main">
<div className="row wrap" style={{ gap: 6 }}>
<span className="name">{s.name}</span>
{s.category && <span className="status-pill">{s.category}</span>}
</div>
{s.description && <span className="desc">{s.description}</span>}
</div>
</div>
))}
</div>
)}
<div className="section-title"><Layers size={12} /> Toolsets ({toolsets.length})</div>
{visibleToolsets.length === 0 ? <div className="empty">Aucun toolset listé.</div> : (
<div className="list">
{visibleToolsets.map((t) => (
<div key={t.name} className="row-item">
<div className="main">
<div className="row wrap" style={{ gap: 6 }}>
<span className="name">{t.label || t.name}</span>
{t.enabled !== undefined && <span className={`status-pill ${t.enabled ? 'active' : 'paused'}`}>{t.enabled ? 'actif' : 'inactif'}</span>}
{t.configured === false && <span className="status-pill error">non configuré</span>}
</div>
{t.description && <span className="desc">{t.description}</span>}
{t.tools && t.tools.length > 0 && <span className="meta">{t.tools.slice(0, 12).join(' · ')}{t.tools.length > 12 ? ` · +${t.tools.length - 12}` : ''}</span>}
</div>
</div>
))}
</div>
)}
</div>
);
}
function SessionsTab() {
const sessions = useHermes((s) => s.sessions);
const refresh = useHermes((s) => s.refreshSessions);
const client = useHermes((s) => s.client);
const transport = useHermes((s) => s.transport);
const sessionId = useSettings((s) => s.settings.hermesSessionId);
const setSessionId = useSettings((s) => s.setHermesSessionId);
const chat = useChat();
const activeId = sessionId.replace(/^hs:/, '');
const load = async (id: string) => {
try {
const messages = await client().sessionMessages(id);
chat.clear();
for (const m of messages.slice(-60)) {
if (!m.content) continue;
chat.addMessage({ role: m.role === 'user' ? 'user' : 'assistant', content: String(m.content), status: 'done', source: 'hermes', timestamp: m.timestamp ? new Date(m.timestamp).getTime() : Date.now() });
}
setSessionId(transport === 'sessions' ? `hs:${id}` : id);
} catch (err) {
chat.setError(`Session : ${(err as Error).message}`);
}
};
const fresh = () => {
chat.clear();
setSessionId(`eveflow-${Date.now().toString(36)}`);
};
return (
<div>
<div className="row" style={{ marginBottom: 10, justifyContent: 'space-between' }}>
<span className="muted mono" style={{ fontSize: 11 }}>session {previewText(activeId, 28)}</span>
<span className="row">
<button className="icon-btn" title="Recharger" onClick={() => void refresh()}><RefreshCw size={14} /></button>
<button className="btn small" onClick={fresh}><Plus size={13} /> Nouvelle</button>
</span>
</div>
{sessions.length === 0 ? <div className="empty">Aucune session listée par Hermes.</div> : (
<div className="list">
{sessions.map((s) => (
<div key={s.id} className={`row-item clickable`} style={s.id === activeId ? { borderColor: 'var(--accent)' } : undefined} onClick={() => void load(s.id)}>
<div className="main">
<span className="name">{s.title || s.id}</span>
<span className="meta">{[s.platform ?? s.source, s.message_count ? `${s.message_count} msg` : null, formatDateTime(s.updated_at ?? s.created_at)].filter(Boolean).join(' · ')}</span>
</div>
<div className="actions">
<button className="icon-btn" title="Bifurquer (fork)" onClick={(e) => { e.stopPropagation(); client().forkSession(s.id).then(() => refresh()).catch((err: Error) => chat.setError(err.message)); }}><GitBranch size={13} /></button>
<button className="icon-btn danger" title="Supprimer" onClick={(e) => { e.stopPropagation(); if (confirm('Supprimer cette session Hermes ?')) client().deleteSession(s.id).then(() => refresh()).catch((err: Error) => chat.setError(err.message)); }}><Trash2 size={13} /></button>
</div>
</div>
))}
</div>
)}
</div>
);
}
function LinkTab() {
const { link, linkDetail, health, capabilities, transport, models, webhook } = useHermes();
const connect = useHermes((s) => s.connect);
const hermes = useSettings((s) => s.settings.hermes);
const [info, setInfo] = useState<{ sharedDir: string; logPath: string; version: string } | null>(null);
useEffect(() => {
bridge()?.system.appInfo().then(setInfo).catch(() => undefined);
}, []);
const checks = (health?.readiness as { checks?: Record<string, unknown> } | undefined)?.checks ?? {};
const features = capabilities?.features ?? {};
return (
<div>
<div className="section-title"><Cpu size={12} /> Liaison Hermes</div>
<dl className="kv">
<dt>État</dt><dd><span className={`status-pill ${link}`}>{link}</span> {linkDetail}</dd>
<dt>URL</dt><dd>{hermes.url || '—'}</dd>
<dt>Transport</dt><dd>{transport}{hermes.transport === 'auto' ? ' (auto)' : ''}</dd>
<dt>Modèle</dt><dd>{hermes.model || String(capabilities?.model ?? '—')}</dd>
{capabilities?.version ? <><dt>Version</dt><dd>{String(capabilities.version)}</dd></> : null}
<dt>Modèles</dt><dd>{models.length ? models.map((m) => m.id).slice(0, 6).join(', ') + (models.length > 6 ? '…' : '') : '—'}</dd>
</dl>
<div className="row" style={{ marginTop: 8 }}>
<button className="btn small" onClick={() => void connect()}><RefreshCw size={13} /> Reconnecter</button>
</div>
{Object.keys(features).length > 0 && (
<>
<div className="section-title"><Layers size={12} /> Capacités</div>
<div className="row wrap" style={{ gap: 6 }}>
{Object.entries(features).map(([k, v]) => (
<span key={k} className={`status-pill ${v ? 'ok' : ''}`}>{k}{typeof v === 'string' ? `: ${v}` : ''}</span>
))}
</div>
</>
)}
{Object.keys(checks).length > 0 && (
<>
<div className="section-title"><CheckCircle2 size={12} /> Santé</div>
<dl className="kv">
{Object.entries(checks).map(([k, v]) => (
<><dt key={`${k}-t`}>{k}</dt><dd key={`${k}-d`}>{typeof v === 'object' ? JSON.stringify(v) : String(v)}</dd></>
))}
</dl>
</>
)}
<div className="section-title"><Webhook size={12} /> Webhook entrant</div>
<dl className="kv">
<dt>État</dt><dd><span className={`status-pill ${webhook?.listening ? 'online' : 'offline'}`}>{webhook?.listening ? 'à l’écoute' : 'inactif'}</span> {webhook?.error}</dd>
<dt>Endpoint</dt><dd>POST http://&lt;cette-machine&gt;:{webhook?.port ?? 7842}{webhook?.path ?? '/eveflow/hook'}</dd>
<dt>Secret</dt><dd>{webhook?.secretConfigured ? 'requis (X-EveFlow-Secret)' : 'aucun'}</dd>
</dl>
{info && (
<>
<div className="section-title"><FolderOpen size={12} /> Application</div>
<dl className="kv">
<dt>Version</dt><dd>{info.version}</dd>
<dt>Partage</dt><dd><a href="#" onClick={(e) => { e.preventDefault(); bridge()?.files.openPath(info.sharedDir); }}>{info.sharedDir}</a></dd>
<dt>Journal</dt><dd><a href="#" onClick={(e) => { e.preventDefault(); bridge()?.files.showInFolder(info.logPath); }}>{info.logPath}</a></dd>
</dl>
</>
)}
</div>
);
}
export function OpsPanel() {
const [tab, setTab] = useState<Tab>('activity');
const running = useChat((s) => s.activity.filter((a) => a.status === 'running').length);
const unread = useHermes((s) => s.jobRuns.filter((r) => !r.read).length);
const link = useHermes((s) => s.link);
const tabs: Array<{ id: Tab; label: string; icon: React.ReactNode; badge?: number }> = [
{ id: 'activity', label: 'Outils', icon: <Wrench size={13} />, badge: running },
{ id: 'jobs', label: 'Crons', icon: <CalendarClock size={13} />, badge: unread },
{ id: 'skills', label: 'Skills', icon: <Sparkles size={13} /> },
{ id: 'sessions', label: 'Sessions', icon: <History size={13} /> },
{ id: 'link', label: 'Liaison', icon: <Cpu size={13} /> }
];
return (
<section className="panel bracket ops-panel">
<header className="panel-head">
<span className={`led ${link === 'online' ? '' : link === 'offline' ? 'error' : link === 'unknown' ? 'off' : 'warn'}`} />
<span>Hermes Ops</span>
</header>
<div className="tabs">
{tabs.map((t) => (
<button key={t.id} className={`tab${tab === t.id ? ' active' : ''}`} onClick={() => setTab(t.id)}>
{t.icon} {t.label}
{t.badge ? <span className="badge">{t.badge}</span> : null}
</button>
))}
</div>
<div className="panel-body">
{tab === 'activity' && <ActivityTab />}
{tab === 'jobs' && (<><JobsTab /><HistoryList /></>)}
{tab === 'skills' && <SkillsTab />}
{tab === 'sessions' && <SessionsTab />}
{tab === 'link' && <LinkTab />}
</div>
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Activity } from 'lucide-react';
import { useMetrics } from '../../hooks/useMetrics';
import { formatDuration } from '../../lib/text';
function Gauge({ value, max, label, display, warn }: { value: number; max: number; label: string; display: string; warn?: number }) {
const ratio = Math.max(0, Math.min(1, value / max));
const r = 24;
const c = 2 * Math.PI * r;
const arc = c * 0.75;
const color = warn !== undefined && value >= warn ? 'var(--danger)' : 'var(--accent)';
return (
<div className="gauge">
<svg viewBox="0 0 62 62">
<circle cx="31" cy="31" r={r} fill="none" stroke="rgba(var(--accent-rgb),0.15)" strokeWidth="4" strokeDasharray={`${arc} ${c}`} transform="rotate(135 31 31)" strokeLinecap="round" />
<circle cx="31" cy="31" r={r} fill="none" stroke={color} strokeWidth="4" strokeDasharray={`${arc * ratio} ${c}`} transform="rotate(135 31 31)" strokeLinecap="round" style={{ transition: 'stroke-dasharray 600ms ease', filter: `drop-shadow(0 0 4px ${color})` }} />
<text x="31" y="35" textAnchor="middle" fontSize="11" fontFamily="var(--font-mono)" fill="var(--ink-0)">{display}</text>
</svg>
<span className="label">{label}</span>
</div>
);
}
export function Telemetry() {
const m = useMetrics(true);
return (
<section className="panel bracket">
<header className="panel-head">
<Activity size={14} />
<span>Système</span>
<span className="spacer" />
<span className="chip">{m.hostname}</span>
</header>
<div className="telemetry">
<Gauge value={m.cpuLoad} max={100} label="CPU" display={`${Math.round(m.cpuLoad)}%`} warn={90} />
<Gauge value={m.memUsedPct} max={100} label="RAM" display={`${Math.round(m.memUsedPct)}%`} warn={92} />
<Gauge value={m.fps} max={60} label="FPS" display={`${m.fps}`} />
<Gauge value={m.cpuFreqMhz} max={5500} label="GHz" display={m.cpuFreqMhz ? (m.cpuFreqMhz / 1000).toFixed(1) : '—'} />
</div>
<div className="telemetry-foot">
<span>{m.cpuCores} cœurs{m.cpuModel ? ` · ${m.cpuModel.replace(/\(R\)|\(TM\)|CPU|@.*$/g, '').trim()}` : ''}</span>
<span>{m.memTotalGb ? `${m.memTotalGb} Go` : ''}{m.uptimeSec ? ` · up ${formatDuration(m.uptimeSec)}` : ''}</span>
</div>
</section>
);
}
+405
View File
@@ -0,0 +1,405 @@
import { useEffect, useState } from 'react';
import { X, Settings, CheckCircle2, XCircle, Loader2, Mic, Volume2, RotateCcw, Play } from 'lucide-react';
import { bridge } from '../../lib/bridge';
import { HermesClient } from '../../services/hermes/client';
import { listSystemVoices } from '../../services/voice/tts';
import { speech } from '../../services/voice/speech';
import { listMicrophones } from '../../services/voice/capture';
import { transcribeWav } from '../../services/voice/stt';
import { encodeWav } from '../../services/voice/wav';
import { useHermes } from '../../state/hermes';
import { DEFAULT_SETTINGS, useSettings, type HudTheme } from '../../state/settings';
import { useVoice } from '../../state/voice';
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'webhook' | 'ui';
interface Props {
onClose: () => void;
}
function Toggle({ on, onChange, label, hint }: { on: boolean; onChange: (v: boolean) => void; label: string; hint?: string }) {
return (
<div className="switch" onClick={() => onChange(!on)}>
<div className="text">
<span>{label}</span>
{hint && <small>{hint}</small>}
</div>
<span className={`toggle${on ? ' on' : ''}`} />
</div>
);
}
type TestState = { status: 'idle' | 'running' | 'ok' | 'fail'; message: string };
export function SettingsDrawer({ onClose }: Props) {
const settings = useSettings((s) => s.settings);
const update = useSettings((s) => s.update);
const reset = useSettings((s) => s.reset);
const hermesStore = useHermes();
const micDevices = useVoice((s) => s.micDevices);
const [section, setSection] = useState<Section>('hermes');
const [hermesTest, setHermesTest] = useState<TestState>({ status: 'idle', message: '' });
const [sttTest, setSttTest] = useState<TestState>({ status: 'idle', message: '' });
const [ttsTest, setTtsTest] = useState<TestState>({ status: 'idle', message: '' });
const [voices, setVoices] = useState(listSystemVoices());
const [webhookSecretVisible, setWebhookSecretVisible] = useState(false);
useEffect(() => {
const refresh = () => setVoices(listSystemVoices());
refresh();
speechSynthesis.addEventListener?.('voiceschanged', refresh);
void listMicrophones().then((d) => useVoice.getState().setMicDevices(d));
return () => speechSynthesis.removeEventListener?.('voiceschanged', refresh);
}, []);
const testHermes = async () => {
setHermesTest({ status: 'running', message: 'connexion…' });
try {
const client = new HermesClient(settings.hermes);
const health = await client.health();
let caps = '';
try {
const c = await client.capabilities();
caps = ` · ${String(c.model ?? '')} · ${Object.entries(c.features ?? {}).filter(([, v]) => v === true).length} fonctions`;
} catch {
caps = ' · capabilities indisponibles';
}
setHermesTest({ status: 'ok', message: `${health.status}${caps}` });
void hermesStore.connect();
} catch (err) {
setHermesTest({ status: 'fail', message: (err as Error).message });
}
};
const testStt = async () => {
setSttTest({ status: 'running', message: 'envoi d’un WAV de test…' });
try {
const samples = new Float32Array(16000);
for (let i = 0; i < samples.length; i++) samples[i] = Math.sin(i / 20) * 0.05;
const text = await transcribeWav(encodeWav(samples, 16000), settings.voice);
setSttTest({ status: 'ok', message: `endpoint OK (réponse: "${text.slice(0, 40) || 'vide'}")` });
} catch (err) {
setSttTest({ status: 'fail', message: (err as Error).message });
}
};
const testTts = () => {
setTtsTest({ status: 'running', message: 'synthèse…' });
speech.say(`Bonjour, je suis ${settings.assistantName}. Les systèmes sont opérationnels.`);
setTimeout(() => setTtsTest({ status: 'ok', message: 'commande envoyée (écoutez le résultat)' }), 600);
};
const applyWebhook = async () => {
const api = bridge();
if (!api) return;
await api.store.set('webhook', settings.webhook);
const status = await api.hermes.webhookRestart();
useHermes.setState({ webhook: status });
};
const Result = ({ t }: { t: TestState }) =>
t.status === 'idle' ? null : (
<div className={`test-result ${t.status === 'ok' ? 'ok' : t.status === 'fail' ? 'fail' : ''}`}>
{t.status === 'running' ? <Loader2 size={13} className="spin" /> : t.status === 'ok' ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
<span>{t.message}</span>
</div>
);
const nav: Array<[Section, string]> = [
['hermes', 'Hermes'],
['voice', 'Micro / STT'],
['speech', 'Voix / TTS'],
['webhook', 'Webhook'],
['general', 'Général'],
['ui', 'Interface']
];
return (
<aside className="settings-drawer">
<header className="panel-head">
<Settings size={14} />
<span>Configuration</span>
<span className="spacer" />
<button className="icon-btn" onClick={onClose} title="Fermer"><X size={16} /></button>
</header>
<nav className="settings-nav">
{nav.map(([id, label]) => (
<button key={id} className={section === id ? 'active' : ''} onClick={() => setSection(id)}>{label}</button>
))}
</nav>
<div className="settings-body">
{section === 'hermes' && (
<>
<div className="card">
<div className="field">
<label>URL du serveur API Hermes</label>
<input className="input" value={settings.hermes.url} placeholder="http://127.0.0.1:8642" onChange={(e) => update({ hermes: { url: e.target.value } })} />
<span className="hint">Serveur lancé avec <code>hermes gateway</code> (API_SERVER_ENABLED=true, port 8642 par défaut). L’URL peut se terminer par /v1.</span>
</div>
<div className="field">
<label>Clé API (API_SERVER_KEY)</label>
<input className="input" type="password" value={settings.hermes.apiKey} onChange={(e) => update({ hermes: { apiKey: e.target.value } })} />
</div>
<div className="grid-2">
<div className="field">
<label>Modèle</label>
<input className="input" list="hermes-models" value={settings.hermes.model} placeholder="(défaut du serveur)" onChange={(e) => update({ hermes: { model: e.target.value } })} />
<datalist id="hermes-models">
{hermesStore.models.map((m) => <option key={m.id} value={m.id} />)}
</datalist>
</div>
<div className="field">
<label>Clé de mémoire (X-Hermes-Session-Key)</label>
<input className="input" value={settings.hermes.sessionKey} onChange={(e) => update({ hermes: { sessionKey: e.target.value } })} />
</div>
</div>
<div className="grid-2">
<div className="field">
<label>Transport</label>
<select className="select" value={settings.hermes.transport} onChange={(e) => update({ hermes: { transport: e.target.value as typeof settings.hermes.transport } })}>
<option value="auto">Auto (selon /v1/capabilities)</option>
<option value="runs">Runs API (SSE, approbations, steer)</option>
<option value="sessions">Sessions API (mémoire serveur)</option>
<option value="completions">Chat completions (OpenAI + outils EveFlow)</option>
</select>
</div>
<div className="field">
<label>Effort de raisonnement</label>
<select className="select" value={settings.hermes.reasoningEffort} onChange={(e) => update({ hermes: { reasoningEffort: e.target.value as typeof settings.hermes.reasoningEffort } })}>
<option value="">Défaut</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
</div>
<div className="field">
<label>Instructions EveFlow (superposées au prompt Hermes)</label>
<textarea className="textarea" value={settings.hermes.instructions} onChange={(e) => update({ hermes: { instructions: e.target.value } })} />
</div>
<Toggle on={settings.hermes.localTools} onChange={(v) => update({ hermes: { localTools: v } })} label="Outils EveFlow côté client" hint="En mode chat completions : état du HUD, fichiers partagés, notifications." />
<div className="row" style={{ marginTop: 10 }}>
<button className="btn primary small" onClick={() => void testHermes()}><Play size={13} /> Tester la liaison</button>
</div>
<div style={{ marginTop: 8 }}><Result t={hermesTest} /></div>
</div>
</>
)}
{section === 'voice' && (
<div className="card">
<div className="field">
<label>Moteur de reconnaissance</label>
<select className="select" value={settings.voice.provider} onChange={(e) => update({ voice: { provider: e.target.value as typeof settings.voice.provider } })}>
<option value="openai-compatible">API compatible OpenAI (Qwen3-ASR, Whisper, Speaches, LocalAI…)</option>
<option value="browser">Reconnaissance Chromium (en ligne, secours)</option>
</select>
</div>
{settings.voice.provider === 'openai-compatible' && (
<>
<div className="field">
<label>URL de l’API STT</label>
<input className="input" value={settings.voice.apiUrl} placeholder="http://127.0.0.1:8000/v1" onChange={(e) => update({ voice: { apiUrl: e.target.value } })} />
</div>
<div className="grid-2">
<div className="field">
<label>Modèle</label>
<input className="input" value={settings.voice.model} onChange={(e) => update({ voice: { model: e.target.value } })} />
</div>
<div className="field">
<label>Clé API</label>
<input className="input" type="password" value={settings.voice.apiKey} onChange={(e) => update({ voice: { apiKey: e.target.value } })} />
</div>
</div>
</>
)}
<div className="grid-2">
<div className="field">
<label>Langue</label>
<select className="select" value={settings.voice.language} onChange={(e) => update({ voice: { language: e.target.value }, language: e.target.value })}>
<option value="fr-FR">Français</option>
<option value="en-US">English</option>
<option value="es-ES">Español</option>
<option value="de-DE">Deutsch</option>
<option value="it-IT">Italiano</option>
</select>
</div>
<div className="field">
<label>Microphone</label>
<select className="select" value={settings.voice.micDeviceId} onChange={(e) => update({ voice: { micDeviceId: e.target.value } })}>
<option value="">Par défaut du système</option>
{micDevices.map((d) => <option key={d.deviceId} value={d.deviceId}>{d.label}</option>)}
</select>
</div>
</div>
<div className="field">
<label>Mode de capture</label>
<div className="seg">
<button className={settings.voice.captureMode === 'auto' ? 'active' : ''} onClick={() => update({ voice: { captureMode: 'auto' } })}>Auto (détection de silence)</button>
<button className={settings.voice.captureMode === 'manual' ? 'active' : ''} onClick={() => update({ voice: { captureMode: 'manual' } })}>Manuel (clic pour arrêter)</button>
</div>
</div>
<div className="grid-2">
<div className="field">
<label>Sensibilité : {settings.voice.sensitivity}/5</label>
<input className="range" type="range" min={1} max={5} step={1} value={settings.voice.sensitivity} onChange={(e) => update({ voice: { sensitivity: Number(e.target.value) } })} />
</div>
<div className="field">
<label>Silence de fin : {settings.voice.silenceMs} ms</label>
<input className="range" type="range" min={400} max={2500} step={100} value={settings.voice.silenceMs} onChange={(e) => update({ voice: { silenceMs: Number(e.target.value) } })} />
</div>
</div>
<Toggle on={settings.voice.handsFree} onChange={(v) => { update({ voice: { handsFree: v } }); useVoice.getState().setHandsFree(v); }} label="Mains libres au démarrage" hint="Le micro se réactive automatiquement après chaque réponse." />
<Toggle on={settings.voice.wakeChime} onChange={(v) => update({ voice: { wakeChime: v } })} label="Signal sonore d’écoute" />
<div className="row" style={{ marginTop: 10 }}>
<button className="btn small" onClick={() => void testStt()} disabled={settings.voice.provider !== 'openai-compatible'}><Mic size={13} /> Tester l’API STT</button>
</div>
<div style={{ marginTop: 8 }}><Result t={sttTest} /></div>
</div>
)}
{section === 'speech' && (
<div className="card">
<div className="field">
<label>Moteur de synthèse</label>
<select className="select" value={settings.speech.provider} onChange={(e) => update({ speech: { provider: e.target.value as typeof settings.speech.provider } })}>
<option value="openai-compatible">API compatible OpenAI /v1/audio/speech (Kokoro, Piper, OpenAI, LocalAI…)</option>
<option value="system">Voix système Windows</option>
<option value="google-free">Google Translate (gratuit, en ligne)</option>
<option value="off">Désactivée</option>
</select>
</div>
{settings.speech.provider === 'openai-compatible' && (
<>
<div className="field">
<label>URL de l’API TTS</label>
<input className="input" value={settings.speech.apiUrl} placeholder="http://127.0.0.1:8000/v1" onChange={(e) => update({ speech: { apiUrl: e.target.value } })} />
</div>
<div className="grid-2">
<div className="field">
<label>Modèle</label>
<input className="input" value={settings.speech.model} onChange={(e) => update({ speech: { model: e.target.value } })} />
</div>
<div className="field">
<label>Voix</label>
<input className="input" value={settings.speech.voice} placeholder="alloy, onyx, af_heart…" onChange={(e) => update({ speech: { voice: e.target.value } })} />
</div>
<div className="field">
<label>Clé API</label>
<input className="input" type="password" value={settings.speech.apiKey} onChange={(e) => update({ speech: { apiKey: e.target.value } })} />
</div>
<div className="field">
<label>Format</label>
<select className="select" value={settings.speech.format} onChange={(e) => update({ speech: { format: e.target.value as typeof settings.speech.format } })}>
<option value="mp3">mp3</option>
<option value="wav">wav</option>
<option value="opus">opus</option>
</select>
</div>
</div>
</>
)}
{settings.speech.provider === 'system' && (
<div className="field">
<label>Voix système</label>
<select className="select" value={settings.speech.systemVoice} onChange={(e) => update({ speech: { systemVoice: e.target.value } })}>
<option value="">Automatique ({settings.speech.language})</option>
{voices.map((v) => <option key={v.name} value={v.name}>{v.name} · {v.lang}</option>)}
</select>
</div>
)}
<div className="grid-2">
<div className="field">
<label>Vitesse : {settings.speech.speed.toFixed(2)}</label>
<input className="range" type="range" min={0.6} max={1.8} step={0.05} value={settings.speech.speed} onChange={(e) => update({ speech: { speed: Number(e.target.value) } })} />
</div>
<div className="field">
<label>Volume : {Math.round(settings.speech.volume * 100)}%</label>
<input className="range" type="range" min={0} max={1.2} step={0.05} value={settings.speech.volume} onChange={(e) => update({ speech: { volume: Number(e.target.value) } })} />
</div>
</div>
<Toggle on={settings.speech.autoSpeak} onChange={(v) => update({ speech: { autoSpeak: v } })} label="Lire les réponses en streaming" hint="Chaque phrase est prononcée dès qu’elle est complète." />
<Toggle on={settings.speech.speakIncoming} onChange={(v) => update({ speech: { speakIncoming: v } })} label="Lire les messages entrants (webhook, crons)" />
<div className="row" style={{ marginTop: 10 }}>
<button className="btn small" onClick={testTts} disabled={settings.speech.provider === 'off'}><Volume2 size={13} /> Tester la voix</button>
</div>
<div style={{ marginTop: 8 }}><Result t={ttsTest} /></div>
</div>
)}
{section === 'webhook' && (
<div className="card">
<Toggle on={settings.webhook.enabled} onChange={(v) => update({ webhook: { enabled: v } })} label="Serveur webhook local" hint="Hermes (crons, gateway, scripts) peut pousser des messages vers EveFlow." />
<div className="grid-2">
<div className="field">
<label>Port</label>
<input className="input" type="number" min={1024} max={65535} value={settings.webhook.port} onChange={(e) => update({ webhook: { port: Number(e.target.value) || 7842 } })} />
</div>
<div className="field">
<label>Secret (en-tête X-EveFlow-Secret)</label>
<div className="row">
<input className="input" type={webhookSecretVisible ? 'text' : 'password'} value={settings.webhook.secret} onChange={(e) => update({ webhook: { secret: e.target.value } })} />
<button className="btn ghost small" onClick={() => setWebhookSecretVisible((v) => !v)}>{webhookSecretVisible ? 'cacher' : 'voir'}</button>
</div>
</div>
</div>
<div className="field">
<label>Exemple d’appel</label>
<pre className="input" style={{ whiteSpace: 'pre-wrap', fontSize: 11.5, userSelect: 'text' }}>{`curl -X POST http://<ip-de-ce-pc>:${settings.webhook.port}/eveflow/hook \\
-H "Content-Type: application/json"${settings.webhook.secret ? ' \\\n -H "X-EveFlow-Secret: ***"' : ''} \\
-d '{"role":"assistant","text":"Rapport terminé","source":"telegram"}'`}</pre>
<span className="hint">Formats acceptés : {'{role,text}'}, {'{event:"run.completed",input,output}'}, {'{event:"job.completed",job:{name},output,status}'}, {'{type:"message",payload:{text}}'}.</span>
</div>
<div className="row">
<button className="btn primary small" onClick={() => void applyWebhook()} disabled={!bridge()}>Appliquer et redémarrer</button>
{hermesStore.webhook && <span className={`status-pill ${hermesStore.webhook.listening ? 'online' : 'offline'}`}>{hermesStore.webhook.listening ? `port ${hermesStore.webhook.port}` : hermesStore.webhook.error ?? 'inactif'}</span>}
</div>
</div>
)}
{section === 'general' && (
<div className="card">
<div className="grid-2">
<div className="field">
<label>Nom de l’assistant</label>
<input className="input" value={settings.assistantName} onChange={(e) => update({ assistantName: e.target.value.toUpperCase().slice(0, 18) || 'JARVIS' })} />
</div>
<div className="field">
<label>Votre nom</label>
<input className="input" value={settings.userName} placeholder="Monsieur" onChange={(e) => update({ userName: e.target.value.slice(0, 24) })} />
</div>
</div>
<div className="field">
<label>Thème du HUD</label>
<div className="theme-swatches">
{(['arc', 'gold', 'crimson', 'emerald'] as HudTheme[]).map((t) => {
const colors: Record<HudTheme, string> = { arc: '#34e4ff', gold: '#ffc35a', crimson: '#ff5c6c', emerald: '#4ef0a8' };
return <button key={t} className={`swatch${settings.theme === t ? ' active' : ''}`} style={{ background: colors[t] }} title={t} onClick={() => update({ theme: t })} />;
})}
</div>
</div>
<div className="field">
<label>Raccourcis globaux</label>
<span className="hint"><span className="kbd">Ctrl+Shift+Espace</span> micro · <span className="kbd">Ctrl+Shift+J</span> afficher/masquer · <span className="kbd">Ctrl+Shift+Échap</span> couper la voix</span>
</div>
<div className="divider" />
<button className="btn danger small" onClick={() => { if (confirm('Réinitialiser tous les paramètres ?')) reset(); }}><RotateCcw size={13} /> Réinitialiser</button>
</div>
)}
{section === 'ui' && (
<div className="card">
<Toggle on={settings.ui.showTelemetry} onChange={(v) => update({ ui: { showTelemetry: v } })} label="Télémétrie système" />
<Toggle on={settings.ui.showReasoning} onChange={(v) => update({ ui: { showReasoning: v } })} label="Afficher le raisonnement du modèle" hint="Quand Hermes le transmet (reasoning.available)." />
<Toggle on={settings.ui.reduceMotion} onChange={(v) => update({ ui: { reduceMotion: v } })} label="Réduire les animations" />
<div className="field" style={{ marginTop: 10 }}>
<label>Opacité du widget compact : {Math.round(settings.ui.compactOpacity * 100)}%</label>
<input className="range" type="range" min={0.5} max={1} step={0.02} value={settings.ui.compactOpacity} onChange={(e) => update({ ui: { compactOpacity: Number(e.target.value) } })} />
</div>
<span className="hint">Valeurs par défaut : {DEFAULT_SETTINGS.assistantName}, thème {DEFAULT_SETTINGS.theme}.</span>
</div>
)}
</div>
</aside>
);
}