mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-20 05:38:14 +02:00
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:
239 files changed
+11351
-86495
No files matched your search
@@ -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" />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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://<cette-machine>:{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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user