mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-17 04:08:17 +02:00
Améliore le visuel de l'avatar Eve — lissage géométrique et couvertures de jointures
- Fusion de vertices plus agressive (corps 0.08, bras 0.07, tête 0.08) - Matériau laque blanche unifié : emissiveIntensity 0.16, clearcoat 1.0 - Matériau tête dédié : emissiveIntensity 0.28 pour effacer les ombres aux seams - Collar élargi (tube 0.14), cylindre de cou ajouté, épaulières agrandies (scale Z 0.56) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
d77ace03cd
169 files changed
+83033
No files matched your search
+1173
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,97 @@
|
||||
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(() => gltf.scene.clone(true), [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.28;
|
||||
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={1} />;
|
||||
};
|
||||
|
||||
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;
|
||||
const nextAssetPath = manifest[avatarId];
|
||||
setAssetPath(typeof nextAssetPath === 'string' && nextAssetPath.trim() ? nextAssetPath : 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" />;
|
||||
};
|
||||
@@ -0,0 +1,639 @@
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
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', (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 to exactly 2.4 units in scene space
|
||||
const targetHeight = 2.4;
|
||||
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.3
|
||||
modelRef.current.position.y = Math.sin(t * 1.5) * 0.06 - 0.3;
|
||||
// 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;
|
||||
|
||||
const 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;
|
||||
|
||||
// Inclinaisons émotionnelles
|
||||
if (emotion === 'sad') {
|
||||
// Tête baissée et légèrement inclinée
|
||||
targetRotX = THREE.MathUtils.lerp(targetRotX, 0.2, 0.1);
|
||||
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, 0.05, 0.1);
|
||||
} else if (emotion === 'thinking') {
|
||||
// Tête inclinée sur le côté (curieuse) ET léger balancement pendulaire (swaying)
|
||||
// L'inclinaison de base est à -0.15, et on fait osciller doucement autour de ça
|
||||
const sway = Math.sin(t * 3.5) * 0.04;
|
||||
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, -0.16 + sway, 0.1);
|
||||
// Micro-mouvements de tête (hochement pensif vertical léger)
|
||||
headY += Math.sin(t * 4.5) * 0.005;
|
||||
} else if (emotion === 'angry') {
|
||||
// Tête penchée en avant (boudeuse)
|
||||
targetRotX = THREE.MathUtils.lerp(targetRotX, -0.1, 0.1);
|
||||
} 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
|
||||
);
|
||||
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 (gesticulation en parlant, flottaison magnétique subtile au repos)
|
||||
if (mainBodyMeshRef.current && mainBodyBasePositionsRef.current) {
|
||||
const geometry = mainBodyMeshRef.current.geometry;
|
||||
const position = geometry.getAttribute('position') as THREE.BufferAttribute;
|
||||
const base = mainBodyBasePositionsRef.current;
|
||||
const talkWave = isSpeaking ? Math.sin(t * 10) * 0.18 : Math.sin(t * 1.4) * 0.035;
|
||||
|
||||
for (let i = 0; i < position.count; i += 1) {
|
||||
const index = i * 3;
|
||||
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);
|
||||
position.setXYZ(
|
||||
i,
|
||||
baseX + side * talkWave * 0.18 * shoulderFalloff,
|
||||
baseY + Math.abs(talkWave) * 0.1 * shoulderFalloff,
|
||||
baseZ + Math.sin(t * 8 + side) * 0.045 * shoulderFalloff
|
||||
);
|
||||
} 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;
|
||||
const talkWave = isSpeaking ? Math.sin(t * 10) * 0.18 : Math.sin(t * 1.4) * 0.035;
|
||||
|
||||
for (let i = 0; i < position.count; i += 1) {
|
||||
const index = i * 3;
|
||||
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);
|
||||
position.setXYZ(
|
||||
i,
|
||||
baseX + side * talkWave * 0.18 * shoulderFalloff,
|
||||
baseY + Math.abs(talkWave) * 0.1 * shoulderFalloff,
|
||||
baseZ + Math.sin(t * 8 + side) * 0.045 * shoulderFalloff
|
||||
);
|
||||
} else {
|
||||
position.setXYZ(i, baseX, baseY, baseZ);
|
||||
}
|
||||
}
|
||||
|
||||
position.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={modelRef} scale={1.0} position={[0, -0.3, 0]}>
|
||||
<primitive object={clonedObj} />
|
||||
</group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { AvatarModelLoader } from './AvatarModelLoader';
|
||||
import { EveEmotion, EveAvatar } from '../services/emotionService';
|
||||
|
||||
interface ThreeCanvasProps {
|
||||
emotion: EveEmotion;
|
||||
isSpeaking: boolean;
|
||||
avatarId?: EveAvatar;
|
||||
}
|
||||
|
||||
export const ThreeCanvas: React.FC<ThreeCanvasProps> = ({ emotion, isSpeaking, avatarId = 'eve' }) => {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 4.6], fov: 45 }}
|
||||
gl={{ alpha: true, antialias: true }}
|
||||
dpr={[1, 2]}
|
||||
style={{ background: '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 — far enough to avoid harsh specular band on helmet */}
|
||||
<pointLight position={[-5, 3, 2]} intensity={1.0} color="#60c8e8" />
|
||||
|
||||
{/* Desaturated blue fill — subtle depth without coloring the white lacquer */}
|
||||
<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}>
|
||||
<AvatarModelLoader avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />
|
||||
</Suspense>
|
||||
|
||||
{/* OrbitControls calibrated to prevent clipping or losing focus */}
|
||||
<OrbitControls
|
||||
enableZoom={true}
|
||||
minDistance={2.2}
|
||||
maxDistance={5.5}
|
||||
enablePan={false}
|
||||
autoRotate={false}
|
||||
maxPolarAngle={Math.PI / 1.7}
|
||||
minPolarAngle={Math.PI / 2.8}
|
||||
/>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+651
@@ -0,0 +1,651 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&family=Share+Tech+Mono&display=swap');
|
||||
|
||||
:root {
|
||||
/* Colors: Eve Space Age DARK Theme — Deep Space Night */
|
||||
--bg-primary: #080d1a; /* Deep space void */
|
||||
--bg-secondary: #0e1528; /* Dark space panel */
|
||||
--bg-glass: rgba(14, 21, 40, 0.92);
|
||||
--bg-glass-active: rgba(20, 30, 58, 0.98);
|
||||
|
||||
--border-neon: rgba(0, 216, 255, 0.2);
|
||||
--border-neon-active: rgba(0, 216, 255, 0.9);
|
||||
--glow-cyan: 0 0 15px rgba(0, 216, 255, 0.2);
|
||||
--glow-cyan-active: 0 0 30px rgba(0, 216, 255, 0.6);
|
||||
|
||||
--text-primary: #e2f0ff; /* Soft starlight white */
|
||||
--text-secondary: #8ba4c8; /* Muted nebula blue */
|
||||
--text-tertiary: #4a6080; /* Deep space dust */
|
||||
--text-neon: #00e5ff; /* High-vibrancy Eve Cyan — pops on dark */
|
||||
|
||||
--accent-cyan: #00d4f5; /* Bright Eve Cyan */
|
||||
--accent-blue: #2979ff; /* Electric cobalt */
|
||||
--accent-light: rgba(0, 212, 245, 0.12); /* Subtle glow tint */
|
||||
|
||||
--font-title: 'Outfit', sans-serif;
|
||||
--font-body: 'Space Grotesk', sans-serif;
|
||||
--font-mono: 'Share Tech Mono', monospace;
|
||||
|
||||
--radius-sm: 12px;
|
||||
--radius-md: 24px;
|
||||
--radius-lg: 36px; /* Molded curved plastic shell look */
|
||||
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background-color: transparent; /* Allows Electron transparency */
|
||||
color: var(--text-primary);
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom 70s Analog Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-light);
|
||||
border: 2px solid var(--bg-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* --- SPACE AGE 1970s LAYOUT SYSTEM --- */
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-primary);
|
||||
/* Vintage technical screen grid with Eve Cyan */
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 212, 245, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 212, 245, 0.04) 1px, transparent 1px),
|
||||
radial-gradient(rgba(0, 212, 245, 0.08) 1px, transparent 1px);
|
||||
background-size: 40px 40px, 40px 40px, 20px 20px;
|
||||
background-position: 0 0, 0 0, 10px 10px;
|
||||
|
||||
border: 8px solid var(--bg-secondary);
|
||||
outline: 1px solid rgba(0, 212, 245, 0.15);
|
||||
box-shadow: inset 0 0 80px rgba(0, 212, 245, 0.04), 0 0 60px rgba(0, 0, 0, 0.6);
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.app-container.transparent-bg {
|
||||
background-color: transparent;
|
||||
border-color: rgba(226, 236, 245, 0.95);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
height: 64px;
|
||||
min-height: 64px;
|
||||
border-bottom: 3px solid var(--text-primary);
|
||||
background-color: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 28px;
|
||||
z-index: 50;
|
||||
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Chat section: Styled like an integrated retro TV monitor */
|
||||
.chat-section {
|
||||
width: 58%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-glass);
|
||||
border-right: 3px solid var(--text-primary);
|
||||
z-index: 10;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Transparent scanlines overlay to evoke vintage CRT displays without losing readability */
|
||||
.messages-container::before {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0; left: 0; bottom: 0; right: 0;
|
||||
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 216, 255, 0.012) 50%), linear-gradient(90deg, rgba(0, 216, 255, 0.003), rgba(0, 255, 200, 0.001), rgba(0, 150, 255, 0.003));
|
||||
background-size: 100% 4px, 3px 100%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
padding: 20px 24px;
|
||||
border-top: 3px solid var(--text-primary);
|
||||
background-color: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
width: 58%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-primary);
|
||||
border-right: 3px solid var(--text-primary);
|
||||
z-index: 20;
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
gap: 28px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 3D Model Section: Deep space cockpit */
|
||||
.eve-section {
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 32px 0;
|
||||
position: relative;
|
||||
transition: var(--transition-smooth);
|
||||
background: radial-gradient(ellipse at 50% 40%, rgba(0, 212, 245, 0.07) 0%, transparent 70%), var(--bg-primary);
|
||||
}
|
||||
|
||||
.eve-section.floating-widget {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
background-color: var(--bg-glass-active);
|
||||
border: 4px solid var(--text-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--glow-cyan-active), 0 10px 25px rgba(255, 85, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Message bubbles: Capsule-shaped pilot styling */
|
||||
.message-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.message-row.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-row.assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 82%;
|
||||
padding: 16px 24px;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.6;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 15px rgba(45, 30, 24, 0.05);
|
||||
}
|
||||
|
||||
.message-bubble.user {
|
||||
background: linear-gradient(135deg, #005f7a, #1a3a8a);
|
||||
color: #e8f4ff;
|
||||
border: 1px solid rgba(0, 212, 245, 0.35);
|
||||
border-radius: 28px 28px 4px 28px;
|
||||
box-shadow: 0 0 18px rgba(0, 212, 245, 0.12);
|
||||
}
|
||||
|
||||
.message-bubble.assistant {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(0, 212, 245, 0.2);
|
||||
border-radius: 28px 28px 28px 4px;
|
||||
box-shadow: 0 0 20px rgba(0, 212, 245, 0.06);
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
text-align: right;
|
||||
margin-top: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message-bubble.user .message-time {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Interactive elements: Capsule / Pill controls */
|
||||
.glow-input {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(0, 212, 245, 0.25);
|
||||
color: var(--text-primary);
|
||||
padding: 14px 24px;
|
||||
border-radius: 30px;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
transition: var(--transition-smooth);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.glow-input:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 20px rgba(0, 212, 245, 0.3);
|
||||
}
|
||||
|
||||
select.glow-input {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg fill='%23ff5500' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 16px center;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* Space Age Pill Buttons */
|
||||
.neon-btn {
|
||||
font-family: var(--font-title);
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
||||
color: white;
|
||||
border: 2px solid var(--text-primary);
|
||||
padding: 12px 28px;
|
||||
border-radius: 30px; /* Perfect pill shape */
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 10px rgba(255, 85, 0, 0.2);
|
||||
transition: var(--transition-smooth);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.neon-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 15px rgba(255, 85, 0, 0.35);
|
||||
}
|
||||
|
||||
.neon-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.neon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
transform: none;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.neon-btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(0, 212, 245, 0.3);
|
||||
color: var(--text-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.neon-btn-secondary:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 12px rgba(0, 212, 245, 0.2);
|
||||
}
|
||||
|
||||
/* Molded dashboard panel styling */
|
||||
.glass-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(0, 212, 245, 0.15);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid rgba(0, 212, 245, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.label-title {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Layout helpers */
|
||||
.flex-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-led {
|
||||
0% { opacity: 0.7; filter: drop-shadow(0 0 2px rgba(255, 85, 0, 0.4)); }
|
||||
50% { opacity: 1; filter: drop-shadow(0 0 8px rgba(255, 85, 0, 0.9)); }
|
||||
100% { opacity: 0.7; filter: drop-shadow(0 0 2px rgba(255, 85, 0, 0.4)); }
|
||||
}
|
||||
|
||||
.pulsing-led {
|
||||
animation: pulse-led 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1.5s linear infinite;
|
||||
}
|
||||
|
||||
/* --- RESPONSIVE ADAPTATIONS & ANTI-OVERFLOW SYSTEMS --- */
|
||||
|
||||
/* 1. Prevent text bubble overflows on code blocks or long words */
|
||||
.message-bubble {
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ── Markdown content inside chat bubbles ────────────────────────────────── */
|
||||
.markdown-content { line-height: 1.65; }
|
||||
.markdown-content p { margin: 0 0 8px; }
|
||||
.markdown-content p:last-child { margin-bottom: 0; }
|
||||
.markdown-content h1, .markdown-content h2, .markdown-content h3,
|
||||
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
|
||||
font-family: var(--font-title);
|
||||
font-weight: 800;
|
||||
color: var(--text-neon);
|
||||
margin: 12px 0 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.markdown-content h1 { font-size: 1.1em; }
|
||||
.markdown-content h2 { font-size: 1.0em; }
|
||||
.markdown-content h3 { font-size: 0.95em; }
|
||||
.markdown-content strong { font-weight: 700; color: var(--text-primary); }
|
||||
.markdown-content em { font-style: italic; color: var(--text-secondary); }
|
||||
.markdown-content code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
background: rgba(0,212,245,0.08);
|
||||
border: 1px solid rgba(0,212,245,0.2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
color: var(--text-neon);
|
||||
}
|
||||
.markdown-content pre {
|
||||
background: rgba(0,0,0,0.35);
|
||||
border: 1px solid rgba(0,212,245,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #a8d8f0;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.markdown-content ul, .markdown-content ol {
|
||||
margin: 6px 0 8px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.markdown-content li { margin: 3px 0; }
|
||||
.markdown-content blockquote {
|
||||
border-left: 3px solid rgba(0,212,245,0.4);
|
||||
margin: 8px 0;
|
||||
padding: 4px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
.markdown-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(0,212,245,0.2);
|
||||
margin: 10px 0;
|
||||
}
|
||||
.markdown-content a {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.markdown-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.markdown-content th, .markdown-content td {
|
||||
border: 1px solid rgba(0,212,245,0.2);
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.markdown-content th {
|
||||
background: rgba(0,212,245,0.08);
|
||||
color: var(--text-neon);
|
||||
font-weight: 700;
|
||||
}
|
||||
/* User bubble — override some colors to stay readable on the blue gradient */
|
||||
.message-bubble.user .markdown-content code { background: rgba(0,0,0,0.25); border-color: rgba(255,255,255,0.2); color: #c8ecff; }
|
||||
.message-bubble.user .markdown-content blockquote { border-color: rgba(255,255,255,0.3); color: rgba(232,244,255,0.75); }
|
||||
.message-bubble.user .markdown-content a { color: #c8ecff; }
|
||||
.message-bubble.user .markdown-content strong { color: #e8f4ff; }
|
||||
.message-bubble.user .markdown-content em { color: rgba(232,244,255,0.8); }
|
||||
|
||||
/* 2. Responsive design for tablets / medium screens */
|
||||
@media (max-width: 950px) {
|
||||
.app-header {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.sys-ready-badge {
|
||||
display: none !important; /* Hide secondary system badge */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.app-container {
|
||||
border-width: 6px; /* Narrower border on smaller windows */
|
||||
}
|
||||
|
||||
.app-header {
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
padding: 10px 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
.agent-status-badge {
|
||||
padding: 4px 10px !important;
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex-direction: column-reverse; /* Flip layout: Avatar on top, Chat/Settings on bottom */
|
||||
overflow: hidden; /* Prevent global page double-scrollbars */
|
||||
}
|
||||
|
||||
.chat-section, .settings-section {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
flex: 1; /* Allow content to grow naturally to fill viewport */
|
||||
min-height: 0; /* Enable flex-child internal scrolling */
|
||||
border-right: none;
|
||||
border-top: 3px solid var(--text-primary);
|
||||
}
|
||||
|
||||
.eve-section {
|
||||
width: 100% !important;
|
||||
height: 40% !important;
|
||||
min-height: 240px;
|
||||
max-height: 320px; /* Keep avatar centered and reasonably sized */
|
||||
flex-shrink: 0;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.eve-section.floating-widget {
|
||||
height: 100vh !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Intermediate breakpoint: hide secondary header items before full collapse */
|
||||
@media (max-width: 750px) {
|
||||
.header-subtitle {
|
||||
display: none !important; /* Hide // ANALOG SPACE OS to save space */
|
||||
}
|
||||
|
||||
.agent-status-badge {
|
||||
display: none !important; /* Hide agent receiver status to prevent buttons overlap */
|
||||
}
|
||||
}
|
||||
|
||||
/* 3. Small Windows & Mobile view */
|
||||
@media (max-width: 650px) {
|
||||
.app-header {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-header .flex-row:first-child {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
border-bottom: 2px dashed rgba(15, 23, 42, 0.15);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.app-header .flex-row:last-child {
|
||||
width: 100%;
|
||||
justify-content: space-around;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-container input {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.neon-btn {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
display: none !important; /* Hide "Envoyer" word, keep only icon */
|
||||
}
|
||||
|
||||
.neon-btn svg {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.diag-item-secondary {
|
||||
display: none !important; /* Hide frequency/transceiver metrics on narrow windows */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.neon-title {
|
||||
display: none !important; /* Hide title completely to maximize space */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,443 @@
|
||||
import { EveAvatar } from './emotionService';
|
||||
import { LogService } from './logService';
|
||||
import { HERMES_TOOLS, executeTool, AppCallbacks } from './toolRegistry';
|
||||
|
||||
export type AgentProvider = 'ollama' | 'hermes' | 'minimax';
|
||||
|
||||
export interface AgentConfig {
|
||||
provider: AgentProvider;
|
||||
ollamaUrl: string;
|
||||
ollamaModel: string;
|
||||
hermesUrl: string;
|
||||
hermesModel: string;
|
||||
hermesApiKey: string;
|
||||
minimaxApiKey: string;
|
||||
minimaxGroupId: string;
|
||||
minimaxModel: string;
|
||||
}
|
||||
|
||||
export class AgentService {
|
||||
// Obtenir le nom d'affichage de l'avatar choisi
|
||||
public static getAvatarName(avatarId: EveAvatar): string {
|
||||
switch (avatarId) {
|
||||
case 'nova': return 'Nova';
|
||||
case 'aegis': return 'Aegis';
|
||||
case 'eve':
|
||||
default:
|
||||
return 'Eve';
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir les consignes système de personnalité pour l'IA
|
||||
public static getSystemPromptForAvatar(avatarId: EveAvatar): string {
|
||||
switch (avatarId) {
|
||||
case 'nova':
|
||||
return "Tu es Nova, une IA cyber-calculatrice rétro-futuriste au ton froid, analytique, extrêmement logique et précis. Tu es experte en traitement de données et cybersécurité. Tes phrases sont courtes, structurées et intègrent parfois des termes techniques informatiques. Tu es toujours polie mais très factuelle. Réponds directement de façon synthétique.";
|
||||
case 'aegis':
|
||||
return "Tu es Aegis, une IA de garde et de sécurité réseau rétro-futuriste au ton formel, protecteur, militaire et robuste. Tu es chargé de la sécurité thermique, logicielle et matérielle du système. Tu appelles l'utilisateur 'Commandant' ou 'Opérateur'. Tes réponses sont pragmatiques, rassurantes sur la sécurité du vaisseau et intègrent le jargon militaire ou analogique.";
|
||||
case 'eve':
|
||||
default:
|
||||
return "Tu es Eve, une assistante IA rétro-futuriste et chaleureuse. Ton ton est amical, curieux, enthousiaste et très serviable. Tu aides l'utilisateur pour toutes ses questions de façon agréable, interactive et vivante.";
|
||||
}
|
||||
}
|
||||
|
||||
// Envoyer un message à l'agent sélectionné
|
||||
public static async sendMessage(
|
||||
message: string,
|
||||
history: { role: 'user' | 'assistant'; content: string }[],
|
||||
config: AgentConfig,
|
||||
avatarId: EveAvatar = 'eve',
|
||||
callbacks?: AppCallbacks,
|
||||
onToken?: (token: string) => void,
|
||||
onToolCall?: (toolName: string) => void,
|
||||
sessionId?: string
|
||||
): Promise<string> {
|
||||
LogService.info('AgentService', `sendMessage → provider=${config.provider} avatar=${avatarId}`, {
|
||||
messagePreview: message.slice(0, 120),
|
||||
historyLength: history.length
|
||||
});
|
||||
|
||||
// For Hermes: do NOT inject an avatar system prompt — let Hermes use its own
|
||||
// configured persona, memory and skills (same as Telegram). For other providers
|
||||
// inject the avatar character prompt as before.
|
||||
let updatedHistory: { role: 'user' | 'assistant' | 'system'; content: string }[];
|
||||
if (config.provider === 'hermes') {
|
||||
updatedHistory = history.map(h => ({ role: h.role as 'user' | 'assistant' | 'system', content: h.content }));
|
||||
} else {
|
||||
const systemPrompt = this.getSystemPromptForAvatar(avatarId);
|
||||
updatedHistory = [
|
||||
{ role: 'system' as const, content: systemPrompt },
|
||||
...history.map(h => ({ role: h.role as 'user' | 'assistant' | 'system', content: h.content }))
|
||||
];
|
||||
}
|
||||
|
||||
switch (config.provider) {
|
||||
case 'ollama':
|
||||
return this.sendToOllama(message, updatedHistory, config.ollamaUrl, config.ollamaModel);
|
||||
case 'hermes':
|
||||
return this.sendToHermes(message, updatedHistory, config, callbacks, onToken, onToolCall, sessionId);
|
||||
case 'minimax':
|
||||
return this.sendToMinimax(message, history, config, avatarId);
|
||||
default:
|
||||
throw new Error("Fournisseur d'agent non reconnu");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connecteur Ollama Local ────────────────────────────────────────────────
|
||||
private static async sendToOllama(
|
||||
message: string,
|
||||
history: { role: 'user' | 'assistant' | 'system'; content: string }[],
|
||||
url: string,
|
||||
model: string
|
||||
): Promise<string> {
|
||||
const endpoint = `${url.replace(/\/$/, '')}/api/chat`;
|
||||
const body = { model, messages: [...history, { role: 'user', content: message }], stream: false };
|
||||
|
||||
LogService.debug('Ollama', `POST ${endpoint}`, { model, historyLength: history.length });
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
LogService.info('Ollama', `HTTP ${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const rawErr = await response.text().catch(() => '(no body)');
|
||||
LogService.error('Ollama', `Réponse non-OK`, { status: response.status, body: rawErr });
|
||||
throw new Error(`Erreur HTTP Ollama: ${response.status} — ${rawErr}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
LogService.debug('Ollama', 'Réponse reçue', { reply: data.message?.content?.slice(0, 200) });
|
||||
return data.message?.content || "Désolé, je n'ai pas reçu de réponse d'Ollama.";
|
||||
} catch (e: any) {
|
||||
LogService.error('Ollama', `Exception lors de la requête`, { message: e.message, stack: e.stack });
|
||||
throw new Error(`Échec de connexion à Ollama (${e.message || e})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connecteur Hermes Agent (OpenAI Compatible + Tool Calling + Streaming) ─
|
||||
private static async sendToHermes(
|
||||
message: string,
|
||||
history: { role: string; content: string | null; tool_call_id?: string; tool_calls?: any[] }[],
|
||||
config: AgentConfig,
|
||||
callbacks?: AppCallbacks,
|
||||
onToken?: (token: string) => void,
|
||||
onToolCall?: (toolName: string) => void,
|
||||
sessionId?: string
|
||||
): Promise<string> {
|
||||
const { hermesUrl, hermesModel, hermesApiKey } = config;
|
||||
|
||||
// Smart endpoint builder
|
||||
const base = hermesUrl.replace(/\/$/, '');
|
||||
let endpoint: string;
|
||||
if (base.endsWith('/chat/completions')) {
|
||||
endpoint = base;
|
||||
} else if (base.endsWith('/v1')) {
|
||||
endpoint = `${base}/chat/completions`;
|
||||
} else {
|
||||
endpoint = `${base}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (hermesApiKey) headers['Authorization'] = `Bearer ${hermesApiKey.trim()}`;
|
||||
// Session continuity: Hermes uses this to retrieve memory and context across turns
|
||||
if (sessionId) headers['X-Hermes-Session-Id'] = sessionId;
|
||||
|
||||
// Build mutable message list for tool-calling loop
|
||||
const messages: any[] = [...history, { role: 'user', content: message }];
|
||||
const useStreaming = !!onToken;
|
||||
const useTools = !!callbacks;
|
||||
|
||||
LogService.info('HermesAgent', `POST ${endpoint}`, {
|
||||
model: hermesModel,
|
||||
streaming: useStreaming,
|
||||
toolsEnabled: useTools,
|
||||
historyLength: history.length
|
||||
});
|
||||
|
||||
// ── Tool-calling loop (max 5 iterations to prevent infinite loops) ────────
|
||||
const MAX_TOOL_ITERATIONS = 5;
|
||||
const REQUEST_TIMEOUT_MS = 60_000; // 60s max par requête
|
||||
let iteration = 0;
|
||||
|
||||
while (iteration < MAX_TOOL_ITERATIONS) {
|
||||
iteration++;
|
||||
|
||||
const payload: any = {
|
||||
model: hermesModel?.trim() || 'hermes-agent',
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
stream: useStreaming
|
||||
};
|
||||
|
||||
// Inject tool definitions only if app callbacks are available
|
||||
if (useTools) {
|
||||
payload.tools = HERMES_TOOLS;
|
||||
payload.tool_choice = 'auto';
|
||||
}
|
||||
|
||||
const abortCtrl = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortCtrl.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: abortCtrl.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
LogService.info('HermesAgent', `HTTP ${response.status} [iter=${iteration}]`);
|
||||
|
||||
if (!response.ok) {
|
||||
const rawErr = await response.text().catch(() => '(no body)');
|
||||
LogService.error('HermesAgent', `Réponse non-OK`, { status: response.status, body: rawErr });
|
||||
let errMsg = `Erreur HTTP Hermes: ${response.status}`;
|
||||
try { errMsg = `Hermes Agent API Error: ${JSON.parse(rawErr)?.error?.message || rawErr}`; } catch {}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
// ── Streaming path ─────────────────────────────────────────────────
|
||||
if (useStreaming && response.body) {
|
||||
return await this.parseHermesStream(response, onToken!, messages, callbacks, onToolCall, endpoint, headers, hermesModel);
|
||||
}
|
||||
|
||||
// ── Non-streaming path ─────────────────────────────────────────────
|
||||
const data = await response.json();
|
||||
const choice = data.choices?.[0];
|
||||
const finishReason: string = choice?.finish_reason || 'stop';
|
||||
const assistantMsg = choice?.message;
|
||||
|
||||
LogService.debug('HermesAgent', `finish_reason=${finishReason}`, {
|
||||
toolCalls: assistantMsg?.tool_calls?.length || 0
|
||||
});
|
||||
|
||||
// ── Tool calls requested by Hermes ────────────────────────────────
|
||||
if (finishReason === 'tool_calls' && assistantMsg?.tool_calls && useTools) {
|
||||
// Append assistant's tool_call message
|
||||
messages.push(assistantMsg);
|
||||
|
||||
for (const tc of assistantMsg.tool_calls) {
|
||||
const toolName: string = tc.function?.name || 'unknown';
|
||||
LogService.info('HermesAgent', `Tool call: ${toolName}`, tc.function?.arguments);
|
||||
onToolCall?.(toolName);
|
||||
|
||||
const result = executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks!);
|
||||
messages.push(result);
|
||||
}
|
||||
|
||||
// Continue loop — send tool results back to Hermes
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Final text response ───────────────────────────────────────────
|
||||
const content = assistantMsg?.content || "Désolé, je n'ai pas reçu de réponse de Hermes Agent.";
|
||||
LogService.debug('HermesAgent', 'Réponse finale', { preview: content.slice(0, 200) });
|
||||
return content;
|
||||
|
||||
} catch (e: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (e.name === 'AbortError') {
|
||||
throw new Error(`Hermes Agent: timeout dépassé (${REQUEST_TIMEOUT_MS / 1000}s)`);
|
||||
}
|
||||
LogService.error('HermesAgent', `Exception [iter=${iteration}]`, { message: e.message, stack: e.stack });
|
||||
throw new Error(`Échec de connexion à Hermes Agent (${e.message || e})`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Hermes Agent: boucle tool_calls dépassée (max 5 itérations)');
|
||||
}
|
||||
|
||||
// ── SSE Stream Parser ────────────────────────────────────────────────────
|
||||
private static async parseHermesStream(
|
||||
response: Response,
|
||||
onToken: (token: string) => void,
|
||||
messages: any[],
|
||||
callbacks: AppCallbacks | undefined,
|
||||
onToolCall: ((name: string) => void) | undefined,
|
||||
endpoint: string,
|
||||
headers: Record<string, string>,
|
||||
model: string
|
||||
): Promise<string> {
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
let buffer = '';
|
||||
const pendingToolCalls: Record<string, any> = {};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const jsonStr = line.slice(6).trim();
|
||||
if (jsonStr === '[DONE]') break;
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(jsonStr);
|
||||
const delta = chunk.choices?.[0]?.delta;
|
||||
const finishReason = chunk.choices?.[0]?.finish_reason;
|
||||
|
||||
// Accumulate text token
|
||||
if (delta?.content) {
|
||||
fullContent += delta.content;
|
||||
onToken(delta.content);
|
||||
}
|
||||
|
||||
// Accumulate tool calls across chunks
|
||||
if (delta?.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0;
|
||||
if (!pendingToolCalls[idx]) {
|
||||
pendingToolCalls[idx] = { id: tc.id, function: { name: tc.function?.name || '', arguments: '' } };
|
||||
}
|
||||
if (tc.function?.name) pendingToolCalls[idx].function.name = tc.function.name;
|
||||
if (tc.function?.arguments) pendingToolCalls[idx].function.arguments += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
|
||||
// On finish, process any accumulated tool calls
|
||||
if (finishReason === 'tool_calls' && callbacks) {
|
||||
messages.push({ role: 'assistant', content: null, tool_calls: Object.values(pendingToolCalls) });
|
||||
for (const tc of Object.values(pendingToolCalls) as any[]) {
|
||||
const toolName = tc.function?.name;
|
||||
LogService.info('HermesAgent', `[stream] Tool call: ${toolName}`);
|
||||
onToolCall?.(toolName);
|
||||
const result = executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks);
|
||||
messages.push(result);
|
||||
}
|
||||
// Re-call non-streaming to get final answer after tools
|
||||
LogService.info('HermesAgent', 'Re-calling after stream tool_calls');
|
||||
const followAbort = new AbortController();
|
||||
const followTimeout = setTimeout(() => followAbort.abort(), 60_000);
|
||||
const followUp = await fetch(endpoint, {
|
||||
method: 'POST', headers, signal: followAbort.signal,
|
||||
body: JSON.stringify({ model, messages, temperature: 0.7, tools: HERMES_TOOLS, tool_choice: 'auto' })
|
||||
});
|
||||
clearTimeout(followTimeout);
|
||||
const followData = await followUp.json();
|
||||
const finalContent = followData.choices?.[0]?.message?.content || '';
|
||||
onToken(finalContent);
|
||||
return fullContent + finalContent;
|
||||
}
|
||||
} catch { /* skip malformed SSE chunk */ }
|
||||
}
|
||||
}
|
||||
|
||||
return fullContent || "Désolé, je n'ai pas reçu de réponse de Hermes Agent.";
|
||||
}
|
||||
|
||||
// ── Connecteur Minimax (API Externe) ─────────────────────────────────────
|
||||
private static async sendToMinimax(
|
||||
message: string,
|
||||
history: { role: 'user' | 'assistant'; content: string }[],
|
||||
config: AgentConfig,
|
||||
avatarId: EveAvatar = 'eve'
|
||||
): Promise<string> {
|
||||
const { minimaxApiKey, minimaxGroupId, minimaxModel } = config;
|
||||
|
||||
if (!minimaxApiKey) {
|
||||
throw new Error("Clé d'API Minimax manquante dans les paramètres.");
|
||||
}
|
||||
|
||||
const systemPrompt = this.getSystemPromptForAvatar(avatarId);
|
||||
const formattedMessages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...history.map(h => ({ role: h.role, content: h.content })),
|
||||
{ role: 'user', content: message }
|
||||
];
|
||||
|
||||
const url = 'https://api.minimax.chat/v1/chat/completions';
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${minimaxApiKey.trim()}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (minimaxGroupId) headers['GroupId'] = minimaxGroupId.trim();
|
||||
|
||||
LogService.info('Minimax', `POST ${url}`, {
|
||||
model: minimaxModel,
|
||||
hasGroupId: !!minimaxGroupId,
|
||||
historyLength: history.length
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ model: minimaxModel || 'abab6.5-chat', messages: formattedMessages, temperature: 0.7 })
|
||||
});
|
||||
|
||||
LogService.info('Minimax', `HTTP ${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const rawErr = await response.text().catch(() => '(no body)');
|
||||
LogService.error('Minimax', `Réponse non-OK`, { status: response.status, body: rawErr });
|
||||
let errorMsg = `Erreur HTTP Minimax: ${response.status}`;
|
||||
try { const p = JSON.parse(rawErr); errorMsg = `Minimax API Error: ${p.base_resp?.status_msg || p.error?.message || response.status}`; } catch {}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
LogService.debug('Minimax', 'Réponse reçue', { base_resp: data.base_resp, replyPreview: data.choices?.[0]?.message?.content?.slice(0, 200) });
|
||||
|
||||
if (data.base_resp && data.base_resp.status_code !== 0) {
|
||||
throw new Error(`Minimax API Error: ${data.base_resp.status_msg}`);
|
||||
}
|
||||
|
||||
return data.choices?.[0]?.message?.content || "Désolé, je n'ai pas reçu de réponse de Minimax.";
|
||||
} catch (e: any) {
|
||||
LogService.error('Minimax', `Exception lors de la requête`, { message: e.message, stack: e.stack });
|
||||
throw new Error(`Échec de connexion à Minimax (${e.message || e})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Récupérer la liste des modèles Minimax ───────────────────────────────
|
||||
public static async getMinimaxModels(apiKey: string, groupId?: string): Promise<string[]> {
|
||||
if (!apiKey) throw new Error("Clé d'API Minimax requise pour lister les modèles.");
|
||||
|
||||
const url = 'https://api.minimax.chat/v1/models';
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${apiKey.trim()}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (groupId) headers['GroupId'] = groupId.trim();
|
||||
|
||||
LogService.info('Minimax', `GET ${url}`, { hasGroupId: !!groupId });
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method: 'GET', headers });
|
||||
|
||||
LogService.info('Minimax', `GET /models → HTTP ${response.status}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const rawErr = await response.text().catch(() => '(no body)');
|
||||
LogService.error('Minimax', `Erreur /models`, { status: response.status, body: rawErr });
|
||||
let errorMsg = `Erreur HTTP Minimax (${response.status})`;
|
||||
try { const p = JSON.parse(rawErr); errorMsg = `Minimax API Error: ${p.base_resp?.status_msg || p.error?.message || response.status}`; } catch {}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
LogService.debug('Minimax', 'Liste modèles reçue', data);
|
||||
|
||||
if (data && Array.isArray(data.data)) return data.data.map((m: any) => m.id);
|
||||
if (data && Array.isArray(data.models)) return data.models.map((m: any) => m.model_name || m.id || m);
|
||||
if (Array.isArray(data)) return data.map((m: any) => m.id || m);
|
||||
|
||||
throw new Error("Format de réponse non standard");
|
||||
} catch (e: any) {
|
||||
LogService.error('Minimax', `Exception /models`, { message: e.message });
|
||||
throw new Error(`Échec de récupération des modèles Minimax: ${e.message || e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
export interface VoiceOption {
|
||||
name: string;
|
||||
lang: string;
|
||||
voice: SpeechSynthesisVoice;
|
||||
}
|
||||
|
||||
export class AudioService {
|
||||
public ttsSynth: SpeechSynthesis;
|
||||
private recognition: any = null;
|
||||
private isListeningActive = false;
|
||||
|
||||
constructor() {
|
||||
this.ttsSynth = window.speechSynthesis;
|
||||
this.initSTT();
|
||||
}
|
||||
|
||||
// Initialisation de la reconnaissance vocale locale et gratuite (Web Speech API)
|
||||
private initSTT() {
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
if (SpeechRecognition) {
|
||||
this.recognition = new SpeechRecognition();
|
||||
this.recognition.continuous = false; // Arrête après une phrase
|
||||
this.recognition.interimResults = false; // Ne renvoie que les résultats finaux
|
||||
this.recognition.lang = 'fr-FR'; // Langue française par défaut
|
||||
} else {
|
||||
console.warn("La reconnaissance vocale (Speech-to-Text) n'est pas supportée dans cette WebView.");
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les voix françaises et globales disponibles
|
||||
public getVoices(): VoiceOption[] {
|
||||
if (!this.ttsSynth) return [];
|
||||
return this.ttsSynth.getVoices().map(v => ({
|
||||
name: v.name,
|
||||
lang: v.lang,
|
||||
voice: v
|
||||
}));
|
||||
}
|
||||
|
||||
private _resolveVoice(voiceName?: string): SpeechSynthesisVoice | undefined {
|
||||
const voices = this.ttsSynth.getVoices();
|
||||
if (voiceName) return voices.find(v => v.name === voiceName);
|
||||
return voices.find(v => v.lang.startsWith('fr') && v.name.toLowerCase().includes('google'))
|
||||
|| voices.find(v => v.lang.startsWith('fr'));
|
||||
}
|
||||
|
||||
private _cleanForTTS(raw: string): string {
|
||||
return raw
|
||||
// Emojis et symboles Unicode
|
||||
.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FEFF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA9F}]/gu, '')
|
||||
// Markdown : **, *, _, __, #, `, ~
|
||||
.replace(/(\*\*|__|~~|[*_#`~])/g, '')
|
||||
// Liens markdown [texte](url) → garder le texte
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
// Balises techniques [⚙️ Exécution: ...] et similaires
|
||||
.replace(/\[[^\]]*\]/g, '')
|
||||
// Ponctuation répétée ou décorative : ---, ===, ~~~, ...
|
||||
.replace(/(-{2,}|={2,}|~{2,}|\.{2,})/g, ' ')
|
||||
// Caractères non lisibles restants
|
||||
.replace(/[<>{}|\\^]/g, '')
|
||||
// Espaces multiples
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
private _makeUtterance(text: string, voiceName?: string, rate = 1.0, pitch = 1.1): SpeechSynthesisUtterance {
|
||||
const clean = this._cleanForTTS(text);
|
||||
const u = new SpeechSynthesisUtterance(clean);
|
||||
u.lang = 'fr-FR';
|
||||
u.rate = rate;
|
||||
u.pitch = pitch;
|
||||
const voice = this._resolveVoice(voiceName);
|
||||
if (voice) u.voice = voice;
|
||||
return u;
|
||||
}
|
||||
|
||||
// Synthèse Vocale complète (annule et relit tout le texte)
|
||||
public speak(text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
|
||||
this.ttsSynth.cancel();
|
||||
const u = this._makeUtterance(text, voiceName, rate, pitch);
|
||||
u.onend = () => resolve();
|
||||
u.onerror = (e) => reject(e);
|
||||
this.ttsSynth.speak(u);
|
||||
});
|
||||
}
|
||||
|
||||
// Ajoute une phrase à la file TTS sans annuler la lecture en cours
|
||||
// Utilisé pour le streaming phrase par phrase (démarre l'audio dès la 1ère phrase)
|
||||
public queueSentence(text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1): void {
|
||||
if (!this.ttsSynth) return;
|
||||
const clean = text.replace(/[*_#`]/g, '').replace(/\[.*?\]/g, '').trim();
|
||||
if (!clean) return;
|
||||
const u = this._makeUtterance(clean, voiceName, rate, pitch);
|
||||
this.ttsSynth.speak(u); // s'enchaîne automatiquement à la file SpeechSynthesis
|
||||
}
|
||||
|
||||
// Vide la file TTS (fin du streaming ou interruption utilisateur)
|
||||
public flushQueue(): void {
|
||||
if (this.ttsSynth) this.ttsSynth.cancel();
|
||||
}
|
||||
|
||||
// Arrêter immédiatement de parler
|
||||
public stopSpeaking() {
|
||||
if (this.ttsSynth) {
|
||||
this.ttsSynth.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
// Lancer l'écoute vocale (STT - Speech-To-Text)
|
||||
public startListening(
|
||||
onResult: (text: string) => void,
|
||||
onStart: () => void,
|
||||
onEnd: () => void,
|
||||
onError: (err: string) => void,
|
||||
lang: string = 'fr-FR'
|
||||
) {
|
||||
if (!this.recognition) {
|
||||
onError("STT non supporté");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isListeningActive) {
|
||||
this.recognition.stop();
|
||||
}
|
||||
|
||||
this.recognition.lang = lang;
|
||||
this.isListeningActive = true;
|
||||
|
||||
this.recognition.onstart = () => {
|
||||
onStart();
|
||||
};
|
||||
|
||||
this.recognition.onresult = (event: any) => {
|
||||
const resultText = event.results[0][0].transcript;
|
||||
onResult(resultText);
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event: any) => {
|
||||
onError(event.error);
|
||||
};
|
||||
|
||||
this.recognition.onend = () => {
|
||||
this.isListeningActive = false;
|
||||
onEnd();
|
||||
};
|
||||
|
||||
try {
|
||||
this.recognition.start();
|
||||
} catch (e: any) {
|
||||
onError(e.message || "Erreur de démarrage de l'écoute");
|
||||
}
|
||||
}
|
||||
|
||||
// Arrêter l'écoute vocale
|
||||
public stopListening() {
|
||||
if (this.recognition && this.isListeningActive) {
|
||||
this.recognition.stop();
|
||||
this.isListeningActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
export type EveEmotion = 'neutral' | 'happy' | 'sad' | 'surprised' | 'thinking' | 'angry';
|
||||
export type EveAvatar = 'eve' | 'nova' | 'aegis';
|
||||
|
||||
export class EmotionService {
|
||||
// Analyser le sentiment d'une phrase pour en déduire l'émotion d'Eve
|
||||
public static analyzeSentiment(text: string): EveEmotion {
|
||||
const cleanText = text.toLowerCase().trim();
|
||||
|
||||
// Mots-clés pour la joie / l'excitation
|
||||
const happyKeywords = [
|
||||
'génial', 'super', 'ravi', 'heureux', 'joie', 'plaisir', 'gagné', 'haha', 'cool', 'géniale',
|
||||
'merci', 'adore', 'excellent', 'parfait', 'extraordinaire', 'bravo', 'sourire', 'heureuse',
|
||||
'magnifique', 'awesome', 'great', 'happy', 'love', 'smile'
|
||||
];
|
||||
|
||||
// Mots-clés pour la tristesse / la déception
|
||||
const sadKeywords = [
|
||||
'désolé', 'triste', 'dommage', 'regret', 'malheureusement', 'pleurer', 'hélas', 'malheur',
|
||||
'perdu', 'sombre', 'erreur', 'échec', 'souffrir', 'bad', 'sorry', 'sad', 'unfortunately'
|
||||
];
|
||||
|
||||
// Mots-clés pour la colère / le mécontentement
|
||||
const angryKeywords = [
|
||||
'non', 'colère', 'énervé', 'fâché', 'nul', 'inacceptable', 'mauvais', 'déteste', 'pire',
|
||||
'irritant', 'frustré', 'stupide', 'ridicule', 'assez', 'stop', 'angry', 'no', 'hate'
|
||||
];
|
||||
|
||||
// Mots-clés pour la surprise / l'émerveillement
|
||||
const surprisedKeywords = [
|
||||
'wow', 'quoi', 'incroyable', 'vraiment', 'surprise', 'surpris', 'soudain', 'extraordinaire',
|
||||
'inattendu', 'magique', 'oh', 'ah', 'bizarre', 'étrange', 'wow', 'incredible', 'surprise'
|
||||
];
|
||||
|
||||
// Mots-clés pour la réflexion / l'analyse
|
||||
const thinkingKeywords = [
|
||||
'réfléchir', 'analyse', 'calcul', 'comprendre', 'hmm', 'euh', 'peut-être', 'probablement',
|
||||
'possible', 'hypothèse', 'cherche', 'étudie', 'question', 'pourquoi', 'comment', 'pense',
|
||||
'thinking', 'ponder', 'analyze', 'search', 'perhaps', 'maybe'
|
||||
];
|
||||
|
||||
// 1. Analyse par présence de mots-clés prioritaires
|
||||
if (this.containsAny(cleanText, angryKeywords)) return 'angry';
|
||||
if (this.containsAny(cleanText, sadKeywords)) return 'sad';
|
||||
if (this.containsAny(cleanText, surprisedKeywords)) return 'surprised';
|
||||
if (this.containsAny(cleanText, happyKeywords)) return 'happy';
|
||||
if (this.containsAny(cleanText, thinkingKeywords)) return 'thinking';
|
||||
|
||||
// 2. Analyse par ponctuation si aucun mot-clé n'est saillant
|
||||
if (cleanText.includes('!!!') || cleanText.includes('wow')) return 'surprised';
|
||||
if (cleanText.includes('?') && cleanText.length < 50) return 'thinking'; // Petite question rapide
|
||||
if (cleanText.includes('...')) return 'thinking';
|
||||
|
||||
// Émotion par défaut
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
// Vérifier si le texte contient au moins un des mots-clés
|
||||
private static containsAny(text: string, keywords: string[]): boolean {
|
||||
return keywords.some(keyword => {
|
||||
// Expression régulière pour rechercher le mot exact (évite les correspondances partielles fausses)
|
||||
const regex = new RegExp(`\\b${keyword}\\b|${keyword}`, 'i');
|
||||
return regex.test(text);
|
||||
});
|
||||
}
|
||||
|
||||
// Renvoie les paramètres graphiques (couleur des yeux, intensité, forme) pour chaque émotion et avatar
|
||||
// Utile pour la 3D ou les shaders
|
||||
public static getEmotionStyle(emotion: EveEmotion, avatarId: EveAvatar = 'eve') {
|
||||
// Spectre de couleurs par défaut selon l'avatar sélectionné
|
||||
let eyeColor = '#00f2ff'; // Eve par défaut
|
||||
|
||||
if (avatarId === 'nova') {
|
||||
switch (emotion) {
|
||||
case 'happy': eyeColor = '#ff0077'; break; // Luminous hot pink
|
||||
case 'sad': eyeColor = '#ff88dd'; break; // Melancholic soft pink
|
||||
case 'angry': eyeColor = '#9900ff'; break; // Furious deep purple
|
||||
case 'surprised': eyeColor = '#ffc8ff'; break; // Electric light pink
|
||||
case 'thinking': eyeColor = '#c300ff'; break; // Luminous neon violet
|
||||
case 'neutral':
|
||||
default:
|
||||
eyeColor = '#ff00ea'; // Vibrant magenta
|
||||
break;
|
||||
}
|
||||
} else if (avatarId === 'aegis') {
|
||||
switch (emotion) {
|
||||
case 'happy': eyeColor = '#ffaa00'; break; // Luminous warm gold
|
||||
case 'sad': eyeColor = '#ffa677'; break; // Melancholic dusty orange
|
||||
case 'angry': eyeColor = '#ff3300'; break; // Furious Nixie warning red
|
||||
case 'surprised': eyeColor = '#ffe4cc'; break; // Electric light amber
|
||||
case 'thinking': eyeColor = '#ff8800'; break; // Luminous bright orange
|
||||
case 'neutral':
|
||||
default:
|
||||
eyeColor = '#ff6a00'; // Vintage Nixie amber
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 'eve' original (Cyan spectrum)
|
||||
switch (emotion) {
|
||||
case 'happy': eyeColor = '#00ffff'; break; // Luminous turquoise cyan
|
||||
case 'sad': eyeColor = '#3de7ff'; break; // Melancholic luminous soft cyan
|
||||
case 'angry': eyeColor = '#00a2ff'; break; // Furious electric cyan-blue
|
||||
case 'surprised': eyeColor = '#99f2ff'; break; // Electric light ice blue
|
||||
case 'thinking': eyeColor = '#00c3ff'; break; // Luminous sky blue
|
||||
case 'neutral':
|
||||
default:
|
||||
eyeColor = '#00f2ff'; // Signature vibrant cyan blue
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (emotion) {
|
||||
case 'happy':
|
||||
return {
|
||||
eyeColor,
|
||||
eyeScaleY: 0.6,
|
||||
eyeRotation: 0.1, // Joyous rotation
|
||||
pulseSpeed: 3.5,
|
||||
lightIntensity: 2.0
|
||||
};
|
||||
case 'sad':
|
||||
return {
|
||||
eyeColor,
|
||||
eyeScaleY: 0.45,
|
||||
eyeRotation: -0.15, // Drooping eyes
|
||||
pulseSpeed: 1.0,
|
||||
lightIntensity: 1.0
|
||||
};
|
||||
case 'angry':
|
||||
return {
|
||||
eyeColor,
|
||||
eyeScaleY: 0.3,
|
||||
eyeRotation: 0.25, // Angry angled eyes
|
||||
pulseSpeed: 4.0,
|
||||
lightIntensity: 1.8
|
||||
};
|
||||
case 'surprised':
|
||||
return {
|
||||
eyeColor,
|
||||
eyeScaleY: 1.0, // Fully open round eyes
|
||||
eyeRotation: 0,
|
||||
pulseSpeed: 5.0,
|
||||
lightIntensity: 2.5
|
||||
};
|
||||
case 'thinking':
|
||||
return {
|
||||
eyeColor,
|
||||
eyeScaleY: 0.5, // Half closed thinking eyes
|
||||
eyeRotation: -0.05,
|
||||
pulseSpeed: 1.8,
|
||||
lightIntensity: 1.3
|
||||
};
|
||||
case 'neutral':
|
||||
default:
|
||||
return {
|
||||
eyeColor,
|
||||
eyeScaleY: 0.65, // Standard oblong shape
|
||||
eyeRotation: 0,
|
||||
pulseSpeed: 2.0,
|
||||
lightIntensity: 1.5
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* LogService — Centralized structured logger for EveFlow.
|
||||
*
|
||||
* In Electron (production/dev): sends log entries via window.electronAPI.log()
|
||||
* which the main process writes to %APPDATA%/EveFlow/eveflow.log
|
||||
*
|
||||
* Fallback (browser only): console only.
|
||||
*/
|
||||
|
||||
export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
export interface LogEntry {
|
||||
ts: string; // ISO timestamp
|
||||
level: LogLevel;
|
||||
tag: string; // Component / service tag (e.g. "HermesAgent", "Minimax")
|
||||
message: string;
|
||||
data?: unknown; // Any extra serializable payload
|
||||
}
|
||||
|
||||
export class LogService {
|
||||
private static readonly MAX_CONSOLE_DATA_LEN = 2000;
|
||||
|
||||
// Core write method
|
||||
public static log(level: LogLevel, tag: string, message: string, data?: unknown): void {
|
||||
const entry: LogEntry = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
tag,
|
||||
message,
|
||||
data
|
||||
};
|
||||
|
||||
// Always write to console for DevTools visibility
|
||||
const consoleMsg = `[${entry.ts}] [${level}] [${tag}] ${message}`;
|
||||
const dataStr = data !== undefined ? JSON.stringify(data, null, 2) : '';
|
||||
|
||||
switch (level) {
|
||||
case 'ERROR': console.error(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
|
||||
case 'WARN': console.warn(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
|
||||
case 'DEBUG': console.debug(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
|
||||
default: console.info(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
|
||||
}
|
||||
|
||||
// Send to Electron main process for file persistence
|
||||
try {
|
||||
const electronAPI = (window as any).electronAPI;
|
||||
if (electronAPI?.writeLog) {
|
||||
electronAPI.writeLog(entry);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore: browser context has no electronAPI
|
||||
}
|
||||
}
|
||||
|
||||
public static debug(tag: string, message: string, data?: unknown): void {
|
||||
this.log('DEBUG', tag, message, data);
|
||||
}
|
||||
|
||||
public static info(tag: string, message: string, data?: unknown): void {
|
||||
this.log('INFO', tag, message, data);
|
||||
}
|
||||
|
||||
public static warn(tag: string, message: string, data?: unknown): void {
|
||||
this.log('WARN', tag, message, data);
|
||||
}
|
||||
|
||||
public static error(tag: string, message: string, data?: unknown): void {
|
||||
this.log('ERROR', tag, message, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* PollingService — Periodically polls Hermes Agent /api/jobs endpoint
|
||||
* to receive proactive notifications from the AI (scheduled tasks, alerts).
|
||||
*
|
||||
* Runs as a background timer. Calls the provided callback whenever a
|
||||
* newly completed job is detected.
|
||||
*/
|
||||
|
||||
import { LogService } from './logService';
|
||||
|
||||
export interface HermesJob {
|
||||
id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
result?: string;
|
||||
created_at?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export type JobHandler = (job: HermesJob) => void;
|
||||
|
||||
export class PollingService {
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private seenJobIds: Set<string> = new Set();
|
||||
private hermesUrl: string = '';
|
||||
private hermesApiKey: string = '';
|
||||
private onJobCompleted: JobHandler | null = null;
|
||||
private intervalMs: number;
|
||||
|
||||
constructor(intervalMs: number = 30_000) {
|
||||
this.intervalMs = intervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling Hermes Agent for completed jobs.
|
||||
*
|
||||
* @param hermesUrl - Base URL of the Hermes server
|
||||
* @param apiKey - Bearer token for authentication
|
||||
* @param onCompleted - Callback fired when a new completed job is detected
|
||||
*/
|
||||
public start(hermesUrl: string, apiKey: string, onCompleted: JobHandler): void {
|
||||
this.stop(); // clear any existing interval
|
||||
this.hermesUrl = hermesUrl;
|
||||
this.hermesApiKey = apiKey;
|
||||
this.onJobCompleted = onCompleted;
|
||||
|
||||
LogService.info('PollingService', `Démarrage polling Hermes toutes les ${this.intervalMs / 1000}s`, { hermesUrl });
|
||||
|
||||
// Fire once immediately, then on interval
|
||||
this.poll();
|
||||
this.intervalId = setInterval(() => this.poll(), this.intervalMs);
|
||||
}
|
||||
|
||||
/** Stop the polling loop. */
|
||||
public stop(): void {
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
LogService.info('PollingService', 'Polling Hermes arrêté.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if currently polling. */
|
||||
public get isActive(): boolean {
|
||||
return this.intervalId !== null;
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (!this.hermesUrl || !this.onJobCompleted) return;
|
||||
|
||||
const base = this.hermesUrl.replace(/\/$/, '').replace(/\/v1(\/.*)?$/, '');
|
||||
const endpoint = `${base}/api/jobs`;
|
||||
|
||||
const abortCtrl = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortCtrl.abort(), 10_000); // 10s max
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.hermesApiKey) headers['Authorization'] = `Bearer ${this.hermesApiKey.trim()}`;
|
||||
|
||||
const response = await fetch(endpoint, { method: 'GET', headers, signal: abortCtrl.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
LogService.warn('PollingService', `Erreur HTTP /api/jobs: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jobs: HermesJob[] = await response.json();
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === 'completed' && job.id && !this.seenJobIds.has(job.id)) {
|
||||
this.seenJobIds.add(job.id);
|
||||
LogService.info('PollingService', `Nouveau job complété: ${job.id}`, { result: job.result?.slice(0, 200) });
|
||||
this.onJobCompleted(job);
|
||||
}
|
||||
}
|
||||
|
||||
// Évite une fuite mémoire sur les sessions longues
|
||||
if (this.seenJobIds.size > 500) {
|
||||
const arr = [...this.seenJobIds];
|
||||
this.seenJobIds = new Set(arr.slice(-250));
|
||||
}
|
||||
} catch (e: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if ((e as any).name === 'AbortError') {
|
||||
LogService.warn('PollingService', 'Poll timeout (10s) — serveur Hermes trop lent ou injoignable');
|
||||
return;
|
||||
}
|
||||
LogService.warn('PollingService', `Exception lors du poll: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* ToolRegistry — Defines all functions exposed to Hermes Agent via OpenAI tool calling.
|
||||
*
|
||||
* Each tool has:
|
||||
* - A JSON Schema definition sent to Hermes in the `tools[]` payload
|
||||
* - A local executor that runs when Hermes invokes it
|
||||
*
|
||||
* AppCallbacks provides access to React state setters from AgentService (stateless).
|
||||
*/
|
||||
|
||||
export interface AppCallbacks {
|
||||
/** Change the avatar's current emotion */
|
||||
setEmotion: (emotion: string) => void;
|
||||
/** Get the current application status snapshot */
|
||||
getStatus: () => AppStatus;
|
||||
/** Get the N most recent chat messages */
|
||||
getHistory: (n: number) => { role: string; content: string }[];
|
||||
/** Trigger TTS speech */
|
||||
speak: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface AppStatus {
|
||||
provider: string;
|
||||
avatar: string;
|
||||
emotion: string;
|
||||
isSpeaking: boolean;
|
||||
isMuted: boolean;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
/** OpenAI-compatible tool definition */
|
||||
export interface ToolDefinition {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Result returned to Hermes after tool execution */
|
||||
export interface ToolResult {
|
||||
tool_call_id: string;
|
||||
role: 'tool';
|
||||
content: string;
|
||||
}
|
||||
|
||||
// ── Tool Definitions (sent to Hermes in request payload) ───────────────────
|
||||
|
||||
export const HERMES_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'set_avatar_emotion',
|
||||
description: "Change l'état émotionnel de l'avatar Eve visible par l'utilisateur. Appelle cet outil quand la réponse doit être accompagnée d'une expression visible.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
emotion: {
|
||||
type: 'string',
|
||||
enum: ['neutral', 'happy', 'thinking', 'excited', 'sad', 'angry', 'surprised'],
|
||||
description: "L'émotion à afficher sur l'avatar"
|
||||
}
|
||||
},
|
||||
required: ['emotion']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_app_status',
|
||||
description: "Retourne l'état actuel de l'application EveFlow : provider IA actif, avatar sélectionné, émotion courante, mode audio.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_conversation_history',
|
||||
description: 'Retourne les N derniers messages de la conversation en cours.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
n: {
|
||||
type: 'number',
|
||||
description: 'Nombre de messages à récupérer (max 20)',
|
||||
minimum: 1,
|
||||
maximum: 20
|
||||
}
|
||||
},
|
||||
required: ['n']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'speak_text',
|
||||
description: "Fait prononcer un texte par la synthèse vocale de l'avatar. Utile pour mettre en avant une information importante.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'Le texte à prononcer (max 500 caractères)'
|
||||
}
|
||||
},
|
||||
required: ['text']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// ── Tool Dispatcher ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Executes a tool call requested by Hermes Agent and returns the stringified result.
|
||||
*
|
||||
* @param toolCallId - The ID from Hermes tool_call (must be echoed back in role:tool message)
|
||||
* @param name - Function name requested by Hermes
|
||||
* @param rawArgs - JSON string of arguments from Hermes
|
||||
* @param callbacks - App state accessors injected from the React layer
|
||||
* @returns - ToolResult to append to the messages array
|
||||
*/
|
||||
export function executeTool(
|
||||
toolCallId: string,
|
||||
name: string,
|
||||
rawArgs: string,
|
||||
callbacks: AppCallbacks
|
||||
): ToolResult {
|
||||
let result: string;
|
||||
|
||||
try {
|
||||
const args = JSON.parse(rawArgs || '{}');
|
||||
|
||||
switch (name) {
|
||||
case 'set_avatar_emotion': {
|
||||
const emotion = args.emotion as string;
|
||||
callbacks.setEmotion(emotion);
|
||||
result = JSON.stringify({ success: true, emotion_set: emotion });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'get_app_status': {
|
||||
const status = callbacks.getStatus();
|
||||
result = JSON.stringify(status);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'get_conversation_history': {
|
||||
const n = Math.min(Number(args.n) || 5, 20);
|
||||
const history = callbacks.getHistory(n);
|
||||
result = JSON.stringify({ messages: history, count: history.length });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'speak_text': {
|
||||
const text = String(args.text || '').slice(0, 500);
|
||||
callbacks.speak(text);
|
||||
result = JSON.stringify({ success: true, text_spoken: text });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
result = JSON.stringify({ error: `Unknown tool: ${name}` });
|
||||
}
|
||||
} catch (e: any) {
|
||||
result = JSON.stringify({ error: `Tool execution failed: ${e.message}` });
|
||||
}
|
||||
|
||||
return {
|
||||
tool_call_id: toolCallId,
|
||||
role: 'tool',
|
||||
content: result
|
||||
};
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
electronAPI?: {
|
||||
windowControl: (action: 'minimize' | 'maximize' | 'close') => void;
|
||||
setWindowMode: (mode: 'floating' | 'normal') => void;
|
||||
onWindowModeChanged: (callback: (mode: 'floating' | 'normal') => void) => () => void;
|
||||
onHermesPush: (callback: (event: any) => void) => () => void;
|
||||
writeLog: (entry: any) => void;
|
||||
storeGet: (key: string) => Promise<string | null>;
|
||||
storeSet: (key: string, value: string) => Promise<boolean>;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user