mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-21 06:08:14 +02:00
feat!: rebuild EveFlow as a JARVIS-style voice HUD for Hermes Agent (v2.0.0)
Complete rewrite of the application: - Toolchain: Electron 44, Vite 8, React 19, TypeScript 5.9, Zustand, Vitest; main process rewritten in TypeScript (electron/), typed IPC contract (shared/), sandboxed renderer with webSecurity on and an HTTP/SSE proxy in the main process. - Voice: AudioWorklet microphone capture at 16 kHz with adaptive energy VAD and auto-stop, WAV encoder, OpenAI-compatible STT, TTS queue with sentence-level streaming, prefetch and Web Audio playback feeding an analyser; hands-free mode, global push-to-talk hotkey, system-voice fallback. - Hermes: full API client (capabilities, health, models, skills, toolsets, sessions, jobs) with automatic transport selection: runs API (SSE lifecycle, approvals, steer, stop) > sessions stream > chat completions with local tools; tolerant event normalisation; webhook receiver hardened (secret, size limit). - UI: JARVIS arc-reactor core on Canvas 2D reacting to the real audio signal, holographic HUD layout (transcript, core, Hermes ops panel with tools/crons/ skills/sessions/link tabs, telemetry), settings drawer, approval modals, compact floating widget, four themes, tray icon and global shortcuts. - Removed the 3D Eve robot, three.js and legacy assets; migrated 1.x settings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
This commit is contained in:
239 files changed
+11351
-86495
No files matched your search
+143
-2631
File diff suppressed because it is too large.
Load diff
@@ -1,109 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useFrame, useLoader } from '@react-three/fiber';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import * as THREE from 'three';
|
||||
import { EveModel } from './EveModel';
|
||||
import { EveAvatar, EveEmotion, EmotionService } from '../services/emotionService';
|
||||
|
||||
interface AvatarModelLoaderProps {
|
||||
avatarId: EveAvatar;
|
||||
emotion: EveEmotion;
|
||||
isSpeaking: boolean;
|
||||
}
|
||||
|
||||
type AvatarManifest = Partial<Record<Exclude<EveAvatar, 'eve'>, string | null>>;
|
||||
|
||||
const AVATAR_MANIFEST_PATH = './avatars/avatar-manifest.json';
|
||||
|
||||
const LoadedGlbAvatar: React.FC<{
|
||||
path: string;
|
||||
avatarId: EveAvatar;
|
||||
emotion: EveEmotion;
|
||||
isSpeaking: boolean;
|
||||
}> = ({ path, avatarId, emotion, isSpeaking }) => {
|
||||
const gltf = useLoader(GLTFLoader, path);
|
||||
const scene = useMemo(() => {
|
||||
const cloned = gltf.scene.clone(true);
|
||||
cloned.position.y = 0.27;
|
||||
return cloned;
|
||||
}, [gltf.scene]);
|
||||
|
||||
useEffect(() => {
|
||||
const style = EmotionService.getEmotionStyle(emotion, avatarId);
|
||||
scene.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
child.castShadow = false;
|
||||
child.receiveShadow = false;
|
||||
|
||||
const materialName = Array.isArray(child.material)
|
||||
? child.material.map((material) => material.name).join(' ')
|
||||
: child.material?.name || '';
|
||||
|
||||
if (/eye|led|visor|screen|emissive/i.test(`${child.name} ${materialName}`)) {
|
||||
const eyeMaterial = new THREE.MeshBasicMaterial({
|
||||
color: new THREE.Color(style.eyeColor),
|
||||
transparent: true,
|
||||
opacity: 0.96
|
||||
});
|
||||
child.material = eyeMaterial;
|
||||
}
|
||||
});
|
||||
}, [avatarId, emotion, scene]);
|
||||
|
||||
useFrame((state) => {
|
||||
const t = state.clock.getElapsedTime();
|
||||
scene.position.y = Math.sin(t * 1.4) * 0.05 + 0.27;
|
||||
scene.rotation.y = Math.sin(t * 0.45) * 0.05;
|
||||
|
||||
if (isSpeaking) {
|
||||
scene.rotation.z = Math.sin(t * 8) * 0.025;
|
||||
} else {
|
||||
scene.rotation.z = THREE.MathUtils.lerp(scene.rotation.z, 0, 0.12);
|
||||
}
|
||||
});
|
||||
|
||||
return <primitive object={scene} scale={0.48} />;
|
||||
};
|
||||
|
||||
export const AvatarModelLoader: React.FC<AvatarModelLoaderProps> = ({ avatarId, emotion, isSpeaking }) => {
|
||||
const [assetPath, setAssetPath] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setAssetPath(null);
|
||||
|
||||
if (avatarId === 'eve') return;
|
||||
|
||||
fetch(AVATAR_MANIFEST_PATH, { cache: 'no-store' })
|
||||
.then((response) => {
|
||||
if (!response.ok) return null;
|
||||
return response.json() as Promise<AvatarManifest>;
|
||||
})
|
||||
.then((manifest) => {
|
||||
if (cancelled || !manifest) return;
|
||||
let nextAssetPath = manifest[avatarId];
|
||||
if (typeof nextAssetPath === 'string' && nextAssetPath.trim()) {
|
||||
// Si le chemin commence par un slash, on le rend relatif pour fonctionner sous le protocole file:// en production
|
||||
if (nextAssetPath.startsWith('/')) {
|
||||
nextAssetPath = '.' + nextAssetPath;
|
||||
}
|
||||
setAssetPath(nextAssetPath);
|
||||
} else {
|
||||
setAssetPath(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAssetPath(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [avatarId]);
|
||||
|
||||
if (avatarId !== 'eve' && assetPath) {
|
||||
return <LoadedGlbAvatar path={assetPath} avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />;
|
||||
}
|
||||
|
||||
return <EveModel emotion={emotion} isSpeaking={isSpeaking} avatarId="eve" />;
|
||||
};
|
||||
@@ -1,767 +0,0 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useFrame, useLoader } from '@react-three/fiber';
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js';
|
||||
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
||||
import * as THREE from 'three';
|
||||
import { EveEmotion, EveAvatar, EmotionService } from '../services/emotionService';
|
||||
|
||||
interface EveModelProps {
|
||||
emotion: EveEmotion;
|
||||
isSpeaking: boolean;
|
||||
avatarId?: EveAvatar;
|
||||
}
|
||||
|
||||
const createSmoothGeometry = (geometry: THREE.BufferGeometry, tolerance = 0.01) => {
|
||||
const smoothGeometry = geometry.clone();
|
||||
smoothGeometry.deleteAttribute('normal');
|
||||
smoothGeometry.deleteAttribute('uv');
|
||||
if (smoothGeometry.hasAttribute('color')) {
|
||||
smoothGeometry.deleteAttribute('color');
|
||||
}
|
||||
if (smoothGeometry.hasAttribute('uv2')) {
|
||||
smoothGeometry.deleteAttribute('uv2');
|
||||
}
|
||||
|
||||
const mergedGeometry = mergeVertices(smoothGeometry, tolerance);
|
||||
mergedGeometry.computeVertexNormals();
|
||||
mergedGeometry.computeBoundingBox();
|
||||
mergedGeometry.computeBoundingSphere();
|
||||
|
||||
return mergedGeometry;
|
||||
};
|
||||
|
||||
const createVisorShaderMaterial = () => {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
eyeColor: { value: new THREE.Color('#00f2ff') },
|
||||
eyeScaleY: { value: 0.75 },
|
||||
eyeRotation: { value: 0 },
|
||||
eyeOffsetX: { value: 0 },
|
||||
eyeOffsetY: { value: 0 },
|
||||
speakingPulse: { value: 0 },
|
||||
thinkingVal: { value: 0 },
|
||||
uTime: { value: 0 }
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec3 vLocalPosition;
|
||||
varying vec3 vNormal;
|
||||
|
||||
void main() {
|
||||
vLocalPosition = position;
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 eyeColor;
|
||||
uniform float eyeScaleY;
|
||||
uniform float eyeRotation;
|
||||
uniform float eyeOffsetX;
|
||||
uniform float eyeOffsetY;
|
||||
uniform float speakingPulse;
|
||||
uniform float thinkingVal;
|
||||
uniform float uTime;
|
||||
|
||||
varying vec3 vLocalPosition;
|
||||
varying vec3 vNormal;
|
||||
|
||||
vec2 rotateAround(vec2 point, vec2 center, float angle) {
|
||||
float c = cos(angle);
|
||||
float s = sin(angle);
|
||||
vec2 localPoint = point - center;
|
||||
return vec2(
|
||||
localPoint.x * c - localPoint.y * s,
|
||||
localPoint.x * s + localPoint.y * c
|
||||
) + center;
|
||||
}
|
||||
|
||||
float eyeMask(vec2 point, vec2 center, float width, float height, float angle) {
|
||||
vec2 rotatedPoint = rotateAround(point, center, angle);
|
||||
vec2 normalizedPoint = (rotatedPoint - center) / vec2(width, height);
|
||||
float distanceFromCenter = dot(normalizedPoint, normalizedPoint);
|
||||
return 1.0 - smoothstep(0.64, 0.92, distanceFromCenter);
|
||||
}
|
||||
|
||||
float eyeGlow(vec2 point, vec2 center, float width, float height, float angle) {
|
||||
vec2 rotatedPoint = rotateAround(point, center, angle);
|
||||
vec2 normalizedPoint = (rotatedPoint - center) / vec2(width, height);
|
||||
float distanceFromCenter = dot(normalizedPoint, normalizedPoint);
|
||||
return 1.0 - smoothstep(0.82, 1.85, distanceFromCenter);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 facePoint = vLocalPosition.xy;
|
||||
float eyeHeight = max(0.11, 0.18 * eyeScaleY + speakingPulse);
|
||||
vec2 leftCenter = vec2(-0.72 + eyeOffsetX, 8.56 + eyeOffsetY);
|
||||
vec2 rightCenter = vec2(0.72 + eyeOffsetX, 8.56 + eyeOffsetY);
|
||||
|
||||
float leftEye = eyeMask(facePoint, leftCenter, 0.43, eyeHeight, eyeRotation);
|
||||
float rightEye = eyeMask(facePoint, rightCenter, 0.43, eyeHeight, -eyeRotation);
|
||||
float leftGlow = eyeGlow(facePoint, leftCenter, 0.49, eyeHeight * 1.35, eyeRotation);
|
||||
float rightGlow = eyeGlow(facePoint, rightCenter, 0.49, eyeHeight * 1.35, -eyeRotation);
|
||||
|
||||
float eye = max(leftEye, rightEye);
|
||||
float glow = max(leftGlow, rightGlow);
|
||||
float core = eyeMask(facePoint, leftCenter + vec2(0.0, 0.01), 0.25, eyeHeight * 0.42, eyeRotation)
|
||||
+ eyeMask(facePoint, rightCenter + vec2(0.0, 0.01), 0.25, eyeHeight * 0.42, -eyeRotation);
|
||||
|
||||
vec3 viewLight = normalize(vec3(-0.35, 0.42, 1.0));
|
||||
float highlight = pow(max(dot(normalize(vNormal), viewLight), 0.0), 16.0) * 0.23;
|
||||
float rim = pow(1.0 - abs(vNormal.z), 2.0) * 0.08;
|
||||
|
||||
// Lueur de calcul douce pendant la réflexion (remplace les bandes dures)
|
||||
float scanline = 0.0;
|
||||
if (thinkingVal > 0.05) {
|
||||
// Bandes très espacées et très graduelles — effet data-pulse discret
|
||||
float wave = sin(vLocalPosition.y * 2.5 - uTime * 4.0) * 0.5 + 0.5;
|
||||
scanline = smoothstep(0.55, 0.95, wave) * thinkingVal * 0.45;
|
||||
// Respiration lente (pas de scintillement haute fréquence agressif)
|
||||
scanline *= (0.85 + 0.15 * sin(uTime * 2.2));
|
||||
}
|
||||
|
||||
vec3 color = vec3(0.002, 0.004, 0.006);
|
||||
color += vec3(highlight + rim);
|
||||
color += eyeColor * glow * 0.13;
|
||||
color += eyeColor * eye * 1.85;
|
||||
color += vec3(0.88, 1.0, 1.0) * clamp(core, 0.0, 1.0) * 0.55;
|
||||
color += eyeColor * scanline * 0.85;
|
||||
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: true,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates local 3D vertex deformation offsets for Eve's arms based on current emotion,
|
||||
* time, and side/height falloff coordinates.
|
||||
*/
|
||||
const getEmotionalArmOffset = (
|
||||
side: number,
|
||||
shoulderFalloff: number,
|
||||
t: number,
|
||||
emotion: EveEmotion,
|
||||
isSpeaking: boolean
|
||||
): { dx: number; dy: number; dz: number } => {
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
let dz = 0;
|
||||
|
||||
// 1. Base speaking gesticulation wave
|
||||
const talkAmp = isSpeaking ? 1.0 : 0.0;
|
||||
// Dynamic talking wave: faster and wider
|
||||
const talkX = talkAmp * side * Math.sin(t * 12) * 0.12 * shoulderFalloff;
|
||||
const talkY = talkAmp * Math.abs(Math.cos(t * 12)) * 0.12 * shoulderFalloff;
|
||||
const talkZ = talkAmp * Math.sin(t * 10 + side) * 0.08 * shoulderFalloff;
|
||||
|
||||
// 2. Emotional posture offset and secondary oscillations
|
||||
switch (emotion) {
|
||||
case 'happy': {
|
||||
// Fast, excited bouncing and outward waving
|
||||
const bounceX = side * (Math.sin(t * 8.5) * 0.18 + 0.1) * shoulderFalloff;
|
||||
const bounceY = Math.abs(Math.sin(t * 13)) * 0.26 * shoulderFalloff;
|
||||
const bounceZ = Math.sin(t * 11 + side) * 0.08 * shoulderFalloff;
|
||||
|
||||
// Joyous gesticulation is fast
|
||||
dx = bounceX + talkX * 1.3;
|
||||
dy = bounceY + talkY * 1.3;
|
||||
dz = bounceZ + talkZ * 1.3;
|
||||
break;
|
||||
}
|
||||
case 'sad': {
|
||||
// Heavy, lifeless drooping arms, slightly pulled in close to the body
|
||||
const droopX = -side * 0.16 * shoulderFalloff;
|
||||
const droopY = -0.32 * shoulderFalloff; // Sags downwards
|
||||
const droopZ = Math.sin(t * 0.6) * 0.015 * shoulderFalloff; // Barely breathing
|
||||
|
||||
// When sad, talking gesticulation is extremely muted and slow
|
||||
dx = droopX + talkX * 0.25;
|
||||
dy = droopY + talkY * 0.25;
|
||||
dz = droopZ + talkZ * 0.25;
|
||||
break;
|
||||
}
|
||||
case 'thinking': {
|
||||
// Asymmetric pose: Right arm (side > 0) raised high to think/touch helmet,
|
||||
// Left arm (side < 0) hanging restfully.
|
||||
if (side > 0) {
|
||||
// Right arm raised towards helmet
|
||||
const thinkX = -side * 0.18 * shoulderFalloff; // Brought inward
|
||||
const thinkY = (1.18 + Math.sin(t * 2.2) * 0.04) * shoulderFalloff; // High vertical reach
|
||||
const thinkZ = (0.38 + Math.cos(t * 2.2) * 0.04) * shoulderFalloff; // Pushed forward
|
||||
|
||||
dx = thinkX + talkX * 0.15; // Raised arm barely moves during speech
|
||||
dy = thinkY + talkY * 0.15;
|
||||
dz = thinkZ + talkZ * 0.15;
|
||||
} else {
|
||||
// Left arm relaxed, drifting slowly
|
||||
const restX = -side * 0.04 * shoulderFalloff;
|
||||
const restY = -0.12 * shoulderFalloff;
|
||||
const restZ = Math.sin(t * 1.4) * 0.035 * shoulderFalloff;
|
||||
|
||||
dx = restX + talkX * 0.8;
|
||||
dy = restY + talkY * 0.8;
|
||||
dz = restZ + talkZ * 0.8;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'angry': {
|
||||
// Rigid, tensed arms, pushed slightly outwards, high-freq anger trembling
|
||||
const stiffX = side * 0.14 * shoulderFalloff;
|
||||
const stiffY = -0.08 * shoulderFalloff;
|
||||
const tremble = Math.sin(t * 28) * 0.028 * shoulderFalloff; // Furious shaking
|
||||
const stiffZ = tremble;
|
||||
|
||||
// Anger talking is abrupt, stiff and aggressive
|
||||
dx = stiffX + talkX * 0.6;
|
||||
dy = stiffY + talkY * 0.6;
|
||||
dz = stiffZ + talkZ * 0.6;
|
||||
break;
|
||||
}
|
||||
case 'surprised': {
|
||||
// Arms immediately thrown upwards and wide (shock reflex)
|
||||
const shockX = side * 0.32 * shoulderFalloff;
|
||||
const shockY = (0.42 + Math.sin(t * 16) * 0.08) * shoulderFalloff; // Fast jitter
|
||||
const shockZ = -0.16 * shoulderFalloff; // Recoil
|
||||
|
||||
// Speech gesticulation is fast but overwhelmed by shock
|
||||
dx = shockX + talkX * 0.8;
|
||||
dy = shockY + talkY * 0.8;
|
||||
dz = shockZ + talkZ * 0.8;
|
||||
break;
|
||||
}
|
||||
case 'neutral':
|
||||
default: {
|
||||
// Gentle breathing floating cycle
|
||||
const breatheX = side * Math.sin(t * 1.5) * 0.02 * shoulderFalloff;
|
||||
const breatheY = Math.abs(Math.sin(t * 1.5)) * 0.03 * shoulderFalloff;
|
||||
const breatheZ = Math.sin(t * 1.5 + side) * 0.03 * shoulderFalloff;
|
||||
|
||||
dx = breatheX + talkX;
|
||||
dy = breatheY + talkY;
|
||||
dz = breatheZ + talkZ;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { dx, dy, dz };
|
||||
};
|
||||
|
||||
export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarId = 'eve' }) => {
|
||||
const modelRef = useRef<THREE.Group>(null);
|
||||
const headRef = useRef<THREE.Object3D | null>(null);
|
||||
const visorRef = useRef<THREE.Object3D | null>(null);
|
||||
const eyesRef = useRef<THREE.Object3D | null>(null);
|
||||
const visorMaterialRef = useRef<THREE.ShaderMaterial | null>(null);
|
||||
const mainBodyMeshRef = useRef<THREE.Mesh | null>(null);
|
||||
|
||||
const mainBodyBasePositionsRef = useRef<Float32Array | null>(null);
|
||||
const armsRef = useRef<THREE.Mesh[]>([]);
|
||||
|
||||
// Perfect pivot reference and center memory for neck-like head rotation
|
||||
const headGroupRef = useRef<THREE.Group | null>(null);
|
||||
const headCenterRef = useRef<THREE.Vector3>(new THREE.Vector3());
|
||||
|
||||
// Charger les matériaux et le modèle OBJ depuis le répertoire public
|
||||
const materials = useLoader(MTLLoader, './Eve/EVE.mtl');
|
||||
const obj = useLoader(OBJLoader, './Eve/EVE.obj.bin', (loader) => {
|
||||
materials.preload();
|
||||
loader.setMaterials(materials);
|
||||
});
|
||||
|
||||
// Cloner le modèle pour éviter les interférences si réutilisé
|
||||
const clonedObj = React.useMemo(() => obj.clone(), [obj]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!clonedObj) return;
|
||||
|
||||
// Reset references to avoid old instances
|
||||
const headMeshes: THREE.Mesh[] = [];
|
||||
const visorMeshes: THREE.Mesh[] = [];
|
||||
const eyesMeshes: THREE.Mesh[] = [];
|
||||
const armsMeshes: THREE.Mesh[] = [];
|
||||
const bodyMeshes: THREE.Mesh[] = [];
|
||||
|
||||
headRef.current = null;
|
||||
visorRef.current = null;
|
||||
eyesRef.current = null;
|
||||
armsRef.current = [];
|
||||
headGroupRef.current = null;
|
||||
visorMaterialRef.current = null;
|
||||
mainBodyMeshRef.current = null;
|
||||
mainBodyBasePositionsRef.current = null;
|
||||
|
||||
// Remove helper covers from previous dev/StrictMode effect passes.
|
||||
[
|
||||
'eveBodyCollarSeamCover',
|
||||
'eveNeckSeamCover',
|
||||
'eveLeftShoulderSeamCover',
|
||||
'eveRightShoulderSeamCover'
|
||||
].forEach((helperName) => {
|
||||
const helper = clonedObj.getObjectByName(helperName);
|
||||
if (helper?.parent) {
|
||||
helper.parent.remove(helper);
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Classify meshes to assign control references and extract original textures
|
||||
clonedObj.traverse((child: THREE.Object3D) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
const lowerName = (child.name || '').toLowerCase();
|
||||
|
||||
// Hide weapons accessories completely (as requested: only the character)
|
||||
if (lowerName.includes('gun')) {
|
||||
child.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lowerName.includes('head')) {
|
||||
headMeshes.push(child);
|
||||
} else if (lowerName.includes('visor') || lowerName.includes('visiere') || lowerName.includes('screen') || lowerName.includes('ecran')) {
|
||||
visorMeshes.push(child);
|
||||
} else if (lowerName.includes('eye') || lowerName.includes('yeux')) {
|
||||
eyesMeshes.push(child);
|
||||
} else if (lowerName.includes('upperbody')) {
|
||||
bodyMeshes.push(child);
|
||||
} else if (lowerName.includes('arm')) {
|
||||
armsMeshes.push(child);
|
||||
} else {
|
||||
bodyMeshes.push(child);
|
||||
}
|
||||
}
|
||||
});
|
||||
const resolvedHeadMesh = headMeshes[0] || null;
|
||||
const resolvedVisorMesh = visorMeshes[0] || null;
|
||||
|
||||
const existingGroup = clonedObj.getObjectByName('headGroup');
|
||||
|
||||
// 2. Build High-Fidelity Retro-Futuristic space-age materials
|
||||
|
||||
// A. White lacquer glossy material for body parts, limbs and helmet shell.
|
||||
// Specular dispersion values (higher roughness and low metalness) are optimized
|
||||
// alongside a light emissive fill to diffuse self-shadows and mask assembly lines.
|
||||
const whiteLacquerMaterial = new THREE.MeshPhysicalMaterial({
|
||||
color: new THREE.Color('#ffffff'),
|
||||
roughness: 0.14,
|
||||
metalness: 0.14,
|
||||
clearcoat: 1.0,
|
||||
clearcoatRoughness: 0.04,
|
||||
emissive: new THREE.Color('#ffffff'),
|
||||
emissiveIntensity: 0.16,
|
||||
reflectivity: 0.92,
|
||||
depthWrite: true,
|
||||
transparent: false
|
||||
});
|
||||
|
||||
const seamCoverMaterial = new THREE.MeshPhysicalMaterial({
|
||||
color: new THREE.Color('#ffffff'),
|
||||
roughness: 0.14,
|
||||
metalness: 0.14,
|
||||
clearcoat: 1.0,
|
||||
clearcoatRoughness: 0.04,
|
||||
emissive: new THREE.Color('#ffffff'),
|
||||
emissiveIntensity: 0.16,
|
||||
reflectivity: 0.92,
|
||||
depthWrite: true,
|
||||
transparent: false
|
||||
});
|
||||
bodyMeshes.forEach((mesh) => {
|
||||
// Weld nearby vertices to resolve seams perfectly
|
||||
mesh.geometry = createSmoothGeometry(mesh.geometry, 0.08);
|
||||
mesh.material = whiteLacquerMaterial;
|
||||
mesh.castShadow = false;
|
||||
mesh.receiveShadow = false;
|
||||
|
||||
if ((mesh.name || '').toLowerCase() === 'eve') {
|
||||
mainBodyMeshRef.current = mesh;
|
||||
mainBodyBasePositionsRef.current = new Float32Array(mesh.geometry.getAttribute('position').array);
|
||||
}
|
||||
});
|
||||
|
||||
if (resolvedHeadMesh) {
|
||||
// Smooth out head geometry specifically to erase forehead seams and curves
|
||||
resolvedHeadMesh.geometry = createSmoothGeometry(resolvedHeadMesh.geometry, 0.08);
|
||||
// Dedicated head material: higher emissive to fill shadow gaps at seam edges
|
||||
resolvedHeadMesh.material = new THREE.MeshPhysicalMaterial({
|
||||
color: new THREE.Color('#ffffff'),
|
||||
roughness: 0.12,
|
||||
metalness: 0.12,
|
||||
clearcoat: 1.0,
|
||||
clearcoatRoughness: 0.03,
|
||||
emissive: new THREE.Color('#ffffff'),
|
||||
emissiveIntensity: 0.28,
|
||||
reflectivity: 0.95,
|
||||
depthWrite: true,
|
||||
transparent: false,
|
||||
});
|
||||
resolvedHeadMesh.castShadow = false;
|
||||
resolvedHeadMesh.receiveShadow = false;
|
||||
}
|
||||
|
||||
armsRef.current = [];
|
||||
armsMeshes.forEach((mesh) => {
|
||||
// Apply smooth geometry and white lacquer material to the floating arms
|
||||
mesh.geometry = createSmoothGeometry(mesh.geometry, 0.07);
|
||||
mesh.material = whiteLacquerMaterial;
|
||||
mesh.castShadow = false;
|
||||
mesh.receiveShadow = false;
|
||||
|
||||
// Store base coordinates for dynamic relative offset animations
|
||||
mesh.userData.initialPosition = mesh.position.clone();
|
||||
mesh.userData.initialRotation = mesh.rotation.clone();
|
||||
mesh.userData.basePositions = new Float32Array(mesh.geometry.getAttribute('position').array);
|
||||
|
||||
armsRef.current.push(mesh);
|
||||
});
|
||||
|
||||
// B. Glossy black acrylic plastic cosmonaut visor (fully opaque black shield)
|
||||
if (resolvedVisorMesh) {
|
||||
resolvedVisorMesh.geometry.computeVertexNormals();
|
||||
const visorMaterial = createVisorShaderMaterial();
|
||||
visorMaterialRef.current = visorMaterial;
|
||||
|
||||
resolvedVisorMesh.material = visorMaterial;
|
||||
resolvedVisorMesh.renderOrder = 2; // Render after the head shell
|
||||
}
|
||||
|
||||
// C. Hide the low-resolution texture eyes. Clean Eve-style eyes are generated below.
|
||||
eyesMeshes.forEach((mesh) => {
|
||||
mesh.visible = false;
|
||||
});
|
||||
|
||||
// 3. Compute local bounding box of the whole model to standardize height
|
||||
const localBox = new THREE.Box3();
|
||||
clonedObj.traverse((child: THREE.Object3D) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
if (!child.geometry.boundingBox) {
|
||||
child.geometry.computeBoundingBox();
|
||||
}
|
||||
if (child.geometry.boundingBox) {
|
||||
localBox.union(child.geometry.boundingBox);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const size = new THREE.Vector3();
|
||||
localBox.getSize(size);
|
||||
const center = new THREE.Vector3();
|
||||
localBox.getCenter(center);
|
||||
|
||||
// Normalize height conservatively so the avatar stays inside the right panel.
|
||||
const targetHeight = 1.18;
|
||||
const scaleFactor = targetHeight / (size.y || 1);
|
||||
clonedObj.scale.setScalar(scaleFactor);
|
||||
|
||||
// Center model pivot perfectly at [0, 0, 0] in global workspace
|
||||
clonedObj.position.x = -center.x * scaleFactor;
|
||||
clonedObj.position.y = -center.y * scaleFactor;
|
||||
clonedObj.position.z = -center.z * scaleFactor;
|
||||
|
||||
// Collar ring — wider tube to fully bridge body-to-neck gap
|
||||
const bodyCollar = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(1.66, 0.14, 28, 160),
|
||||
seamCoverMaterial
|
||||
);
|
||||
bodyCollar.name = 'eveBodyCollarSeamCover';
|
||||
bodyCollar.position.set(0, 6.43, -0.04);
|
||||
bodyCollar.rotation.x = Math.PI / 2;
|
||||
bodyCollar.scale.z = 0.88;
|
||||
bodyCollar.renderOrder = 1;
|
||||
bodyCollar.castShadow = false;
|
||||
bodyCollar.receiveShadow = false;
|
||||
clonedObj.add(bodyCollar);
|
||||
|
||||
// Neck cylinder — fills the gap between collar ring and helmet base
|
||||
const neckCover = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.56, 0.70, 0.52, 36, 1, false),
|
||||
seamCoverMaterial
|
||||
);
|
||||
neckCover.name = 'eveNeckSeamCover';
|
||||
neckCover.position.set(0, 7.05, -0.04);
|
||||
neckCover.renderOrder = 1;
|
||||
neckCover.castShadow = false;
|
||||
neckCover.receiveShadow = false;
|
||||
clonedObj.add(neckCover);
|
||||
|
||||
// Shoulder caps — larger and deeper to fully cover arm-body junction
|
||||
const shoulderGeometry = new THREE.SphereGeometry(0.42, 40, 24);
|
||||
const leftShoulderCover = new THREE.Mesh(shoulderGeometry, seamCoverMaterial);
|
||||
const rightShoulderCover = new THREE.Mesh(shoulderGeometry.clone(), seamCoverMaterial);
|
||||
leftShoulderCover.name = 'eveLeftShoulderSeamCover';
|
||||
rightShoulderCover.name = 'eveRightShoulderSeamCover';
|
||||
leftShoulderCover.position.set(-2.58, 5.9, 0.03);
|
||||
rightShoulderCover.position.set(2.58, 5.9, 0.03);
|
||||
leftShoulderCover.scale.set(0.72, 0.88, 0.56);
|
||||
rightShoulderCover.scale.set(0.72, 0.88, 0.56);
|
||||
leftShoulderCover.renderOrder = 1;
|
||||
rightShoulderCover.renderOrder = 1;
|
||||
leftShoulderCover.castShadow = false;
|
||||
rightShoulderCover.castShadow = false;
|
||||
leftShoulderCover.receiveShadow = false;
|
||||
rightShoulderCover.receiveShadow = false;
|
||||
clonedObj.add(leftShoulderCover, rightShoulderCover);
|
||||
|
||||
// 4. Construct the synchronized Head Group with perfect pivots and local translations
|
||||
if (resolvedHeadMesh) {
|
||||
// A. Calculate local center of head geometry
|
||||
const headGeom = resolvedHeadMesh.geometry;
|
||||
if (!headGeom.boundingBox) {
|
||||
headGeom.computeBoundingBox();
|
||||
}
|
||||
const headCenter = new THREE.Vector3();
|
||||
if (headGeom.boundingBox) {
|
||||
headGeom.boundingBox.getCenter(headCenter);
|
||||
}
|
||||
headCenterRef.current.copy(headCenter);
|
||||
|
||||
// B. Create head group and place it at headCenter (in clonedObj local space)
|
||||
const headGroup = new THREE.Group();
|
||||
headGroup.name = "headGroup";
|
||||
headGroup.position.copy(headCenter);
|
||||
clonedObj.add(headGroup);
|
||||
headGroupRef.current = headGroup;
|
||||
|
||||
// C. Position Head: Keep geometry intact to maintain perfect Blender-defined coordinates, position relative to headGroup
|
||||
headGroup.add(resolvedHeadMesh);
|
||||
resolvedHeadMesh.position.set(-headCenter.x, -headCenter.y, -headCenter.z);
|
||||
|
||||
// D. Position Visor: Keep geometry intact for absolute precision, and position at headCenter
|
||||
if (resolvedVisorMesh) {
|
||||
headGroup.add(resolvedVisorMesh);
|
||||
resolvedVisorMesh.position.set(-headCenter.x, -headCenter.y, -headCenter.z);
|
||||
}
|
||||
|
||||
headRef.current = resolvedHeadMesh;
|
||||
visorRef.current = resolvedVisorMesh;
|
||||
eyesRef.current = null;
|
||||
|
||||
if (existingGroup?.parent && existingGroup !== headGroup) {
|
||||
existingGroup.parent.remove(existingGroup);
|
||||
}
|
||||
}
|
||||
}, [clonedObj, avatarId]);
|
||||
|
||||
// Boucle de rendu pour animer Eve en temps réel (Animations fluides R3F)
|
||||
useFrame((state) => {
|
||||
const t = state.clock.getElapsedTime();
|
||||
|
||||
// Style émotionnel actuel
|
||||
const emoStyle = EmotionService.getEmotionStyle(emotion, avatarId);
|
||||
|
||||
// 1. Animation globale de flottaison (Lévitation magnétique)
|
||||
if (modelRef.current) {
|
||||
// Oscillation en hauteur stabilisée autour de y = 0.27 pour mieux occuper la fenetre avatar.
|
||||
modelRef.current.position.y = Math.sin(t * 1.5) * 0.06 + 0.27;
|
||||
// Légère rotation sur elle-même (mouvement de veille)
|
||||
modelRef.current.rotation.y = Math.sin(t * 0.5) * 0.05;
|
||||
}
|
||||
|
||||
// 2. Animation de la tête flottante autonome (incluant la visière et les yeux)
|
||||
if (headGroupRef.current && headCenterRef.current) {
|
||||
const headCenter = headCenterRef.current;
|
||||
|
||||
// Flottaison déphasée par rapport au corps pour accentuer l'effet magnétique
|
||||
let headY = Math.sin(t * 1.8) * 0.02 + 0.01;
|
||||
|
||||
// Orientation naturelle vers la souris pour donner vie au robot
|
||||
const mouseX = state.pointer.x * 0.25;
|
||||
const mouseY = state.pointer.y * 0.2;
|
||||
|
||||
let targetRotY = THREE.MathUtils.lerp(headGroupRef.current.rotation.y, mouseX, 0.1);
|
||||
let targetRotX = THREE.MathUtils.lerp(headGroupRef.current.rotation.x, -mouseY + 0.05, 0.1);
|
||||
let targetRotZ = 0;
|
||||
let headZOffset = 0;
|
||||
|
||||
// Inclinaisons émotionnelles ultra-expressives
|
||||
if (emotion === 'sad') {
|
||||
// Tête très baissée et abattue
|
||||
targetRotX = THREE.MathUtils.lerp(targetRotX, 0.35, 0.1);
|
||||
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, 0.12, 0.1);
|
||||
headY -= 0.02; // Tête enfoncée dans les épaules
|
||||
} else if (emotion === 'thinking') {
|
||||
// Tête inclinée sur le côté (curieuse) ET léger balancement pendulaire (swaying)
|
||||
const sway = Math.sin(t * 3.5) * 0.04;
|
||||
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, -0.22 + sway, 0.1);
|
||||
// Micro-mouvements de tête (hochement pensif vertical et balayage horizontal lent)
|
||||
headY += Math.sin(t * 4.5) * 0.005;
|
||||
} else if (emotion === 'angry') {
|
||||
// Tête très penchée en avant (froncée/agacée) avec micro-tremblements horizontaux
|
||||
targetRotX = THREE.MathUtils.lerp(targetRotX, 0.22, 0.1);
|
||||
const angerTremble = Math.sin(t * 22) * 0.015;
|
||||
targetRotY = THREE.MathUtils.lerp(headGroupRef.current.rotation.y, mouseX + angerTremble, 0.1);
|
||||
} else if (emotion === 'happy') {
|
||||
// Excitation joyeuse : petits hochements de tête rapides (bobbing)
|
||||
const joyBob = Math.sin(t * 6.5) * 0.03;
|
||||
targetRotX = THREE.MathUtils.lerp(targetRotX, joyBob - mouseY + 0.02, 0.1);
|
||||
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, Math.cos(t * 4.0) * 0.025, 0.1);
|
||||
} else if (emotion === 'surprised') {
|
||||
// Recul brusque de surprise de la tête et inclinaison vers le haut
|
||||
targetRotX = THREE.MathUtils.lerp(targetRotX, -0.25 - mouseY, 0.1);
|
||||
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, Math.sin(t * 10) * 0.01, 0.1);
|
||||
headY += 0.025; // Tête étirée
|
||||
headZOffset -= 0.05; // Recul
|
||||
} else {
|
||||
targetRotZ = THREE.MathUtils.lerp(headGroupRef.current.rotation.z, 0, 0.1);
|
||||
}
|
||||
|
||||
// Appliquer les transformations au Groupe Tête (tout bouge et pivote en parfait accord autour du pivot naturel)
|
||||
headGroupRef.current.position.set(
|
||||
headCenter.x,
|
||||
headCenter.y + headY,
|
||||
headCenter.z + headZOffset
|
||||
);
|
||||
headGroupRef.current.rotation.set(targetRotX, targetRotY, targetRotZ);
|
||||
}
|
||||
|
||||
// 3. Animation des Yeux LED (Expressions d'émotion + Clignotement + Parole + Micro-saccades de veille)
|
||||
if (visorMaterialRef.current) {
|
||||
// Clignotement naturel toutes les 4.2 secondes
|
||||
const isBlinking = t % 4.2 < 0.15;
|
||||
|
||||
// A. Taille verticale des yeux
|
||||
let targetScaleY = isBlinking ? 0.02 : emoStyle.eyeScaleY;
|
||||
|
||||
// Respiration des yeux (micro-pulsation de veille) : oscillation subtile à ~0.38Hz
|
||||
if (!isBlinking) {
|
||||
targetScaleY += Math.sin(t * 2.4) * 0.018;
|
||||
}
|
||||
|
||||
// Effet de parole : si Eve parle, ses yeux oscillent légèrement en hauteur au rythme de sa voix
|
||||
if (isSpeaking && !isBlinking) {
|
||||
targetScaleY += Math.sin(t * 18) * 0.08;
|
||||
}
|
||||
|
||||
// B. Position des yeux (micro-saccades autonomes de veille + émotions)
|
||||
let targetEyeOffsetY = 0;
|
||||
let targetEyeOffsetX = 0;
|
||||
|
||||
if (emotion === 'surprised') {
|
||||
targetEyeOffsetY = 0.05;
|
||||
} else if (emotion === 'sad') {
|
||||
targetEyeOffsetY = -0.04;
|
||||
} else if (emotion === 'thinking') {
|
||||
// Balayage lent et pensif
|
||||
targetEyeOffsetY = Math.sin(t * 2.6) * 0.025;
|
||||
targetEyeOffsetX = Math.cos(t * 1.8) * 0.04;
|
||||
// Pulsation LED de calcul intense : les yeux vibrent légèrement en taille à haute fréquence (15 rad/s)
|
||||
if (!isBlinking) {
|
||||
targetScaleY += Math.sin(t * 15.0) * 0.05;
|
||||
}
|
||||
} else {
|
||||
// Mode veille standard : génération de micro-saccades oculaires pseudo-aléatoires toutes les 2.8 secondes
|
||||
const saccadeCycle = Math.floor(t * 0.35); // change toutes les ~2.8s
|
||||
|
||||
// Micro-saccades horizontales et verticales basées sur des fréquences non harmoniques
|
||||
const rawSaccadeX = Math.sin(saccadeCycle * 17.31 + 2.5) * Math.cos(saccadeCycle * 7.89);
|
||||
const rawSaccadeY = Math.cos(saccadeCycle * 13.17 - 1.2) * Math.sin(saccadeCycle * 5.43);
|
||||
|
||||
// Limitation des décalages pour garder un aspect centré et naturel
|
||||
targetEyeOffsetX = rawSaccadeX * 0.045; // max ±0.045
|
||||
targetEyeOffsetY = rawSaccadeY * 0.025; // max ±0.025
|
||||
|
||||
// Ajout d'une très légère dérive continue (bruit oculaire de veille)
|
||||
targetEyeOffsetX += Math.sin(t * 0.8) * 0.008;
|
||||
targetEyeOffsetY += Math.cos(t * 0.5) * 0.005;
|
||||
}
|
||||
|
||||
visorMaterialRef.current.uniforms.eyeColor.value.set(emoStyle.eyeColor);
|
||||
visorMaterialRef.current.uniforms.eyeScaleY.value = targetScaleY;
|
||||
visorMaterialRef.current.uniforms.eyeRotation.value = emoStyle.eyeRotation;
|
||||
visorMaterialRef.current.uniforms.eyeOffsetX.value = targetEyeOffsetX;
|
||||
visorMaterialRef.current.uniforms.eyeOffsetY.value = targetEyeOffsetY;
|
||||
visorMaterialRef.current.uniforms.speakingPulse.value = isSpeaking ? 0.025 : 0;
|
||||
|
||||
// Mise à jour des uniforms uTime et de la valeur de transition de réflexion
|
||||
visorMaterialRef.current.uniforms.uTime.value = t;
|
||||
let targetThinkingVal = emotion === 'thinking' ? 1.0 : 0.0;
|
||||
visorMaterialRef.current.uniforms.thinkingVal.value = THREE.MathUtils.lerp(
|
||||
visorMaterialRef.current.uniforms.thinkingVal.value,
|
||||
targetThinkingVal,
|
||||
0.08
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Animation dynamique des bras (déformation vertex organique selon l'émotion et la parole)
|
||||
if (mainBodyMeshRef.current && mainBodyBasePositionsRef.current) {
|
||||
const geometry = mainBodyMeshRef.current.geometry;
|
||||
const position = geometry.getAttribute('position') as THREE.BufferAttribute;
|
||||
const base = mainBodyBasePositionsRef.current;
|
||||
|
||||
for (let i = 0; i < position.count; i += 1) {
|
||||
const index = i * 3;
|
||||
const baseX = base[index];
|
||||
const baseY = base[index + 1];
|
||||
const baseZ = base[index + 2];
|
||||
const isArmRegion = Math.abs(baseX) > 2.05 && baseY > 1.0 && baseY < 6.25;
|
||||
|
||||
if (isArmRegion) {
|
||||
const side = Math.sign(baseX);
|
||||
const shoulderFalloff = THREE.MathUtils.clamp((6.25 - baseY) / 4.8, 0, 1);
|
||||
const offset = getEmotionalArmOffset(side, shoulderFalloff, t, emotion, isSpeaking);
|
||||
position.setXYZ(
|
||||
i,
|
||||
baseX + offset.dx,
|
||||
baseY + offset.dy,
|
||||
baseZ + offset.dz
|
||||
);
|
||||
} else {
|
||||
position.setXYZ(i, baseX, baseY, baseZ);
|
||||
}
|
||||
}
|
||||
|
||||
position.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
}
|
||||
|
||||
if (armsRef.current.length > 0) {
|
||||
armsRef.current.forEach((mesh) => {
|
||||
// Neutralize global transformation to let vertex deformation do the work perfectly in the same coordinate space
|
||||
mesh.position.set(0, 0, 0);
|
||||
mesh.rotation.set(0, 0, 0);
|
||||
|
||||
const base = mesh.userData.basePositions as Float32Array;
|
||||
if (!base) return;
|
||||
|
||||
const geometry = mesh.geometry;
|
||||
const position = geometry.getAttribute('position') as THREE.BufferAttribute;
|
||||
|
||||
for (let i = 0; i < position.count; i += 1) {
|
||||
const index = i * 3;
|
||||
const baseX = base[index];
|
||||
const baseY = base[index + 1];
|
||||
const baseZ = base[index + 2];
|
||||
const isArmRegion = Math.abs(baseX) > 2.05 && baseY > 1.0 && baseY < 6.25;
|
||||
|
||||
if (isArmRegion) {
|
||||
const side = Math.sign(baseX);
|
||||
const shoulderFalloff = THREE.MathUtils.clamp((6.25 - baseY) / 4.8, 0, 1);
|
||||
const offset = getEmotionalArmOffset(side, shoulderFalloff, t, emotion, isSpeaking);
|
||||
position.setXYZ(
|
||||
i,
|
||||
baseX + offset.dx,
|
||||
baseY + offset.dy,
|
||||
baseZ + offset.dz
|
||||
);
|
||||
} else {
|
||||
position.setXYZ(i, baseX, baseY, baseZ);
|
||||
}
|
||||
}
|
||||
|
||||
position.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={modelRef} scale={1.0} position={[0, 0.27, 0]}>
|
||||
<primitive object={clonedObj} />
|
||||
</group>
|
||||
);
|
||||
};
|
||||
@@ -1,629 +0,0 @@
|
||||
import React, { Suspense, useRef } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { AvatarModelLoader } from './AvatarModelLoader';
|
||||
import { EveEmotion, EveAvatar } from '../services/emotionService';
|
||||
|
||||
export interface ToolEvent {
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
status: 'idle' | 'running' | 'done' | 'error';
|
||||
ts: number;
|
||||
}
|
||||
|
||||
interface ThreeCanvasProps {
|
||||
emotion: EveEmotion;
|
||||
isSpeaking: boolean;
|
||||
avatarId?: EveAvatar;
|
||||
isFloatingMode?: boolean;
|
||||
}
|
||||
|
||||
interface HologramProjectionProps {
|
||||
emotion: EveEmotion;
|
||||
isSpeaking: boolean;
|
||||
avatarId?: EveAvatar;
|
||||
}
|
||||
|
||||
interface ParticleInstance {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
speed: number;
|
||||
scale: number;
|
||||
phase: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D Holographic rising particles representing active data streams.
|
||||
* Flowing upwards in an expanding spiral fashion, strictly confined within the restricted projection space (below the avatar).
|
||||
* Restrained dynamically to the avatar's real-time bottom baseline to prevent overlapping with the avatar's body.
|
||||
*/
|
||||
const HologramParticles: React.FC<{ color: THREE.Color; avatarId?: EveAvatar }> = ({ color, avatarId = 'eve' }) => {
|
||||
const count = 54; // restrained density so the projector stays clean and readable
|
||||
const particlesRef = useRef<THREE.Group>(null);
|
||||
|
||||
// Persist random initial coordinates within the cone volume, speed modifiers, and scaling factors
|
||||
const particleData = React.useMemo<ParticleInstance[]>(() => {
|
||||
const data: ParticleInstance[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const y = -0.8 + Math.random() * 0.6;
|
||||
const ratio = (y + 0.8) / 0.6;
|
||||
const radiusAtY = 0.17 + 0.17 * ratio;
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const radius = Math.random() * radiusAtY;
|
||||
data.push({
|
||||
x: Math.cos(angle) * radius,
|
||||
y: y,
|
||||
z: Math.sin(angle) * radius,
|
||||
speed: 0.002 + Math.random() * 0.004, // Elegant floating speed below EVE
|
||||
scale: 0.16 + Math.random() * 0.4,
|
||||
phase: Math.random() * Math.PI * 2
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!particlesRef.current) return;
|
||||
const children = particlesRef.current.children;
|
||||
const t = state.clock.getElapsedTime();
|
||||
|
||||
// Compute dynamic avatar bottom in real-time
|
||||
const isEve = avatarId === 'eve';
|
||||
const avatarY = isEve
|
||||
? Math.sin(t * 1.5) * 0.06 + 0.27
|
||||
: Math.sin(t * 1.4) * 0.05 + 0.27;
|
||||
const yBottomLocal = (avatarY + 0.1) - 0.59;
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i] as THREE.Mesh;
|
||||
const data = particleData[i];
|
||||
|
||||
// Rise animation
|
||||
data.y += data.speed;
|
||||
|
||||
// Spiral micro-rotation effect
|
||||
const currentRadius = Math.sqrt(data.x * data.x + data.z * data.z);
|
||||
const angle = Math.atan2(data.z, data.x) + 0.014;
|
||||
data.x = Math.cos(angle) * currentRadius;
|
||||
data.z = Math.sin(angle) * currentRadius;
|
||||
|
||||
// Radial constraints: inverted cone is narrower at the bottom (0.17 radius) than top (0.34 radius)
|
||||
const ratio = (data.y + 0.8) / 0.6;
|
||||
const radiusAtY = 0.17 + 0.17 * ratio;
|
||||
|
||||
// Reset position if exceeding top or radial boundaries
|
||||
if (data.y > yBottomLocal || currentRadius > radiusAtY) {
|
||||
data.y = -0.8;
|
||||
const newAngle = Math.random() * Math.PI * 2;
|
||||
const newRadius = Math.random() * 0.11; // Start close to the lens focal point
|
||||
data.x = Math.cos(newAngle) * newRadius;
|
||||
data.z = Math.sin(newAngle) * newRadius;
|
||||
}
|
||||
|
||||
child.position.set(data.x, data.y, data.z);
|
||||
|
||||
// Sinuous individual flickering
|
||||
if (child.material) {
|
||||
const mat = child.material as THREE.MeshBasicMaterial;
|
||||
mat.opacity = 0.45 + Math.sin(t * 5.0 + data.phase) * 0.15;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={particlesRef}>
|
||||
{particleData.map((data, i) => (
|
||||
<mesh key={i} scale={data.scale}>
|
||||
{/* Energy crystals shape using Dodecahedron for premium specular feel */}
|
||||
<dodecahedronGeometry args={[0.018, 0]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={0.5}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Animated holographic chamber behind Eve.
|
||||
* Strong enough to be visible, but abstract enough to avoid the old planet/star look.
|
||||
*/
|
||||
const TechnicalBackdrop: React.FC = () => {
|
||||
const haloRef = useRef<THREE.Group>(null);
|
||||
const sweepRef = useRef<THREE.Group>(null);
|
||||
const gateRef = useRef<THREE.Group>(null);
|
||||
const nodeMat = React.useMemo(() => (
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: '#00f2ff',
|
||||
transparent: true,
|
||||
opacity: 0.72,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
})
|
||||
), []);
|
||||
const dataLines = React.useMemo(() => ([
|
||||
{ y: 1.02, width: 2.15, phase: 0.1, speed: 0.36, opacity: 0.34 },
|
||||
{ y: 0.68, width: 2.55, phase: 1.6, speed: 0.28, opacity: 0.26 },
|
||||
{ y: 0.24, width: 2.18, phase: 2.8, speed: 0.42, opacity: 0.32 },
|
||||
{ y: -0.22, width: 2.44, phase: 4.2, speed: 0.31, opacity: 0.24 },
|
||||
{ y: -0.68, width: 1.92, phase: 5.4, speed: 0.48, opacity: 0.31 }
|
||||
]), []);
|
||||
const orbitNodes = React.useMemo(() => ([
|
||||
{ radius: 1.04, angle: 0.18, size: 0.034 },
|
||||
{ radius: 1.36, angle: 1.7, size: 0.024 },
|
||||
{ radius: 1.68, angle: 2.78, size: 0.028 },
|
||||
{ radius: 1.28, angle: 4.35, size: 0.022 },
|
||||
{ radius: 1.52, angle: 5.52, size: 0.026 }
|
||||
]), []);
|
||||
|
||||
useFrame((state) => {
|
||||
const t = state.clock.getElapsedTime();
|
||||
if (haloRef.current) {
|
||||
haloRef.current.rotation.z = t * 0.035;
|
||||
haloRef.current.scale.setScalar(1 + Math.sin(t * 0.7) * 0.025);
|
||||
}
|
||||
if (gateRef.current) {
|
||||
gateRef.current.rotation.z = Math.sin(t * 0.22) * 0.035;
|
||||
}
|
||||
if (sweepRef.current) {
|
||||
sweepRef.current.children.forEach((child, index) => {
|
||||
const line = dataLines[index];
|
||||
child.position.x = Math.sin(t * line.speed + line.phase) * 0.82;
|
||||
const mesh = child as THREE.Mesh;
|
||||
const material = mesh.material as THREE.MeshBasicMaterial;
|
||||
material.opacity = line.opacity + (Math.sin(t * 1.4 + line.phase) + 1) * 0.08;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={[0, 0.02, -1.72]}>
|
||||
<mesh position={[0, 0, -0.12]}>
|
||||
<planeGeometry args={[3.5, 3.85]} />
|
||||
<meshBasicMaterial
|
||||
color="#005e78"
|
||||
transparent
|
||||
opacity={0.16}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<group ref={sweepRef} position={[0, 0, -0.01]}>
|
||||
{dataLines.map((line, index) => (
|
||||
<mesh key={index} position={[0, line.y, 0]} rotation={[0, 0, index % 2 === 0 ? 0.05 : -0.06]}>
|
||||
<planeGeometry args={[line.width, 0.018]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={line.opacity}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
|
||||
<group ref={gateRef}>
|
||||
{[-1.28, -0.86, 0.86, 1.28].map((x, index) => (
|
||||
<mesh key={x} position={[x, 0, -0.03]}>
|
||||
<planeGeometry args={[0.028, 3.35 - index % 2 * 0.34]} />
|
||||
<meshBasicMaterial
|
||||
color={index < 2 ? '#00f2ff' : '#8af7ff'}
|
||||
transparent
|
||||
opacity={index % 2 === 0 ? 0.19 : 0.13}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
|
||||
<group ref={haloRef}>
|
||||
{[0.86, 1.14, 1.48, 1.78].map((radius, index) => (
|
||||
<mesh key={radius} rotation={[0, 0, index * 0.18]}>
|
||||
<ringGeometry args={[radius, radius + 0.012, 128]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={0.28 - index * 0.045}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
<mesh>
|
||||
<ringGeometry args={[0.58, 0.592, 96]} />
|
||||
<meshBasicMaterial
|
||||
color="#8af7ff"
|
||||
transparent
|
||||
opacity={0.27}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{orbitNodes.map((node, index) => (
|
||||
<mesh
|
||||
key={index}
|
||||
position={[
|
||||
Math.cos(node.angle) * node.radius,
|
||||
Math.sin(node.angle) * node.radius,
|
||||
0.01
|
||||
]}
|
||||
material={nodeMat}
|
||||
>
|
||||
<circleGeometry args={[node.size, 24]} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
|
||||
<mesh position={[0, 0, -0.08]}>
|
||||
<circleGeometry args={[1.28, 96]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={0.07}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{[
|
||||
[0, -0.94, 0]
|
||||
].map((position, index) => (
|
||||
<mesh key={index} position={position as [number, number, number]}>
|
||||
<planeGeometry args={[1.55, 0.014]} />
|
||||
<meshBasicMaterial
|
||||
color="#8af7ff"
|
||||
transparent
|
||||
opacity={0.28}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 3D Hologram Projection platform and light cone visualizer.
|
||||
* Fully interactive and reactive to speech and emotional state.
|
||||
* Fixed to a premium cyan hue (#00f2ff) to align perfectly with the EVEFLOW brand.
|
||||
* Confines the light cone strictly below the avatar's body, stretching to touch her base dynamically.
|
||||
*/
|
||||
const HologramProjection: React.FC<HologramProjectionProps> = ({ emotion, isSpeaking, avatarId = 'eve' }) => {
|
||||
const beamRef = useRef<THREE.Mesh>(null);
|
||||
const glowRef = useRef<THREE.Mesh>(null);
|
||||
const baseRef = useRef<THREE.Group>(null);
|
||||
|
||||
// Cybernetic rotative diagnostic ring groups
|
||||
const ringGroup1Ref = useRef<THREE.Group>(null);
|
||||
const ringGroup2Ref = useRef<THREE.Group>(null);
|
||||
const ringGroup3Ref = useRef<THREE.Group>(null);
|
||||
|
||||
// Permanently fixed to signature premium cyan to preserve brand identity and theme consistency
|
||||
const stateColor = React.useMemo(() => new THREE.Color('#00f2ff'), []);
|
||||
const isThinking = emotion === 'thinking';
|
||||
|
||||
// Custom ShaderMaterial to generate a smooth, high-fidelity opacity gradient fading towards the top
|
||||
const coneMaterial = React.useMemo(() => {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
color: { value: stateColor },
|
||||
opacity: { value: 0.42 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec3 vLocalPosition;
|
||||
void main() {
|
||||
vLocalPosition = position;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 color;
|
||||
uniform float opacity;
|
||||
varying vec3 vLocalPosition;
|
||||
void main() {
|
||||
// Normalizing local Y from -0.3 (bottom) to 0.3 (top) for a total height of 0.6
|
||||
float factor = (vLocalPosition.y + 0.3) / 0.6;
|
||||
// Smooth progressive fade out to absolute transparency at the top
|
||||
float grad = clamp(1.0 - factor, 0.0, 1.0);
|
||||
// Cubic curve for ultra-soft, premium cinematic light scattering
|
||||
grad = pow(grad, 1.6);
|
||||
gl_FragColor = vec4(color, grad * opacity);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
}, [stateColor]);
|
||||
|
||||
useFrame((state) => {
|
||||
const t = state.clock.getElapsedTime();
|
||||
|
||||
// Dynamic tracking of avatar's float baseline to keep perfect visual contact
|
||||
const isEve = avatarId === 'eve';
|
||||
const avatarY = isEve
|
||||
? Math.sin(t * 1.5) * 0.06 + 0.27
|
||||
: Math.sin(t * 1.4) * 0.05 + 0.27;
|
||||
|
||||
// The bottom of the avatar shell is approximately Y_avatar - 0.59
|
||||
const yBottomLocal = (avatarY + 0.1) - 0.59;
|
||||
|
||||
// Lens source is at Y = -0.80 local (aligned to the new flat holographic emitter lens)
|
||||
const yLensLocal = -0.80;
|
||||
const currentHeight = yBottomLocal - yLensLocal;
|
||||
const currentCenterY = yLensLocal + currentHeight / 2;
|
||||
|
||||
// Scale vertically relative to the default geometry height of 0.6
|
||||
if (beamRef.current) {
|
||||
beamRef.current.scale.y = currentHeight / 0.6;
|
||||
beamRef.current.position.y = currentCenterY;
|
||||
}
|
||||
|
||||
// Volumetric base flicker intensity simulation (high-fidelity holographic noise) - Boosted for pronounced cyan look
|
||||
let baseFlicker = 0.42 + Math.sin(t * 7.5) * 0.05 + Math.cos(t * 3.2) * 0.025;
|
||||
|
||||
if (isThinking) {
|
||||
baseFlicker += Math.sin(t * 22.0) * 0.035;
|
||||
}
|
||||
|
||||
if (isSpeaking) {
|
||||
const voiceFrequencyOscillation = Math.sin(t * 18.0) * 0.08 + Math.abs(Math.cos(t * 12.0)) * 0.05;
|
||||
baseFlicker += voiceFrequencyOscillation;
|
||||
}
|
||||
|
||||
// Volumetric soft cone opacity adjustment through custom shader uniforms
|
||||
if (beamRef.current && beamRef.current.material) {
|
||||
const mat = beamRef.current.material as THREE.ShaderMaterial;
|
||||
if (mat.uniforms && mat.uniforms.opacity && mat.uniforms.color) {
|
||||
mat.uniforms.opacity.value = baseFlicker;
|
||||
mat.uniforms.color.value.lerp(stateColor, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth cybernetic diagnostics rotations
|
||||
if (ringGroup1Ref.current) ringGroup1Ref.current.rotation.z = t * 0.16;
|
||||
if (ringGroup2Ref.current) ringGroup2Ref.current.rotation.z = -t * 0.10;
|
||||
if (ringGroup3Ref.current) ringGroup3Ref.current.rotation.z = t * 0.06;
|
||||
if (baseRef.current) baseRef.current.scale.setScalar(1 + Math.sin(t * 2.2) * 0.018);
|
||||
|
||||
// Glow plate adjustment - Pulsing emission intensity simulated via opacity for BasicMaterial
|
||||
if (glowRef.current && glowRef.current.material) {
|
||||
const mat = glowRef.current.material as THREE.MeshBasicMaterial;
|
||||
mat.opacity = 0.70 + Math.sin(t * 5.0) * 0.15;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={[0, -0.1, 0]}>
|
||||
{/* 1. Volumetric hologram light cone - Dynamically scaled and positioned to touch EVE's bottom */}
|
||||
<mesh ref={beamRef} position={[0, -0.50, 0]}>
|
||||
<cylinderGeometry args={[0.34, 0.15, 0.6, 48, 1, true]} />
|
||||
<primitive object={coneMaterial} attach="material" />
|
||||
</mesh>
|
||||
|
||||
{/* High-fidelity upward particle flow synchronized to avatar bottom limit */}
|
||||
<HologramParticles color={stateColor} avatarId={avatarId} />
|
||||
|
||||
{/* 2. Émetteur Holographique Pur (100% Lumineux & Intégré) */}
|
||||
<group position={[0, -0.05, 0]}>
|
||||
<group ref={baseRef}>
|
||||
<mesh position={[0, -0.93, 0]}>
|
||||
<cylinderGeometry args={[0.32, 0.38, 0.08, 72]} />
|
||||
<meshPhysicalMaterial
|
||||
color="#dff8ff"
|
||||
roughness={0.18}
|
||||
metalness={0.18}
|
||||
clearcoat={1}
|
||||
clearcoatRoughness={0.08}
|
||||
emissive="#4defff"
|
||||
emissiveIntensity={0.25}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, -0.835, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[0.28, 72]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={0.48}
|
||||
blending={THREE.AdditiveBlending}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, -0.828, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.25, 0.27, 72]} />
|
||||
<meshBasicMaterial
|
||||
color="#ffffff"
|
||||
transparent
|
||||
opacity={0.58}
|
||||
blending={THREE.AdditiveBlending}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, -1.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[0.44, 72]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={0.13}
|
||||
blending={THREE.AdditiveBlending}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
<pointLight position={[0, -0.76, 0.35]} intensity={0.95} color="#00f2ff" distance={2.4} />
|
||||
{/* A. Lentille émissive centrale - Source principale de lumière */}
|
||||
<mesh
|
||||
ref={glowRef}
|
||||
position={[0, -0.80, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<ringGeometry args={[0, 0.14, 32]} />
|
||||
<meshBasicMaterial
|
||||
color="#ffffff"
|
||||
transparent
|
||||
opacity={0.88}
|
||||
blending={THREE.AdditiveBlending}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* B. Premier anneau concentrique de confinement d'énergie */}
|
||||
<mesh position={[0, -0.798, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.155, 0.16, 64]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={0.45}
|
||||
blending={THREE.AdditiveBlending}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* C. Anneau segmenté rotatif interne (Sens horaire) */}
|
||||
<group ref={ringGroup1Ref} position={[0, -0.796, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<mesh>
|
||||
<ringGeometry args={[0.18, 0.19, 32, 1, 0, Math.PI * 0.45]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={0.65}
|
||||
blending={THREE.AdditiveBlending}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh rotation={[0, 0, Math.PI]}>
|
||||
<ringGeometry args={[0.18, 0.19, 32, 1, 0, Math.PI * 0.45]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={0.65}
|
||||
blending={THREE.AdditiveBlending}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* D. Anneau segmenté rotatif externe (Sens anti-horaire) */}
|
||||
<group ref={ringGroup2Ref} position={[0, -0.794, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<mesh>
|
||||
<ringGeometry args={[0.22, 0.23, 32, 1, Math.PI * 0.2, Math.PI * 0.5]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={0.40}
|
||||
blending={THREE.AdditiveBlending}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh rotation={[0, 0, Math.PI]}>
|
||||
<ringGeometry args={[0.22, 0.23, 32, 1, Math.PI * 0.2, Math.PI * 0.5]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={0.40}
|
||||
blending={THREE.AdditiveBlending}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* E. Anneau de ciblage tech externe avec micro-graduations */}
|
||||
<group ref={ringGroup3Ref} position={[0, -0.792, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<mesh key={i} rotation={[0, 0, (i * Math.PI) / 3]}>
|
||||
<ringGeometry args={[0.26, 0.264, 16, 1, 0, Math.PI * 0.04]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={0.28}
|
||||
blending={THREE.AdditiveBlending}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
|
||||
{/* F. Grand anneau de balayage de diagnostic extérieur pulsant */}
|
||||
<mesh position={[0, -0.798, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.31, 0.313, 64]} />
|
||||
<meshBasicMaterial
|
||||
color="#00f2ff"
|
||||
transparent
|
||||
opacity={0.18}
|
||||
blending={THREE.AdditiveBlending}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThreeCanvas: React.FC<ThreeCanvasProps> = ({ emotion, isSpeaking, avatarId = 'eve', isFloatingMode = false }) => {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<Canvas
|
||||
camera={{ position: isFloatingMode ? [0, 0.72, 3.7] : [0, 1.1, 5.8], fov: isFloatingMode ? 30 : 36 }}
|
||||
gl={{ alpha: true, antialias: true, premultipliedAlpha: false }}
|
||||
onCreated={({ gl }) => {
|
||||
gl.setClearColor(0x000000, 0);
|
||||
}}
|
||||
dpr={[1, 2]}
|
||||
style={{ background: 'transparent', backgroundColor: 'transparent' }}
|
||||
>
|
||||
{/* Soft ambient — dark space base */}
|
||||
<ambientLight intensity={0.6} color="#b0c8e8" />
|
||||
<hemisphereLight args={['#1a2a4a', '#050810', 0.8]} />
|
||||
|
||||
{/* Main key light — warm white rim from top-right */}
|
||||
<directionalLight
|
||||
position={[5, 8, 5]}
|
||||
intensity={2.0}
|
||||
color="#e8f4ff"
|
||||
/>
|
||||
|
||||
{/* Cyan atmosphere rim */}
|
||||
<pointLight position={[-5, 3, 2]} intensity={1.0} color="#60c8e8" />
|
||||
|
||||
{/* Desaturated blue fill */}
|
||||
<pointLight position={[4, -2, 2]} intensity={0.45} color="#8ab4d8" />
|
||||
|
||||
{/* Soft white back fill to keep geometry readable */}
|
||||
<pointLight position={[0, 3, -4]} intensity={1.0} color="#ffffff" />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<TechnicalBackdrop />
|
||||
<HologramProjection emotion={emotion} isSpeaking={isSpeaking} avatarId={avatarId} />
|
||||
<AvatarModelLoader avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />
|
||||
</Suspense>
|
||||
|
||||
{/* OrbitControls calibrated to prevent clipping or losing focus */}
|
||||
<OrbitControls
|
||||
enableZoom={true}
|
||||
minDistance={isFloatingMode ? 2.4 : 4.8}
|
||||
maxDistance={isFloatingMode ? 4.7 : 6.8}
|
||||
enablePan={false}
|
||||
autoRotate={false}
|
||||
maxPolarAngle={Math.PI / 1.7}
|
||||
minPolarAngle={Math.PI / 2.8}
|
||||
/>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MessageSquare, Trash2, X } from 'lucide-react';
|
||||
import { useChat } from '../../state/chat';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { CommandBar } from './CommandBar';
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ChatPanel({ compact }: Props) {
|
||||
const messages = useChat((s) => s.messages);
|
||||
const clear = useChat((s) => s.clear);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [zoom, setZoom] = useState<string | null>(null);
|
||||
const [stick, setStick] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stick) return;
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, stick]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
setStick(el.scrollHeight - el.scrollTop - el.clientHeight < 80);
|
||||
};
|
||||
|
||||
const visible = compact ? messages.slice(-6) : messages;
|
||||
|
||||
return (
|
||||
<section className={`panel bracket chat-panel${compact ? ' compact' : ''}`}>
|
||||
{!compact && (
|
||||
<header className="panel-head">
|
||||
<MessageSquare size={14} />
|
||||
<span>Transmission</span>
|
||||
<span className="spacer" />
|
||||
<span className="chip">{messages.length} msg</span>
|
||||
<button className="icon-btn" title="Effacer la conversation" onClick={clear} disabled={messages.length === 0}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
<div ref={listRef} className={`chat-list${compact ? ' compact' : ''}`} onScroll={onScroll}>
|
||||
{visible.length === 0 && (
|
||||
<div className="empty">
|
||||
{compact ? 'En attente…' : `Canal ouvert. Parlez ou écrivez à ${settings.assistantName}.`}
|
||||
{!compact && (
|
||||
<div className="muted" style={{ marginTop: 8, fontSize: 12 }}>
|
||||
<span className="kbd">Ctrl</span> + <span className="kbd">Shift</span> + <span className="kbd">Espace</span> active le micro depuis n’importe où.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{visible.map((m) => (
|
||||
<MessageBubble
|
||||
key={m.id}
|
||||
message={m}
|
||||
assistantName={settings.assistantName}
|
||||
userName={settings.userName}
|
||||
showReasoning={settings.ui.showReasoning}
|
||||
compact={compact}
|
||||
onOpenImage={setZoom}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<CommandBar compact={compact} />
|
||||
{zoom && (
|
||||
<div className="overlay" onClick={() => setZoom(null)}>
|
||||
<img src={zoom} alt="aperçu" style={{ maxWidth: '92%', maxHeight: '90%', borderRadius: 10, border: '1px solid var(--line-strong)' }} />
|
||||
<button className="icon-btn" style={{ position: 'absolute', top: 12, right: 12 }} onClick={() => setZoom(null)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Mic, Paperclip, Send, Square, X, Radio, Loader2, Volume2, VolumeX, Navigation } from 'lucide-react';
|
||||
import { sendMessage, steer, stopGeneration } from '../../services/conversation';
|
||||
import { speech } from '../../services/voice/speech';
|
||||
import { voiceController } from '../../services/voice/voiceController';
|
||||
import { useChat } from '../../state/chat';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { useVoice } from '../../state/voice';
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const MAX_IMAGE_EDGE = 1280;
|
||||
|
||||
async function fileToDataUrl(file: File): Promise<string> {
|
||||
const raw = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
if (!file.type.startsWith('image/') || file.type === 'image/svg+xml' || file.type === 'image/gif') return raw;
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const scale = Math.min(1, MAX_IMAGE_EDGE / Math.max(img.width, img.height));
|
||||
if (scale === 1 && file.size < 900_000) return resolve(raw);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(img.width * scale);
|
||||
canvas.height = Math.round(img.height * scale);
|
||||
canvas.getContext('2d')?.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
resolve(canvas.toDataURL('image/jpeg', 0.82));
|
||||
};
|
||||
img.onerror = () => resolve(raw);
|
||||
img.src = raw;
|
||||
});
|
||||
}
|
||||
|
||||
export function CommandBar({ compact }: Props) {
|
||||
const draft = useChat((s) => s.draft);
|
||||
const setDraft = useChat((s) => s.setDraft);
|
||||
const isSending = useChat((s) => s.isSending);
|
||||
const currentRunId = useChat((s) => s.currentRunId);
|
||||
const phase = useVoice((s) => s.phase);
|
||||
const level = useVoice((s) => s.inputLevel);
|
||||
const handsFree = useVoice((s) => s.handsFree);
|
||||
const ttsState = useVoice((s) => s.tts);
|
||||
const speechProvider = useSettings((s) => s.settings.speech.provider);
|
||||
const autoSpeak = useSettings((s) => s.settings.speech.autoSpeak);
|
||||
const update = useSettings((s) => s.update);
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [steerMode, setSteerMode] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(160, el.scrollHeight)}px`;
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSending) setSteerMode(false);
|
||||
}, [isSending]);
|
||||
|
||||
const submit = async () => {
|
||||
const text = draft.trim();
|
||||
if (steerMode && currentRunId) {
|
||||
if (await steer(text)) setDraft('');
|
||||
return;
|
||||
}
|
||||
if (!text && images.length === 0) return;
|
||||
const attached = images;
|
||||
setDraft('');
|
||||
setImages([]);
|
||||
await sendMessage(text, attached);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
if (e.key === 'Escape' && isSending) stopGeneration();
|
||||
};
|
||||
|
||||
const onFiles = async (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
const next: string[] = [];
|
||||
for (const file of Array.from(files).slice(0, 4)) {
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
if (file.size > 12 * 1024 * 1024) continue;
|
||||
next.push(await fileToDataUrl(file));
|
||||
}
|
||||
setImages((prev) => [...prev, ...next].slice(0, 4));
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const onPaste = (e: React.ClipboardEvent) => {
|
||||
const items = Array.from(e.clipboardData.items).filter((i) => i.type.startsWith('image/'));
|
||||
if (items.length === 0) return;
|
||||
const list = new DataTransfer();
|
||||
for (const item of items) {
|
||||
const f = item.getAsFile();
|
||||
if (f) list.items.add(f);
|
||||
}
|
||||
void onFiles(list.files);
|
||||
};
|
||||
|
||||
const listening = phase !== 'off';
|
||||
const micClass = phase === 'speech' ? 'speech' : phase === 'transcribing' ? 'transcribing' : listening ? 'listening' : '';
|
||||
const micTitle = phase === 'transcribing' ? 'Transcription…' : listening ? 'Arrêter l’écoute' : 'Parler (Ctrl+Shift+Espace)';
|
||||
const placeholder = steerMode ? 'Consigne à injecter dans le run en cours…' : compact ? 'Message…' : 'Commande, question ou mission pour Hermes…';
|
||||
|
||||
return (
|
||||
<div className="command-bar">
|
||||
{images.length > 0 && (
|
||||
<div className="attachments">
|
||||
{images.map((img, i) => (
|
||||
<div key={i} className="attachment">
|
||||
<img src={img} alt="" />
|
||||
<button onClick={() => setImages((prev) => prev.filter((_, j) => j !== i))} title="Retirer"><X size={11} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="command-row">
|
||||
<button
|
||||
className={`mic-btn ${micClass}`}
|
||||
style={{ ['--level' as string]: level }}
|
||||
title={micTitle}
|
||||
onClick={() => voiceController.toggle()}
|
||||
disabled={phase === 'transcribing'}
|
||||
>
|
||||
{phase === 'transcribing' ? <Loader2 size={20} className="spin" /> : listening ? <Square size={18} /> : <Mic size={20} />}
|
||||
</button>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={`command-input${steerMode ? ' steer' : ''}`}
|
||||
rows={1}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{!compact && (
|
||||
<button className="icon-btn" title="Joindre une image" onClick={() => fileRef.current?.click()} style={{ height: 46, width: 40 }}>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void onFiles(e.target.files)} />
|
||||
{isSending && !steerMode ? (
|
||||
<button className="send-btn stop" title="Interrompre (Échap)" onClick={stopGeneration}>
|
||||
<Square size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button className="send-btn primary" title={steerMode ? 'Envoyer la consigne' : 'Envoyer (Entrée)'} onClick={() => void submit()} disabled={!draft.trim() && images.length === 0}>
|
||||
{steerMode ? <Navigation size={18} /> : <Send size={18} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!compact && (
|
||||
<div className="command-foot">
|
||||
<button className={`toggle-btn${handsFree ? ' on' : ''}`} onClick={() => voiceController.setHandsFree(!handsFree)} title="Écoute continue : le micro se réactive après chaque réponse">
|
||||
<Radio size={12} /> mains libres
|
||||
</button>
|
||||
<button className={`toggle-btn${autoSpeak && speechProvider !== 'off' ? ' on' : ''}`} onClick={() => update({ speech: { autoSpeak: !autoSpeak } })} title="Lire les réponses à voix haute">
|
||||
{autoSpeak && speechProvider !== 'off' ? <Volume2 size={12} /> : <VolumeX size={12} />} voix
|
||||
</button>
|
||||
{ttsState !== 'idle' && (
|
||||
<button className="toggle-btn on" onClick={() => speech.stop()} title="Couper la voix">
|
||||
<Square size={10} /> {ttsState === 'loading' ? 'synthèse…' : 'parle'}
|
||||
</button>
|
||||
)}
|
||||
{isSending && currentRunId && (
|
||||
<button className={`toggle-btn${steerMode ? ' on' : ''}`} onClick={() => setSteerMode((v) => !v)} title="Injecter une consigne dans le run en cours">
|
||||
<Navigation size={12} /> steer
|
||||
</button>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<span>{isSending ? 'Hermes travaille…' : listening ? (phase === 'speech' ? 'parole détectée' : 'écoute…') : 'Entrée ↵ envoyer · Shift+Entrée ligne'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { memo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Copy, Volume2, Check } from 'lucide-react';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { formatTime, preprocessMedia } from '../../lib/text';
|
||||
import { speech } from '../../services/voice/speech';
|
||||
import type { ChatMessage } from '../../state/chat';
|
||||
import { ResilientImage } from './ResilientImage';
|
||||
|
||||
interface Props {
|
||||
message: ChatMessage;
|
||||
assistantName: string;
|
||||
userName: string;
|
||||
showReasoning: boolean;
|
||||
compact?: boolean;
|
||||
onOpenImage?: (src: string) => void;
|
||||
}
|
||||
|
||||
function openLink(href: string): void {
|
||||
const api = bridge();
|
||||
if (/^(file:\/\/|[a-zA-Z]:[\\/])/.test(href) && api) {
|
||||
api.files.openPath(href).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
window.open(href, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
export const MessageBubble = memo(function MessageBubble({ message, assistantName, userName, showReasoning, compact, onOpenImage }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isUser = message.role === 'user';
|
||||
const who = isUser ? userName || 'VOUS' : message.role === 'system' ? 'SYSTÈME' : assistantName;
|
||||
const status = message.status ?? 'done';
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(message.content).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`msg ${message.role}`}>
|
||||
{!compact && (
|
||||
<div className="msg-meta">
|
||||
<span className="who">{who}</span>
|
||||
<span>{formatTime(message.timestamp)}</span>
|
||||
{message.source && message.source !== 'eveflow' && <span className={`src ${message.source}`}>{message.jobName ? `cron · ${message.jobName}` : message.source}</span>}
|
||||
{message.transport && message.role === 'assistant' && <span className="src">{message.transport}</span>}
|
||||
{message.usage?.total_tokens ? <span>{message.usage.total_tokens} tok</span> : null}
|
||||
{message.role === 'assistant' && status === 'done' && (
|
||||
<span className="msg-actions">
|
||||
<button className="icon-btn" title="Copier" onClick={copy}>{copied ? <Check size={13} /> : <Copy size={13} />}</button>
|
||||
<button className="icon-btn" title="Lire à voix haute" onClick={() => speech.say(message.content)}><Volume2 size={13} /></button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`bubble ${status}`}>
|
||||
{message.images && message.images.length > 0 && (
|
||||
<div className="msg-images">
|
||||
{message.images.map((img, i) => (
|
||||
<ResilientImage key={i} src={img} alt={`image ${i + 1}`} onOpen={onOpenImage} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showReasoning && message.reasoning && (
|
||||
<details className="reasoning">
|
||||
<summary>raisonnement</summary>
|
||||
{message.reasoning}
|
||||
</details>
|
||||
)}
|
||||
{message.role === 'user' ? (
|
||||
<span style={{ whiteSpace: 'pre-wrap' }}>{message.content}</span>
|
||||
) : (
|
||||
<div className="md">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
img: ({ src, alt }) => <ResilientImage src={typeof src === 'string' ? src : ''} alt={alt} onOpen={onOpenImage} />,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) openLink(href);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{preprocessMedia(message.content) || (status === 'streaming' ? '' : '…')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { ShieldAlert, HelpCircle, KeyRound } from 'lucide-react';
|
||||
import { resolvePending } from '../../services/conversation';
|
||||
import { useChat, type PendingRequest } from '../../state/chat';
|
||||
|
||||
function RequestCard({ request }: { request: PendingRequest }) {
|
||||
const [answer, setAnswer] = useState('');
|
||||
const icon = request.kind === 'approval' ? <ShieldAlert size={16} /> : request.kind === 'clarify' ? <HelpCircle size={16} /> : <KeyRound size={16} />;
|
||||
|
||||
return (
|
||||
<div className="modal">
|
||||
<div className="request-card">
|
||||
<div className="title">
|
||||
{icon}
|
||||
{request.title}
|
||||
{request.tool && <span className="chip warn">{request.tool}</span>}
|
||||
</div>
|
||||
<div className="desc">{request.description}</div>
|
||||
{request.args && <pre>{request.args}</pre>}
|
||||
{request.kind === 'approval' ? (
|
||||
<div className="actions">
|
||||
<button className="btn danger" onClick={() => void resolvePending(request, 'deny')}>Refuser</button>
|
||||
<button className="btn" onClick={() => void resolvePending(request, 'once')}>Autoriser une fois</button>
|
||||
<button className="btn" onClick={() => void resolvePending(request, 'session')}>Pour la session</button>
|
||||
<button className="btn primary" onClick={() => void resolvePending(request, 'always')}>Toujours</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{request.options && request.options.length > 0 && (
|
||||
<div className="actions" style={{ justifyContent: 'flex-start' }}>
|
||||
{request.options.map((opt) => (
|
||||
<button key={opt} className="btn small" onClick={() => void resolvePending(request, opt)}>{opt}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className="input"
|
||||
type={request.secret ? 'password' : 'text'}
|
||||
placeholder={request.secret ? 'Saisie confidentielle' : 'Votre réponse…'}
|
||||
value={answer}
|
||||
autoFocus
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && answer.trim()) void resolvePending(request, answer.trim());
|
||||
}}
|
||||
/>
|
||||
<div className="actions">
|
||||
<button className="btn ghost" onClick={() => useChat.getState().removePending(request.id)}>Ignorer</button>
|
||||
<button className="btn primary" disabled={!answer.trim()} onClick={() => void resolvePending(request, answer.trim())}>Envoyer</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PendingRequests() {
|
||||
const pending = useChat((s) => s.pending);
|
||||
if (pending.length === 0) return null;
|
||||
return (
|
||||
<div className="overlay">
|
||||
<RequestCard key={pending[0].id} request={pending[0]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { httpFetch } from '../../lib/transport';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { hermesBaseUrl } from '../../services/hermes/client';
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
const failures = new Map<string, string>();
|
||||
|
||||
interface Props {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
onOpen?: (src: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders images coming from Hermes: data URLs, http(s), Windows/file paths (read through the
|
||||
* main process) and server-side absolute paths (fetched from the Hermes host with auth).
|
||||
*/
|
||||
export function ResilientImage({ src = '', alt, onOpen }: Props) {
|
||||
const [resolved, setResolved] = useState<string>(() => cache.get(src) ?? '');
|
||||
const [error, setError] = useState<string>(() => failures.get(src) ?? '');
|
||||
const hermes = useSettings((s) => s.settings.hermes);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) return;
|
||||
let cancelled = false;
|
||||
const ok = (value: string) => {
|
||||
cache.set(src, value);
|
||||
if (!cancelled) {
|
||||
setResolved(value);
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
const fail = (message: string) => {
|
||||
failures.set(src, message);
|
||||
if (!cancelled) setError(message);
|
||||
};
|
||||
if (cache.has(src)) {
|
||||
setResolved(cache.get(src)!);
|
||||
return;
|
||||
}
|
||||
if (src.startsWith('data:') || src.startsWith('blob:') || /^https?:\/\//i.test(src)) {
|
||||
ok(src);
|
||||
return;
|
||||
}
|
||||
const api = bridge();
|
||||
const isLocal = src.startsWith('file://') || /^[a-zA-Z]:[\\/]/.test(src);
|
||||
const fromHermes = async () => {
|
||||
if (!hermes.url) throw new Error('chemin local inaccessible');
|
||||
const base = hermesBaseUrl(hermes.url);
|
||||
const path = src.replace(/^file:\/\//, '').replace(/\\/g, '/');
|
||||
const headers: Record<string, string> = {};
|
||||
if (hermes.apiKey) headers.Authorization = `Bearer ${hermes.apiKey}`;
|
||||
const res = await httpFetch({ url: `${base}/${path.replace(/^\//, '')}`, headers, responseType: 'binary', timeoutMs: 15_000 });
|
||||
if (!res.ok || !res.binary) throw new Error(`HTTP ${res.status}`);
|
||||
const type = res.headers['content-type'] || 'image/png';
|
||||
const bytes = new Uint8Array(res.binary);
|
||||
return URL.createObjectURL(new Blob([bytes], { type }));
|
||||
};
|
||||
const task = isLocal && api ? api.files.readLocal(src).catch(fromHermes) : fromHermes();
|
||||
task.then(ok).catch((err: Error) => fail(err.message || 'image inaccessible'));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src, hermes.url, hermes.apiKey]);
|
||||
|
||||
if (error && !resolved) {
|
||||
return (
|
||||
<span className="img-error" title={src}>
|
||||
Image inaccessible ({error})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!resolved) return <span className="img-error pulse">chargement…</span>;
|
||||
return <img src={resolved} alt={alt ?? 'image'} loading="lazy" onClick={() => onOpen?.(resolved)} />;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Maximize2, X, Minus } from 'lucide-react';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { CoreStage } from '../hud/CoreStage';
|
||||
import { ChatPanel } from '../chat/ChatPanel';
|
||||
|
||||
export function CompactWidget() {
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const api = bridge();
|
||||
return (
|
||||
<div className="compact-root" style={{ ['--compact-opacity' as string]: settings.ui.compactOpacity }}>
|
||||
<div className="compact-head">
|
||||
<span className="title">{settings.assistantName}</span>
|
||||
{api && (
|
||||
<>
|
||||
<button className="icon-btn" title="Mode HUD complet" onClick={() => api.window.setMode('hud')}><Maximize2 size={14} /></button>
|
||||
<button className="icon-btn" title="Masquer (Ctrl+Shift+J)" onClick={() => api.window.control('hide')}><Minus size={14} /></button>
|
||||
<button className="icon-btn close" title="Fermer" onClick={() => api.window.control('close')}><X size={14} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CoreStage compact />
|
||||
<div className="compact-body">
|
||||
<ChatPanel compact />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useChat } from '../../state/chat';
|
||||
import { useHermes } from '../../state/hermes';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { useVoice } from '../../state/voice';
|
||||
import { previewText } from '../../lib/text';
|
||||
import { JarvisCore } from './JarvisCore';
|
||||
|
||||
const STATE_LABEL: Record<string, string> = {
|
||||
idle: 'En veille',
|
||||
listening: 'À l’écoute',
|
||||
thinking: 'Analyse',
|
||||
speaking: 'Transmission',
|
||||
alert: 'Attention',
|
||||
error: 'Anomalie',
|
||||
success: 'Terminé'
|
||||
};
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function CoreStage({ compact }: Props) {
|
||||
const hud = useChat((s) => s.hudOverride ?? s.hud);
|
||||
const activity = useChat((s) => s.activity);
|
||||
const isSending = useChat((s) => s.isSending);
|
||||
const error = useChat((s) => s.error);
|
||||
const phase = useVoice((s) => s.phase);
|
||||
const level = useVoice((s) => s.inputLevel);
|
||||
const transcript = useVoice((s) => s.lastTranscript);
|
||||
const voiceError = useVoice((s) => s.error);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const transport = useHermes((s) => s.transport);
|
||||
const link = useHermes((s) => s.link);
|
||||
|
||||
const running = activity.filter((a) => a.status === 'running').slice(-1)[0];
|
||||
let sub = '';
|
||||
if (hud === 'listening') sub = phase === 'speech' ? 'parole détectée…' : phase === 'transcribing' ? 'transcription…' : 'parlez maintenant';
|
||||
else if (hud === 'thinking') sub = running ? `${running.name}${running.detail ? ` · ${previewText(running.detail, 60)}` : ''}` : isSending ? `Hermes · ${transport}` : '';
|
||||
else if (hud === 'error') sub = previewText(error ?? voiceError ?? 'erreur', 90);
|
||||
else if (hud === 'speaking') sub = 'synthèse vocale';
|
||||
else if (transcript) sub = `« ${previewText(transcript, 80)} »`;
|
||||
else sub = link === 'online' ? `liaison Hermes · ${transport}` : link === 'offline' ? 'Hermes hors ligne' : '';
|
||||
|
||||
return (
|
||||
<div className={compact ? 'compact-core' : 'core-stage'}>
|
||||
{!compact && (
|
||||
<div className="core-name">
|
||||
{settings.assistantName}
|
||||
<small>EVEFLOW · HERMES INTERFACE</small>
|
||||
</div>
|
||||
)}
|
||||
<div className={compact ? undefined : 'core-canvas-wrap'} style={compact ? { position: 'absolute', inset: '0 0 36px 0' } : undefined}>
|
||||
<JarvisCore state={hud} inputLevel={level} reduceMotion={settings.ui.reduceMotion} />
|
||||
</div>
|
||||
<div className="core-caption">
|
||||
<div className={`core-state ${hud}`}>{STATE_LABEL[hud] ?? hud}</div>
|
||||
{sub && <div className="core-sub">{sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* JARVIS-style arc reactor core rendered on a 2D canvas.
|
||||
* Rotating tick rings, segmented arcs and an audio-reactive radial spectrum that reads the
|
||||
* real TTS output (or the microphone while listening).
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { audioBus } from '../../services/voice/audioBus';
|
||||
import type { HudState } from '../../state/chat';
|
||||
|
||||
interface Props {
|
||||
state: HudState;
|
||||
/** 0..1 override for the microphone level (VAD), used while listening. */
|
||||
inputLevel?: number;
|
||||
reduceMotion?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Palette {
|
||||
main: [number, number, number];
|
||||
glow: [number, number, number];
|
||||
}
|
||||
|
||||
const PALETTES: Record<HudState, Palette> = {
|
||||
idle: { main: [52, 228, 255], glow: [52, 228, 255] },
|
||||
listening: { main: [90, 240, 255], glow: [52, 228, 255] },
|
||||
thinking: { main: [255, 195, 90], glow: [255, 170, 60] },
|
||||
speaking: { main: [140, 245, 255], glow: [52, 228, 255] },
|
||||
alert: { main: [255, 171, 64], glow: [255, 120, 40] },
|
||||
error: { main: [255, 92, 108], glow: [255, 60, 80] },
|
||||
success: { main: [78, 240, 168], glow: [60, 220, 150] }
|
||||
};
|
||||
|
||||
const BARS = 72;
|
||||
|
||||
function readThemeAccent(): [number, number, number] | null {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim();
|
||||
if (!raw) return null;
|
||||
const parts = raw.split(',').map((p) => Number(p.trim()));
|
||||
return parts.length === 3 && parts.every((n) => Number.isFinite(n)) ? (parts as [number, number, number]) : null;
|
||||
}
|
||||
|
||||
export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, className }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef(state);
|
||||
const inputRef = useRef(inputLevel);
|
||||
const motionRef = useRef(reduceMotion);
|
||||
stateRef.current = state;
|
||||
inputRef.current = inputLevel;
|
||||
motionRef.current = reduceMotion;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let raf = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = 1;
|
||||
const bars = new Float32Array(BARS);
|
||||
const color = { main: [52, 228, 255] as [number, number, number], glow: [52, 228, 255] as [number, number, number] };
|
||||
let energy = 0;
|
||||
let spin = 0;
|
||||
let spin2 = 0;
|
||||
let spin3 = 0;
|
||||
let last = performance.now();
|
||||
let visible = true;
|
||||
|
||||
const resize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
width = Math.max(1, Math.floor(rect.width));
|
||||
height = Math.max(1, Math.floor(rect.height));
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
};
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(canvas);
|
||||
resize();
|
||||
|
||||
const onVisibility = () => {
|
||||
visible = document.visibilityState === 'visible';
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
const rgba = (c: [number, number, number], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
|
||||
|
||||
const draw = (now: number) => {
|
||||
raf = requestAnimationFrame(draw);
|
||||
if (!visible) return;
|
||||
const dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
const st = stateRef.current;
|
||||
const motion = motionRef.current ? 0.25 : 1;
|
||||
|
||||
// ── target colours & energy
|
||||
const target = PALETTES[st];
|
||||
const themeAccent = st === 'idle' || st === 'listening' || st === 'speaking' ? readThemeAccent() : null;
|
||||
const mainTarget = themeAccent ?? target.main;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
color.main[i] = lerp(color.main[i], mainTarget[i], 0.08);
|
||||
color.glow[i] = lerp(color.glow[i], (themeAccent ?? target.glow)[i], 0.08);
|
||||
}
|
||||
const speaking = st === 'speaking';
|
||||
const listening = st === 'listening';
|
||||
const spectrum = speaking ? audioBus.spectrum(BARS, 'output') : listening ? audioBus.spectrum(BARS, 'input') : null;
|
||||
const level = speaking ? audioBus.outputLevel() : listening ? Math.max(inputRef.current, audioBus.inputLevel()) : 0;
|
||||
const targetEnergy = st === 'thinking' ? 0.45 : st === 'alert' || st === 'error' ? 0.6 : level;
|
||||
energy = lerp(energy, targetEnergy, speaking || listening ? 0.35 : 0.08);
|
||||
for (let i = 0; i < BARS; i++) {
|
||||
let t = 0;
|
||||
if (spectrum) {
|
||||
const v = spectrum[i];
|
||||
t = Math.pow(v, 1.4);
|
||||
if (listening) t = Math.max(t, inputRef.current * (0.5 + 0.5 * Math.sin(now / 90 + i)));
|
||||
} else if (st === 'thinking') {
|
||||
t = 0.15 + 0.12 * Math.sin(now / 260 + i * 0.35);
|
||||
} else if (st === 'idle') {
|
||||
t = 0.05 + 0.04 * Math.sin(now / 900 + i * 0.5);
|
||||
} else {
|
||||
t = 0.12 + 0.08 * Math.sin(now / 140 + i * 0.7);
|
||||
}
|
||||
bars[i] = lerp(bars[i], t, t > bars[i] ? 0.5 : 0.12);
|
||||
}
|
||||
|
||||
// ── rotation
|
||||
const speed = (st === 'thinking' ? 1.6 : st === 'alert' || st === 'error' ? 1.2 : speaking ? 0.7 : 0.35) * motion;
|
||||
spin += dt * 0.35 * speed;
|
||||
spin2 -= dt * 0.55 * speed;
|
||||
spin3 += dt * 0.9 * speed;
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const R = Math.min(width, height) * 0.46;
|
||||
const main = color.main;
|
||||
const glow = color.glow;
|
||||
|
||||
// ── ambient glow
|
||||
const halo = ctx.createRadialGradient(cx, cy, R * 0.1, cx, cy, R * 1.05);
|
||||
halo.addColorStop(0, rgba(glow, 0.18 + energy * 0.25));
|
||||
halo.addColorStop(0.5, rgba(glow, 0.05));
|
||||
halo.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = halo;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// ── outer tick ring
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(spin);
|
||||
ctx.strokeStyle = rgba(main, 0.55);
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const a = (i / 120) * Math.PI * 2;
|
||||
const long = i % 10 === 0;
|
||||
const mid = i % 5 === 0;
|
||||
const r0 = R * (long ? 0.93 : mid ? 0.955 : 0.97);
|
||||
ctx.globalAlpha = long ? 0.9 : mid ? 0.6 : 0.3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.cos(a) * r0, Math.sin(a) * r0);
|
||||
ctx.lineTo(Math.cos(a) * R, Math.sin(a) * R);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, R, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = rgba(main, 0.35);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// ── segmented arc ring
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(spin2);
|
||||
ctx.lineCap = 'butt';
|
||||
const segments = 6;
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const start = (i / segments) * Math.PI * 2;
|
||||
const span = (Math.PI * 2) / segments - 0.22;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, R * 0.86, start, start + span);
|
||||
ctx.lineWidth = 6;
|
||||
ctx.strokeStyle = rgba(main, 0.16 + (i % 2 === 0 ? 0.18 : 0.05) + energy * 0.2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, R * 0.82, start + 0.08, start + span * 0.45);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = rgba(main, 0.75);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// ── dashed ring
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(spin3);
|
||||
ctx.setLineDash([2, 6]);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = rgba(main, 0.45);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, R * 0.7, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
// ── audio spectrum bars
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.lineCap = 'round';
|
||||
const inner = R * 0.5;
|
||||
const maxLen = R * 0.16;
|
||||
for (let i = 0; i < BARS; i++) {
|
||||
const a = (i / BARS) * Math.PI * 2 - Math.PI / 2;
|
||||
const len = 2 + bars[i] * maxLen;
|
||||
const x0 = Math.cos(a) * inner;
|
||||
const y0 = Math.sin(a) * inner;
|
||||
const x1 = Math.cos(a) * (inner + len);
|
||||
const y1 = Math.sin(a) * (inner + len);
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.strokeStyle = rgba(main, 0.35 + bars[i] * 0.65);
|
||||
ctx.shadowBlur = 8 * bars[i];
|
||||
ctx.shadowColor = rgba(glow, 0.8);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0, y0);
|
||||
ctx.lineTo(x1, y1);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.restore();
|
||||
|
||||
// ── reactor core
|
||||
const coreR = R * 0.36;
|
||||
const breath = 1 + Math.sin(now / 700) * 0.02 + energy * 0.08;
|
||||
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR * breath);
|
||||
core.addColorStop(0, `rgba(255,255,255,${0.75 + energy * 0.25})`);
|
||||
core.addColorStop(0.25, rgba(main, 0.55 + energy * 0.3));
|
||||
core.addColorStop(0.7, rgba(glow, 0.12));
|
||||
core.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = core;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, coreR * breath, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, coreR * (0.42 + i * 0.16) * breath, 0, Math.PI * 2);
|
||||
ctx.lineWidth = i === 0 ? 2 : 1;
|
||||
ctx.strokeStyle = rgba(main, 0.7 - i * 0.2);
|
||||
ctx.stroke();
|
||||
}
|
||||
// reactor triangle
|
||||
ctx.rotate(-spin2 * 0.5);
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const a = (i / 3) * Math.PI * 2 - Math.PI / 2;
|
||||
const r = coreR * 0.5;
|
||||
if (i === 0) ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r);
|
||||
else ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.strokeStyle = rgba(main, 0.55);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// ── thinking orbiters
|
||||
if (st === 'thinking' || st === 'alert') {
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const a = spin3 * 2 + (i / 3) * Math.PI * 2;
|
||||
const r = R * 0.78;
|
||||
ctx.beginPath();
|
||||
ctx.arc(Math.cos(a) * r, Math.sin(a) * r, 3.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = rgba(main, 0.95);
|
||||
ctx.shadowBlur = 14;
|
||||
ctx.shadowColor = rgba(glow, 1);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── listening ring (input level)
|
||||
if (listening) {
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, R * (0.6 + level * 0.12), 0, Math.PI * 2);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = rgba(main, 0.25 + level * 0.6);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
observer.disconnect();
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <canvas ref={canvasRef} className={className} aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Minus, X, Settings, PictureInPicture2, RefreshCw, Webhook } from 'lucide-react';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { useClock } from '../../hooks/useMetrics';
|
||||
import { useHermes } from '../../state/hermes';
|
||||
import { useSettings } from '../../state/settings';
|
||||
|
||||
interface Props {
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export function TopBar({ onOpenSettings }: Props) {
|
||||
const now = useClock();
|
||||
const link = useHermes((s) => s.link);
|
||||
const linkDetail = useHermes((s) => s.linkDetail);
|
||||
const transport = useHermes((s) => s.transport);
|
||||
const capabilities = useHermes((s) => s.capabilities);
|
||||
const webhook = useHermes((s) => s.webhook);
|
||||
const connect = useHermes((s) => s.connect);
|
||||
const hermesUrl = useSettings((s) => s.settings.hermes.url);
|
||||
const model = useSettings((s) => s.settings.hermes.model);
|
||||
const api = bridge();
|
||||
|
||||
const host = (() => {
|
||||
try {
|
||||
return new URL(hermesUrl).host;
|
||||
} catch {
|
||||
return hermesUrl || '—';
|
||||
}
|
||||
})();
|
||||
const linkLabel = link === 'online' ? 'HERMES LINK' : link === 'checking' ? 'CONNEXION' : link === 'degraded' ? 'DÉGRADÉ' : link === 'offline' ? 'HORS LIGNE' : 'HERMES';
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="core-dot" />
|
||||
EVEFLOW
|
||||
<small>v2</small>
|
||||
</div>
|
||||
<div className="status-strip">
|
||||
<span className={`chip ${link}`} title={linkDetail || host}>
|
||||
<span className="dot" /> {linkLabel} · {host}
|
||||
</span>
|
||||
{link === 'online' && (
|
||||
<span className="chip accent" title="Transport de conversation">
|
||||
{transport}
|
||||
</span>
|
||||
)}
|
||||
{(model || capabilities?.model) && <span className="chip" title="Modèle">{model || String(capabilities?.model)}</span>}
|
||||
{webhook && (
|
||||
<span className={`chip ${webhook.listening ? 'online' : 'offline'}`} title={webhook.error ?? `POST ${webhook.path}`}>
|
||||
<Webhook size={11} /> :{webhook.port}
|
||||
</span>
|
||||
)}
|
||||
<button className="icon-btn" title="Reconnecter Hermes" onClick={() => void connect()}>
|
||||
<RefreshCw size={14} className={link === 'checking' ? 'spin' : undefined} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="clock">{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
|
||||
<div className="controls">
|
||||
<button className="icon-btn" title="Paramètres" onClick={onOpenSettings}><Settings size={16} /></button>
|
||||
{api && (
|
||||
<>
|
||||
<button className="icon-btn" title="Mode compact flottant" onClick={() => api.window.setMode('compact')}><PictureInPicture2 size={16} /></button>
|
||||
<button className="icon-btn" title="Réduire" onClick={() => api.window.control('minimize')}><Minus size={16} /></button>
|
||||
<button className="icon-btn close" title="Fermer" onClick={() => api.window.control('close')}><X size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Wrench, CalendarClock, Sparkles, Layers, History, Play, Pause, Trash2, Pencil, Plus, Save, X, RefreshCw,
|
||||
CheckCircle2, XCircle, Loader2, Bot, Cpu, GitBranch, Webhook, FolderOpen
|
||||
} from 'lucide-react';
|
||||
import { formatDateTime, formatTime, previewText } from '../../lib/text';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { jobSchedule, jobOutput } from '../../services/hermes/client';
|
||||
import type { HermesJob, HermesJobDraft } from '../../services/hermes/types';
|
||||
import { useChat } from '../../state/chat';
|
||||
import { jobStatus, useHermes } from '../../state/hermes';
|
||||
import { useSettings } from '../../state/settings';
|
||||
|
||||
type Tab = 'activity' | 'jobs' | 'skills' | 'sessions' | 'link';
|
||||
|
||||
const emptyDraft = (): HermesJobDraft => ({ name: '', schedule: 'every 1h', prompt: '', deliver: 'local' });
|
||||
|
||||
function ActivityTab() {
|
||||
const activity = useChat((s) => s.activity);
|
||||
const items = [...activity].reverse();
|
||||
if (items.length === 0) return <div className="empty">Aucune activité outil pour l’instant.</div>;
|
||||
return (
|
||||
<div>
|
||||
{items.map((a) => (
|
||||
<div key={a.id} className={`activity-item ${a.status}`}>
|
||||
<span className="icon">
|
||||
{a.status === 'running' ? <Loader2 size={13} className="spin" /> : a.status === 'done' ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
|
||||
</span>
|
||||
<div>
|
||||
<div className="name">{a.kind === 'subagent' ? <><Bot size={11} /> </> : null}{a.name}</div>
|
||||
{a.detail && <div className="detail">{previewText(a.detail, 220)}</div>}
|
||||
{a.output && <div className="detail muted">{previewText(a.output, 220)}</div>}
|
||||
</div>
|
||||
<span className="time">{formatTime(a.startedAt)}{a.endedAt ? ` · ${((a.endedAt - a.startedAt) / 1000).toFixed(1)}s` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobsTab() {
|
||||
const jobs = useHermes((s) => s.jobs);
|
||||
const busy = useHermes((s) => s.busy);
|
||||
const lastSyncAt = useHermes((s) => s.lastSyncAt);
|
||||
const refreshJobs = useHermes((s) => s.refreshJobs);
|
||||
const createJob = useHermes((s) => s.createJob);
|
||||
const updateJob = useHermes((s) => s.updateJob);
|
||||
const jobAction = useHermes((s) => s.jobAction);
|
||||
const setError = useChat((s) => s.setError);
|
||||
const [draft, setDraft] = useState<HermesJobDraft>(emptyDraft);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
|
||||
const run = async (task: Promise<void>) => {
|
||||
try {
|
||||
await task;
|
||||
} catch (err) {
|
||||
setError(`Cron Hermes : ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (job: HermesJob) => {
|
||||
setEditing(job.id);
|
||||
setDraft({ name: job.name ?? job.id, schedule: jobSchedule(job), prompt: job.prompt ?? '', deliver: job.deliver ?? 'local' });
|
||||
setShowEditor(true);
|
||||
};
|
||||
const save = async () => {
|
||||
if (editing) await run(updateJob(editing, draft));
|
||||
else await run(createJob(draft));
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft());
|
||||
setShowEditor(false);
|
||||
};
|
||||
const valid = draft.name.trim() && draft.schedule.trim() && draft.prompt.trim();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ marginBottom: 10, justifyContent: 'space-between' }}>
|
||||
<span className="muted mono" style={{ fontSize: 11 }}>{lastSyncAt ? `sync ${formatTime(lastSyncAt)}` : 'non synchronisé'}</span>
|
||||
<span className="row">
|
||||
<button className="icon-btn" title="Synchroniser" onClick={() => void run(refreshJobs())}><RefreshCw size={14} className={busy ? 'spin' : undefined} /></button>
|
||||
<button className="btn small" onClick={() => { setEditing(null); setDraft(emptyDraft()); setShowEditor((v) => !v); }}><Plus size={13} /> Cron</button>
|
||||
</span>
|
||||
</div>
|
||||
{showEditor && (
|
||||
<div className="job-editor">
|
||||
<input className="input" placeholder="Nom de la mission" value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
|
||||
<input className="input" placeholder="every 1h · 0 9 * * MON-FRI · in 30m · every monday 9am" value={draft.schedule} onChange={(e) => setDraft({ ...draft, schedule: e.target.value })} />
|
||||
<textarea className="textarea" placeholder="Instruction à exécuter par Hermes" value={draft.prompt} onChange={(e) => setDraft({ ...draft, prompt: e.target.value })} />
|
||||
<input className="input" placeholder="deliver : local · origin · telegram · all" value={draft.deliver ?? ''} onChange={(e) => setDraft({ ...draft, deliver: e.target.value })} />
|
||||
<div className="row">
|
||||
<button className="btn ghost small" onClick={() => { setShowEditor(false); setEditing(null); }}><X size={13} /> Annuler</button>
|
||||
<button className="btn primary small" disabled={!valid || busy} onClick={() => void save()}><Save size={13} /> {editing ? 'Mettre à jour' : 'Créer'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{jobs.length === 0 ? (
|
||||
<div className="empty">Aucun cron Hermes. Créez une mission planifiée en langage naturel.</div>
|
||||
) : (
|
||||
<div className="list">
|
||||
{jobs.map((job) => {
|
||||
const status = jobStatus(job).toLowerCase();
|
||||
const paused = status === 'paused' || job.enabled === false;
|
||||
const output = jobOutput(job);
|
||||
return (
|
||||
<div key={job.id} className="row-item">
|
||||
<div className="main">
|
||||
<div className="row wrap" style={{ gap: 6 }}>
|
||||
<span className="name">{job.name || job.id}</span>
|
||||
<span className={`status-pill ${status}`}>{status}</span>
|
||||
{job.last_status && <span className={`status-pill ${String(job.last_status).toLowerCase()}`}>{String(job.last_status)}</span>}
|
||||
</div>
|
||||
<span className="meta">{jobSchedule(job)} · prochain {formatDateTime(job.next_run_at)}</span>
|
||||
{job.prompt && <span className="desc">{previewText(job.prompt, 140)}</span>}
|
||||
{output && <span className="meta">↳ {previewText(output, 120)}</span>}
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="icon-btn" title="Exécuter maintenant" onClick={() => void run(jobAction(job.id, 'run'))}><Play size={13} /></button>
|
||||
<button className="icon-btn" title={paused ? 'Reprendre' : 'Mettre en pause'} onClick={() => void run(jobAction(job.id, paused ? 'resume' : 'pause'))}>{paused ? <Play size={13} /> : <Pause size={13} />}</button>
|
||||
<button className="icon-btn" title="Modifier" onClick={() => startEdit(job)}><Pencil size={13} /></button>
|
||||
<button className="icon-btn danger" title="Supprimer" onClick={() => { if (confirm(`Supprimer le cron « ${job.name || job.id} » ?`)) void run(jobAction(job.id, 'delete')); }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryList() {
|
||||
const runs = useHermes((s) => s.jobRuns);
|
||||
const markRead = useHermes((s) => s.markRunRead);
|
||||
const addMessage = useChat((s) => s.addMessage);
|
||||
if (runs.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="section-title"><History size={12} /> Résultats récents</div>
|
||||
<div className="list">
|
||||
{runs.slice(0, 20).map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="row-item clickable"
|
||||
style={{ opacity: r.read ? 0.7 : 1 }}
|
||||
onClick={() => {
|
||||
markRead(r.id);
|
||||
addMessage({ role: 'assistant', content: r.output, source: 'cron', jobName: r.jobName, status: 'done', timestamp: new Date(r.at).getTime() || Date.now() });
|
||||
}}
|
||||
>
|
||||
<div className="main">
|
||||
<div className="row wrap" style={{ gap: 6 }}>
|
||||
<span className="name">{r.jobName}</span>
|
||||
<span className={`status-pill ${r.status}`}>{r.status}</span>
|
||||
</div>
|
||||
<span className="desc">{previewText(r.output, 160)}</span>
|
||||
<span className="meta">{formatDateTime(r.at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsTab() {
|
||||
const skills = useHermes((s) => s.skills);
|
||||
const toolsets = useHermes((s) => s.toolsets);
|
||||
const refresh = useHermes((s) => s.refreshCatalog);
|
||||
const [filter, setFilter] = useState('');
|
||||
const q = filter.toLowerCase();
|
||||
const visibleSkills = skills.filter((s) => !q || `${s.name} ${s.description ?? ''} ${s.category ?? ''}`.toLowerCase().includes(q));
|
||||
const visibleToolsets = toolsets.filter((t) => !q || `${t.name} ${t.label ?? ''} ${t.description ?? ''} ${(t.tools ?? []).join(' ')}`.toLowerCase().includes(q));
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ marginBottom: 10 }}>
|
||||
<input className="input" placeholder="Filtrer les skills et toolsets…" value={filter} onChange={(e) => setFilter(e.target.value)} />
|
||||
<button className="icon-btn" title="Recharger" onClick={() => void refresh()}><RefreshCw size={14} /></button>
|
||||
</div>
|
||||
<div className="section-title"><Sparkles size={12} /> Skills ({skills.length})</div>
|
||||
{visibleSkills.length === 0 ? <div className="empty">Aucun skill exposé par ce serveur.</div> : (
|
||||
<div className="list">
|
||||
{visibleSkills.map((s) => (
|
||||
<div key={s.name} className="row-item">
|
||||
<div className="main">
|
||||
<div className="row wrap" style={{ gap: 6 }}>
|
||||
<span className="name">{s.name}</span>
|
||||
{s.category && <span className="status-pill">{s.category}</span>}
|
||||
</div>
|
||||
{s.description && <span className="desc">{s.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="section-title"><Layers size={12} /> Toolsets ({toolsets.length})</div>
|
||||
{visibleToolsets.length === 0 ? <div className="empty">Aucun toolset listé.</div> : (
|
||||
<div className="list">
|
||||
{visibleToolsets.map((t) => (
|
||||
<div key={t.name} className="row-item">
|
||||
<div className="main">
|
||||
<div className="row wrap" style={{ gap: 6 }}>
|
||||
<span className="name">{t.label || t.name}</span>
|
||||
{t.enabled !== undefined && <span className={`status-pill ${t.enabled ? 'active' : 'paused'}`}>{t.enabled ? 'actif' : 'inactif'}</span>}
|
||||
{t.configured === false && <span className="status-pill error">non configuré</span>}
|
||||
</div>
|
||||
{t.description && <span className="desc">{t.description}</span>}
|
||||
{t.tools && t.tools.length > 0 && <span className="meta">{t.tools.slice(0, 12).join(' · ')}{t.tools.length > 12 ? ` · +${t.tools.length - 12}` : ''}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsTab() {
|
||||
const sessions = useHermes((s) => s.sessions);
|
||||
const refresh = useHermes((s) => s.refreshSessions);
|
||||
const client = useHermes((s) => s.client);
|
||||
const transport = useHermes((s) => s.transport);
|
||||
const sessionId = useSettings((s) => s.settings.hermesSessionId);
|
||||
const setSessionId = useSettings((s) => s.setHermesSessionId);
|
||||
const chat = useChat();
|
||||
|
||||
const activeId = sessionId.replace(/^hs:/, '');
|
||||
const load = async (id: string) => {
|
||||
try {
|
||||
const messages = await client().sessionMessages(id);
|
||||
chat.clear();
|
||||
for (const m of messages.slice(-60)) {
|
||||
if (!m.content) continue;
|
||||
chat.addMessage({ role: m.role === 'user' ? 'user' : 'assistant', content: String(m.content), status: 'done', source: 'hermes', timestamp: m.timestamp ? new Date(m.timestamp).getTime() : Date.now() });
|
||||
}
|
||||
setSessionId(transport === 'sessions' ? `hs:${id}` : id);
|
||||
} catch (err) {
|
||||
chat.setError(`Session : ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
const fresh = () => {
|
||||
chat.clear();
|
||||
setSessionId(`eveflow-${Date.now().toString(36)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ marginBottom: 10, justifyContent: 'space-between' }}>
|
||||
<span className="muted mono" style={{ fontSize: 11 }}>session {previewText(activeId, 28)}</span>
|
||||
<span className="row">
|
||||
<button className="icon-btn" title="Recharger" onClick={() => void refresh()}><RefreshCw size={14} /></button>
|
||||
<button className="btn small" onClick={fresh}><Plus size={13} /> Nouvelle</button>
|
||||
</span>
|
||||
</div>
|
||||
{sessions.length === 0 ? <div className="empty">Aucune session listée par Hermes.</div> : (
|
||||
<div className="list">
|
||||
{sessions.map((s) => (
|
||||
<div key={s.id} className={`row-item clickable`} style={s.id === activeId ? { borderColor: 'var(--accent)' } : undefined} onClick={() => void load(s.id)}>
|
||||
<div className="main">
|
||||
<span className="name">{s.title || s.id}</span>
|
||||
<span className="meta">{[s.platform ?? s.source, s.message_count ? `${s.message_count} msg` : null, formatDateTime(s.updated_at ?? s.created_at)].filter(Boolean).join(' · ')}</span>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="icon-btn" title="Bifurquer (fork)" onClick={(e) => { e.stopPropagation(); client().forkSession(s.id).then(() => refresh()).catch((err: Error) => chat.setError(err.message)); }}><GitBranch size={13} /></button>
|
||||
<button className="icon-btn danger" title="Supprimer" onClick={(e) => { e.stopPropagation(); if (confirm('Supprimer cette session Hermes ?')) client().deleteSession(s.id).then(() => refresh()).catch((err: Error) => chat.setError(err.message)); }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkTab() {
|
||||
const { link, linkDetail, health, capabilities, transport, models, webhook } = useHermes();
|
||||
const connect = useHermes((s) => s.connect);
|
||||
const hermes = useSettings((s) => s.settings.hermes);
|
||||
const [info, setInfo] = useState<{ sharedDir: string; logPath: string; version: string } | null>(null);
|
||||
useEffect(() => {
|
||||
bridge()?.system.appInfo().then(setInfo).catch(() => undefined);
|
||||
}, []);
|
||||
const checks = (health?.readiness as { checks?: Record<string, unknown> } | undefined)?.checks ?? {};
|
||||
const features = capabilities?.features ?? {};
|
||||
return (
|
||||
<div>
|
||||
<div className="section-title"><Cpu size={12} /> Liaison Hermes</div>
|
||||
<dl className="kv">
|
||||
<dt>État</dt><dd><span className={`status-pill ${link}`}>{link}</span> {linkDetail}</dd>
|
||||
<dt>URL</dt><dd>{hermes.url || '—'}</dd>
|
||||
<dt>Transport</dt><dd>{transport}{hermes.transport === 'auto' ? ' (auto)' : ''}</dd>
|
||||
<dt>Modèle</dt><dd>{hermes.model || String(capabilities?.model ?? '—')}</dd>
|
||||
{capabilities?.version ? <><dt>Version</dt><dd>{String(capabilities.version)}</dd></> : null}
|
||||
<dt>Modèles</dt><dd>{models.length ? models.map((m) => m.id).slice(0, 6).join(', ') + (models.length > 6 ? '…' : '') : '—'}</dd>
|
||||
</dl>
|
||||
<div className="row" style={{ marginTop: 8 }}>
|
||||
<button className="btn small" onClick={() => void connect()}><RefreshCw size={13} /> Reconnecter</button>
|
||||
</div>
|
||||
{Object.keys(features).length > 0 && (
|
||||
<>
|
||||
<div className="section-title"><Layers size={12} /> Capacités</div>
|
||||
<div className="row wrap" style={{ gap: 6 }}>
|
||||
{Object.entries(features).map(([k, v]) => (
|
||||
<span key={k} className={`status-pill ${v ? 'ok' : ''}`}>{k}{typeof v === 'string' ? `: ${v}` : ''}</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{Object.keys(checks).length > 0 && (
|
||||
<>
|
||||
<div className="section-title"><CheckCircle2 size={12} /> Santé</div>
|
||||
<dl className="kv">
|
||||
{Object.entries(checks).map(([k, v]) => (
|
||||
<><dt key={`${k}-t`}>{k}</dt><dd key={`${k}-d`}>{typeof v === 'object' ? JSON.stringify(v) : String(v)}</dd></>
|
||||
))}
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
<div className="section-title"><Webhook size={12} /> Webhook entrant</div>
|
||||
<dl className="kv">
|
||||
<dt>État</dt><dd><span className={`status-pill ${webhook?.listening ? 'online' : 'offline'}`}>{webhook?.listening ? 'à l’écoute' : 'inactif'}</span> {webhook?.error}</dd>
|
||||
<dt>Endpoint</dt><dd>POST http://<cette-machine>:{webhook?.port ?? 7842}{webhook?.path ?? '/eveflow/hook'}</dd>
|
||||
<dt>Secret</dt><dd>{webhook?.secretConfigured ? 'requis (X-EveFlow-Secret)' : 'aucun'}</dd>
|
||||
</dl>
|
||||
{info && (
|
||||
<>
|
||||
<div className="section-title"><FolderOpen size={12} /> Application</div>
|
||||
<dl className="kv">
|
||||
<dt>Version</dt><dd>{info.version}</dd>
|
||||
<dt>Partage</dt><dd><a href="#" onClick={(e) => { e.preventDefault(); bridge()?.files.openPath(info.sharedDir); }}>{info.sharedDir}</a></dd>
|
||||
<dt>Journal</dt><dd><a href="#" onClick={(e) => { e.preventDefault(); bridge()?.files.showInFolder(info.logPath); }}>{info.logPath}</a></dd>
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpsPanel() {
|
||||
const [tab, setTab] = useState<Tab>('activity');
|
||||
const running = useChat((s) => s.activity.filter((a) => a.status === 'running').length);
|
||||
const unread = useHermes((s) => s.jobRuns.filter((r) => !r.read).length);
|
||||
const link = useHermes((s) => s.link);
|
||||
|
||||
const tabs: Array<{ id: Tab; label: string; icon: React.ReactNode; badge?: number }> = [
|
||||
{ id: 'activity', label: 'Outils', icon: <Wrench size={13} />, badge: running },
|
||||
{ id: 'jobs', label: 'Crons', icon: <CalendarClock size={13} />, badge: unread },
|
||||
{ id: 'skills', label: 'Skills', icon: <Sparkles size={13} /> },
|
||||
{ id: 'sessions', label: 'Sessions', icon: <History size={13} /> },
|
||||
{ id: 'link', label: 'Liaison', icon: <Cpu size={13} /> }
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="panel bracket ops-panel">
|
||||
<header className="panel-head">
|
||||
<span className={`led ${link === 'online' ? '' : link === 'offline' ? 'error' : link === 'unknown' ? 'off' : 'warn'}`} />
|
||||
<span>Hermes Ops</span>
|
||||
</header>
|
||||
<div className="tabs">
|
||||
{tabs.map((t) => (
|
||||
<button key={t.id} className={`tab${tab === t.id ? ' active' : ''}`} onClick={() => setTab(t.id)}>
|
||||
{t.icon} {t.label}
|
||||
{t.badge ? <span className="badge">{t.badge}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{tab === 'activity' && <ActivityTab />}
|
||||
{tab === 'jobs' && (<><JobsTab /><HistoryList /></>)}
|
||||
{tab === 'skills' && <SkillsTab />}
|
||||
{tab === 'sessions' && <SessionsTab />}
|
||||
{tab === 'link' && <LinkTab />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Activity } from 'lucide-react';
|
||||
import { useMetrics } from '../../hooks/useMetrics';
|
||||
import { formatDuration } from '../../lib/text';
|
||||
|
||||
function Gauge({ value, max, label, display, warn }: { value: number; max: number; label: string; display: string; warn?: number }) {
|
||||
const ratio = Math.max(0, Math.min(1, value / max));
|
||||
const r = 24;
|
||||
const c = 2 * Math.PI * r;
|
||||
const arc = c * 0.75;
|
||||
const color = warn !== undefined && value >= warn ? 'var(--danger)' : 'var(--accent)';
|
||||
return (
|
||||
<div className="gauge">
|
||||
<svg viewBox="0 0 62 62">
|
||||
<circle cx="31" cy="31" r={r} fill="none" stroke="rgba(var(--accent-rgb),0.15)" strokeWidth="4" strokeDasharray={`${arc} ${c}`} transform="rotate(135 31 31)" strokeLinecap="round" />
|
||||
<circle cx="31" cy="31" r={r} fill="none" stroke={color} strokeWidth="4" strokeDasharray={`${arc * ratio} ${c}`} transform="rotate(135 31 31)" strokeLinecap="round" style={{ transition: 'stroke-dasharray 600ms ease', filter: `drop-shadow(0 0 4px ${color})` }} />
|
||||
<text x="31" y="35" textAnchor="middle" fontSize="11" fontFamily="var(--font-mono)" fill="var(--ink-0)">{display}</text>
|
||||
</svg>
|
||||
<span className="label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Telemetry() {
|
||||
const m = useMetrics(true);
|
||||
return (
|
||||
<section className="panel bracket">
|
||||
<header className="panel-head">
|
||||
<Activity size={14} />
|
||||
<span>Système</span>
|
||||
<span className="spacer" />
|
||||
<span className="chip">{m.hostname}</span>
|
||||
</header>
|
||||
<div className="telemetry">
|
||||
<Gauge value={m.cpuLoad} max={100} label="CPU" display={`${Math.round(m.cpuLoad)}%`} warn={90} />
|
||||
<Gauge value={m.memUsedPct} max={100} label="RAM" display={`${Math.round(m.memUsedPct)}%`} warn={92} />
|
||||
<Gauge value={m.fps} max={60} label="FPS" display={`${m.fps}`} />
|
||||
<Gauge value={m.cpuFreqMhz} max={5500} label="GHz" display={m.cpuFreqMhz ? (m.cpuFreqMhz / 1000).toFixed(1) : '—'} />
|
||||
</div>
|
||||
<div className="telemetry-foot">
|
||||
<span>{m.cpuCores} cœurs{m.cpuModel ? ` · ${m.cpuModel.replace(/\(R\)|\(TM\)|CPU|@.*$/g, '').trim()}` : ''}</span>
|
||||
<span>{m.memTotalGb ? `${m.memTotalGb} Go` : ''}{m.uptimeSec ? ` · up ${formatDuration(m.uptimeSec)}` : ''}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X, Settings, CheckCircle2, XCircle, Loader2, Mic, Volume2, RotateCcw, Play } from 'lucide-react';
|
||||
import { bridge } from '../../lib/bridge';
|
||||
import { HermesClient } from '../../services/hermes/client';
|
||||
import { listSystemVoices } from '../../services/voice/tts';
|
||||
import { speech } from '../../services/voice/speech';
|
||||
import { listMicrophones } from '../../services/voice/capture';
|
||||
import { transcribeWav } from '../../services/voice/stt';
|
||||
import { encodeWav } from '../../services/voice/wav';
|
||||
import { useHermes } from '../../state/hermes';
|
||||
import { DEFAULT_SETTINGS, useSettings, type HudTheme } from '../../state/settings';
|
||||
import { useVoice } from '../../state/voice';
|
||||
|
||||
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'webhook' | 'ui';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Toggle({ on, onChange, label, hint }: { on: boolean; onChange: (v: boolean) => void; label: string; hint?: string }) {
|
||||
return (
|
||||
<div className="switch" onClick={() => onChange(!on)}>
|
||||
<div className="text">
|
||||
<span>{label}</span>
|
||||
{hint && <small>{hint}</small>}
|
||||
</div>
|
||||
<span className={`toggle${on ? ' on' : ''}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TestState = { status: 'idle' | 'running' | 'ok' | 'fail'; message: string };
|
||||
|
||||
export function SettingsDrawer({ onClose }: Props) {
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const update = useSettings((s) => s.update);
|
||||
const reset = useSettings((s) => s.reset);
|
||||
const hermesStore = useHermes();
|
||||
const micDevices = useVoice((s) => s.micDevices);
|
||||
const [section, setSection] = useState<Section>('hermes');
|
||||
const [hermesTest, setHermesTest] = useState<TestState>({ status: 'idle', message: '' });
|
||||
const [sttTest, setSttTest] = useState<TestState>({ status: 'idle', message: '' });
|
||||
const [ttsTest, setTtsTest] = useState<TestState>({ status: 'idle', message: '' });
|
||||
const [voices, setVoices] = useState(listSystemVoices());
|
||||
const [webhookSecretVisible, setWebhookSecretVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setVoices(listSystemVoices());
|
||||
refresh();
|
||||
speechSynthesis.addEventListener?.('voiceschanged', refresh);
|
||||
void listMicrophones().then((d) => useVoice.getState().setMicDevices(d));
|
||||
return () => speechSynthesis.removeEventListener?.('voiceschanged', refresh);
|
||||
}, []);
|
||||
|
||||
const testHermes = async () => {
|
||||
setHermesTest({ status: 'running', message: 'connexion…' });
|
||||
try {
|
||||
const client = new HermesClient(settings.hermes);
|
||||
const health = await client.health();
|
||||
let caps = '';
|
||||
try {
|
||||
const c = await client.capabilities();
|
||||
caps = ` · ${String(c.model ?? '')} · ${Object.entries(c.features ?? {}).filter(([, v]) => v === true).length} fonctions`;
|
||||
} catch {
|
||||
caps = ' · capabilities indisponibles';
|
||||
}
|
||||
setHermesTest({ status: 'ok', message: `${health.status}${caps}` });
|
||||
void hermesStore.connect();
|
||||
} catch (err) {
|
||||
setHermesTest({ status: 'fail', message: (err as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
const testStt = async () => {
|
||||
setSttTest({ status: 'running', message: 'envoi d’un WAV de test…' });
|
||||
try {
|
||||
const samples = new Float32Array(16000);
|
||||
for (let i = 0; i < samples.length; i++) samples[i] = Math.sin(i / 20) * 0.05;
|
||||
const text = await transcribeWav(encodeWav(samples, 16000), settings.voice);
|
||||
setSttTest({ status: 'ok', message: `endpoint OK (réponse: "${text.slice(0, 40) || 'vide'}")` });
|
||||
} catch (err) {
|
||||
setSttTest({ status: 'fail', message: (err as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
const testTts = () => {
|
||||
setTtsTest({ status: 'running', message: 'synthèse…' });
|
||||
speech.say(`Bonjour, je suis ${settings.assistantName}. Les systèmes sont opérationnels.`);
|
||||
setTimeout(() => setTtsTest({ status: 'ok', message: 'commande envoyée (écoutez le résultat)' }), 600);
|
||||
};
|
||||
|
||||
const applyWebhook = async () => {
|
||||
const api = bridge();
|
||||
if (!api) return;
|
||||
await api.store.set('webhook', settings.webhook);
|
||||
const status = await api.hermes.webhookRestart();
|
||||
useHermes.setState({ webhook: status });
|
||||
};
|
||||
|
||||
const Result = ({ t }: { t: TestState }) =>
|
||||
t.status === 'idle' ? null : (
|
||||
<div className={`test-result ${t.status === 'ok' ? 'ok' : t.status === 'fail' ? 'fail' : ''}`}>
|
||||
{t.status === 'running' ? <Loader2 size={13} className="spin" /> : t.status === 'ok' ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
|
||||
<span>{t.message}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const nav: Array<[Section, string]> = [
|
||||
['hermes', 'Hermes'],
|
||||
['voice', 'Micro / STT'],
|
||||
['speech', 'Voix / TTS'],
|
||||
['webhook', 'Webhook'],
|
||||
['general', 'Général'],
|
||||
['ui', 'Interface']
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="settings-drawer">
|
||||
<header className="panel-head">
|
||||
<Settings size={14} />
|
||||
<span>Configuration</span>
|
||||
<span className="spacer" />
|
||||
<button className="icon-btn" onClick={onClose} title="Fermer"><X size={16} /></button>
|
||||
</header>
|
||||
<nav className="settings-nav">
|
||||
{nav.map(([id, label]) => (
|
||||
<button key={id} className={section === id ? 'active' : ''} onClick={() => setSection(id)}>{label}</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="settings-body">
|
||||
{section === 'hermes' && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="field">
|
||||
<label>URL du serveur API Hermes</label>
|
||||
<input className="input" value={settings.hermes.url} placeholder="http://127.0.0.1:8642" onChange={(e) => update({ hermes: { url: e.target.value } })} />
|
||||
<span className="hint">Serveur lancé avec <code>hermes gateway</code> (API_SERVER_ENABLED=true, port 8642 par défaut). L’URL peut se terminer par /v1.</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Clé API (API_SERVER_KEY)</label>
|
||||
<input className="input" type="password" value={settings.hermes.apiKey} onChange={(e) => update({ hermes: { apiKey: e.target.value } })} />
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Modèle</label>
|
||||
<input className="input" list="hermes-models" value={settings.hermes.model} placeholder="(défaut du serveur)" onChange={(e) => update({ hermes: { model: e.target.value } })} />
|
||||
<datalist id="hermes-models">
|
||||
{hermesStore.models.map((m) => <option key={m.id} value={m.id} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Clé de mémoire (X-Hermes-Session-Key)</label>
|
||||
<input className="input" value={settings.hermes.sessionKey} onChange={(e) => update({ hermes: { sessionKey: e.target.value } })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Transport</label>
|
||||
<select className="select" value={settings.hermes.transport} onChange={(e) => update({ hermes: { transport: e.target.value as typeof settings.hermes.transport } })}>
|
||||
<option value="auto">Auto (selon /v1/capabilities)</option>
|
||||
<option value="runs">Runs API (SSE, approbations, steer)</option>
|
||||
<option value="sessions">Sessions API (mémoire serveur)</option>
|
||||
<option value="completions">Chat completions (OpenAI + outils EveFlow)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Effort de raisonnement</label>
|
||||
<select className="select" value={settings.hermes.reasoningEffort} onChange={(e) => update({ hermes: { reasoningEffort: e.target.value as typeof settings.hermes.reasoningEffort } })}>
|
||||
<option value="">Défaut</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Instructions EveFlow (superposées au prompt Hermes)</label>
|
||||
<textarea className="textarea" value={settings.hermes.instructions} onChange={(e) => update({ hermes: { instructions: e.target.value } })} />
|
||||
</div>
|
||||
<Toggle on={settings.hermes.localTools} onChange={(v) => update({ hermes: { localTools: v } })} label="Outils EveFlow côté client" hint="En mode chat completions : état du HUD, fichiers partagés, notifications." />
|
||||
<div className="row" style={{ marginTop: 10 }}>
|
||||
<button className="btn primary small" onClick={() => void testHermes()}><Play size={13} /> Tester la liaison</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}><Result t={hermesTest} /></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === 'voice' && (
|
||||
<div className="card">
|
||||
<div className="field">
|
||||
<label>Moteur de reconnaissance</label>
|
||||
<select className="select" value={settings.voice.provider} onChange={(e) => update({ voice: { provider: e.target.value as typeof settings.voice.provider } })}>
|
||||
<option value="openai-compatible">API compatible OpenAI (Qwen3-ASR, Whisper, Speaches, LocalAI…)</option>
|
||||
<option value="browser">Reconnaissance Chromium (en ligne, secours)</option>
|
||||
</select>
|
||||
</div>
|
||||
{settings.voice.provider === 'openai-compatible' && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label>URL de l’API STT</label>
|
||||
<input className="input" value={settings.voice.apiUrl} placeholder="http://127.0.0.1:8000/v1" onChange={(e) => update({ voice: { apiUrl: e.target.value } })} />
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Modèle</label>
|
||||
<input className="input" value={settings.voice.model} onChange={(e) => update({ voice: { model: e.target.value } })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Clé API</label>
|
||||
<input className="input" type="password" value={settings.voice.apiKey} onChange={(e) => update({ voice: { apiKey: e.target.value } })} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Langue</label>
|
||||
<select className="select" value={settings.voice.language} onChange={(e) => update({ voice: { language: e.target.value }, language: e.target.value })}>
|
||||
<option value="fr-FR">Français</option>
|
||||
<option value="en-US">English</option>
|
||||
<option value="es-ES">Español</option>
|
||||
<option value="de-DE">Deutsch</option>
|
||||
<option value="it-IT">Italiano</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Microphone</label>
|
||||
<select className="select" value={settings.voice.micDeviceId} onChange={(e) => update({ voice: { micDeviceId: e.target.value } })}>
|
||||
<option value="">Par défaut du système</option>
|
||||
{micDevices.map((d) => <option key={d.deviceId} value={d.deviceId}>{d.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Mode de capture</label>
|
||||
<div className="seg">
|
||||
<button className={settings.voice.captureMode === 'auto' ? 'active' : ''} onClick={() => update({ voice: { captureMode: 'auto' } })}>Auto (détection de silence)</button>
|
||||
<button className={settings.voice.captureMode === 'manual' ? 'active' : ''} onClick={() => update({ voice: { captureMode: 'manual' } })}>Manuel (clic pour arrêter)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Sensibilité : {settings.voice.sensitivity}/5</label>
|
||||
<input className="range" type="range" min={1} max={5} step={1} value={settings.voice.sensitivity} onChange={(e) => update({ voice: { sensitivity: Number(e.target.value) } })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Silence de fin : {settings.voice.silenceMs} ms</label>
|
||||
<input className="range" type="range" min={400} max={2500} step={100} value={settings.voice.silenceMs} onChange={(e) => update({ voice: { silenceMs: Number(e.target.value) } })} />
|
||||
</div>
|
||||
</div>
|
||||
<Toggle on={settings.voice.handsFree} onChange={(v) => { update({ voice: { handsFree: v } }); useVoice.getState().setHandsFree(v); }} label="Mains libres au démarrage" hint="Le micro se réactive automatiquement après chaque réponse." />
|
||||
<Toggle on={settings.voice.wakeChime} onChange={(v) => update({ voice: { wakeChime: v } })} label="Signal sonore d’écoute" />
|
||||
<div className="row" style={{ marginTop: 10 }}>
|
||||
<button className="btn small" onClick={() => void testStt()} disabled={settings.voice.provider !== 'openai-compatible'}><Mic size={13} /> Tester l’API STT</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}><Result t={sttTest} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'speech' && (
|
||||
<div className="card">
|
||||
<div className="field">
|
||||
<label>Moteur de synthèse</label>
|
||||
<select className="select" value={settings.speech.provider} onChange={(e) => update({ speech: { provider: e.target.value as typeof settings.speech.provider } })}>
|
||||
<option value="openai-compatible">API compatible OpenAI /v1/audio/speech (Kokoro, Piper, OpenAI, LocalAI…)</option>
|
||||
<option value="system">Voix système Windows</option>
|
||||
<option value="google-free">Google Translate (gratuit, en ligne)</option>
|
||||
<option value="off">Désactivée</option>
|
||||
</select>
|
||||
</div>
|
||||
{settings.speech.provider === 'openai-compatible' && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label>URL de l’API TTS</label>
|
||||
<input className="input" value={settings.speech.apiUrl} placeholder="http://127.0.0.1:8000/v1" onChange={(e) => update({ speech: { apiUrl: e.target.value } })} />
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Modèle</label>
|
||||
<input className="input" value={settings.speech.model} onChange={(e) => update({ speech: { model: e.target.value } })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Voix</label>
|
||||
<input className="input" value={settings.speech.voice} placeholder="alloy, onyx, af_heart…" onChange={(e) => update({ speech: { voice: e.target.value } })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Clé API</label>
|
||||
<input className="input" type="password" value={settings.speech.apiKey} onChange={(e) => update({ speech: { apiKey: e.target.value } })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Format</label>
|
||||
<select className="select" value={settings.speech.format} onChange={(e) => update({ speech: { format: e.target.value as typeof settings.speech.format } })}>
|
||||
<option value="mp3">mp3</option>
|
||||
<option value="wav">wav</option>
|
||||
<option value="opus">opus</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{settings.speech.provider === 'system' && (
|
||||
<div className="field">
|
||||
<label>Voix système</label>
|
||||
<select className="select" value={settings.speech.systemVoice} onChange={(e) => update({ speech: { systemVoice: e.target.value } })}>
|
||||
<option value="">Automatique ({settings.speech.language})</option>
|
||||
{voices.map((v) => <option key={v.name} value={v.name}>{v.name} · {v.lang}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Vitesse : {settings.speech.speed.toFixed(2)}</label>
|
||||
<input className="range" type="range" min={0.6} max={1.8} step={0.05} value={settings.speech.speed} onChange={(e) => update({ speech: { speed: Number(e.target.value) } })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Volume : {Math.round(settings.speech.volume * 100)}%</label>
|
||||
<input className="range" type="range" min={0} max={1.2} step={0.05} value={settings.speech.volume} onChange={(e) => update({ speech: { volume: Number(e.target.value) } })} />
|
||||
</div>
|
||||
</div>
|
||||
<Toggle on={settings.speech.autoSpeak} onChange={(v) => update({ speech: { autoSpeak: v } })} label="Lire les réponses en streaming" hint="Chaque phrase est prononcée dès qu’elle est complète." />
|
||||
<Toggle on={settings.speech.speakIncoming} onChange={(v) => update({ speech: { speakIncoming: v } })} label="Lire les messages entrants (webhook, crons)" />
|
||||
<div className="row" style={{ marginTop: 10 }}>
|
||||
<button className="btn small" onClick={testTts} disabled={settings.speech.provider === 'off'}><Volume2 size={13} /> Tester la voix</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}><Result t={ttsTest} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'webhook' && (
|
||||
<div className="card">
|
||||
<Toggle on={settings.webhook.enabled} onChange={(v) => update({ webhook: { enabled: v } })} label="Serveur webhook local" hint="Hermes (crons, gateway, scripts) peut pousser des messages vers EveFlow." />
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Port</label>
|
||||
<input className="input" type="number" min={1024} max={65535} value={settings.webhook.port} onChange={(e) => update({ webhook: { port: Number(e.target.value) || 7842 } })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Secret (en-tête X-EveFlow-Secret)</label>
|
||||
<div className="row">
|
||||
<input className="input" type={webhookSecretVisible ? 'text' : 'password'} value={settings.webhook.secret} onChange={(e) => update({ webhook: { secret: e.target.value } })} />
|
||||
<button className="btn ghost small" onClick={() => setWebhookSecretVisible((v) => !v)}>{webhookSecretVisible ? 'cacher' : 'voir'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Exemple d’appel</label>
|
||||
<pre className="input" style={{ whiteSpace: 'pre-wrap', fontSize: 11.5, userSelect: 'text' }}>{`curl -X POST http://<ip-de-ce-pc>:${settings.webhook.port}/eveflow/hook \\
|
||||
-H "Content-Type: application/json"${settings.webhook.secret ? ' \\\n -H "X-EveFlow-Secret: ***"' : ''} \\
|
||||
-d '{"role":"assistant","text":"Rapport terminé","source":"telegram"}'`}</pre>
|
||||
<span className="hint">Formats acceptés : {'{role,text}'}, {'{event:"run.completed",input,output}'}, {'{event:"job.completed",job:{name},output,status}'}, {'{type:"message",payload:{text}}'}.</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="btn primary small" onClick={() => void applyWebhook()} disabled={!bridge()}>Appliquer et redémarrer</button>
|
||||
{hermesStore.webhook && <span className={`status-pill ${hermesStore.webhook.listening ? 'online' : 'offline'}`}>{hermesStore.webhook.listening ? `port ${hermesStore.webhook.port}` : hermesStore.webhook.error ?? 'inactif'}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'general' && (
|
||||
<div className="card">
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Nom de l’assistant</label>
|
||||
<input className="input" value={settings.assistantName} onChange={(e) => update({ assistantName: e.target.value.toUpperCase().slice(0, 18) || 'JARVIS' })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Votre nom</label>
|
||||
<input className="input" value={settings.userName} placeholder="Monsieur" onChange={(e) => update({ userName: e.target.value.slice(0, 24) })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Thème du HUD</label>
|
||||
<div className="theme-swatches">
|
||||
{(['arc', 'gold', 'crimson', 'emerald'] as HudTheme[]).map((t) => {
|
||||
const colors: Record<HudTheme, string> = { arc: '#34e4ff', gold: '#ffc35a', crimson: '#ff5c6c', emerald: '#4ef0a8' };
|
||||
return <button key={t} className={`swatch${settings.theme === t ? ' active' : ''}`} style={{ background: colors[t] }} title={t} onClick={() => update({ theme: t })} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Raccourcis globaux</label>
|
||||
<span className="hint"><span className="kbd">Ctrl+Shift+Espace</span> micro · <span className="kbd">Ctrl+Shift+J</span> afficher/masquer · <span className="kbd">Ctrl+Shift+Échap</span> couper la voix</span>
|
||||
</div>
|
||||
<div className="divider" />
|
||||
<button className="btn danger small" onClick={() => { if (confirm('Réinitialiser tous les paramètres ?')) reset(); }}><RotateCcw size={13} /> Réinitialiser</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'ui' && (
|
||||
<div className="card">
|
||||
<Toggle on={settings.ui.showTelemetry} onChange={(v) => update({ ui: { showTelemetry: v } })} label="Télémétrie système" />
|
||||
<Toggle on={settings.ui.showReasoning} onChange={(v) => update({ ui: { showReasoning: v } })} label="Afficher le raisonnement du modèle" hint="Quand Hermes le transmet (reasoning.available)." />
|
||||
<Toggle on={settings.ui.reduceMotion} onChange={(v) => update({ ui: { reduceMotion: v } })} label="Réduire les animations" />
|
||||
<div className="field" style={{ marginTop: 10 }}>
|
||||
<label>Opacité du widget compact : {Math.round(settings.ui.compactOpacity * 100)}%</label>
|
||||
<input className="range" type="range" min={0.5} max={1} step={0.02} value={settings.ui.compactOpacity} onChange={(e) => update({ ui: { compactOpacity: Number(e.target.value) } })} />
|
||||
</div>
|
||||
<span className="hint">Valeurs par défaut : {DEFAULT_SETTINGS.assistantName}, thème {DEFAULT_SETTINGS.theme}.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { SystemMetrics } from '../../shared/ipc';
|
||||
import { bridge } from '../lib/bridge';
|
||||
|
||||
export interface LiveMetrics extends SystemMetrics {
|
||||
fps: number;
|
||||
}
|
||||
|
||||
const EMPTY: LiveMetrics = {
|
||||
hostname: 'LOCALHOST',
|
||||
platform: navigator.platform,
|
||||
cpuModel: '',
|
||||
cpuCores: navigator.hardwareConcurrency || 0,
|
||||
cpuLoad: 0,
|
||||
cpuFreqMhz: 0,
|
||||
memUsedPct: 0,
|
||||
memTotalGb: 0,
|
||||
uptimeSec: 0,
|
||||
fps: 60
|
||||
};
|
||||
|
||||
export function useMetrics(enabled = true, intervalMs = 2000): LiveMetrics {
|
||||
const [metrics, setMetrics] = useState<LiveMetrics>(EMPTY);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let frames = 0;
|
||||
let lastFpsTick = performance.now();
|
||||
let raf = 0;
|
||||
const loop = () => {
|
||||
frames++;
|
||||
const now = performance.now();
|
||||
if (now - lastFpsTick >= 1000) {
|
||||
const fps = Math.round((frames * 1000) / (now - lastFpsTick));
|
||||
frames = 0;
|
||||
lastFpsTick = now;
|
||||
setMetrics((m) => (m.fps === fps ? m : { ...m, fps }));
|
||||
}
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
const api = bridge();
|
||||
const poll = () => {
|
||||
if (!api) return;
|
||||
api.system.metrics().then((m) => setMetrics((prev) => ({ ...prev, ...m }))).catch(() => undefined);
|
||||
};
|
||||
poll();
|
||||
const timer = setInterval(poll, intervalMs);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [enabled, intervalMs]);
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
export function useClock(): Date {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
-2775
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
import type { EveFlowBridge } from '../../shared/bridge';
|
||||
|
||||
/** Returns the Electron bridge, or null when running in a plain browser (Vite dev without Electron). */
|
||||
export function bridge(): EveFlowBridge | null {
|
||||
return typeof window !== 'undefined' && window.eveflow ? window.eveflow : null;
|
||||
}
|
||||
|
||||
export const isElectron = (): boolean => bridge() !== null;
|
||||
@@ -0,0 +1,10 @@
|
||||
export function uid(prefix = ''): string {
|
||||
const random =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
return prefix ? `${prefix}-${random}` : random;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { LogEntry } from '../../shared/ipc';
|
||||
import { bridge } from './bridge';
|
||||
|
||||
type Level = LogEntry['level'];
|
||||
|
||||
const MAX_CONSOLE = 1500;
|
||||
|
||||
function emit(level: Level, tag: string, message: string, data?: unknown): void {
|
||||
const entry: LogEntry = { ts: new Date().toISOString(), level, tag, message, data };
|
||||
const line = `[${tag}] ${message}`;
|
||||
const extra = data !== undefined ? safe(data) : '';
|
||||
if (level === 'ERROR') console.error(line, extra);
|
||||
else if (level === 'WARN') console.warn(line, extra);
|
||||
else if (level === 'DEBUG') console.debug(line, extra);
|
||||
else console.info(line, extra);
|
||||
try {
|
||||
bridge()?.log(entry);
|
||||
} catch {
|
||||
/* browser context */
|
||||
}
|
||||
}
|
||||
|
||||
function safe(data: unknown): string {
|
||||
try {
|
||||
const text = JSON.stringify(data);
|
||||
return text.length > MAX_CONSOLE ? text.slice(0, MAX_CONSOLE) + '...' : text;
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
|
||||
export const Log = {
|
||||
debug: (tag: string, message: string, data?: unknown) => emit('DEBUG', tag, message, data),
|
||||
info: (tag: string, message: string, data?: unknown) => emit('INFO', tag, message, data),
|
||||
warn: (tag: string, message: string, data?: unknown) => emit('WARN', tag, message, data),
|
||||
error: (tag: string, message: string, data?: unknown) => emit('ERROR', tag, message, data)
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { bridge } from './bridge';
|
||||
|
||||
/**
|
||||
* Persistent key/value storage: Electron userData JSON file when available,
|
||||
* localStorage otherwise. Values are JSON-serialisable objects.
|
||||
*/
|
||||
export async function persistGet<T>(key: string): Promise<T | null> {
|
||||
const api = bridge();
|
||||
if (api) {
|
||||
try {
|
||||
const value = await api.store.get<T>(key);
|
||||
if (value !== null && value !== undefined) return value;
|
||||
} catch {
|
||||
/* fall through to localStorage */
|
||||
}
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function persistSet(key: string, value: unknown): void {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* quota or unavailable */
|
||||
}
|
||||
void bridge()?.store.set(key, value).catch(() => undefined);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export interface SseMessage {
|
||||
event: string;
|
||||
data: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental Server-Sent Events parser (spec-compliant subset: event/data/id/comments,
|
||||
* multi-line data joined with "\n", CRLF tolerant). Feed arbitrary text chunks.
|
||||
*/
|
||||
export class SseParser {
|
||||
private buffer = '';
|
||||
private event = '';
|
||||
private data: string[] = [];
|
||||
private id: string | undefined;
|
||||
|
||||
constructor(private readonly onMessage: (message: SseMessage) => void) {}
|
||||
|
||||
feed(chunk: string): void {
|
||||
this.buffer += chunk;
|
||||
let index: number;
|
||||
while ((index = this.buffer.search(/\r\n|\n|\r/)) !== -1) {
|
||||
const line = this.buffer.slice(0, index);
|
||||
const sepLength = this.buffer.startsWith('\r\n', index) ? 2 : 1;
|
||||
this.buffer = this.buffer.slice(index + sepLength);
|
||||
this.processLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush any pending message (call when the stream ends). */
|
||||
end(): void {
|
||||
if (this.buffer.length) {
|
||||
const rest = this.buffer;
|
||||
this.buffer = '';
|
||||
this.processLine(rest);
|
||||
}
|
||||
this.dispatch();
|
||||
}
|
||||
|
||||
private processLine(line: string): void {
|
||||
if (line === '') {
|
||||
this.dispatch();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(':')) return;
|
||||
const colon = line.indexOf(':');
|
||||
const field = colon === -1 ? line : line.slice(0, colon);
|
||||
let value = colon === -1 ? '' : line.slice(colon + 1);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
switch (field) {
|
||||
case 'event': this.event = value; break;
|
||||
case 'data': this.data.push(value); break;
|
||||
case 'id': this.id = value; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(): void {
|
||||
if (this.data.length === 0 && !this.event) return;
|
||||
const message: SseMessage = { event: this.event || 'message', data: this.data.join('\n'), id: this.id };
|
||||
this.event = '';
|
||||
this.data = [];
|
||||
this.onMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function tryParseJson<T = unknown>(text: string): T | null {
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/** Text utilities shared by the chat renderer and the speech pipeline. */
|
||||
|
||||
const PHONETIC: Record<string, string> = {
|
||||
api: 'a-pé-i',
|
||||
cors: 'korss',
|
||||
svg: 'ess-vé-gé',
|
||||
cpu: 'cé-pé-u',
|
||||
gpu: 'gé-pé-u',
|
||||
fps: 'eff-pé-ess',
|
||||
tts: 'té-té-ess',
|
||||
stt: 'ess-té-té',
|
||||
ui: 'u-i',
|
||||
json: 'djé-zone',
|
||||
url: 'u-err-el',
|
||||
html: 'ach-té-em-el',
|
||||
css: 'cé-ess-ess',
|
||||
js: 'ji-ess',
|
||||
github: 'guite-heub',
|
||||
git: 'guite',
|
||||
npm: 'enne-pé-em',
|
||||
cli: 'cé-el-i',
|
||||
ipc: 'i-pé-cé',
|
||||
hermes: 'hermès',
|
||||
ok: 'oké'
|
||||
};
|
||||
|
||||
/** Convert `MEDIA:/path` tokens (Hermes gateway shorthand) to markdown images. */
|
||||
export function preprocessMedia(content: string): string {
|
||||
if (!content) return content;
|
||||
return content.replace(/MEDIA:\s*(\S+)/g, (_m, p: string) => `\n\n\n\n`);
|
||||
}
|
||||
|
||||
/** Strip markdown, code, links, URLs and emojis so the text can be spoken naturally. */
|
||||
export function cleanForSpeech(raw: string): string {
|
||||
if (!raw) return '';
|
||||
let text = raw.replace(/MEDIA:\s*\S+/g, '');
|
||||
text = text.replace(/```[\s\S]*?```/g, ' ');
|
||||
text = text.replace(/<think>[\s\S]*?<\/think>/gi, ' ');
|
||||
text = text.replace(/`([^`]+)`/g, '$1');
|
||||
text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
|
||||
text = text.replace(/\[([^\]]+)\]\((?:[^)(]+|\([^)(]*\))*\)/g, '$1');
|
||||
text = text.replace(/(?:https?|ftp|file):\/\/\S+/gi, '');
|
||||
text = text.replace(/www\.\S+/gi, '');
|
||||
text = text.replace(/^\s{0,3}#{1,6}\s+/gm, '');
|
||||
text = text.replace(/^\s*[-*+]\s+/gm, ', ');
|
||||
text = text.replace(/^\s*\d+[.)]\s+/gm, ', ');
|
||||
text = text.replace(/^\s*>\s?/gm, '');
|
||||
text = text.replace(/\|/g, ' ');
|
||||
text = text.replace(/:\s*(?:\r?\n)/g, '. ');
|
||||
text = text.replace(/(?:\r?\n){2,}/g, '. ');
|
||||
text = text.replace(/\r?\n/g, ' ');
|
||||
text = text
|
||||
.split(/\s+/)
|
||||
.filter((word) => !(word.includes('/') && word.length > 3) && !/^[\d.]+:\d+$/.test(word) && !/^\d{1,3}(\.\d{1,3}){3}$/.test(word))
|
||||
.join(' ');
|
||||
text = text
|
||||
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE00}-\u{FEFF}\u{200D}]/gu, '')
|
||||
.replace(/(\*\*|__|~~|[*_#`~])/g, '')
|
||||
.replace(/\[[^\]]*\]/g, '')
|
||||
.replace(/(-{2,}|={2,}|~{2,})/g, ' ')
|
||||
.replace(/\.{3,}/g, '...')
|
||||
.replace(/[<>{}\\^]/g, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
return applyPhonetics(text);
|
||||
}
|
||||
|
||||
function applyPhonetics(text: string): string {
|
||||
let out = text;
|
||||
for (const [key, value] of Object.entries(PHONETIC)) {
|
||||
const escaped = key.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
out = out.replace(new RegExp(`(?<=^|\\s|\\p{P})${escaped}(?=$|\\s|\\p{P})`, 'giu'), value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull complete sentences out of a streaming buffer.
|
||||
* Returns the sentences ready to be spoken and the remainder to keep buffering.
|
||||
*/
|
||||
export function extractSentences(buffer: string, minLength = 12): { sentences: string[]; rest: string } {
|
||||
const sentences: string[] = [];
|
||||
let rest = buffer;
|
||||
// Never split inside an unfinished code block.
|
||||
const fences = (buffer.match(/```/g) ?? []).length;
|
||||
if (fences % 2 === 1) return { sentences, rest };
|
||||
const regex = /[^.!?\n\r]+(?:[.!?]+(?=\s|$)|[\n\r]+)/g;
|
||||
let consumed = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let pending = '';
|
||||
while ((match = regex.exec(buffer)) !== null) {
|
||||
if (match.index !== consumed) break; // gap: stop at first non-contiguous match
|
||||
const candidate = (pending + match[0]).trim();
|
||||
consumed = match.index + match[0].length;
|
||||
if (candidate.length < minLength || /\b(?:\d+|[A-Z]|M|Mme|Dr|etc|ex)\.$/.test(candidate)) {
|
||||
pending = pending + match[0];
|
||||
continue;
|
||||
}
|
||||
sentences.push(candidate);
|
||||
pending = '';
|
||||
}
|
||||
rest = pending + buffer.slice(consumed);
|
||||
return { sentences, rest };
|
||||
}
|
||||
|
||||
/** Split a finished text into speakable chunks of bounded size. */
|
||||
export function chunkForSpeech(text: string, maxLength = 220): string[] {
|
||||
const { sentences, rest } = extractSentences(text + '\n', 1);
|
||||
const all = [...sentences, rest.trim()].filter(Boolean);
|
||||
const out: string[] = [];
|
||||
for (const sentence of all) {
|
||||
if (sentence.length <= maxLength) {
|
||||
out.push(sentence);
|
||||
continue;
|
||||
}
|
||||
let current = '';
|
||||
for (const word of sentence.split(/\s+/)) {
|
||||
if ((current + ' ' + word).trim().length > maxLength) {
|
||||
if (current) out.push(current.trim());
|
||||
current = word;
|
||||
} else {
|
||||
current = current ? `${current} ${word}` : word;
|
||||
}
|
||||
}
|
||||
if (current) out.push(current.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function previewText(value: string, max = 96): string {
|
||||
const flat = value.replace(/\s+/g, ' ').trim();
|
||||
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
||||
}
|
||||
|
||||
export function formatTime(date: Date | string | number): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date | string | number | undefined): string {
|
||||
if (!date) return '—';
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return Number.isNaN(d.getTime()) ? String(date) : d.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const s = Math.max(0, Math.floor(seconds));
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}j ${h}h`;
|
||||
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`;
|
||||
return `${m}m ${(s % 60).toString().padStart(2, '0')}s`;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { HttpProxyRequest, HttpProxyResponse, HttpStreamEvent, HttpStreamStart } from '../../shared/ipc';
|
||||
import { bridge } from './bridge';
|
||||
import { uid } from './id';
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(public readonly status: number, message: string, public readonly body?: string) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot HTTP request routed through the Electron main process (or fetch in a browser). */
|
||||
export async function httpFetch(req: HttpProxyRequest): Promise<HttpProxyResponse> {
|
||||
const api = bridge();
|
||||
if (api) return api.http.fetch(req);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), req.timeoutMs ?? 60_000);
|
||||
try {
|
||||
const headers: Record<string, string> = { ...(req.headers ?? {}) };
|
||||
let body: BodyInit | undefined;
|
||||
if (req.multipart) {
|
||||
const form = new FormData();
|
||||
for (const [k, v] of Object.entries(req.multipart.fields)) form.append(k, v);
|
||||
const f = req.multipart.file;
|
||||
const bytes = new Uint8Array(f.data);
|
||||
form.append(req.multipart.fileField ?? 'file', new Blob([bytes], { type: f.type }), f.name);
|
||||
body = form;
|
||||
delete headers['Content-Type'];
|
||||
} else {
|
||||
body = req.body;
|
||||
}
|
||||
const res = await fetch(req.url, { method: req.method ?? 'GET', headers, body, signal: controller.signal });
|
||||
const outHeaders: Record<string, string> = {};
|
||||
res.headers.forEach((v, k) => (outHeaders[k.toLowerCase()] = v));
|
||||
const base = { ok: res.ok, status: res.status, statusText: res.statusText, headers: outHeaders };
|
||||
if (req.responseType === 'binary') return { ...base, binary: new Uint8Array(await res.arrayBuffer()) };
|
||||
return { ...base, text: await res.text() };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export interface StreamHandlers {
|
||||
onChunk: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface StreamHandle {
|
||||
start: HttpStreamStart;
|
||||
done: Promise<void>;
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
// One global listener dispatches IPC stream events to per-request handlers.
|
||||
const streamHandlers = new Map<string, { onChunk: (t: string) => void; onEnd: () => void; onError: (m: string) => void }>();
|
||||
let listenerInstalled = false;
|
||||
|
||||
function installListener(): void {
|
||||
if (listenerInstalled) return;
|
||||
const api = bridge();
|
||||
if (!api) return;
|
||||
listenerInstalled = true;
|
||||
api.http.onStreamEvent((event: HttpStreamEvent) => {
|
||||
const h = streamHandlers.get(event.id);
|
||||
if (!h) return;
|
||||
if (event.type === 'chunk') h.onChunk(event.text);
|
||||
else if (event.type === 'end') {
|
||||
streamHandlers.delete(event.id);
|
||||
h.onEnd();
|
||||
} else {
|
||||
streamHandlers.delete(event.id);
|
||||
h.onError(event.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming HTTP request. Resolves once headers are received; `done` resolves when the body ends.
|
||||
* Text chunks are delivered incrementally (SSE parsing is up to the caller).
|
||||
*/
|
||||
export async function httpStream(req: HttpProxyRequest, handlers: StreamHandlers): Promise<StreamHandle> {
|
||||
const api = bridge();
|
||||
if (api) {
|
||||
installListener();
|
||||
const id = uid('stream');
|
||||
let resolveDone!: () => void;
|
||||
let rejectDone!: (err: Error) => void;
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
resolveDone = resolve;
|
||||
rejectDone = reject;
|
||||
});
|
||||
streamHandlers.set(id, {
|
||||
onChunk: handlers.onChunk,
|
||||
onEnd: () => resolveDone(),
|
||||
onError: (message) => rejectDone(new Error(message))
|
||||
});
|
||||
let start: HttpStreamStart;
|
||||
try {
|
||||
start = await api.http.streamStart(id, req);
|
||||
} catch (err) {
|
||||
streamHandlers.delete(id);
|
||||
throw err;
|
||||
}
|
||||
return { start, done, abort: () => api.http.streamAbort(id) };
|
||||
}
|
||||
|
||||
// Browser fallback (Vite dev in a normal browser)
|
||||
const controller = new AbortController();
|
||||
const res = await fetch(req.url, {
|
||||
method: req.method ?? 'GET',
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
signal: controller.signal
|
||||
});
|
||||
const headers: Record<string, string> = {};
|
||||
res.headers.forEach((v, k) => (headers[k.toLowerCase()] = v));
|
||||
const start: HttpStreamStart = { id: uid('stream'), ok: res.ok, status: res.status, statusText: res.statusText, headers };
|
||||
const done = (async () => {
|
||||
if (!res.body) return;
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
for (;;) {
|
||||
const { done: finished, value } = await reader.read();
|
||||
if (finished) break;
|
||||
handlers.onChunk(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
const tail = decoder.decode();
|
||||
if (tail) handlers.onChunk(tail);
|
||||
})();
|
||||
return { start, done, abort: () => controller.abort() };
|
||||
}
|
||||
|
||||
/** Collects the whole body of a streaming response (used to read error payloads). */
|
||||
export async function collectStreamBody(handle: StreamHandle, first: string[]): Promise<string> {
|
||||
await handle.done.catch(() => undefined);
|
||||
return first.join('');
|
||||
}
|
||||
+7
-2
@@ -1,7 +1,12 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import './styles/tokens.css';
|
||||
import './styles/app.css';
|
||||
import './styles/chat.css';
|
||||
import './styles/panels.css';
|
||||
import './styles/settings.css';
|
||||
import './styles/compact.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
import { EveAvatar } from './emotionService';
|
||||
import { LogService } from './logService';
|
||||
import { HERMES_TOOLS, executeTool, AppCallbacks } from './toolRegistry';
|
||||
|
||||
export type AgentProvider = 'ollama' | 'hermes';
|
||||
|
||||
export interface AgentConfig {
|
||||
provider: AgentProvider;
|
||||
ollamaUrl: string;
|
||||
ollamaModel: string;
|
||||
hermesUrl: string;
|
||||
hermesModel: string;
|
||||
hermesApiKey: string;
|
||||
hermesSessionKey: 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, detail?: string, status?: 'running' | 'done' | 'error') => void,
|
||||
sessionId?: string,
|
||||
onSessionIdChange?: (newSessionId: string) => void,
|
||||
images?: string[]
|
||||
): Promise<string> {
|
||||
LogService.info('AgentService', `sendMessage → provider=${config.provider} avatar=${avatarId}`, {
|
||||
messagePreview: message.slice(0, 120),
|
||||
historyLength: history.length,
|
||||
imagesCount: images?.length || 0
|
||||
});
|
||||
|
||||
// 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 = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: [
|
||||
'Platform: EveFlow desktop chat, connected through the OpenAI-compatible API, not a Hermes messaging gateway.',
|
||||
'Do not use MEDIA:/path tags for images or files here: those are only intercepted by Telegram/Discord/Slack gateways and local paths are not web-accessible in EveFlow.',
|
||||
'When showing an internet image, return a normal Markdown image with a public http(s) URL: .',
|
||||
'When creating a diagram or SVG for EveFlow, use the generate_shared_image tool so the app writes it locally and can display it.'
|
||||
].join(' ')
|
||||
},
|
||||
...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, images);
|
||||
case 'hermes':
|
||||
return this.sendToHermes(message, updatedHistory, config, callbacks, onToken, onToolCall, sessionId, onSessionIdChange, images);
|
||||
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,
|
||||
images?: string[]
|
||||
): Promise<string> {
|
||||
const endpoint = `${url.replace(/\/$/, '')}/api/chat`;
|
||||
|
||||
// Pour Ollama local, extraire uniquement le bloc base64 de la Data URL
|
||||
const formattedImages = images?.map(img => {
|
||||
const parts = img.split(',');
|
||||
return parts.length > 1 ? parts[1] : parts[0];
|
||||
});
|
||||
|
||||
const userMessage: any = { role: 'user', content: message };
|
||||
if (formattedImages && formattedImages.length > 0) {
|
||||
userMessage.images = formattedImages;
|
||||
}
|
||||
|
||||
const body = { model, messages: [...history, userMessage], 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, detail?: string, status?: 'running' | 'done' | 'error') => void,
|
||||
sessionId?: string,
|
||||
onSessionIdChange?: (newSessionId: string) => void,
|
||||
images?: string[]
|
||||
): Promise<string> {
|
||||
const { hermesUrl, hermesModel, hermesApiKey, hermesSessionKey } = 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;
|
||||
// Stable memory scoping: link this request to long-term memory (USER.md / MEMORY.md)
|
||||
if (hermesSessionKey) headers['X-Hermes-Session-Key'] = hermesSessionKey.trim();
|
||||
|
||||
// Construction du format de message multimodal d'OpenAI pour Hermes Agent
|
||||
let userContent: any = message;
|
||||
if (images && images.length > 0) {
|
||||
userContent = [
|
||||
{ type: 'text', text: message },
|
||||
...images.map(img => ({
|
||||
type: 'image_url',
|
||||
image_url: { url: img }
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
// Build mutable message list for tool-calling loop
|
||||
const messages: any[] = [...history, { role: 'user', content: userContent }];
|
||||
const useStreaming = !!onToken;
|
||||
const useTools = !!callbacks;
|
||||
|
||||
LogService.info('HermesAgent', `POST ${endpoint}`, {
|
||||
model: hermesModel,
|
||||
streaming: useStreaming,
|
||||
toolsEnabled: useTools,
|
||||
historyLength: history.length,
|
||||
hasSessionKey: !!hermesSessionKey
|
||||
});
|
||||
|
||||
// ── 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}]`);
|
||||
|
||||
// Capture session rotation from response headers (context compression or new session)
|
||||
const returnedSessionId = response.headers.get('x-hermes-session-id') || response.headers.get('X-Hermes-Session-Id');
|
||||
if (returnedSessionId && onSessionIdChange && returnedSessionId !== sessionId) {
|
||||
LogService.info('HermesAgent', `Session rotated: ${sessionId} -> ${returnedSessionId}`);
|
||||
onSessionIdChange(returnedSessionId);
|
||||
}
|
||||
|
||||
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, tc.function?.arguments, 'running');
|
||||
|
||||
const result = await 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, detail?: string, status?: 'running' | 'done' | 'error') => 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> = {};
|
||||
let sseEvent = 'message';
|
||||
|
||||
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('event: ')) {
|
||||
sseEvent = line.slice(7).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const jsonStr = line.slice(6).trim();
|
||||
if (jsonStr === '[DONE]') {
|
||||
sseEvent = 'message';
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(jsonStr);
|
||||
|
||||
if (sseEvent === 'hermes.tool.progress') {
|
||||
const tool = String(chunk.tool || chunk.name || chunk.label || 'outil');
|
||||
const label = String(chunk.label || chunk.preview || tool);
|
||||
const emoji = chunk.emoji ? `${chunk.emoji} ` : '';
|
||||
const detail = [chunk.preview, chunk.args, chunk.skill, chunk.toolset]
|
||||
.filter(Boolean)
|
||||
.map((value: any) => typeof value === 'string' ? value : JSON.stringify(value))
|
||||
.join(' | ');
|
||||
onToolCall?.(`${emoji}${label}`, detail || tool, 'running');
|
||||
sseEvent = 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
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, tc.function?.arguments, 'running');
|
||||
const result = await 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.";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,946 +0,0 @@
|
||||
export interface VoiceOption {
|
||||
name: string;
|
||||
lang: string;
|
||||
voice: SpeechSynthesisVoice;
|
||||
}
|
||||
|
||||
// Dictionnaire phonétique local pour corriger la prononciation des acronymes et anglicismes en français
|
||||
const PHONETIC_DICTIONARY: Record<string, string> = {
|
||||
'api': 'a-pé-i',
|
||||
'cors': 'korss',
|
||||
'svg': 'ess-vé-gé',
|
||||
'cpu': 'cé-pé-u',
|
||||
'fps': 'eff-pé-ess',
|
||||
'tts': 'té-té-ess',
|
||||
'ui': 'u-i',
|
||||
'ai': 'a-i',
|
||||
'ia': 'i-a',
|
||||
'json': 'dji-zone',
|
||||
'url': 'u-err-el',
|
||||
'html': 'ach-té-em-el',
|
||||
'css': 'cé-ess-ess',
|
||||
'js': 'ji-ess',
|
||||
'typescript': 'taïpe-skript',
|
||||
'javascript': 'djava-skript',
|
||||
'electron': 'élèktron',
|
||||
'react': 'ri-akt',
|
||||
'github': 'gitte-ub',
|
||||
'git': 'gitte',
|
||||
'setup': 'sétup',
|
||||
'c++': 'cé-plus-plus',
|
||||
'node.js': 'node-j-s',
|
||||
'nodejs': 'node-j-s',
|
||||
'database': 'deïta-beïss',
|
||||
'token': 'tokène',
|
||||
'cli': 'cé-el-i',
|
||||
'npm': 'en-pé-em',
|
||||
'npx': 'en-pé-ix',
|
||||
'vite': 'vite',
|
||||
'vscode': 'vé-ess-code',
|
||||
'vs code': 'vé-ess-code',
|
||||
'windows': 'windoze',
|
||||
'macos': 'mak-os',
|
||||
'linux': 'linux',
|
||||
'docker': 'dokeur',
|
||||
'cyberpunk': 'saïbeur-pounk',
|
||||
'avatar': 'avatar',
|
||||
'chat': 'tchat',
|
||||
'prompt': 'prompte',
|
||||
'welcome': 'ouel-kome',
|
||||
'ipc': 'i-pé-cé'
|
||||
};
|
||||
|
||||
export class AudioService {
|
||||
public ttsSynth: SpeechSynthesis;
|
||||
private recognition: any = null;
|
||||
private isListeningActive = false;
|
||||
private currentAudioElement: HTMLAudioElement | null = null;
|
||||
private audioQueue: { text: string; voiceName?: string; rate: number; pitch: number; provider: 'system' | 'google-free' | 'openai-tts' }[] = [];
|
||||
private isPlayingQueue = false;
|
||||
private activeReject: ((err: any) => void) | null = null;
|
||||
|
||||
// ASR/STT & TTS Custom API settings
|
||||
private sttProvider: 'browser' | 'qwen3-asr' = 'browser';
|
||||
private sttApiUrl = 'http://127.0.0.1:8000/v1';
|
||||
private sttApiKey = '';
|
||||
private sttModel = 'Qwen/Qwen3-ASR-0.6B';
|
||||
|
||||
// Propriétés pour l'enregistrement WAV direct
|
||||
private audioContext: AudioContext | null = null;
|
||||
private scriptProcessor: ScriptProcessorNode | null = null;
|
||||
private micStream: MediaStream | null = null;
|
||||
private leftChannel: Float32Array[] = [];
|
||||
private recordingLength = 0;
|
||||
private sampleRate = 44100;
|
||||
|
||||
// Callbacks pour l'écoute
|
||||
private activeOnResult: ((text: string) => void) | null = null;
|
||||
private activeOnEnd: (() => void) | null = null;
|
||||
private activeOnError: ((err: string) => void) | null = null;
|
||||
private activeLang = 'fr-FR';
|
||||
|
||||
private ttsApiUrl = 'http://127.0.0.1:8000/v1';
|
||||
private ttsApiKey = '';
|
||||
private ttsModel = 'tts-1';
|
||||
|
||||
public get isSpeakingActive(): boolean {
|
||||
return this.isPlayingQueue || this.audioQueue.length > 0;
|
||||
}
|
||||
|
||||
private logError(tag: string, message: string, data?: any) {
|
||||
console.error(`[${tag}] ${message}`, data);
|
||||
if ((window as any).electronAPI?.writeLog) {
|
||||
(window as any).electronAPI.writeLog({
|
||||
ts: new Date().toISOString(),
|
||||
level: 'ERROR',
|
||||
tag,
|
||||
message,
|
||||
data: data ? { message: data.message, stack: data.stack, ...data } : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private logWarn(tag: string, message: string, data?: any) {
|
||||
console.warn(`[${tag}] ${message}`, data);
|
||||
if ((window as any).electronAPI?.writeLog) {
|
||||
(window as any).electronAPI.writeLog({
|
||||
ts: new Date().toISOString(),
|
||||
level: 'WARN',
|
||||
tag,
|
||||
message,
|
||||
data: data ? { message: data.message, stack: data.stack, ...data } : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private logInfo(tag: string, message: string, data?: any) {
|
||||
console.log(`[${tag}] ${message}`, data);
|
||||
if ((window as any).electronAPI?.writeLog) {
|
||||
(window as any).electronAPI.writeLog({
|
||||
ts: new Date().toISOString(),
|
||||
level: 'INFO',
|
||||
tag,
|
||||
message,
|
||||
data: data ? { message: data.message, stack: data.stack, ...data } : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.ttsSynth = window.speechSynthesis;
|
||||
this.initSTT();
|
||||
}
|
||||
|
||||
// Permet de mettre à jour dynamiquement la configuration de la reconnaissance vocale
|
||||
public updateSTTConfig(provider: 'browser' | 'qwen3-asr', apiUrl: string, apiKey: string, model: string) {
|
||||
this.sttProvider = provider;
|
||||
this.sttApiUrl = apiUrl;
|
||||
this.sttApiKey = apiKey;
|
||||
this.sttModel = model;
|
||||
}
|
||||
|
||||
// Permet de mettre à jour dynamiquement la configuration du lecteur de voix personnalisé
|
||||
public updateTTSConfig(apiUrl: string, apiKey: string, model: string) {
|
||||
this.ttsApiUrl = apiUrl;
|
||||
this.ttsApiKey = apiKey;
|
||||
this.ttsModel = model;
|
||||
}
|
||||
|
||||
// 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.");
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer un score de qualité pour trier les voix locales
|
||||
private _getVoiceScore(v: SpeechSynthesisVoice): number {
|
||||
const lang = v.lang.toLowerCase();
|
||||
const name = v.name.toLowerCase();
|
||||
|
||||
if (!lang.startsWith('fr')) return 0;
|
||||
|
||||
let score = 100; // Base français
|
||||
|
||||
if (name.includes('google') || name.includes('online')) {
|
||||
score += 50;
|
||||
}
|
||||
if (name.includes('natural') || name.includes('neural')) {
|
||||
score += 40;
|
||||
}
|
||||
if (name.includes('mobile')) {
|
||||
score += 30;
|
||||
}
|
||||
if (name.includes('julie') || name.includes('paul')) {
|
||||
score += 20;
|
||||
}
|
||||
if (name.includes('hortense')) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// Récupérer les voix françaises et globales disponibles
|
||||
public getVoices(): VoiceOption[] {
|
||||
if (!this.ttsSynth) return [];
|
||||
|
||||
const allVoices = this.ttsSynth.getVoices();
|
||||
|
||||
// Trier pour mettre les meilleures voix françaises en haut
|
||||
const sorted = [...allVoices].sort((a, b) => {
|
||||
const scoreA = this._getVoiceScore(a);
|
||||
const scoreB = this._getVoiceScore(b);
|
||||
|
||||
if (scoreA !== scoreB) {
|
||||
return scoreB - scoreA;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return sorted.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);
|
||||
|
||||
const frVoices = voices.filter(v => v.lang.toLowerCase().startsWith('fr'));
|
||||
if (frVoices.length === 0) return voices.find(v => v.lang.startsWith('en'));
|
||||
|
||||
// Sélectionner la voix française locale avec le meilleur score
|
||||
return frVoices.sort((a, b) => this._getVoiceScore(b) - this._getVoiceScore(a))[0];
|
||||
}
|
||||
|
||||
private _applyPhoneticCorrections(text: string): string {
|
||||
let corrected = text;
|
||||
for (const [key, replacement] of Object.entries(PHONETIC_DICTIONARY)) {
|
||||
const escapedKey = key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
// Regex recherchant le mot technique avec des limites Unicode ou de ponctuations
|
||||
const regex = new RegExp(`(?<=^|\\s|\\p{P})${escapedKey}(?=$|\\s|\\p{P})`, 'giu');
|
||||
corrected = corrected.replace(regex, replacement);
|
||||
}
|
||||
return corrected;
|
||||
}
|
||||
|
||||
private _cleanForTTS(raw: string): string {
|
||||
// 0a. Supprimer les tokens MEDIA: (chemins de fichiers envoyés par le bot) pour ne pas les lire à voix haute
|
||||
let noMedia = raw.replace(/MEDIA:\s*\S+/g, '');
|
||||
|
||||
// 0b. Supprimer complètement les blocs de code Markdown (```code```) car ils ne sont pas prononçables oralement
|
||||
let noCode = noMedia.replace(/```[\s\S]*?```/g, ' ');
|
||||
|
||||
// 1. Améliorer la ponctuation Markdown pour forcer des pauses de diction naturelles
|
||||
// Remplacer les puces de liste (ex: - item, * item) par une pause douce (virgule)
|
||||
let formatted = noCode.replace(/(?:\r?\n)\s*[-*+]\s+/g, ', ');
|
||||
|
||||
// Remplacer les deux-points suivis d'un saut de ligne par un point
|
||||
formatted = formatted.replace(/:\s*(?:\r?\n)/g, '. ');
|
||||
|
||||
// Remplacer les doubles sauts de ligne par des points (pauses de paragraphes)
|
||||
formatted = formatted.replace(/(?:\r?\n){2,}/g, '. ');
|
||||
|
||||
// Remplacer les sauts de ligne simples restant par des espaces
|
||||
formatted = formatted.replace(/(?:\r?\n)/g, ' ');
|
||||
|
||||
// 2. Extraire le texte des liens Markdown [Texte](url)
|
||||
let cleaned = formatted.replace(/\[([^\]]+)\]\((?:[^)(]+|\([^)(]*\))*\)/g, '$1');
|
||||
|
||||
// 3. Supprimer toutes les URLs nues (http, https, ftp, file)
|
||||
cleaned = cleaned.replace(/(?:https?|ftp|file):\/\/\S+/gi, '');
|
||||
|
||||
// 4. Supprimer les adresses www.
|
||||
cleaned = cleaned.replace(/www\.\S+/gi, '');
|
||||
|
||||
// 5. Filtrer les mots techniques complexes (chemins de fichiers, adresses IP)
|
||||
const words = cleaned.split(/\s+/);
|
||||
cleaned = words
|
||||
.filter(word => {
|
||||
if (word.includes('/') || word.includes('\\')) {
|
||||
return false;
|
||||
}
|
||||
if (/(?:[a-zA-Z0-9-]+\.[a-zA-Z]{2,6}|localhost|[\d.]+):\d+/.test(word)) {
|
||||
return false;
|
||||
}
|
||||
if (/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/.test(word)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
// 6. Nettoyer les caractères Markdown, Emojis et symboles non prononçables
|
||||
cleaned = cleaned
|
||||
// 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 restant : **, *, _, __, #, `, ~
|
||||
.replace(/(\*\*|__|~~|[*_#`~])/g, '')
|
||||
// Balises techniques résiduelles [⚙️ Exécution...]
|
||||
.replace(/\[[^\]]*\]/g, '')
|
||||
// Ponctuation répétée ou décorative : ---, ===, ~~~, ...
|
||||
.replace(/(-{2,}|={2,}|~{2,}|\.{2,})/g, ' ')
|
||||
// Caractères techniques restants non lisibles
|
||||
.replace(/[<>{}|\\^]/g, '')
|
||||
// Espaces multiples
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
|
||||
// 7. Appliquer les corrections phonétiques locales
|
||||
return this._applyPhoneticCorrections(cleaned);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Moteur Google TTS gratuit en ligne avec découpage intelligent
|
||||
private async speakGoogleFree(text: string, rate: number): Promise<void> {
|
||||
const cleanText = this._cleanForTTS(text);
|
||||
if (!cleanText.trim()) return;
|
||||
|
||||
const chunks: string[] = [];
|
||||
let currentChunk = '';
|
||||
|
||||
// Regex pour couper en phrases
|
||||
const sentences = cleanText.match(/[^.!?]+[.!?]*|.+/g) || [cleanText];
|
||||
for (const sentence of sentences) {
|
||||
if ((currentChunk + sentence).length > 180) {
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk.trim());
|
||||
currentChunk = '';
|
||||
}
|
||||
if (sentence.length > 180) {
|
||||
const words = sentence.split(/\s+/);
|
||||
let temp = '';
|
||||
for (const word of words) {
|
||||
if ((temp + ' ' + word).length > 180) {
|
||||
chunks.push(temp.trim());
|
||||
temp = word;
|
||||
} else {
|
||||
temp += (temp ? ' ' : '') + word;
|
||||
}
|
||||
}
|
||||
if (temp) currentChunk = temp;
|
||||
} else {
|
||||
currentChunk = sentence;
|
||||
}
|
||||
} else {
|
||||
currentChunk += (currentChunk ? ' ' : '') + sentence;
|
||||
}
|
||||
}
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk.trim());
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (!chunk) continue;
|
||||
const url = `https://translate.google.com/translate_tts?ie=UTF-8&tl=fr&client=tw-ob&q=${encodeURIComponent(chunk)}`;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const audio = new Audio(url);
|
||||
this.currentAudioElement = audio;
|
||||
audio.playbackRate = rate;
|
||||
this.activeReject = reject;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
audio.pause();
|
||||
reject(new Error("playback_stopped"));
|
||||
}, 15000);
|
||||
|
||||
audio.onended = () => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
resolve();
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
const code = audio.error ? audio.error.code : 'UNKNOWN';
|
||||
const msg = audio.error ? audio.error.message : 'No message';
|
||||
reject(new Error(`Erreur de chargement Google TTS (code: ${code}, msg: ${msg})`));
|
||||
};
|
||||
|
||||
audio.play().catch((err) => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Synthese vocale personnalisee via API OpenAI /v1/audio/speech
|
||||
private async speakCustomAPI(text: string, voiceName?: string, rate: number = 1.0): Promise<void> {
|
||||
const cleanText = this._cleanForTTS(text);
|
||||
if (!cleanText.trim()) return;
|
||||
|
||||
if (!this.ttsApiUrl) {
|
||||
throw new Error("L'URL de l'API TTS n'est pas configurée.");
|
||||
}
|
||||
|
||||
const base = this.ttsApiUrl.replace(/\/$/, '');
|
||||
let endpoint: string;
|
||||
if (base.endsWith('/speech')) {
|
||||
endpoint = base;
|
||||
} else if (base.endsWith('/v1')) {
|
||||
endpoint = `${base}/audio/speech`;
|
||||
} else {
|
||||
endpoint = `${base}/v1/audio/speech`;
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: this.ttsModel || 'tts-1',
|
||||
input: cleanText,
|
||||
voice: voiceName || 'alloy'
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (this.ttsApiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.ttsApiKey.trim()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const rawErr = await response.text().catch(() => '(no body)');
|
||||
let errMsg = `Erreur TTS API: ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(rawErr);
|
||||
errMsg = parsed.error?.message || parsed.message || rawErr;
|
||||
} catch {}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const audio = new Audio(url);
|
||||
this.currentAudioElement = audio;
|
||||
audio.playbackRate = rate;
|
||||
this.activeReject = reject;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
audio.pause();
|
||||
reject(new Error("playback_stopped"));
|
||||
}, 60000); // 60 secondes max pour une portion
|
||||
|
||||
audio.onended = () => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve();
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
URL.revokeObjectURL(url);
|
||||
const code = audio.error ? audio.error.code : 'UNKNOWN';
|
||||
const msg = audio.error ? audio.error.message : 'No message';
|
||||
reject(new Error(`Erreur de lecture audio API (code: ${code}, msg: ${msg})`));
|
||||
};
|
||||
|
||||
audio.play().catch((err) => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
URL.revokeObjectURL(url);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// File de traitement asynchrone des phrases de synthèse vocale (Zero Trust & Zero Dependency)
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isPlayingQueue) return;
|
||||
this.isPlayingQueue = true;
|
||||
|
||||
while (this.audioQueue.length > 0) {
|
||||
const item = this.audioQueue.shift();
|
||||
if (!item) continue;
|
||||
|
||||
try {
|
||||
if (item.provider === 'google-free') {
|
||||
await this.speakGoogleFree(item.text, item.rate);
|
||||
} else if (item.provider === 'openai-tts') {
|
||||
await this.speakCustomAPI(item.text, item.voiceName, item.rate);
|
||||
} else {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
|
||||
const u = this._makeUtterance(item.text, item.voiceName, item.rate, item.pitch);
|
||||
u.onend = () => resolve();
|
||||
u.onerror = (e: any) => {
|
||||
if (e && (e.error === 'interrupted' || e.error === 'canceled')) {
|
||||
reject(new Error("playback_stopped"));
|
||||
} else {
|
||||
reject(new Error("Erreur de diction système"));
|
||||
}
|
||||
};
|
||||
this.ttsSynth.speak(u);
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.message === 'playback_stopped') {
|
||||
break;
|
||||
}
|
||||
this.logWarn("AudioService", "Échec de la lecture de la phrase dans la file", err);
|
||||
if (item.provider === 'google-free' || item.provider === 'openai-tts') {
|
||||
// Repli automatique transparent sur la voix locale hors-ligne pour ce segment précis en cas de défaillance
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
|
||||
const u = this._makeUtterance(item.text, item.voiceName, item.rate, item.pitch);
|
||||
u.onend = () => resolve();
|
||||
u.onerror = (e: any) => {
|
||||
if (e && (e.error === 'interrupted' || e.error === 'canceled')) {
|
||||
reject(new Error("playback_stopped"));
|
||||
} else {
|
||||
reject(new Error("Erreur de diction système"));
|
||||
}
|
||||
};
|
||||
this.ttsSynth.speak(u);
|
||||
});
|
||||
} catch (fallbackErr: any) {
|
||||
if (fallbackErr?.message === 'playback_stopped') {
|
||||
break;
|
||||
}
|
||||
this.logError("AudioService", "Échec également du repli système", fallbackErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.isPlayingQueue = false;
|
||||
}
|
||||
|
||||
// Synthèse Vocale complète (annule et relit tout le texte)
|
||||
public async speak(
|
||||
text: string,
|
||||
voiceName?: string,
|
||||
rate: number = 1.0,
|
||||
pitch: number = 1.1,
|
||||
provider: 'system' | 'google-free' | 'openai-tts' = 'google-free'
|
||||
): Promise<void> {
|
||||
this.stopSpeaking();
|
||||
this.queueSentence(text, voiceName, rate, pitch, provider);
|
||||
|
||||
// Attendre la fin complète de la lecture pour résoudre la promesse
|
||||
while (this.isPlayingQueue || this.audioQueue.length > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
// Ajoute une phrase à la file de lecture vocale pour un enchaînement fluide en temps réel (streaming)
|
||||
public queueSentence(
|
||||
text: string,
|
||||
voiceName?: string,
|
||||
rate: number = 1.0,
|
||||
pitch: number = 1.1,
|
||||
provider: 'system' | 'google-free' | 'openai-tts' = 'google-free'
|
||||
): void {
|
||||
const clean = text.trim();
|
||||
if (!clean) return;
|
||||
this.audioQueue.push({ text: clean, voiceName, rate, pitch, provider });
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
// Vide la file TTS (fin du streaming ou interruption utilisateur)
|
||||
public flushQueue(): void {
|
||||
this.audioQueue = [];
|
||||
this.isPlayingQueue = false;
|
||||
if (this.ttsSynth) this.ttsSynth.cancel();
|
||||
if (this.currentAudioElement) {
|
||||
this.currentAudioElement.pause();
|
||||
this.currentAudioElement = null;
|
||||
}
|
||||
if (this.activeReject) {
|
||||
this.activeReject(new Error("playback_stopped"));
|
||||
this.activeReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Arrêter immédiatement de parler et vider la file
|
||||
public stopSpeaking() {
|
||||
this.audioQueue = [];
|
||||
this.isPlayingQueue = false;
|
||||
if (this.ttsSynth) {
|
||||
this.ttsSynth.cancel();
|
||||
}
|
||||
if (this.currentAudioElement) {
|
||||
this.currentAudioElement.pause();
|
||||
this.currentAudioElement = null;
|
||||
}
|
||||
if (this.activeReject) {
|
||||
this.activeReject(new Error("playback_stopped"));
|
||||
this.activeReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Démarrer l'écoute vocale API via AudioContext (WAV Recorder)
|
||||
private async startListeningAPI(
|
||||
onResult: (text: string) => void,
|
||||
onStart: () => void,
|
||||
onEnd: () => void,
|
||||
onError: (err: string) => void,
|
||||
lang: string
|
||||
) {
|
||||
if (this.isListeningActive) {
|
||||
this.stopListening();
|
||||
}
|
||||
|
||||
// Assigner les callbacks pour y avoir accès à l'arrêt
|
||||
this.activeOnResult = onResult;
|
||||
this.activeOnEnd = onEnd;
|
||||
this.activeOnError = onError;
|
||||
this.activeLang = lang;
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.micStream = stream;
|
||||
this.leftChannel = [];
|
||||
this.recordingLength = 0;
|
||||
|
||||
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
|
||||
this.audioContext = new AudioCtx();
|
||||
|
||||
// Assurer que l'AudioContext est actif (non suspendu)
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
this.logWarn("STT", "AudioContext initialisé à l'état suspendu. Tentative de reprise...");
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
|
||||
this.sampleRate = this.audioContext.sampleRate;
|
||||
this.logInfo("STT", "AudioContext démarré avec succès", {
|
||||
state: this.audioContext.state,
|
||||
sampleRate: this.sampleRate
|
||||
});
|
||||
|
||||
const source = this.audioContext.createMediaStreamSource(stream);
|
||||
// bufferSize de 2048, 1 canal d'entrée, 1 de sortie
|
||||
this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1);
|
||||
|
||||
this.scriptProcessor.onaudioprocess = (e) => {
|
||||
if (!this.isListeningActive) return;
|
||||
const inputData = e.inputBuffer.getChannelData(0);
|
||||
// Cloner les échantillons
|
||||
this.leftChannel.push(new Float32Array(inputData));
|
||||
this.recordingLength += inputData.length;
|
||||
};
|
||||
|
||||
source.connect(this.scriptProcessor);
|
||||
this.scriptProcessor.connect(this.audioContext.destination);
|
||||
|
||||
this.isListeningActive = true;
|
||||
onStart();
|
||||
} catch (err: any) {
|
||||
this.isListeningActive = false;
|
||||
this.logError("STT", "Impossible d'accéder au microphone", err);
|
||||
onError(err.message || "Impossible d'accéder au microphone.");
|
||||
}
|
||||
}
|
||||
|
||||
// Arrêter proprement la capture AudioContext et exporter en WAV
|
||||
private stopListeningAPI() {
|
||||
if (!this.isListeningActive) return;
|
||||
this.isListeningActive = false;
|
||||
|
||||
// Déconnecter le processeur
|
||||
if (this.scriptProcessor) {
|
||||
this.scriptProcessor.disconnect();
|
||||
this.scriptProcessor.onaudioprocess = null;
|
||||
this.scriptProcessor = null;
|
||||
}
|
||||
|
||||
// Arrêter le flux micro
|
||||
if (this.micStream) {
|
||||
this.micStream.getTracks().forEach(track => track.stop());
|
||||
this.micStream = null;
|
||||
}
|
||||
|
||||
// Fermer l'audio context
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
this.audioContext = null;
|
||||
}
|
||||
|
||||
const onEndCallback = this.activeOnEnd;
|
||||
const onResultCallback = this.activeOnResult;
|
||||
const onErrorCallback = this.activeOnError;
|
||||
const lang = this.activeLang;
|
||||
|
||||
if (onEndCallback) onEndCallback();
|
||||
|
||||
if (this.recordingLength === 0) {
|
||||
if (onErrorCallback) onErrorCallback("Aucune donnée audio capturée");
|
||||
return;
|
||||
}
|
||||
|
||||
// Exporter le tampon audio en fichier WAV
|
||||
const wavBlob = this.exportWAV(this.leftChannel, this.recordingLength, this.sampleRate);
|
||||
|
||||
this.logInfo("STT", "Arrêt de l'écoute, exportation WAV...", {
|
||||
recordingLength: this.recordingLength,
|
||||
sampleRate: this.sampleRate,
|
||||
wavSize: wavBlob.size
|
||||
});
|
||||
|
||||
this.transcribeAudioWithAPI(wavBlob, lang)
|
||||
.then(text => {
|
||||
if (onResultCallback) onResultCallback(text);
|
||||
})
|
||||
.catch(err => {
|
||||
this.logError("STT", "Erreur de transcription", err);
|
||||
if (onErrorCallback) onErrorCallback(err.message || "Erreur de transcription de l'API");
|
||||
});
|
||||
}
|
||||
|
||||
// Fonctions utilitaires pour générer le conteneur WAV PCM 16-bit
|
||||
private exportWAV(channelData: Float32Array[], recordingLength: number, sampleRate: number): Blob {
|
||||
const originalBuffer = this.mergeBuffers(channelData, recordingLength);
|
||||
|
||||
// Rééchantillonner à 16000 Hz pour la compatibilité avec l'API ASR (Whisper / Qwen3-ASR)
|
||||
const targetSampleRate = 16000;
|
||||
const resampledBuffer = this.downsampleBuffer(originalBuffer, sampleRate, targetSampleRate);
|
||||
|
||||
const wavBuffer = new ArrayBuffer(44 + resampledBuffer.length * 2);
|
||||
const view = new DataView(wavBuffer);
|
||||
|
||||
/* Identifiant RIFF */
|
||||
this.writeString(view, 0, 'RIFF');
|
||||
/* Taille du fichier */
|
||||
view.setUint32(4, 36 + resampledBuffer.length * 2, true);
|
||||
/* Type RIFF */
|
||||
this.writeString(view, 8, 'WAVE');
|
||||
/* Identifiant format fmt */
|
||||
this.writeString(view, 12, 'fmt ');
|
||||
/* Longueur du chunk format */
|
||||
view.setUint32(16, 16, true);
|
||||
/* Format d'encodage (1 = PCM non compressé) */
|
||||
view.setUint16(20, 1, true);
|
||||
/* Nombre de canaux (1 = Mono) */
|
||||
view.setUint16(22, 1, true);
|
||||
/* Fréquence d'échantillonnage */
|
||||
view.setUint32(24, targetSampleRate, true);
|
||||
/* Débit d'octets (targetSampleRate * blockAlign) */
|
||||
view.setUint32(28, targetSampleRate * 2, true);
|
||||
/* Block align (1 canal * 2 octets par échantillon) */
|
||||
view.setUint16(32, 2, true);
|
||||
/* Bits par échantillon (16 bits) */
|
||||
view.setUint16(34, 16, true);
|
||||
/* Identifiant de données data */
|
||||
this.writeString(view, 36, 'data');
|
||||
/* Longueur des données audio */
|
||||
view.setUint32(40, resampledBuffer.length * 2, true);
|
||||
|
||||
this.floatTo16BitPCM(view, 44, resampledBuffer);
|
||||
|
||||
return new Blob([view], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
private downsampleBuffer(buffer: Float32Array, originalSampleRate: number, targetSampleRate: number): Float32Array {
|
||||
if (originalSampleRate === targetSampleRate) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const sampleRateRatio = originalSampleRate / targetSampleRate;
|
||||
const newLength = Math.round(buffer.length / sampleRateRatio);
|
||||
const result = new Float32Array(newLength);
|
||||
|
||||
let offsetResult = 0;
|
||||
let offsetBuffer = 0;
|
||||
|
||||
while (offsetResult < result.length) {
|
||||
const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio);
|
||||
|
||||
// Moyennage simple (box filter) pour éviter l'aliasing
|
||||
let accum = 0;
|
||||
let count = 0;
|
||||
for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
|
||||
accum += buffer[i];
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
result[offsetResult] = accum / count;
|
||||
} else {
|
||||
result[offsetResult] = buffer[Math.min(offsetBuffer, buffer.length - 1)];
|
||||
}
|
||||
|
||||
offsetResult++;
|
||||
offsetBuffer = nextOffsetBuffer;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private mergeBuffers(channelData: Float32Array[], recordingLength: number): Float32Array {
|
||||
const result = new Float32Array(recordingLength);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < channelData.length; i++) {
|
||||
result.set(channelData[i], offset);
|
||||
offset += channelData[i].length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private writeString(view: DataView, offset: number, string: string) {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
private floatTo16BitPCM(output: DataView, offset: number, input: Float32Array) {
|
||||
for (let i = 0; i < input.length; i++, offset += 2) {
|
||||
const s = Math.max(-1, Math.min(1, input[i]));
|
||||
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Requête API Speech-To-Text compatible OpenAI /v1/audio/transcriptions
|
||||
private async transcribeAudioWithAPI(audioBlob: Blob, lang: string): Promise<string> {
|
||||
if (!this.sttApiUrl) {
|
||||
throw new Error("L'URL de l'API STT n'est pas configurée.");
|
||||
}
|
||||
|
||||
const base = this.sttApiUrl.replace(/\/$/, '');
|
||||
let endpoint: string;
|
||||
if (base.endsWith('/transcriptions')) {
|
||||
endpoint = base;
|
||||
} else if (base.endsWith('/v1')) {
|
||||
endpoint = `${base}/audio/transcriptions`;
|
||||
} else {
|
||||
endpoint = `${base}/v1/audio/transcriptions`;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', audioBlob, 'audio.wav');
|
||||
formData.append('model', this.sttModel || 'Qwen/Qwen3-ASR-0.6B');
|
||||
|
||||
if (lang) {
|
||||
const isoLang = lang.split('-')[0];
|
||||
formData.append('language', isoLang);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.sttApiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.sttApiKey.trim()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const rawErr = await response.text().catch(() => '(no body)');
|
||||
let errMsg = `Erreur STT API: ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(rawErr);
|
||||
errMsg = parsed.error?.message || parsed.message || rawErr;
|
||||
} catch {}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data && typeof data.text === 'string') {
|
||||
return data.text;
|
||||
}
|
||||
|
||||
throw new Error("Format de réponse de l'API STT invalide.");
|
||||
}
|
||||
|
||||
// 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.sttProvider === 'qwen3-asr') {
|
||||
this.startListeningAPI(onResult, onStart, onEnd, onError, lang);
|
||||
} else {
|
||||
if (!this.recognition) {
|
||||
onError("STT non supporté par ce navigateur");
|
||||
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.sttProvider === 'qwen3-asr') {
|
||||
this.stopListeningAPI();
|
||||
} else {
|
||||
if (this.recognition && this.isListeningActive) {
|
||||
this.recognition.stop();
|
||||
this.isListeningActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Conversation orchestrator: sends user input to Hermes through the resolved transport,
|
||||
* streams the answer into the chat store, drives the HUD state, speaks the reply and
|
||||
* surfaces approvals / clarifications as pending requests.
|
||||
*/
|
||||
import type { HermesPushEvent } from '../../shared/ipc';
|
||||
import { Log } from '../lib/log';
|
||||
import { previewText } from '../lib/text';
|
||||
import { useChat, type PendingRequest } from '../state/chat';
|
||||
import { useHermes } from '../state/hermes';
|
||||
import { useSettings } from '../state/settings';
|
||||
import { executeLocalTool, LOCAL_TOOL_DEFINITIONS } from './hermes/localTools';
|
||||
import type { HermesStreamEvent, SendHandle } from './hermes/types';
|
||||
import { speech } from './voice/speech';
|
||||
|
||||
let active: SendHandle | null = null;
|
||||
let activeMessageId: string | null = null;
|
||||
|
||||
const toolLabel = (name: string) => name.replace(/_/g, ' ');
|
||||
|
||||
function localToolContext() {
|
||||
const chat = useChat.getState();
|
||||
const settings = useSettings.getState().settings;
|
||||
return {
|
||||
setEmotion: (state: string) => {
|
||||
const map: Record<string, Parameters<typeof chat.setHudOverride>[0]> = {
|
||||
happy: 'success', success: 'success', thinking: 'thinking', alert: 'alert', error: 'error', neutral: null
|
||||
};
|
||||
chat.setHudOverride(map[state] ?? null);
|
||||
setTimeout(() => useChat.getState().setHudOverride(null), 6000);
|
||||
},
|
||||
speak: (text: string) => speech.say(text),
|
||||
getStatus: () => ({
|
||||
transport: useHermes.getState().transport,
|
||||
link: useHermes.getState().link,
|
||||
assistant: settings.assistantName,
|
||||
messages: chat.messages.length,
|
||||
speaking: speech.isSpeaking(),
|
||||
handsFree: settings.voice.handsFree
|
||||
}),
|
||||
getHistory: (n: number) => chat.messages.slice(-n).map((m) => ({ role: m.role, content: m.content })),
|
||||
notify: (title: string, body: string) => {
|
||||
try {
|
||||
if (Notification.permission === 'granted') new Notification(title, { body });
|
||||
} catch {
|
||||
/* notifications unavailable */
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleEvent(event: HermesStreamEvent, messageId: string, runIdRef: { id: string | null }): void {
|
||||
const chat = useChat.getState();
|
||||
switch (event.kind) {
|
||||
case 'run.started':
|
||||
runIdRef.id = event.runId;
|
||||
chat.setSending(true, event.runId);
|
||||
break;
|
||||
case 'delta':
|
||||
chat.appendToMessage(messageId, event.text);
|
||||
speech.pushStream(event.text);
|
||||
break;
|
||||
case 'reasoning':
|
||||
chat.appendToMessage(messageId, event.text, 'reasoning');
|
||||
break;
|
||||
case 'tool.start':
|
||||
chat.pushActivity({ id: event.id, kind: 'tool', name: event.name, status: 'running', detail: event.args ? previewText(event.args, 160) : undefined });
|
||||
chat.setHud('thinking');
|
||||
break;
|
||||
case 'tool.progress':
|
||||
if (!chat.activity.some((a) => (event.id && a.id === event.id) || (a.name === event.name && a.status === 'running'))) {
|
||||
chat.pushActivity({ id: event.id, kind: 'tool', name: event.name, status: 'running', detail: event.label ?? event.detail });
|
||||
}
|
||||
break;
|
||||
case 'tool.end':
|
||||
chat.finishActivity({ id: event.id || undefined, name: event.name }, {
|
||||
status: event.ok ? 'done' : 'error',
|
||||
output: event.output ? previewText(event.output, 400) : undefined
|
||||
});
|
||||
break;
|
||||
case 'subagent.start':
|
||||
chat.pushActivity({ id: event.id, kind: 'subagent', name: event.label ?? 'sous-agent', status: 'running', detail: event.childSessionId });
|
||||
break;
|
||||
case 'subagent.end':
|
||||
chat.finishActivity({ id: event.id }, { status: event.status.includes('fail') ? 'error' : 'done', output: event.summary });
|
||||
break;
|
||||
case 'approval.request':
|
||||
addPending({
|
||||
requestId: event.requestId,
|
||||
runId: runIdRef.id ?? '',
|
||||
kind: 'approval',
|
||||
title: 'Autorisation requise',
|
||||
description: event.description,
|
||||
options: event.options,
|
||||
tool: event.tool,
|
||||
args: event.args
|
||||
});
|
||||
break;
|
||||
case 'clarify.request':
|
||||
addPending({ requestId: event.requestId, runId: runIdRef.id ?? '', kind: 'clarify', title: 'Précision demandée', description: event.question, options: event.options });
|
||||
break;
|
||||
case 'input.request':
|
||||
addPending({ requestId: event.requestId, runId: runIdRef.id ?? '', kind: 'input', title: event.variant === 'sudo' ? 'Mot de passe sudo' : 'Secret requis', description: event.prompt, secret: true });
|
||||
break;
|
||||
case 'request.expire':
|
||||
chat.removePending(event.requestId);
|
||||
break;
|
||||
case 'session':
|
||||
if (event.sessionId && event.sessionId !== useSettings.getState().settings.hermesSessionId) {
|
||||
useSettings.getState().setHermesSessionId(event.sessionId);
|
||||
Log.info('hermes', `session id updated: ${event.sessionId}`);
|
||||
}
|
||||
break;
|
||||
case 'completed':
|
||||
if (event.usage) chat.updateMessage(messageId, { usage: event.usage });
|
||||
break;
|
||||
case 'error':
|
||||
chat.setError(event.message);
|
||||
break;
|
||||
case 'raw':
|
||||
Log.debug('hermes', `event ${event.event}`, event.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function addPending(request: Omit<PendingRequest, 'id' | 'createdAt'>): void {
|
||||
const chat = useChat.getState();
|
||||
chat.addPending(request);
|
||||
chat.setHud('alert');
|
||||
speech.say(request.kind === 'approval' ? 'Autorisation requise.' : request.kind === 'clarify' ? request.description : 'Saisie requise.');
|
||||
}
|
||||
|
||||
export async function sendMessage(text: string, images: string[] = [], source = 'eveflow'): Promise<void> {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && images.length === 0) || active) return;
|
||||
const chat = useChat.getState();
|
||||
const hermes = useHermes.getState();
|
||||
const settings = useSettings.getState().settings;
|
||||
|
||||
speech.stop();
|
||||
chat.setError(null);
|
||||
chat.addMessage({ role: 'user', content: trimmed || '(image)', images: images.length ? images : undefined, source, status: 'done' });
|
||||
const history = chat.messages
|
||||
.filter((m) => m.role !== 'system' && m.status !== 'error')
|
||||
.slice(-12)
|
||||
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }));
|
||||
const messageId = chat.addMessage({ role: 'assistant', content: '', status: 'streaming', transport: hermes.transport });
|
||||
activeMessageId = messageId;
|
||||
chat.setSending(true);
|
||||
chat.setHud('thinking');
|
||||
const startedAt = Date.now();
|
||||
const runIdRef = { id: null as string | null };
|
||||
|
||||
const client = hermes.client();
|
||||
const handle = client.send(
|
||||
{
|
||||
text: trimmed || 'Analyse cette image.',
|
||||
images,
|
||||
history,
|
||||
sessionId: settings.hermesSessionId,
|
||||
onEvent: (event) => handleEvent(event, messageId, runIdRef),
|
||||
localToolDefinitions: LOCAL_TOOL_DEFINITIONS,
|
||||
localToolExecutor: (name, args) => executeLocalTool(name, args, localToolContext())
|
||||
},
|
||||
hermes.transport
|
||||
);
|
||||
active = handle;
|
||||
chat.updateMessage(messageId, { transport: handle.transport });
|
||||
|
||||
try {
|
||||
const reply = await handle.result;
|
||||
const current = useChat.getState().messages.find((m) => m.id === messageId);
|
||||
const finalText = reply || current?.content || '';
|
||||
useChat.getState().updateMessage(messageId, { content: finalText, status: 'done' });
|
||||
// Anything not streamed (non-delta transports) is spoken now.
|
||||
if (settings.speech.autoSpeak) {
|
||||
if (!current?.content && finalText) speech.say(finalText);
|
||||
else speech.endStream();
|
||||
} else {
|
||||
speech.discardStream();
|
||||
}
|
||||
useChat.getState().setHud(speech.isSpeaking() ? 'speaking' : 'idle');
|
||||
if (useHermes.getState().link === 'offline') useHermes.getState().connect().catch(() => undefined);
|
||||
Log.info('hermes', `reply in ${Date.now() - startedAt} ms via ${handle.transport}`, { chars: finalText.length });
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || String(err);
|
||||
const partial = useChat.getState().messages.find((m) => m.id === messageId)?.content ?? '';
|
||||
useChat.getState().updateMessage(messageId, {
|
||||
content: partial || `Liaison Hermes interrompue : ${message}`,
|
||||
status: partial ? 'cancelled' : 'error'
|
||||
});
|
||||
useChat.getState().setError(message);
|
||||
useChat.getState().setHud('error');
|
||||
speech.discardStream();
|
||||
Log.error('hermes', `send failed: ${message}`);
|
||||
if (/HTTP|ECONNREFUSED|fetch|réseau|network/i.test(message)) useHermes.getState().connect().catch(() => undefined);
|
||||
setTimeout(() => {
|
||||
if (useChat.getState().hud === 'error') useChat.getState().setHud('idle');
|
||||
}, 4000);
|
||||
} finally {
|
||||
active = null;
|
||||
activeMessageId = null;
|
||||
useChat.getState().setSending(false);
|
||||
for (const a of useChat.getState().activity) {
|
||||
if (a.status === 'running') useChat.getState().finishActivity({ id: a.id }, { status: 'done' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stopGeneration(): void {
|
||||
if (active) {
|
||||
active.abort();
|
||||
Log.info('hermes', 'generation aborted by user');
|
||||
}
|
||||
speech.stop();
|
||||
if (activeMessageId) useChat.getState().updateMessage(activeMessageId, { status: 'cancelled' });
|
||||
useChat.getState().setHud('idle');
|
||||
}
|
||||
|
||||
export async function steer(text: string): Promise<boolean> {
|
||||
const runId = useChat.getState().currentRunId;
|
||||
if (!runId || !text.trim()) return false;
|
||||
try {
|
||||
await useHermes.getState().client().steerRun(runId, text.trim());
|
||||
useChat.getState().addMessage({ role: 'system', content: `Consigne transmise à Hermes : ${text.trim()}`, status: 'done' });
|
||||
return true;
|
||||
} catch (err) {
|
||||
useChat.getState().setError(`Steer impossible : ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePending(request: PendingRequest, decision: string): Promise<void> {
|
||||
const chat = useChat.getState();
|
||||
chat.removePending(request.id);
|
||||
const client = useHermes.getState().client();
|
||||
try {
|
||||
if (request.kind === 'approval') {
|
||||
const choice = (['once', 'session', 'always', 'deny'].includes(decision) ? decision : decision === 'approve' ? 'once' : 'deny') as 'once' | 'session' | 'always' | 'deny';
|
||||
if (!request.runId) throw new Error('run inconnu');
|
||||
await client.approveRun(request.runId, choice, request.requestId || undefined);
|
||||
chat.addMessage({ role: 'system', content: `Autorisation ${choice === 'deny' ? 'refusée' : 'accordée'}${request.tool ? ` (${toolLabel(request.tool)})` : ''}.`, status: 'done' });
|
||||
} else {
|
||||
// Clarifications and secrets are answered by steering the run with the reply.
|
||||
if (!request.runId) throw new Error('run inconnu');
|
||||
await client.steerRun(request.runId, decision);
|
||||
chat.addMessage({ role: 'system', content: request.secret ? 'Saisie transmise à Hermes.' : `Réponse transmise : ${decision}`, status: 'done' });
|
||||
}
|
||||
chat.setHud(useChat.getState().isSending ? 'thinking' : 'idle');
|
||||
} catch (err) {
|
||||
chat.setError(`Réponse impossible : ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Incoming webhook pushes (Telegram mirror, cron deliveries…). */
|
||||
export function handlePush(event: HermesPushEvent): void {
|
||||
const chat = useChat.getState();
|
||||
const settings = useSettings.getState().settings;
|
||||
if (event.type === 'raw') {
|
||||
Log.debug('webhook', 'raw push', event.raw);
|
||||
chat.pushActivity({ kind: 'system', name: `webhook ${event.source}`, status: 'done', detail: previewText(event.text, 200) });
|
||||
return;
|
||||
}
|
||||
if (!event.text.trim() && !event.images?.length) return;
|
||||
chat.addMessage({
|
||||
role: event.role === 'user' ? 'user' : 'assistant',
|
||||
content: event.text,
|
||||
images: event.images,
|
||||
source: event.source,
|
||||
jobName: event.jobName,
|
||||
status: 'done'
|
||||
});
|
||||
if (event.type === 'job') {
|
||||
chat.pushActivity({ kind: 'job', name: event.jobName || 'cron', status: event.status?.includes('fail') ? 'error' : 'done', detail: previewText(event.text, 160) });
|
||||
void useHermes.getState().refreshJobs();
|
||||
}
|
||||
if (event.role !== 'user' && settings.speech.speakIncoming) {
|
||||
speech.say(event.jobName ? `Résultat de ${event.jobName}. ${event.text}` : event.text);
|
||||
}
|
||||
chat.setHud(event.status?.includes('fail') ? 'alert' : 'success');
|
||||
setTimeout(() => {
|
||||
const s = useChat.getState();
|
||||
if (s.hud === 'success' || s.hud === 'alert') s.setHud(speech.isSpeaking() ? 'speaking' : 'idle');
|
||||
}, 3000);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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,492 @@
|
||||
/**
|
||||
* Full Hermes Agent API client: discovery, runs (SSE lifecycle + approvals + steering),
|
||||
* sessions (server-side memory), OpenAI-compatible chat completions (with EveFlow tools)
|
||||
* and the cron jobs REST API.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { SseParser, tryParseJson } from '../../lib/sse';
|
||||
import { httpFetch, httpStream, HttpError, type StreamHandle } from '../../lib/transport';
|
||||
import { normalizeCompletionChunk, normalizeLifecycleEvent, type CompletionToolCallAccumulator } from './events';
|
||||
import type {
|
||||
HermesCapabilities,
|
||||
HermesChatMessage,
|
||||
HermesConfig,
|
||||
HermesHealth,
|
||||
HermesJob,
|
||||
HermesJobDraft,
|
||||
HermesModel,
|
||||
HermesRunInfo,
|
||||
HermesSession,
|
||||
HermesSessionMessage,
|
||||
HermesSkill,
|
||||
HermesStreamEvent,
|
||||
HermesToolset,
|
||||
HermesTransport,
|
||||
SendHandle,
|
||||
SendOptions
|
||||
} from './types';
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
export type ResolvedTransport = Exclude<HermesTransport, 'auto'>;
|
||||
|
||||
export function hermesBaseUrl(url: string): string {
|
||||
let base = url.trim().replace(/\/+$/, '');
|
||||
base = base.replace(/\/chat\/completions$/, '');
|
||||
base = base.replace(/\/v1$/, '');
|
||||
return base;
|
||||
}
|
||||
|
||||
function extractArray<T>(payload: unknown, keys: string[]): T[] {
|
||||
if (Array.isArray(payload)) return payload as T[];
|
||||
if (isRec(payload)) {
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (Array.isArray(value)) return value as T[];
|
||||
}
|
||||
if (isRec(payload.data)) return extractArray<T>(payload.data, keys);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function errorMessage(status: number, body: string): string {
|
||||
const parsed = tryParseJson<Rec>(body);
|
||||
if (parsed) {
|
||||
const err = parsed.error;
|
||||
if (isRec(err) && typeof err.message === 'string') return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
for (const key of ['message', 'detail', 'error_description']) {
|
||||
if (typeof parsed[key] === 'string') return parsed[key] as string;
|
||||
}
|
||||
}
|
||||
const text = body.trim().slice(0, 240);
|
||||
if (status === 401 || status === 403) return `Authentification refusée (HTTP ${status}). Vérifiez la clé API_SERVER_KEY.`;
|
||||
if (status === 404) return `Endpoint introuvable (HTTP 404)${text ? ` : ${text}` : ''}`;
|
||||
if (status === 429) return 'Hermes est saturé (HTTP 429, max_concurrent_runs atteint).';
|
||||
return `HTTP ${status}${text ? ` : ${text}` : ''}`;
|
||||
}
|
||||
|
||||
export function resolveTransport(config: HermesConfig, caps: HermesCapabilities | null): ResolvedTransport {
|
||||
if (config.transport !== 'auto') return config.transport;
|
||||
if (!caps) return 'completions';
|
||||
const features = caps.features ?? {};
|
||||
const endpoints = caps.endpoints ?? {};
|
||||
if (features.run_submission && features.run_events_sse) return 'runs';
|
||||
if (endpoints.session_chat) return 'sessions';
|
||||
return 'completions';
|
||||
}
|
||||
|
||||
export class HermesClient {
|
||||
constructor(private readonly config: HermesConfig) {}
|
||||
|
||||
get base(): string {
|
||||
return hermesBaseUrl(this.config.url);
|
||||
}
|
||||
|
||||
private headers(extra: Record<string, string> = {}, json = true): Record<string, string> {
|
||||
const h: Record<string, string> = { ...extra };
|
||||
if (json) h['Content-Type'] = 'application/json';
|
||||
h.Accept = h.Accept ?? 'application/json';
|
||||
if (this.config.apiKey.trim()) h.Authorization = `Bearer ${this.config.apiKey.trim()}`;
|
||||
if (this.config.sessionKey.trim()) h['X-Hermes-Session-Key'] = this.config.sessionKey.trim().slice(0, 256);
|
||||
return h;
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; timeoutMs?: number } = {}): Promise<T> {
|
||||
if (!this.config.url.trim()) throw new Error("URL Hermes non configurée.");
|
||||
const url = `${this.base}${path}`;
|
||||
const res = await httpFetch({
|
||||
url,
|
||||
method: init.method ?? 'GET',
|
||||
headers: this.headers(),
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
timeoutMs: init.timeoutMs ?? 20_000
|
||||
});
|
||||
if (!res.ok) throw new HttpError(res.status, errorMessage(res.status, res.text ?? ''), res.text);
|
||||
const text = res.text ?? '';
|
||||
if (!text.trim()) return null as T;
|
||||
const parsed = tryParseJson<T>(text);
|
||||
if (parsed === null) throw new Error(`Réponse Hermes illisible depuis ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ── Discovery ─────────────────────────────────────────────────────────────
|
||||
|
||||
capabilities(): Promise<HermesCapabilities> {
|
||||
return this.request<HermesCapabilities>('/v1/capabilities');
|
||||
}
|
||||
|
||||
async health(): Promise<HermesHealth> {
|
||||
try {
|
||||
return await this.request<HermesHealth>('/health/detailed', { timeoutMs: 8000 });
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw err;
|
||||
const basic = await this.request<HermesHealth | Rec>('/health', { timeoutMs: 8000 });
|
||||
return { status: 'ok', ...(isRec(basic) ? basic : {}) } as HermesHealth;
|
||||
}
|
||||
}
|
||||
|
||||
async models(): Promise<HermesModel[]> {
|
||||
const payload = await this.request<unknown>('/v1/models');
|
||||
return extractArray<HermesModel>(payload, ['data', 'models']);
|
||||
}
|
||||
|
||||
async skills(): Promise<HermesSkill[]> {
|
||||
const payload = await this.request<unknown>('/v1/skills');
|
||||
return extractArray<HermesSkill>(payload, ['skills', 'data', 'items']);
|
||||
}
|
||||
|
||||
async toolsets(): Promise<HermesToolset[]> {
|
||||
const payload = await this.request<unknown>('/v1/toolsets');
|
||||
return extractArray<HermesToolset>(payload, ['toolsets', 'data', 'items']);
|
||||
}
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
async listSessions(limit = 40): Promise<HermesSession[]> {
|
||||
const payload = await this.request<unknown>(`/api/sessions?limit=${limit}`);
|
||||
return extractArray<HermesSession>(payload, ['sessions', 'items', 'data', 'results']);
|
||||
}
|
||||
|
||||
async createSession(title?: string): Promise<HermesSession> {
|
||||
const payload = await this.request<unknown>('/api/sessions', { method: 'POST', body: title ? { title } : {} });
|
||||
if (isRec(payload) && isRec(payload.session)) return payload.session as HermesSession;
|
||||
return payload as HermesSession;
|
||||
}
|
||||
|
||||
getSession(id: string): Promise<HermesSession> {
|
||||
return this.request<HermesSession>(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async sessionMessages(id: string): Promise<HermesSessionMessage[]> {
|
||||
const payload = await this.request<unknown>(`/api/sessions/${encodeURIComponent(id)}/messages`);
|
||||
return extractArray<HermesSessionMessage>(payload, ['messages', 'items', 'data']);
|
||||
}
|
||||
|
||||
updateSession(id: string, patch: { title?: string; end_reason?: string }): Promise<HermesSession> {
|
||||
return this.request<HermesSession>(`/api/sessions/${encodeURIComponent(id)}`, { method: 'PATCH', body: patch });
|
||||
}
|
||||
|
||||
deleteSession(id: string): Promise<void> {
|
||||
return this.request<void>(`/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async forkSession(id: string): Promise<HermesSession> {
|
||||
const payload = await this.request<unknown>(`/api/sessions/${encodeURIComponent(id)}/fork`, { method: 'POST', body: {} });
|
||||
if (isRec(payload) && isRec(payload.session)) return payload.session as HermesSession;
|
||||
return payload as HermesSession;
|
||||
}
|
||||
|
||||
// ── Jobs (cron) ───────────────────────────────────────────────────────────
|
||||
|
||||
async listJobs(): Promise<HermesJob[]> {
|
||||
const payload = await this.request<unknown>('/api/jobs');
|
||||
return extractArray<HermesJob>(payload, ['jobs', 'items', 'data']).map(normalizeJob);
|
||||
}
|
||||
|
||||
private unwrapJob(payload: unknown): HermesJob {
|
||||
if (isRec(payload) && isRec(payload.job)) return normalizeJob(payload.job as HermesJob);
|
||||
return normalizeJob(payload as HermesJob);
|
||||
}
|
||||
|
||||
async createJob(draft: HermesJobDraft): Promise<HermesJob> {
|
||||
const body: Rec = { name: draft.name, schedule: draft.schedule, prompt: draft.prompt };
|
||||
if (draft.deliver) body.deliver = draft.deliver;
|
||||
if (draft.skills?.length) body.skills = draft.skills;
|
||||
return this.unwrapJob(await this.request<unknown>('/api/jobs', { method: 'POST', body }));
|
||||
}
|
||||
|
||||
async updateJob(id: string, patch: Partial<HermesJobDraft>): Promise<HermesJob> {
|
||||
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}`, { method: 'PATCH', body: patch }));
|
||||
}
|
||||
|
||||
deleteJob(id: string): Promise<void> {
|
||||
return this.request<void>(`/api/jobs/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async pauseJob(id: string): Promise<HermesJob> {
|
||||
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}/pause`, { method: 'POST', body: {} }));
|
||||
}
|
||||
|
||||
async resumeJob(id: string): Promise<HermesJob> {
|
||||
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}/resume`, { method: 'POST', body: {} }));
|
||||
}
|
||||
|
||||
async runJob(id: string): Promise<HermesJob> {
|
||||
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}/run`, { method: 'POST', body: {}, timeoutMs: 30_000 }));
|
||||
}
|
||||
|
||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async startRun(body: { input: string; session_id?: string; instructions?: string; model?: string }): Promise<{ run_id: string; status: string }> {
|
||||
const payload: Rec = { input: body.input };
|
||||
if (body.session_id) payload.session_id = body.session_id;
|
||||
if (body.instructions) payload.instructions = body.instructions;
|
||||
if (body.model) payload.model = body.model;
|
||||
return this.request<{ run_id: string; status: string }>('/v1/runs', { method: 'POST', body: payload, timeoutMs: 30_000 });
|
||||
}
|
||||
|
||||
getRun(runId: string): Promise<HermesRunInfo> {
|
||||
return this.request<HermesRunInfo>(`/v1/runs/${encodeURIComponent(runId)}`);
|
||||
}
|
||||
|
||||
stopRun(runId: string): Promise<unknown> {
|
||||
return this.request<unknown>(`/v1/runs/${encodeURIComponent(runId)}/stop`, { method: 'POST', body: {} });
|
||||
}
|
||||
|
||||
steerRun(runId: string, text: string): Promise<unknown> {
|
||||
return this.request<unknown>(`/v1/runs/${encodeURIComponent(runId)}/steer`, { method: 'POST', body: { input: text } });
|
||||
}
|
||||
|
||||
approveRun(runId: string, choice: 'once' | 'session' | 'always' | 'deny', requestId?: string): Promise<unknown> {
|
||||
const body: Rec = { choice };
|
||||
if (requestId) body.request_id = requestId;
|
||||
return this.request<unknown>(`/v1/runs/${encodeURIComponent(runId)}/approval`, { method: 'POST', body });
|
||||
}
|
||||
|
||||
/** Subscribe to the lifecycle stream of a run. */
|
||||
async streamRunEvents(runId: string, onEvent: (event: HermesStreamEvent) => void): Promise<StreamHandle> {
|
||||
return this.openSse(`/v1/runs/${encodeURIComponent(runId)}/events`, { method: 'GET' }, onEvent);
|
||||
}
|
||||
|
||||
private async openSse(
|
||||
path: string,
|
||||
init: { method: 'GET' | 'POST'; body?: unknown },
|
||||
onEvent: (event: HermesStreamEvent) => void
|
||||
): Promise<StreamHandle> {
|
||||
const parser = new SseParser((message) => {
|
||||
const data = tryParseJson<unknown>(message.data) ?? message.data;
|
||||
const eventName = message.event !== 'message' ? message.event : isRec(data) && typeof data.event === 'string' ? data.event : 'message';
|
||||
for (const event of normalizeLifecycleEvent(eventName, data)) onEvent(event);
|
||||
});
|
||||
const errorChunks: string[] = [];
|
||||
const handle = await httpStream(
|
||||
{
|
||||
url: `${this.base}${path}`,
|
||||
method: init.method,
|
||||
headers: this.headers({ Accept: 'text/event-stream' }, init.body !== undefined),
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
timeoutMs: 15 * 60_000
|
||||
},
|
||||
{ onChunk: (text) => (handleReady ? parser.feed(text) : errorChunks.push(text)) }
|
||||
);
|
||||
let handleReady = handle.start.ok;
|
||||
if (!handle.start.ok) {
|
||||
await handle.done.catch(() => undefined);
|
||||
throw new HttpError(handle.start.status, errorMessage(handle.start.status, errorChunks.join('')));
|
||||
}
|
||||
handleReady = true;
|
||||
const done = handle.done.then(() => parser.end());
|
||||
return { ...handle, done };
|
||||
}
|
||||
|
||||
// ── Unified send ──────────────────────────────────────────────────────────
|
||||
|
||||
send(options: SendOptions, transport: ResolvedTransport): SendHandle {
|
||||
const effective: ResolvedTransport = options.images?.length && transport === 'runs' ? 'completions' : transport;
|
||||
let abort: () => void = () => undefined;
|
||||
const result =
|
||||
effective === 'runs'
|
||||
? this.sendViaRuns(options, (fn) => (abort = fn))
|
||||
: effective === 'sessions'
|
||||
? this.sendViaSessions(options, (fn) => (abort = fn))
|
||||
: this.sendViaCompletions(options, (fn) => (abort = fn));
|
||||
return { result, abort: () => abort(), transport: effective };
|
||||
}
|
||||
|
||||
private async sendViaRuns(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> {
|
||||
const { onEvent } = options;
|
||||
const run = await this.startRun({
|
||||
input: options.text,
|
||||
session_id: options.sessionId || undefined,
|
||||
instructions: this.config.instructions || undefined,
|
||||
model: this.config.model || undefined
|
||||
});
|
||||
const runId = run.run_id;
|
||||
onEvent({ kind: 'run.started', runId });
|
||||
Log.info('hermes', `run ${runId} started`);
|
||||
|
||||
let stopped = false;
|
||||
let finalText = '';
|
||||
let streamedText = '';
|
||||
let completed = false;
|
||||
let failure: string | null = null;
|
||||
|
||||
const handle = await this.streamRunEvents(runId, (event) => {
|
||||
if (event.kind === 'delta') streamedText += event.text;
|
||||
if (event.kind === 'completed' && event.status !== 'message') {
|
||||
completed = true;
|
||||
finalText = event.text ?? '';
|
||||
}
|
||||
if (event.kind === 'error') failure = event.message;
|
||||
onEvent(event);
|
||||
});
|
||||
setAbort(() => {
|
||||
stopped = true;
|
||||
handle.abort();
|
||||
this.stopRun(runId).catch(() => undefined);
|
||||
});
|
||||
|
||||
await handle.done;
|
||||
if (stopped) return streamedText;
|
||||
if (failure) throw new Error(failure);
|
||||
|
||||
if (!completed) {
|
||||
// The event buffer may have been consumed by a reconnect; fall back to polling the run status.
|
||||
for (let i = 0; i < 120 && !stopped; i++) {
|
||||
const info = await this.getRun(runId);
|
||||
if (['completed', 'failed', 'cancelled', 'canceled', 'stopped'].includes(info.status)) {
|
||||
if (info.status !== 'completed') throw new Error(String(info.error ?? `run ${info.status}`));
|
||||
finalText = info.output ?? '';
|
||||
onEvent({ kind: 'completed', text: finalText, status: info.status, usage: info.usage });
|
||||
if (info.session_id) onEvent({ kind: 'session', sessionId: info.session_id });
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
return finalText || streamedText;
|
||||
}
|
||||
|
||||
private async sendViaSessions(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> {
|
||||
const { onEvent } = options;
|
||||
let sessionId = options.sessionId;
|
||||
if (!sessionId || !sessionId.startsWith('hs:')) {
|
||||
const session = await this.createSession('EveFlow');
|
||||
sessionId = `hs:${session.id}`;
|
||||
onEvent({ kind: 'session', sessionId });
|
||||
}
|
||||
const realId = sessionId.slice(3);
|
||||
const body: Rec = { input: options.text };
|
||||
if (this.config.instructions) body.instructions = this.config.instructions;
|
||||
if (this.config.model) body.model = this.config.model;
|
||||
|
||||
let streamed = '';
|
||||
let finalText = '';
|
||||
let failure: string | null = null;
|
||||
const handle = await this.openSse(`/api/sessions/${encodeURIComponent(realId)}/chat/stream`, { method: 'POST', body }, (event) => {
|
||||
if (event.kind === 'delta') streamed += event.text;
|
||||
if (event.kind === 'completed' && event.text) finalText = event.text;
|
||||
if (event.kind === 'error') failure = event.message;
|
||||
if (event.kind === 'session') return; // keep our prefixed id
|
||||
onEvent(event);
|
||||
});
|
||||
setAbort(() => handle.abort());
|
||||
await handle.done;
|
||||
if (failure) throw new Error(failure);
|
||||
return finalText || streamed;
|
||||
}
|
||||
|
||||
private async sendViaCompletions(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> {
|
||||
const { onEvent } = options;
|
||||
const useTools = this.config.localTools && !!options.localToolExecutor && !!options.localToolDefinitions?.length;
|
||||
const messages: HermesChatMessage[] = [];
|
||||
if (this.config.instructions) messages.push({ role: 'system', content: this.config.instructions });
|
||||
for (const h of options.history) messages.push({ role: h.role, content: h.content });
|
||||
const userContent: HermesChatMessage['content'] = options.images?.length
|
||||
? [{ type: 'text', text: options.text }, ...options.images.map((url) => ({ type: 'image_url', image_url: { url } }))]
|
||||
: options.text;
|
||||
messages.push({ role: 'user', content: userContent });
|
||||
|
||||
let aborted = false;
|
||||
let currentHandle: StreamHandle | null = null;
|
||||
setAbort(() => {
|
||||
aborted = true;
|
||||
currentHandle?.abort();
|
||||
});
|
||||
|
||||
let fullText = '';
|
||||
for (let iteration = 0; iteration < 6 && !aborted; iteration++) {
|
||||
const payload: Rec = { model: this.config.model || 'hermes-agent', messages, stream: true };
|
||||
if (useTools) {
|
||||
payload.tools = options.localToolDefinitions;
|
||||
payload.tool_choice = 'auto';
|
||||
}
|
||||
if (this.config.reasoningEffort) payload.model_options = { reasoning_effort: this.config.reasoningEffort };
|
||||
|
||||
const toolCalls = new Map<number, CompletionToolCallAccumulator>();
|
||||
let finishReason: string | null = null;
|
||||
let iterationText = '';
|
||||
const errorChunks: string[] = [];
|
||||
let ready = false;
|
||||
const parser = new SseParser((message) => {
|
||||
if (message.data === '[DONE]') return;
|
||||
const data = tryParseJson<unknown>(message.data);
|
||||
if (data === null) return;
|
||||
if (message.event !== 'message' && !(isRec(data) && Array.isArray(data.choices))) {
|
||||
for (const event of normalizeLifecycleEvent(message.event, data)) onEvent(event);
|
||||
return;
|
||||
}
|
||||
const { events, finishReason: reason } = normalizeCompletionChunk(data, toolCalls);
|
||||
for (const event of events) {
|
||||
if (event.kind === 'delta') iterationText += event.text;
|
||||
onEvent(event);
|
||||
}
|
||||
if (reason) finishReason = reason;
|
||||
});
|
||||
|
||||
const headers = this.headers({ Accept: 'text/event-stream' });
|
||||
if (options.sessionId) headers['X-Hermes-Session-Id'] = options.sessionId;
|
||||
const handle = await httpStream(
|
||||
{ url: `${this.base}/v1/chat/completions`, method: 'POST', headers, body: JSON.stringify(payload), timeoutMs: 10 * 60_000 },
|
||||
{ onChunk: (text) => (ready ? parser.feed(text) : errorChunks.push(text)) }
|
||||
);
|
||||
currentHandle = handle;
|
||||
if (!handle.start.ok) {
|
||||
await handle.done.catch(() => undefined);
|
||||
throw new HttpError(handle.start.status, errorMessage(handle.start.status, errorChunks.join('')));
|
||||
}
|
||||
ready = true;
|
||||
const rotated = handle.start.headers['x-hermes-session-id'];
|
||||
if (rotated && rotated !== options.sessionId) onEvent({ kind: 'session', sessionId: rotated });
|
||||
|
||||
await handle.done;
|
||||
parser.end();
|
||||
fullText += iterationText;
|
||||
|
||||
if (finishReason === 'tool_calls' && useTools && toolCalls.size > 0 && !aborted) {
|
||||
const calls = [...toolCalls.values()];
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: iterationText || null,
|
||||
tool_calls: calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: c.arguments } }))
|
||||
});
|
||||
for (const call of calls) {
|
||||
onEvent({ kind: 'tool.start', id: call.id, name: call.name, args: call.arguments });
|
||||
const output = await options.localToolExecutor!(call.name, call.arguments);
|
||||
onEvent({ kind: 'tool.end', id: call.id, name: call.name, output, ok: !output.includes('"error"') });
|
||||
messages.push({ role: 'tool', content: output, tool_call_id: call.id });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
onEvent({ kind: 'completed', status: aborted ? 'cancelled' : 'completed', text: fullText });
|
||||
return fullText;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeJob(job: HermesJob): HermesJob {
|
||||
const raw = job as Rec;
|
||||
const id = String(raw.id ?? raw.job_id ?? raw.name ?? '');
|
||||
return { ...job, id };
|
||||
}
|
||||
|
||||
export function jobStatus(job: HermesJob): string {
|
||||
if (job.state) return String(job.state);
|
||||
if (job.status) return String(job.status);
|
||||
if (job.enabled === false) return 'paused';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
export function jobSchedule(job: HermesJob): string {
|
||||
const s = job.schedule;
|
||||
if (!s) return '—';
|
||||
if (typeof s === 'string') return s;
|
||||
return s.display || s.expr || s.kind || 'schedule';
|
||||
}
|
||||
|
||||
export function jobOutput(job: HermesJob): string {
|
||||
return String(job.last_output ?? job.last_error ?? job.output ?? job.result ?? '');
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Normalises the various Hermes streaming dialects (runs events, sessions stream,
|
||||
* chat-completions chunks) into one HermesStreamEvent vocabulary.
|
||||
* Hermes releases have renamed a few events (tool.started/tool.start, token.delta/message.delta),
|
||||
* so every reader here is deliberately tolerant.
|
||||
*/
|
||||
import type { HermesStreamEvent, HermesUsage } from './types';
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
const s = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
||||
const first = (obj: Rec, keys: string[]): unknown => {
|
||||
for (const k of keys) if (obj[k] !== undefined && obj[k] !== null) return obj[k];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function textOf(data: Rec): string {
|
||||
const direct = first(data, ['delta', 'text', 'content', 'output_text', 'token']);
|
||||
if (typeof direct === 'string') return direct;
|
||||
if (isRec(direct)) return s(first(direct, ['text', 'content', 'delta']));
|
||||
return '';
|
||||
}
|
||||
|
||||
function idOf(data: Rec, fallback = ''): string {
|
||||
return s(first(data, ['tool_call_id', 'call_id', 'id', 'request_id', 'delegation_id'])) || fallback;
|
||||
}
|
||||
|
||||
function toolName(data: Rec): string {
|
||||
const direct = first(data, ['tool_name', 'tool', 'name', 'function']);
|
||||
if (typeof direct === 'string') return direct;
|
||||
if (isRec(direct)) return s(first(direct, ['name', 'tool']));
|
||||
return 'outil';
|
||||
}
|
||||
|
||||
function argsOf(data: Rec): string | undefined {
|
||||
const value = first(data, ['arguments', 'args', 'input', 'params', 'preview']);
|
||||
if (value === undefined) return undefined;
|
||||
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
function optionsOf(data: Rec): string[] | undefined {
|
||||
const value = first(data, ['options', 'choices']);
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.map((v) => (isRec(v) ? s(first(v, ['label', 'value', 'id'])) : s(v))).filter(Boolean);
|
||||
}
|
||||
|
||||
function usageOf(data: Rec): HermesUsage | undefined {
|
||||
return isRec(data.usage) ? (data.usage as HermesUsage) : undefined;
|
||||
}
|
||||
|
||||
/** Runs API + sessions stream share the same lifecycle vocabulary. */
|
||||
export function normalizeLifecycleEvent(event: string, payload: unknown): HermesStreamEvent[] {
|
||||
const name = event.toLowerCase();
|
||||
const data: Rec = isRec(payload) ? payload : { value: payload };
|
||||
const out: HermesStreamEvent[] = [];
|
||||
const sessionId = s(first(data, ['session_id', 'sessionId']));
|
||||
|
||||
switch (name) {
|
||||
case 'token.delta':
|
||||
case 'message.delta':
|
||||
case 'assistant.delta':
|
||||
case 'output_text.delta':
|
||||
case 'response.output_text.delta':
|
||||
case 'delta': {
|
||||
const text = textOf(data);
|
||||
if (text) out.push({ kind: 'delta', text });
|
||||
break;
|
||||
}
|
||||
case 'reasoning.delta':
|
||||
case 'reasoning.available':
|
||||
case 'thinking.delta': {
|
||||
const text = textOf(data);
|
||||
if (text) out.push({ kind: 'reasoning', text });
|
||||
break;
|
||||
}
|
||||
case 'tool.start':
|
||||
case 'tool.started':
|
||||
case 'tool_call.start':
|
||||
out.push({ kind: 'tool.start', id: idOf(data, `tool-${Date.now()}`), name: toolName(data), args: argsOf(data) });
|
||||
break;
|
||||
case 'tool.progress':
|
||||
case 'hermes.tool.progress':
|
||||
out.push({
|
||||
kind: 'tool.progress',
|
||||
id: idOf(data) || undefined,
|
||||
name: toolName(data),
|
||||
label: s(first(data, ['label', 'message', 'status'])) || undefined,
|
||||
detail: s(first(data, ['preview', 'detail', 'output'])) || undefined
|
||||
});
|
||||
break;
|
||||
case 'tool.complete':
|
||||
case 'tool.completed':
|
||||
case 'tool.end':
|
||||
case 'tool_call.complete': {
|
||||
const status = s(first(data, ['status', 'result_status'])).toLowerCase();
|
||||
const error = first(data, ['error']);
|
||||
out.push({
|
||||
kind: 'tool.end',
|
||||
id: idOf(data, ''),
|
||||
name: data.tool_name || data.tool || data.name ? toolName(data) : undefined,
|
||||
output: s(first(data, ['output', 'result', 'content'])) || undefined,
|
||||
ok: !error && status !== 'error' && status !== 'failed'
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'subagent.start':
|
||||
out.push({
|
||||
kind: 'subagent.start',
|
||||
id: s(first(data, ['delegation_id', 'id'])) || `sub-${Date.now()}`,
|
||||
childSessionId: s(data.child_session_id) || undefined,
|
||||
label: s(first(data, ['label', 'task', 'goal'])) || undefined
|
||||
});
|
||||
break;
|
||||
case 'subagent.complete':
|
||||
case 'subagent.end':
|
||||
out.push({
|
||||
kind: 'subagent.end',
|
||||
id: s(first(data, ['delegation_id', 'id'])) || 'sub',
|
||||
status: s(data.status) || 'completed',
|
||||
summary: s(data.summary) || undefined,
|
||||
durationMs: typeof data.duration_ms === 'number' ? data.duration_ms : undefined
|
||||
});
|
||||
break;
|
||||
case 'approval.request':
|
||||
case 'approval.requested':
|
||||
out.push({
|
||||
kind: 'approval.request',
|
||||
requestId: s(first(data, ['request_id', 'approval_id', 'id'])),
|
||||
tool: s(first(data, ['tool_name', 'tool', 'command'])) || undefined,
|
||||
description: s(first(data, ['description', 'message', 'prompt', 'reason', 'command', 'preview'])) || 'Hermes demande une autorisation.',
|
||||
args: argsOf(data),
|
||||
options: optionsOf(data)
|
||||
});
|
||||
break;
|
||||
case 'clarify.request':
|
||||
case 'clarification.request':
|
||||
out.push({
|
||||
kind: 'clarify.request',
|
||||
requestId: s(first(data, ['request_id', 'id'])),
|
||||
question: s(first(data, ['question', 'prompt', 'message'])) || 'Hermes a besoin d’une précision.',
|
||||
options: optionsOf(data)
|
||||
});
|
||||
break;
|
||||
case 'sudo.request':
|
||||
case 'secret.request':
|
||||
out.push({
|
||||
kind: 'input.request',
|
||||
requestId: s(first(data, ['request_id', 'id'])),
|
||||
prompt: s(first(data, ['prompt', 'message', 'description'])) || (name === 'sudo.request' ? 'Mot de passe sudo requis' : 'Secret requis'),
|
||||
secret: true,
|
||||
variant: name === 'sudo.request' ? 'sudo' : 'secret'
|
||||
});
|
||||
break;
|
||||
case 'approval.expire':
|
||||
case 'clarify.expire':
|
||||
case 'sudo.expire':
|
||||
case 'secret.expire':
|
||||
case 'request.expire':
|
||||
out.push({ kind: 'request.expire', requestId: s(first(data, ['request_id', 'id'])) });
|
||||
break;
|
||||
case 'run.started':
|
||||
case 'run.created':
|
||||
out.push({ kind: 'run.started', runId: s(first(data, ['run_id', 'id'])) });
|
||||
break;
|
||||
case 'message.complete':
|
||||
case 'message.completed':
|
||||
case 'assistant.complete': {
|
||||
// Final text; a run.completed usually follows, so only surface the text here.
|
||||
const text = textOf(data);
|
||||
if (text) out.push({ kind: 'completed', text, status: 'message', usage: usageOf(data) });
|
||||
break;
|
||||
}
|
||||
case 'run.completed':
|
||||
case 'run.complete':
|
||||
case 'run.finished':
|
||||
case 'done':
|
||||
case 'complete':
|
||||
out.push({
|
||||
kind: 'completed',
|
||||
text: s(first(data, ['output', 'text', 'content', 'final_output'])) || undefined,
|
||||
status: s(data.status) || 'completed',
|
||||
usage: usageOf(data)
|
||||
});
|
||||
break;
|
||||
case 'run.failed':
|
||||
case 'run.cancelled':
|
||||
case 'run.canceled':
|
||||
case 'error':
|
||||
out.push({ kind: 'error', message: s(first(data, ['error', 'message', 'detail', 'reason'])) || name });
|
||||
break;
|
||||
case 'gateway.ready':
|
||||
case 'ping':
|
||||
case 'heartbeat':
|
||||
break;
|
||||
default:
|
||||
out.push({ kind: 'raw', event, data: payload });
|
||||
}
|
||||
if (sessionId) out.push({ kind: 'session', sessionId });
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface CompletionToolCallAccumulator {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
/** Chat-completions chunk (OpenAI format). Returns delta events plus any accumulated tool calls. */
|
||||
export function normalizeCompletionChunk(
|
||||
chunk: unknown,
|
||||
toolCalls: Map<number, CompletionToolCallAccumulator>
|
||||
): { events: HermesStreamEvent[]; finishReason: string | null } {
|
||||
const events: HermesStreamEvent[] = [];
|
||||
if (!isRec(chunk)) return { events, finishReason: null };
|
||||
const choice = Array.isArray(chunk.choices) && isRec(chunk.choices[0]) ? (chunk.choices[0] as Rec) : null;
|
||||
if (!choice) return { events, finishReason: null };
|
||||
const delta = isRec(choice.delta) ? (choice.delta as Rec) : isRec(choice.message) ? (choice.message as Rec) : null;
|
||||
if (delta) {
|
||||
if (typeof delta.content === 'string' && delta.content) events.push({ kind: 'delta', text: delta.content });
|
||||
const reasoning = first(delta, ['reasoning_content', 'reasoning']);
|
||||
if (typeof reasoning === 'string' && reasoning) events.push({ kind: 'reasoning', text: reasoning });
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
if (!isRec(tc)) continue;
|
||||
const index = typeof tc.index === 'number' ? tc.index : toolCalls.size;
|
||||
const fn = isRec(tc.function) ? (tc.function as Rec) : {};
|
||||
const acc = toolCalls.get(index) ?? { id: s(tc.id), name: '', arguments: '' };
|
||||
if (tc.id) acc.id = s(tc.id);
|
||||
if (typeof fn.name === 'string') acc.name += fn.name;
|
||||
if (typeof fn.arguments === 'string') acc.arguments += fn.arguments;
|
||||
toolCalls.set(index, acc);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { events, finishReason: typeof choice.finish_reason === 'string' ? choice.finish_reason : null };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Client-side tools offered to Hermes in chat-completions mode (OpenAI tool calling).
|
||||
* With the runs/sessions transports Hermes uses its own server-side toolsets instead.
|
||||
*/
|
||||
import { bridge } from '../../lib/bridge';
|
||||
|
||||
export interface LocalToolContext {
|
||||
setEmotion: (emotion: string) => void;
|
||||
speak: (text: string) => void;
|
||||
getStatus: () => Record<string, unknown>;
|
||||
getHistory: (n: number) => Array<{ role: string; content: string }>;
|
||||
notify: (title: string, body: string) => void;
|
||||
}
|
||||
|
||||
export const LOCAL_TOOL_DEFINITIONS = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'set_hud_state',
|
||||
description: "Change l'état visuel du HUD EveFlow (couleur/animation du noyau) pour accompagner la réponse.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
state: { type: 'string', enum: ['neutral', 'happy', 'thinking', 'alert', 'error', 'success'] }
|
||||
},
|
||||
required: ['state']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_app_status',
|
||||
description: "Retourne l'état courant d'EveFlow (transport, voix, nombre de messages, hôte).",
|
||||
parameters: { type: 'object', properties: {} }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_conversation_history',
|
||||
description: 'Retourne les N derniers messages affichés dans EveFlow.',
|
||||
parameters: { type: 'object', properties: { n: { type: 'number', minimum: 1, maximum: 30 } }, required: ['n'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'speak_text',
|
||||
description: 'Fait prononcer immédiatement un texte court par la voix du HUD.',
|
||||
parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'notify_user',
|
||||
description: 'Affiche une notification système Windows à l’utilisateur.',
|
||||
parameters: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title', 'body'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'write_shared_file',
|
||||
description: "Écrit un fichier texte (rapport, script, JSON, HTML, SVG) dans le dossier partagé EveFlow_Shared de l'utilisateur et renvoie son URL.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { filename: { type: 'string' }, content: { type: 'string' } },
|
||||
required: ['filename', 'content']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export async function executeLocalTool(name: string, rawArgs: string, ctx: LocalToolContext): Promise<string> {
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = rawArgs ? (JSON.parse(rawArgs) as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return JSON.stringify({ error: 'invalid JSON arguments' });
|
||||
}
|
||||
try {
|
||||
switch (name) {
|
||||
case 'set_hud_state':
|
||||
ctx.setEmotion(String(args.state ?? 'neutral'));
|
||||
return JSON.stringify({ ok: true });
|
||||
case 'get_app_status':
|
||||
return JSON.stringify(ctx.getStatus());
|
||||
case 'get_conversation_history':
|
||||
return JSON.stringify({ messages: ctx.getHistory(Math.min(30, Number(args.n) || 5)) });
|
||||
case 'speak_text':
|
||||
ctx.speak(String(args.text ?? '').slice(0, 600));
|
||||
return JSON.stringify({ ok: true });
|
||||
case 'notify_user':
|
||||
ctx.notify(String(args.title ?? 'EveFlow'), String(args.body ?? ''));
|
||||
return JSON.stringify({ ok: true });
|
||||
case 'write_shared_file': {
|
||||
const api = bridge();
|
||||
if (!api) return JSON.stringify({ error: 'file system unavailable outside Electron' });
|
||||
const result = await api.files.writeShared(String(args.filename ?? 'fichier.txt'), String(args.content ?? ''), false);
|
||||
return JSON.stringify({ ok: true, ...result });
|
||||
}
|
||||
default:
|
||||
return JSON.stringify({ error: `unknown tool ${name}` });
|
||||
}
|
||||
} catch (err) {
|
||||
return JSON.stringify({ error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
export type HermesTransport = 'auto' | 'runs' | 'sessions' | 'completions';
|
||||
|
||||
export interface HermesConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/** Stable per-user key for long-term memory (X-Hermes-Session-Key). */
|
||||
sessionKey: string;
|
||||
transport: HermesTransport;
|
||||
reasoningEffort: '' | 'low' | 'medium' | 'high';
|
||||
/** Extra instructions layered on top of the Hermes system prompt. */
|
||||
instructions: string;
|
||||
/** Expose EveFlow client tools in chat-completions mode. */
|
||||
localTools: boolean;
|
||||
}
|
||||
|
||||
export interface HermesCapabilities {
|
||||
object?: string;
|
||||
platform?: string;
|
||||
model?: string;
|
||||
version?: string;
|
||||
auth?: { type?: string; required?: boolean };
|
||||
features?: Record<string, boolean | string>;
|
||||
endpoints?: Record<string, boolean>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesHealth {
|
||||
status: string;
|
||||
readiness?: { checks?: Record<string, unknown> };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesModel {
|
||||
id: string;
|
||||
owned_by?: string;
|
||||
provider?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesSkill {
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesToolset {
|
||||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
tools?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesSession {
|
||||
id: string;
|
||||
title?: string;
|
||||
created_at?: string | number;
|
||||
updated_at?: string | number;
|
||||
end_reason?: string | null;
|
||||
message_count?: number;
|
||||
platform?: string;
|
||||
source?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesSessionMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp?: string | number;
|
||||
tool_calls?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesJobSchedule {
|
||||
kind?: string;
|
||||
expr?: string;
|
||||
display?: string;
|
||||
}
|
||||
|
||||
export interface HermesJob {
|
||||
id: string;
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
schedule?: string | HermesJobSchedule;
|
||||
state?: string;
|
||||
status?: string;
|
||||
enabled?: boolean;
|
||||
deliver?: string;
|
||||
next_run_at?: string | number;
|
||||
last_run_at?: string | number;
|
||||
last_status?: string;
|
||||
last_output?: string;
|
||||
last_error?: string;
|
||||
repeat?: number | { times?: number | null; completed?: number };
|
||||
skills?: string[];
|
||||
skill?: string;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
created_at?: string | number;
|
||||
updated_at?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesJobDraft {
|
||||
name: string;
|
||||
schedule: string;
|
||||
prompt: string;
|
||||
deliver?: string;
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
export interface HermesRunInfo {
|
||||
run_id: string;
|
||||
status: string;
|
||||
session_id?: string;
|
||||
model?: string;
|
||||
output?: string;
|
||||
usage?: Record<string, number>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesUsage {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type HermesStreamEvent =
|
||||
| { kind: 'delta'; text: string }
|
||||
| { kind: 'reasoning'; text: string }
|
||||
| { kind: 'tool.start'; id: string; name: string; args?: string }
|
||||
| { kind: 'tool.progress'; id?: string; name: string; label?: string; detail?: string }
|
||||
| { kind: 'tool.end'; id: string; name?: string; output?: string; ok: boolean }
|
||||
| { kind: 'subagent.start'; id: string; childSessionId?: string; label?: string }
|
||||
| { kind: 'subagent.end'; id: string; status: string; summary?: string; durationMs?: number }
|
||||
| { kind: 'approval.request'; requestId: string; tool?: string; description: string; args?: string; options?: string[] }
|
||||
| { kind: 'clarify.request'; requestId: string; question: string; options?: string[] }
|
||||
| { kind: 'input.request'; requestId: string; prompt: string; secret: boolean; variant: 'sudo' | 'secret' }
|
||||
| { kind: 'request.expire'; requestId: string }
|
||||
| { kind: 'session'; sessionId: string }
|
||||
| { kind: 'run.started'; runId: string }
|
||||
| { kind: 'completed'; text?: string; status: string; usage?: HermesUsage }
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'raw'; event: string; data: unknown };
|
||||
|
||||
export interface HermesChatMessage {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string | null | Array<{ type: string; text?: string; image_url?: { url: string } }>;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: unknown[];
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
text: string;
|
||||
images?: string[];
|
||||
/** Recent turns used only by the stateless chat-completions transport. */
|
||||
history: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
sessionId: string;
|
||||
onEvent: (event: HermesStreamEvent) => void;
|
||||
localToolExecutor?: (name: string, args: string) => Promise<string>;
|
||||
localToolDefinitions?: unknown[];
|
||||
}
|
||||
|
||||
export interface SendHandle {
|
||||
/** Resolves with the final assistant text. */
|
||||
result: Promise<string>;
|
||||
abort: () => void;
|
||||
transport: Exclude<HermesTransport, 'auto'>;
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* PollingService — synchronizes Hermes /api/jobs for scheduled work.
|
||||
*
|
||||
* Hermes remains the source of truth. EveFlow polls as a client, keeps a local
|
||||
* display cache, and uses the same service for CRUD actions on remote jobs.
|
||||
*/
|
||||
|
||||
import { LogService } from './logService';
|
||||
|
||||
export type HermesJobStatus =
|
||||
| 'pending'
|
||||
| 'scheduled'
|
||||
| 'paused'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'ok'
|
||||
| string;
|
||||
|
||||
export interface HermesJob {
|
||||
id: string;
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
schedule?: string | { kind?: string; expr?: string; display?: string };
|
||||
status?: HermesJobStatus;
|
||||
state?: HermesJobStatus;
|
||||
enabled?: boolean;
|
||||
result?: string;
|
||||
output?: string;
|
||||
last_result?: string;
|
||||
last_output?: string;
|
||||
error?: string;
|
||||
last_error?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
next_run_at?: string;
|
||||
last_run_at?: string;
|
||||
completed_at?: string;
|
||||
last_status?: string;
|
||||
repeat?: { times?: number | null; completed?: number };
|
||||
skills?: string[];
|
||||
deliver?: string;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesJobDraft {
|
||||
name: string;
|
||||
schedule: string;
|
||||
prompt: string;
|
||||
deliver?: string;
|
||||
}
|
||||
|
||||
export interface HermesSyncSnapshot {
|
||||
jobs: HermesJob[];
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
export type JobsSyncHandler = (snapshot: HermesSyncSnapshot) => void;
|
||||
export type SyncStatusHandler = (status: 'syncing' | 'online' | 'offline', detail?: string) => void;
|
||||
|
||||
const normalizeBaseUrl = (hermesUrl: string): string => (
|
||||
hermesUrl.replace(/\/$/, '').replace(/\/v1(\/.*)?$/, '')
|
||||
);
|
||||
|
||||
const parseJobsPayload = (payload: unknown): HermesJob[] => {
|
||||
if (Array.isArray(payload)) return payload as HermesJob[];
|
||||
if (payload && typeof payload === 'object') {
|
||||
const maybeJobs = (payload as { jobs?: unknown }).jobs;
|
||||
if (Array.isArray(maybeJobs)) return maybeJobs as HermesJob[];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export class PollingService {
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private hermesUrl: string = '';
|
||||
private hermesApiKey: string = '';
|
||||
private onSync: JobsSyncHandler | null = null;
|
||||
private onStatus: SyncStatusHandler | null = null;
|
||||
private intervalMs: number;
|
||||
|
||||
constructor(intervalMs: number = 30_000) {
|
||||
this.intervalMs = intervalMs;
|
||||
}
|
||||
|
||||
public start(
|
||||
hermesUrl: string,
|
||||
apiKey: string,
|
||||
onSync: JobsSyncHandler,
|
||||
onStatus?: SyncStatusHandler
|
||||
): void {
|
||||
this.stop();
|
||||
this.hermesUrl = hermesUrl;
|
||||
this.hermesApiKey = apiKey;
|
||||
this.onSync = onSync;
|
||||
this.onStatus = onStatus || null;
|
||||
|
||||
LogService.info('PollingService', `Synchronisation Hermes jobs toutes les ${this.intervalMs / 1000}s`, { hermesUrl });
|
||||
|
||||
this.sync();
|
||||
this.intervalId = setInterval(() => this.sync(), this.intervalMs);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
LogService.info('PollingService', 'Synchronisation Hermes jobs arrêtée.');
|
||||
}
|
||||
}
|
||||
|
||||
public get isActive(): boolean {
|
||||
return this.intervalId !== null;
|
||||
}
|
||||
|
||||
public async refresh(): Promise<HermesSyncSnapshot> {
|
||||
return this.fetchJobs();
|
||||
}
|
||||
|
||||
public async createJob(draft: HermesJobDraft): Promise<HermesJob> {
|
||||
return this.requestJob('', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: draft.name,
|
||||
schedule: draft.schedule,
|
||||
prompt: draft.prompt,
|
||||
...(draft.deliver ? { deliver: draft.deliver } : {})
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
public async updateJob(jobId: string, patch: Partial<HermesJobDraft>): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch)
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteJob(jobId: string): Promise<void> {
|
||||
await this.requestRaw(`/${encodeURIComponent(jobId)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
public async pauseJob(jobId: string): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}/pause`, { method: 'POST' });
|
||||
}
|
||||
|
||||
public async resumeJob(jobId: string): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}/resume`, { method: 'POST' });
|
||||
}
|
||||
|
||||
public async runJob(jobId: string): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}/run`, { method: 'POST' });
|
||||
}
|
||||
|
||||
private async sync(): Promise<void> {
|
||||
if (!this.hermesUrl || !this.onSync) return;
|
||||
|
||||
try {
|
||||
this.onStatus?.('syncing');
|
||||
const snapshot = await this.fetchJobs();
|
||||
this.onSync(snapshot);
|
||||
this.onStatus?.('online');
|
||||
} catch (e: any) {
|
||||
const message = e?.message || String(e);
|
||||
LogService.warn('PollingService', `Synchronisation Hermes jobs impossible: ${message}`);
|
||||
this.onStatus?.('offline', message);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchJobs(): Promise<HermesSyncSnapshot> {
|
||||
const payload = await this.requestRaw('');
|
||||
return {
|
||||
jobs: parseJobsPayload(payload),
|
||||
syncedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private async requestJob(path: string, init: RequestInit): Promise<HermesJob> {
|
||||
const payload = await this.requestRaw(path, init);
|
||||
if (payload && typeof payload === 'object' && 'job' in payload) {
|
||||
return (payload as { job: HermesJob }).job;
|
||||
}
|
||||
return payload as HermesJob;
|
||||
}
|
||||
|
||||
private async requestRaw(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
if (!this.hermesUrl) throw new Error('URL Hermes manquante');
|
||||
|
||||
const base = normalizeBaseUrl(this.hermesUrl);
|
||||
const endpoint = `${base}/api/jobs${path}`;
|
||||
const abortCtrl = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortCtrl.abort(), 12_000);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.hermesApiKey) headers.Authorization = `Bearer ${this.hermesApiKey.trim()}`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init.headers || {}) },
|
||||
signal: abortCtrl.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const raw = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status}${raw ? `: ${raw.slice(0, 180)}` : ''}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) return null;
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch (e: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (e?.name === 'AbortError') throw new Error('Timeout Hermes /api/jobs');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* 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']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'write_shared_file',
|
||||
description: "Sauvegarde un fichier (rapport, script python, javascript, données JSON, HTML) dans le dossier partagé EveFlow de l'utilisateur Windows et lui partage un lien d'accès. Utilise cet outil chaque fois que l'utilisateur te demande de générer, d'écrire ou de sauvegarder un fichier sur son ordinateur.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filename: {
|
||||
type: 'string',
|
||||
description: "Le nom du fichier avec son extension (ex: rapport.txt, script.py, data.json, index.html)"
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: "Le contenu textuel intégral à écrire dans le fichier"
|
||||
}
|
||||
},
|
||||
required: ['filename', 'content']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'generate_shared_image',
|
||||
description: "Génère un schéma technique, un dessin vectoriel, ou une illustration de haute qualité au format SVG, l'enregistre sur le disque et l'affiche dans le chat. Utilise cet outil dès que l'utilisateur te demande d'afficher, de dessiner ou de générer un graphique, un diagramme ou une image.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filename: {
|
||||
type: 'string',
|
||||
description: "Le nom du fichier SVG (ex: architecture.svg, graphique.svg)"
|
||||
},
|
||||
svgContent: {
|
||||
type: 'string',
|
||||
description: "Le code source SVG complet et valide (incluant les balises <svg>, viewBox, styles et éléments géométriques)"
|
||||
}
|
||||
},
|
||||
required: ['filename', 'svgContent']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// ── 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 async function executeTool(
|
||||
toolCallId: string,
|
||||
name: string,
|
||||
rawArgs: string,
|
||||
callbacks: AppCallbacks
|
||||
): Promise<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;
|
||||
}
|
||||
|
||||
case 'write_shared_file': {
|
||||
const filename = String(args.filename || '');
|
||||
const content = String(args.content || '');
|
||||
if ((window as any).electronAPI?.writeSharedFile) {
|
||||
const fileUrl = await (window as any).electronAPI.writeSharedFile(filename, content, false);
|
||||
result = JSON.stringify({ success: true, url: fileUrl, filename });
|
||||
} else {
|
||||
result = JSON.stringify({ error: "L'API d'écriture de fichier local n'est pas disponible (hors Electron)." });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'generate_shared_image': {
|
||||
const filename = String(args.filename || '');
|
||||
const svgContent = String(args.svgContent || '');
|
||||
if ((window as any).electronAPI?.writeSharedFile) {
|
||||
const fileUrl = await (window as any).electronAPI.writeSharedFile(filename, svgContent, false);
|
||||
result = JSON.stringify({ success: true, url: fileUrl, filename });
|
||||
} else {
|
||||
result = JSON.stringify({ error: "L'API de génération d'image locale n'est pas disponible." });
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Shared Web Audio graph. Everything that is played (TTS) or captured (mic) passes through
|
||||
* analysers so the JARVIS core can react to the real signal.
|
||||
*/
|
||||
class AudioBus {
|
||||
private ctx: AudioContext | null = null;
|
||||
private outputGain: GainNode | null = null;
|
||||
private outputAnalyser: AnalyserNode | null = null;
|
||||
private inputAnalyser: AnalyserNode | null = null;
|
||||
private timeData = new Float32Array(1024);
|
||||
private freqData = new Uint8Array(512);
|
||||
private synthetic = 0;
|
||||
|
||||
get context(): AudioContext {
|
||||
if (!this.ctx) {
|
||||
this.ctx = new AudioContext({ latencyHint: 'interactive' });
|
||||
this.outputGain = this.ctx.createGain();
|
||||
this.outputAnalyser = this.ctx.createAnalyser();
|
||||
this.outputAnalyser.fftSize = 1024;
|
||||
this.outputAnalyser.smoothingTimeConstant = 0.6;
|
||||
this.outputGain.connect(this.outputAnalyser);
|
||||
this.outputAnalyser.connect(this.ctx.destination);
|
||||
}
|
||||
return this.ctx;
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
const ctx = this.context;
|
||||
if (ctx.state === 'suspended') await ctx.resume().catch(() => undefined);
|
||||
}
|
||||
|
||||
get output(): AudioNode {
|
||||
this.context;
|
||||
return this.outputGain!;
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
this.context;
|
||||
this.outputGain!.gain.value = Math.max(0, Math.min(1.5, volume));
|
||||
}
|
||||
|
||||
/** Attach an external analyser (mic capture) so the HUD can read the input level. */
|
||||
setInputAnalyser(analyser: AnalyserNode | null): void {
|
||||
this.inputAnalyser = analyser;
|
||||
}
|
||||
|
||||
/** For engines that bypass Web Audio (system speech synthesis), fake a plausible level. */
|
||||
setSyntheticLevel(level: number): void {
|
||||
this.synthetic = level;
|
||||
}
|
||||
|
||||
private rms(analyser: AnalyserNode): number {
|
||||
if (this.timeData.length !== analyser.fftSize) this.timeData = new Float32Array(analyser.fftSize);
|
||||
analyser.getFloatTimeDomainData(this.timeData);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < this.timeData.length; i++) sum += this.timeData[i] * this.timeData[i];
|
||||
return Math.sqrt(sum / this.timeData.length);
|
||||
}
|
||||
|
||||
/** Output loudness 0..1 (TTS). */
|
||||
outputLevel(): number {
|
||||
if (this.synthetic > 0) return this.synthetic;
|
||||
if (!this.outputAnalyser) return 0;
|
||||
return Math.min(1, this.rms(this.outputAnalyser) * 4);
|
||||
}
|
||||
|
||||
/** Input loudness 0..1 (microphone). */
|
||||
inputLevel(): number {
|
||||
if (!this.inputAnalyser) return 0;
|
||||
return Math.min(1, this.rms(this.inputAnalyser) * 6);
|
||||
}
|
||||
|
||||
/** Normalised output spectrum (0..1) with `bins` values, low to high frequency. */
|
||||
spectrum(bins: number, source: 'output' | 'input' = 'output'): Float32Array {
|
||||
const analyser = source === 'output' ? this.outputAnalyser : this.inputAnalyser;
|
||||
const out = new Float32Array(bins);
|
||||
if (!analyser) {
|
||||
if (this.synthetic > 0) {
|
||||
const t = performance.now() / 1000;
|
||||
for (let i = 0; i < bins; i++) {
|
||||
out[i] = Math.max(0, this.synthetic * (0.6 + 0.4 * Math.sin(t * 9 + i * 0.7)) * (1 - i / bins));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (this.freqData.length !== analyser.frequencyBinCount) this.freqData = new Uint8Array(analyser.frequencyBinCount);
|
||||
analyser.getByteFrequencyData(this.freqData);
|
||||
// Only the lower half of the spectrum carries voice energy.
|
||||
const usable = Math.floor(this.freqData.length * 0.5);
|
||||
for (let i = 0; i < bins; i++) {
|
||||
const from = Math.floor((i / bins) * usable);
|
||||
const to = Math.max(from + 1, Math.floor(((i + 1) / bins) * usable));
|
||||
let sum = 0;
|
||||
for (let j = from; j < to; j++) sum += this.freqData[j];
|
||||
out[i] = sum / (to - from) / 255;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
export const audioBus = new AudioBus();
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Microphone capture built on AudioWorklet (no deprecated ScriptProcessor, no mic monitoring
|
||||
* feedback). Produces 16 kHz WAV utterances, with energy VAD for hands-free auto stop.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { audioBus } from './audioBus';
|
||||
import { DEFAULT_VAD, EnergyVad, type VadOptions, type VadSignal } from './vad';
|
||||
import { buildWav16k, rms, type WavResult } from './wav';
|
||||
|
||||
const WORKLET_SOURCE = `
|
||||
class EveFlowCaptureProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.buffer = new Float32Array(2048);
|
||||
this.offset = 0;
|
||||
}
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (!input || input.length === 0) return true;
|
||||
const channel = input[0];
|
||||
if (!channel) return true;
|
||||
let i = 0;
|
||||
while (i < channel.length) {
|
||||
const n = Math.min(channel.length - i, this.buffer.length - this.offset);
|
||||
this.buffer.set(channel.subarray(i, i + n), this.offset);
|
||||
this.offset += n;
|
||||
i += n;
|
||||
if (this.offset === this.buffer.length) {
|
||||
this.port.postMessage(this.buffer, [this.buffer.buffer]);
|
||||
this.buffer = new Float32Array(2048);
|
||||
this.offset = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('eveflow-capture', EveFlowCaptureProcessor);
|
||||
`;
|
||||
|
||||
export type CaptureMode = 'auto' | 'manual';
|
||||
|
||||
export interface CaptureCallbacks {
|
||||
onLevel?: (level: number) => void;
|
||||
onSpeechStart?: () => void;
|
||||
onUtterance: (wav: WavResult) => void;
|
||||
onEnd: (reason: 'stopped' | 'speech-end' | 'no-speech' | 'too-short' | 'max-length' | 'error') => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface CaptureOptions {
|
||||
deviceId?: string;
|
||||
mode: CaptureMode;
|
||||
vad?: Partial<VadOptions>;
|
||||
callbacks: CaptureCallbacks;
|
||||
}
|
||||
|
||||
export interface MicDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
let workletUrl: string | null = null;
|
||||
|
||||
function getWorkletUrl(): string {
|
||||
if (!workletUrl) workletUrl = URL.createObjectURL(new Blob([WORKLET_SOURCE], { type: 'application/javascript' }));
|
||||
return workletUrl;
|
||||
}
|
||||
|
||||
export async function listMicrophones(): Promise<MicDevice[]> {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices
|
||||
.filter((d) => d.kind === 'audioinput')
|
||||
.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `Microphone ${i + 1}` }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export class MicCapture {
|
||||
private ctx: AudioContext | null = null;
|
||||
private stream: MediaStream | null = null;
|
||||
private worklet: AudioWorkletNode | null = null;
|
||||
private legacy: ScriptProcessorNode | null = null;
|
||||
private chunks: Float32Array[] = [];
|
||||
private totalSamples = 0;
|
||||
private vad: EnergyVad | null = null;
|
||||
private active = false;
|
||||
private options: CaptureOptions | null = null;
|
||||
private sampleRate = 16_000;
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
async start(options: CaptureOptions): Promise<void> {
|
||||
if (this.active) this.stop('stopped');
|
||||
this.options = options;
|
||||
this.chunks = [];
|
||||
this.totalSamples = 0;
|
||||
this.vad = options.mode === 'auto' ? new EnergyVad(options.vad ?? DEFAULT_VAD) : null;
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error("Le microphone n'est pas accessible dans cet environnement (getUserMedia indisponible).");
|
||||
}
|
||||
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: {
|
||||
deviceId: options.deviceId ? { exact: options.deviceId } : undefined,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
channelCount: 1
|
||||
}
|
||||
};
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (err) {
|
||||
const e = err as DOMException;
|
||||
const detail =
|
||||
e.name === 'NotAllowedError'
|
||||
? "Accès au micro refusé. Vérifiez les paramètres de confidentialité Windows (Microphone) et relancez EveFlow."
|
||||
: e.name === 'NotFoundError'
|
||||
? 'Aucun microphone détecté.'
|
||||
: e.name === 'NotReadableError'
|
||||
? 'Le microphone est utilisé par une autre application.'
|
||||
: `${e.name}: ${e.message}`;
|
||||
Log.error('mic', 'getUserMedia failed', { name: e.name, message: e.message });
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
try {
|
||||
this.ctx = new AudioContext({ sampleRate: 16_000, latencyHint: 'interactive' });
|
||||
} catch {
|
||||
this.ctx = new AudioContext({ latencyHint: 'interactive' });
|
||||
}
|
||||
if (this.ctx.state === 'suspended') await this.ctx.resume().catch(() => undefined);
|
||||
this.sampleRate = this.ctx.sampleRate;
|
||||
|
||||
const source = this.ctx.createMediaStreamSource(this.stream);
|
||||
const analyser = this.ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.5;
|
||||
source.connect(analyser);
|
||||
audioBus.setInputAnalyser(analyser);
|
||||
|
||||
try {
|
||||
await this.ctx.audioWorklet.addModule(getWorkletUrl());
|
||||
this.worklet = new AudioWorkletNode(this.ctx, 'eveflow-capture', { numberOfInputs: 1, numberOfOutputs: 0, channelCount: 1 });
|
||||
this.worklet.port.onmessage = (event: MessageEvent<Float32Array>) => this.onSamples(event.data);
|
||||
source.connect(this.worklet);
|
||||
} catch (err) {
|
||||
Log.warn('mic', `AudioWorklet unavailable, falling back to ScriptProcessor: ${(err as Error).message}`);
|
||||
const processor = this.ctx.createScriptProcessor(2048, 1, 1);
|
||||
processor.onaudioprocess = (e) => this.onSamples(new Float32Array(e.inputBuffer.getChannelData(0)));
|
||||
const mute = this.ctx.createGain();
|
||||
mute.gain.value = 0; // Chrome requires a destination path, but we never want to hear the mic.
|
||||
source.connect(processor);
|
||||
processor.connect(mute);
|
||||
mute.connect(this.ctx.destination);
|
||||
this.legacy = processor;
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
Log.info('mic', `capture started (${this.sampleRate} Hz, mode=${options.mode})`);
|
||||
}
|
||||
|
||||
private onSamples(samples: Float32Array): void {
|
||||
if (!this.active || !this.options) return;
|
||||
this.chunks.push(samples);
|
||||
this.totalSamples += samples.length;
|
||||
const level = rms(samples);
|
||||
this.options.callbacks.onLevel?.(Math.min(1, level * 6));
|
||||
|
||||
if (this.vad) {
|
||||
const frameMs = (samples.length / this.sampleRate) * 1000;
|
||||
const signal = this.vad.feed(level, frameMs);
|
||||
this.handleVad(signal);
|
||||
} else if (this.totalSamples / this.sampleRate > 120) {
|
||||
this.finishUtterance('max-length');
|
||||
}
|
||||
}
|
||||
|
||||
private handleVad(signal: VadSignal): void {
|
||||
switch (signal) {
|
||||
case 'speech-start':
|
||||
this.options?.callbacks.onSpeechStart?.();
|
||||
break;
|
||||
case 'speech-end':
|
||||
case 'max-length':
|
||||
this.finishUtterance(signal);
|
||||
break;
|
||||
case 'too-short':
|
||||
case 'no-speech':
|
||||
this.stop(signal);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private finishUtterance(reason: 'speech-end' | 'max-length' | 'stopped'): void {
|
||||
const opts = this.options;
|
||||
const chunks = this.chunks;
|
||||
const rate = this.sampleRate;
|
||||
this.teardown();
|
||||
if (!opts) return;
|
||||
if (chunks.length === 0) {
|
||||
opts.callbacks.onEnd('no-speech');
|
||||
return;
|
||||
}
|
||||
const wav = buildWav16k(chunks, rate);
|
||||
Log.info('mic', `utterance captured: ${wav.durationSec.toFixed(2)}s, ${wav.bytes.length} bytes`);
|
||||
if (wav.durationSec < 0.25) {
|
||||
opts.callbacks.onEnd('too-short');
|
||||
return;
|
||||
}
|
||||
opts.callbacks.onUtterance(wav);
|
||||
opts.callbacks.onEnd(reason);
|
||||
}
|
||||
|
||||
/** Stop capture. In manual mode this finalises the utterance; otherwise reports the reason. */
|
||||
stop(reason: 'stopped' | 'no-speech' | 'too-short' = 'stopped'): void {
|
||||
if (!this.active) return;
|
||||
if (reason === 'stopped') {
|
||||
this.finishUtterance('stopped');
|
||||
return;
|
||||
}
|
||||
const opts = this.options;
|
||||
this.teardown();
|
||||
opts?.callbacks.onEnd(reason);
|
||||
}
|
||||
|
||||
/** Abort without producing an utterance. */
|
||||
cancel(): void {
|
||||
if (!this.active) return;
|
||||
this.teardown();
|
||||
this.options?.callbacks.onEnd('stopped');
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
this.active = false;
|
||||
audioBus.setInputAnalyser(null);
|
||||
if (this.worklet) {
|
||||
this.worklet.port.onmessage = null;
|
||||
this.worklet.disconnect();
|
||||
this.worklet = null;
|
||||
}
|
||||
if (this.legacy) {
|
||||
this.legacy.onaudioprocess = null;
|
||||
this.legacy.disconnect();
|
||||
this.legacy = null;
|
||||
}
|
||||
this.stream?.getTracks().forEach((t) => t.stop());
|
||||
this.stream = null;
|
||||
void this.ctx?.close().catch(() => undefined);
|
||||
this.ctx = null;
|
||||
this.chunks = [];
|
||||
this.totalSamples = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Singleton facade over the TTS engine bound to the settings store, exposing speaking state
|
||||
* to the voice and chat stores.
|
||||
*/
|
||||
import { useChat } from '../../state/chat';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { useVoice } from '../../state/voice';
|
||||
import { TtsEngine } from './tts';
|
||||
|
||||
class SpeechFacade {
|
||||
private engine: TtsEngine | null = null;
|
||||
private streaming = false;
|
||||
private streamEnabled = true;
|
||||
|
||||
private get tts(): TtsEngine {
|
||||
if (!this.engine) {
|
||||
const settings = useSettings.getState().settings;
|
||||
this.engine = new TtsEngine(settings.speech);
|
||||
this.engine.onState((state) => {
|
||||
useVoice.getState().setTts(state);
|
||||
const chat = useChat.getState();
|
||||
if (state === 'speaking') chat.setHud('speaking');
|
||||
else if (state === 'idle' && chat.hud === 'speaking') chat.setHud(chat.isSending ? 'thinking' : 'idle');
|
||||
});
|
||||
useSettings.subscribe((s) => this.engine?.updateConfig(s.settings.speech));
|
||||
}
|
||||
return this.engine;
|
||||
}
|
||||
|
||||
init(): void {
|
||||
this.tts;
|
||||
}
|
||||
|
||||
isSpeaking(): boolean {
|
||||
return this.engine?.isActive ?? false;
|
||||
}
|
||||
|
||||
say(text: string): void {
|
||||
if (useSettings.getState().settings.speech.provider === 'off') return;
|
||||
this.tts.speak(text);
|
||||
}
|
||||
|
||||
pushStream(delta: string): void {
|
||||
if (!useSettings.getState().settings.speech.autoSpeak) return;
|
||||
this.streaming = true;
|
||||
if (this.streamEnabled) this.tts.pushStream(delta);
|
||||
}
|
||||
|
||||
endStream(): void {
|
||||
if (this.streaming) this.tts.endStream();
|
||||
this.streaming = false;
|
||||
}
|
||||
|
||||
discardStream(): void {
|
||||
this.streaming = false;
|
||||
this.tts.stop();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.streaming = false;
|
||||
this.engine?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const speech = new SpeechFacade();
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Log } from '../../lib/log';
|
||||
import { httpFetch } from '../../lib/transport';
|
||||
|
||||
export type SttProvider = 'openai-compatible' | 'browser';
|
||||
|
||||
export interface SttConfig {
|
||||
provider: SttProvider;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
language: string; // BCP-47, e.g. fr-FR
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export function sttEndpoint(apiUrl: string): string {
|
||||
const base = apiUrl.trim().replace(/\/+$/, '');
|
||||
if (base.endsWith('/transcriptions')) return base;
|
||||
if (base.endsWith('/audio')) return `${base}/transcriptions`;
|
||||
if (/\/v\d+$/.test(base)) return `${base}/audio/transcriptions`;
|
||||
return `${base}/v1/audio/transcriptions`;
|
||||
}
|
||||
|
||||
/** Transcribe a WAV buffer through any OpenAI-compatible `/v1/audio/transcriptions` endpoint. */
|
||||
export async function transcribeWav(wav: Uint8Array, config: SttConfig): Promise<string> {
|
||||
if (!config.apiUrl.trim()) throw new Error("URL de l'API de transcription non configurée.");
|
||||
const endpoint = sttEndpoint(config.apiUrl);
|
||||
const fields: Record<string, string> = {
|
||||
model: config.model || 'whisper-1',
|
||||
response_format: 'json'
|
||||
};
|
||||
if (config.language) fields.language = config.language.split('-')[0];
|
||||
if (config.prompt) fields.prompt = config.prompt;
|
||||
const headers: Record<string, string> = {};
|
||||
if (config.apiKey.trim()) headers.Authorization = `Bearer ${config.apiKey.trim()}`;
|
||||
|
||||
Log.info('stt', `POST ${endpoint}`, { model: fields.model, bytes: wav.length });
|
||||
const res = await httpFetch({
|
||||
url: endpoint,
|
||||
method: 'POST',
|
||||
headers,
|
||||
multipart: { fields, file: { name: 'audio.wav', type: 'audio/wav', data: wav } },
|
||||
timeoutMs: 90_000
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = res.text ?? '';
|
||||
try {
|
||||
const parsed = JSON.parse(detail) as { error?: { message?: string }; message?: string; detail?: string };
|
||||
detail = parsed.error?.message ?? parsed.message ?? parsed.detail ?? detail;
|
||||
} catch {
|
||||
/* raw text */
|
||||
}
|
||||
throw new Error(`Transcription refusée (HTTP ${res.status}) : ${detail.slice(0, 240)}`);
|
||||
}
|
||||
const text = res.text ?? '';
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { text?: string; transcript?: string; segments?: Array<{ text: string }> };
|
||||
const value = parsed.text ?? parsed.transcript ?? parsed.segments?.map((s) => s.text).join(' ');
|
||||
if (typeof value === 'string') return value.trim();
|
||||
} catch {
|
||||
if (text.trim()) return text.trim();
|
||||
}
|
||||
throw new Error('Réponse de transcription invalide.');
|
||||
}
|
||||
|
||||
/** Web Speech API fallback (Chromium routes it to Google; works only online, quality varies). */
|
||||
export class BrowserRecognizer {
|
||||
private recognition: SpeechRecognition | null = null;
|
||||
|
||||
static isSupported(): boolean {
|
||||
return typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition);
|
||||
}
|
||||
|
||||
start(lang: string, onResult: (text: string) => void, onError: (message: string) => void, onEnd: () => void): void {
|
||||
const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!Ctor) {
|
||||
onError("La reconnaissance vocale du navigateur n'est pas disponible.");
|
||||
return;
|
||||
}
|
||||
this.stop();
|
||||
const rec = new Ctor();
|
||||
rec.lang = lang;
|
||||
rec.continuous = false;
|
||||
rec.interimResults = false;
|
||||
rec.maxAlternatives = 1;
|
||||
rec.onresult = (event) => {
|
||||
const text = event.results[0]?.[0]?.transcript ?? '';
|
||||
onResult(text);
|
||||
};
|
||||
rec.onerror = (event) => onError(event.error === 'not-allowed' ? 'Micro refusé par le navigateur.' : `Reconnaissance: ${event.error}`);
|
||||
rec.onend = () => {
|
||||
this.recognition = null;
|
||||
onEnd();
|
||||
};
|
||||
this.recognition = rec;
|
||||
try {
|
||||
rec.start();
|
||||
} catch (err) {
|
||||
onError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
try {
|
||||
this.recognition?.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.recognition = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Text-to-speech engine with a sentence queue, prefetching and Web Audio playback so the HUD
|
||||
* reacts to the actual waveform. Providers: OpenAI-compatible /v1/audio/speech, system voices,
|
||||
* and the legacy Google Translate endpoint (no key, online only).
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { chunkForSpeech, cleanForSpeech, extractSentences } from '../../lib/text';
|
||||
import { httpFetch } from '../../lib/transport';
|
||||
import { audioBus } from './audioBus';
|
||||
|
||||
export type TtsProvider = 'openai-compatible' | 'system' | 'google-free' | 'off';
|
||||
|
||||
export interface TtsConfig {
|
||||
provider: TtsProvider;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
speed: number; // 0.5 - 2
|
||||
format: 'mp3' | 'wav' | 'opus';
|
||||
systemVoice: string;
|
||||
language: string;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export type TtsState = 'idle' | 'loading' | 'speaking';
|
||||
|
||||
interface QueueItem {
|
||||
text: string;
|
||||
audio?: Promise<AudioBuffer | null>;
|
||||
}
|
||||
|
||||
export function ttsEndpoint(apiUrl: string): string {
|
||||
const base = apiUrl.trim().replace(/\/+$/, '');
|
||||
if (base.endsWith('/speech')) return base;
|
||||
if (base.endsWith('/audio')) return `${base}/speech`;
|
||||
if (/\/v\d+$/.test(base)) return `${base}/audio/speech`;
|
||||
return `${base}/v1/audio/speech`;
|
||||
}
|
||||
|
||||
export class TtsEngine {
|
||||
private queue: QueueItem[] = [];
|
||||
private playing = false;
|
||||
private currentSource: AudioBufferSourceNode | null = null;
|
||||
private currentUtterance: SpeechSynthesisUtterance | null = null;
|
||||
private streamBuffer = '';
|
||||
private generation = 0;
|
||||
private config: TtsConfig;
|
||||
private listeners = new Set<(state: TtsState) => void>();
|
||||
private state: TtsState = 'idle';
|
||||
|
||||
constructor(config: TtsConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
updateConfig(config: TtsConfig): void {
|
||||
this.config = config;
|
||||
audioBus.setVolume(config.volume);
|
||||
}
|
||||
|
||||
onState(listener: (state: TtsState) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.playing || this.queue.length > 0;
|
||||
}
|
||||
|
||||
private setState(state: TtsState): void {
|
||||
if (this.state === state) return;
|
||||
this.state = state;
|
||||
for (const l of this.listeners) l(state);
|
||||
}
|
||||
|
||||
/** Speak a finished text (clears any pending speech first). */
|
||||
speak(text: string): void {
|
||||
this.stop();
|
||||
for (const chunk of chunkForSpeech(text)) this.enqueue(chunk);
|
||||
}
|
||||
|
||||
/** Feed streamed tokens; complete sentences are spoken as soon as they are available. */
|
||||
pushStream(delta: string): void {
|
||||
this.streamBuffer += delta;
|
||||
const { sentences, rest } = extractSentences(this.streamBuffer);
|
||||
this.streamBuffer = rest;
|
||||
for (const sentence of sentences) this.enqueue(sentence);
|
||||
}
|
||||
|
||||
/** Flush the remainder of a streamed message. */
|
||||
endStream(): void {
|
||||
const rest = this.streamBuffer.trim();
|
||||
this.streamBuffer = '';
|
||||
if (rest) for (const chunk of chunkForSpeech(rest)) this.enqueue(chunk);
|
||||
}
|
||||
|
||||
enqueue(text: string): void {
|
||||
if (this.config.provider === 'off') return;
|
||||
const clean = cleanForSpeech(text);
|
||||
if (!clean || !/[\p{L}\p{N}]/u.test(clean)) return;
|
||||
const item: QueueItem = { text: clean };
|
||||
if (this.config.provider !== 'system') item.audio = this.prefetch(clean, this.generation);
|
||||
this.queue.push(item);
|
||||
void this.drain();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.generation++;
|
||||
this.queue = [];
|
||||
this.streamBuffer = '';
|
||||
if (this.currentSource) {
|
||||
try {
|
||||
this.currentSource.onended = null;
|
||||
this.currentSource.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
this.currentSource = null;
|
||||
}
|
||||
if (this.currentUtterance) {
|
||||
this.currentUtterance.onend = null;
|
||||
this.currentUtterance = null;
|
||||
}
|
||||
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel();
|
||||
audioBus.setSyntheticLevel(0);
|
||||
this.playing = false;
|
||||
this.setState('idle');
|
||||
}
|
||||
|
||||
private async drain(): Promise<void> {
|
||||
if (this.playing) return;
|
||||
this.playing = true;
|
||||
const gen = this.generation;
|
||||
while (this.queue.length > 0 && gen === this.generation) {
|
||||
const item = this.queue.shift()!;
|
||||
try {
|
||||
if (item.audio) {
|
||||
this.setState('loading');
|
||||
const buffer = await item.audio;
|
||||
if (gen !== this.generation) break;
|
||||
if (buffer) {
|
||||
this.setState('speaking');
|
||||
await this.playBuffer(buffer, gen);
|
||||
} else {
|
||||
await this.speakSystem(item.text, gen);
|
||||
}
|
||||
} else {
|
||||
this.setState('speaking');
|
||||
await this.speakSystem(item.text, gen);
|
||||
}
|
||||
} catch (err) {
|
||||
Log.warn('tts', `segment failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (gen === this.generation) {
|
||||
this.playing = false;
|
||||
this.setState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
private prefetch(text: string, gen: number): Promise<AudioBuffer | null> {
|
||||
const task = this.config.provider === 'google-free' ? this.fetchGoogle(text) : this.fetchOpenAi(text);
|
||||
return task
|
||||
.then(async (bytes) => {
|
||||
if (gen !== this.generation || !bytes) return null;
|
||||
await audioBus.resume();
|
||||
const copy = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
return audioBus.context.decodeAudioData(copy);
|
||||
})
|
||||
.catch((err) => {
|
||||
Log.warn('tts', `synthesis failed, falling back to system voice: ${(err as Error).message}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchOpenAi(text: string): Promise<Uint8Array> {
|
||||
const { apiUrl, apiKey, model, voice, speed, format } = this.config;
|
||||
if (!apiUrl.trim()) throw new Error('URL TTS non configurée');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (apiKey.trim()) headers.Authorization = `Bearer ${apiKey.trim()}`;
|
||||
const res = await httpFetch({
|
||||
url: ttsEndpoint(apiUrl),
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: model || 'tts-1',
|
||||
input: text,
|
||||
voice: voice || 'alloy',
|
||||
speed: Math.max(0.5, Math.min(2, speed || 1)),
|
||||
response_format: format || 'mp3'
|
||||
}),
|
||||
responseType: 'binary',
|
||||
timeoutMs: 60_000
|
||||
});
|
||||
if (!res.ok || !res.binary) {
|
||||
const body = res.binary ? new TextDecoder().decode(res.binary).slice(0, 200) : '';
|
||||
throw new Error(`HTTP ${res.status} ${body}`);
|
||||
}
|
||||
return res.binary;
|
||||
}
|
||||
|
||||
private async fetchGoogle(text: string): Promise<Uint8Array> {
|
||||
const url = `https://translate.google.com/translate_tts?ie=UTF-8&tl=${encodeURIComponent(this.config.language.split('-')[0] || 'fr')}&client=tw-ob&q=${encodeURIComponent(text.slice(0, 200))}`;
|
||||
const res = await httpFetch({
|
||||
url,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
||||
Referer: 'https://translate.google.com/'
|
||||
},
|
||||
responseType: 'binary',
|
||||
timeoutMs: 15_000
|
||||
});
|
||||
if (!res.ok || !res.binary) throw new Error(`Google TTS HTTP ${res.status}`);
|
||||
return res.binary;
|
||||
}
|
||||
|
||||
private playBuffer(buffer: AudioBuffer, gen: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (gen !== this.generation) return resolve();
|
||||
const ctx = audioBus.context;
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
if (this.config.provider === 'google-free') source.playbackRate.value = Math.max(0.5, Math.min(2, this.config.speed || 1));
|
||||
source.connect(audioBus.output);
|
||||
source.onended = () => {
|
||||
if (this.currentSource === source) this.currentSource = null;
|
||||
resolve();
|
||||
};
|
||||
this.currentSource = source;
|
||||
source.start();
|
||||
});
|
||||
}
|
||||
|
||||
private speakSystem(text: string, gen: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof speechSynthesis === 'undefined' || gen !== this.generation) return resolve();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = this.config.language || 'fr-FR';
|
||||
utterance.rate = Math.max(0.5, Math.min(2, this.config.speed || 1));
|
||||
utterance.pitch = 1;
|
||||
utterance.volume = Math.max(0, Math.min(1, this.config.volume));
|
||||
const voice = this.resolveSystemVoice();
|
||||
if (voice) utterance.voice = voice;
|
||||
let pulse: ReturnType<typeof setInterval> | null = setInterval(() => {
|
||||
audioBus.setSyntheticLevel(0.35 + Math.random() * 0.4);
|
||||
}, 90);
|
||||
const finish = () => {
|
||||
if (pulse) clearInterval(pulse);
|
||||
pulse = null;
|
||||
audioBus.setSyntheticLevel(0);
|
||||
if (this.currentUtterance === utterance) this.currentUtterance = null;
|
||||
resolve();
|
||||
};
|
||||
utterance.onend = finish;
|
||||
utterance.onerror = finish;
|
||||
this.currentUtterance = utterance;
|
||||
speechSynthesis.speak(utterance);
|
||||
});
|
||||
}
|
||||
|
||||
private resolveSystemVoice(): SpeechSynthesisVoice | undefined {
|
||||
if (typeof speechSynthesis === 'undefined') return undefined;
|
||||
const voices = speechSynthesis.getVoices();
|
||||
if (this.config.systemVoice) {
|
||||
const exact = voices.find((v) => v.name === this.config.systemVoice);
|
||||
if (exact) return exact;
|
||||
}
|
||||
const lang = (this.config.language || 'fr').toLowerCase().split('-')[0];
|
||||
const candidates = voices.filter((v) => v.lang.toLowerCase().startsWith(lang));
|
||||
return candidates.sort((a, b) => scoreVoice(b) - scoreVoice(a))[0] ?? voices[0];
|
||||
}
|
||||
}
|
||||
|
||||
export function scoreVoice(v: SpeechSynthesisVoice): number {
|
||||
const name = v.name.toLowerCase();
|
||||
let score = 0;
|
||||
if (name.includes('natural') || name.includes('neural') || name.includes('online')) score += 40;
|
||||
if (name.includes('google')) score += 30;
|
||||
if (name.includes('microsoft')) score += 10;
|
||||
if (v.localService) score += 5;
|
||||
return score;
|
||||
}
|
||||
|
||||
export function listSystemVoices(): SpeechSynthesisVoice[] {
|
||||
if (typeof speechSynthesis === 'undefined') return [];
|
||||
return [...speechSynthesis.getVoices()].sort((a, b) => a.lang.localeCompare(b.lang) || scoreVoice(b) - scoreVoice(a));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Energy-based voice activity detector with an adaptive noise floor.
|
||||
* Pure logic (no Web Audio) so it can be unit tested.
|
||||
*/
|
||||
export interface VadOptions {
|
||||
/** Signal must exceed noiseFloor * ratio (and absolute minimum) to count as speech. */
|
||||
speechRatio: number;
|
||||
minRms: number;
|
||||
/** Silence duration that ends an utterance. */
|
||||
silenceMs: number;
|
||||
/** Minimum duration of speech before it is considered a real utterance. */
|
||||
minSpeechMs: number;
|
||||
/** Hard cap on one utterance. */
|
||||
maxUtteranceMs: number;
|
||||
/** Give up if nobody speaks for this long after starting. */
|
||||
noSpeechTimeoutMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_VAD: VadOptions = {
|
||||
speechRatio: 2.6,
|
||||
minRms: 0.012,
|
||||
silenceMs: 900,
|
||||
minSpeechMs: 250,
|
||||
maxUtteranceMs: 45_000,
|
||||
noSpeechTimeoutMs: 9_000
|
||||
};
|
||||
|
||||
export type VadSignal = 'idle' | 'speech-start' | 'speaking' | 'speech-end' | 'too-short' | 'no-speech' | 'max-length';
|
||||
|
||||
export class EnergyVad {
|
||||
private noiseFloor: number;
|
||||
private speaking = false;
|
||||
private speechMs = 0;
|
||||
private silenceMs = 0;
|
||||
private elapsedMs = 0;
|
||||
private calibrationMs = 0;
|
||||
readonly options: VadOptions;
|
||||
|
||||
constructor(options: Partial<VadOptions> = {}) {
|
||||
this.options = { ...DEFAULT_VAD, ...options };
|
||||
this.noiseFloor = this.options.minRms;
|
||||
}
|
||||
|
||||
get isSpeaking(): boolean {
|
||||
return this.speaking;
|
||||
}
|
||||
|
||||
get floor(): number {
|
||||
return this.noiseFloor;
|
||||
}
|
||||
|
||||
/** Feed one analysis frame. Returns the transition that occurred. */
|
||||
feed(frameRms: number, frameMs: number): VadSignal {
|
||||
const o = this.options;
|
||||
this.elapsedMs += frameMs;
|
||||
const threshold = Math.max(o.minRms, this.noiseFloor * o.speechRatio);
|
||||
const loud = frameRms > threshold;
|
||||
|
||||
// Adaptive noise floor: fast during the first 400 ms, slow afterwards, only while not speaking.
|
||||
if (!loud || this.calibrationMs < 400) {
|
||||
const alpha = this.calibrationMs < 400 ? 0.25 : 0.02;
|
||||
this.noiseFloor = this.noiseFloor * (1 - alpha) + frameRms * alpha;
|
||||
this.calibrationMs += frameMs;
|
||||
}
|
||||
|
||||
if (!this.speaking) {
|
||||
if (loud) {
|
||||
this.speechMs += frameMs;
|
||||
if (this.speechMs >= Math.min(120, o.minSpeechMs)) {
|
||||
this.speaking = true;
|
||||
this.silenceMs = 0;
|
||||
return 'speech-start';
|
||||
}
|
||||
} else {
|
||||
this.speechMs = 0;
|
||||
if (this.elapsedMs >= o.noSpeechTimeoutMs) return 'no-speech';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
// speaking
|
||||
this.speechMs += frameMs;
|
||||
if (loud) this.silenceMs = 0;
|
||||
else this.silenceMs += frameMs;
|
||||
|
||||
if (this.speechMs >= o.maxUtteranceMs) return this.finish('max-length');
|
||||
if (this.silenceMs >= o.silenceMs) {
|
||||
const spoken = this.speechMs - this.silenceMs;
|
||||
return this.finish(spoken >= o.minSpeechMs ? 'speech-end' : 'too-short');
|
||||
}
|
||||
return 'speaking';
|
||||
}
|
||||
|
||||
private finish(signal: VadSignal): VadSignal {
|
||||
this.speaking = false;
|
||||
this.speechMs = 0;
|
||||
this.silenceMs = 0;
|
||||
return signal;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.speaking = false;
|
||||
this.speechMs = 0;
|
||||
this.silenceMs = 0;
|
||||
this.elapsedMs = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Voice controller: microphone → VAD → STT → conversation, plus hands-free loop and barge-in.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { useChat } from '../../state/chat';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { useVoice } from '../../state/voice';
|
||||
import { sendMessage } from '../conversation';
|
||||
import { audioBus } from './audioBus';
|
||||
import { listMicrophones, MicCapture } from './capture';
|
||||
import { speech } from './speech';
|
||||
import { BrowserRecognizer, transcribeWav } from './stt';
|
||||
import type { WavResult } from './wav';
|
||||
|
||||
const SENSITIVITY_RATIO: Record<number, number> = { 1: 4.5, 2: 3.4, 3: 2.6, 4: 2.0, 5: 1.6 };
|
||||
const SENSITIVITY_MIN_RMS: Record<number, number> = { 1: 0.03, 2: 0.02, 3: 0.012, 4: 0.008, 5: 0.005 };
|
||||
|
||||
class VoiceController {
|
||||
private capture = new MicCapture();
|
||||
private browser = new BrowserRecognizer();
|
||||
private handsFreeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stopping = false;
|
||||
private unsubscribeVoice: (() => void) | null = null;
|
||||
|
||||
init(): void {
|
||||
void listMicrophones().then((devices) => useVoice.getState().setMicDevices(devices));
|
||||
// Hands-free: re-arm the microphone once the assistant stops speaking.
|
||||
this.unsubscribeVoice = useVoice.subscribe((state, prev) => {
|
||||
if (prev.tts !== 'idle' && state.tts === 'idle' && state.handsFree && state.phase === 'off') {
|
||||
this.scheduleHandsFree(350);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.unsubscribeVoice?.();
|
||||
this.stop();
|
||||
}
|
||||
|
||||
get isListening(): boolean {
|
||||
return useVoice.getState().phase !== 'off';
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
if (this.isListening) this.stop();
|
||||
else void this.start();
|
||||
}
|
||||
|
||||
setHandsFree(on: boolean): void {
|
||||
useVoice.getState().setHandsFree(on);
|
||||
useSettings.getState().update({ voice: { handsFree: on } });
|
||||
if (on && !this.isListening && !speech.isSpeaking()) void this.start();
|
||||
if (!on) {
|
||||
this.clearHandsFreeTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleHandsFree(delay: number): void {
|
||||
this.clearHandsFreeTimer();
|
||||
this.handsFreeTimer = setTimeout(() => {
|
||||
const v = useVoice.getState();
|
||||
if (v.handsFree && v.phase === 'off' && !useChat.getState().isSending && !speech.isSpeaking()) void this.start();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearHandsFreeTimer(): void {
|
||||
if (this.handsFreeTimer) clearTimeout(this.handsFreeTimer);
|
||||
this.handsFreeTimer = null;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const voice = useVoice.getState();
|
||||
if (voice.phase !== 'off') return;
|
||||
const settings = useSettings.getState().settings.voice;
|
||||
voice.setError(null);
|
||||
voice.setInterim('');
|
||||
if (!settings.bargeIn) speech.stop();
|
||||
else speech.stop();
|
||||
useChat.getState().setHud('listening');
|
||||
voice.setPhase('arming');
|
||||
|
||||
if (settings.provider === 'browser') {
|
||||
this.startBrowser(settings.language);
|
||||
return;
|
||||
}
|
||||
|
||||
const sensitivity = Math.min(5, Math.max(1, Math.round(settings.sensitivity))) as 1 | 2 | 3 | 4 | 5;
|
||||
try {
|
||||
await audioBus.resume();
|
||||
await this.capture.start({
|
||||
deviceId: settings.micDeviceId || undefined,
|
||||
mode: settings.captureMode,
|
||||
vad: {
|
||||
silenceMs: settings.silenceMs,
|
||||
speechRatio: SENSITIVITY_RATIO[sensitivity],
|
||||
minRms: SENSITIVITY_MIN_RMS[sensitivity],
|
||||
noSpeechTimeoutMs: useVoice.getState().handsFree ? 20_000 : 9_000
|
||||
},
|
||||
callbacks: {
|
||||
onLevel: (level) => useVoice.getState().setInputLevel(level),
|
||||
onSpeechStart: () => useVoice.getState().setPhase('speech'),
|
||||
onUtterance: (wav) => void this.transcribe(wav),
|
||||
onEnd: (reason) => this.onCaptureEnd(reason),
|
||||
onError: (message) => useVoice.getState().setError(message)
|
||||
}
|
||||
});
|
||||
useVoice.getState().setPhase('listening');
|
||||
this.playChime(true);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
useVoice.getState().setError(message);
|
||||
useVoice.getState().setPhase('off');
|
||||
useChat.getState().setHud('error');
|
||||
useChat.getState().setError(message);
|
||||
Log.error('voice', message);
|
||||
setTimeout(() => useChat.getState().setHud('idle'), 2500);
|
||||
}
|
||||
}
|
||||
|
||||
private startBrowser(lang: string): void {
|
||||
useVoice.getState().setPhase('listening');
|
||||
this.browser.start(
|
||||
lang,
|
||||
(text) => {
|
||||
useVoice.getState().setTranscript(text);
|
||||
if (text.trim()) void sendMessage(text, [], 'voice');
|
||||
},
|
||||
(message) => {
|
||||
useVoice.getState().setError(message);
|
||||
useChat.getState().setError(message);
|
||||
},
|
||||
() => this.onCaptureEnd('stopped')
|
||||
);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopping = true;
|
||||
this.clearHandsFreeTimer();
|
||||
if (useSettings.getState().settings.voice.provider === 'browser') this.browser.stop();
|
||||
else if (this.capture.isActive) this.capture.stop('stopped');
|
||||
else this.onCaptureEnd('stopped');
|
||||
this.stopping = false;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.clearHandsFreeTimer();
|
||||
this.browser.stop();
|
||||
this.capture.cancel();
|
||||
}
|
||||
|
||||
private onCaptureEnd(reason: string): void {
|
||||
const voice = useVoice.getState();
|
||||
voice.setInputLevel(0);
|
||||
if (voice.phase !== 'transcribing') voice.setPhase('off');
|
||||
const chat = useChat.getState();
|
||||
if (chat.hud === 'listening') chat.setHud(chat.isSending ? 'thinking' : 'idle');
|
||||
if (reason === 'no-speech') {
|
||||
if (voice.handsFree && !this.stopping) this.scheduleHandsFree(400);
|
||||
else voice.setError("Aucune parole détectée.");
|
||||
} else if (reason === 'too-short') {
|
||||
if (voice.handsFree && !this.stopping) this.scheduleHandsFree(200);
|
||||
}
|
||||
}
|
||||
|
||||
private async transcribe(wav: WavResult): Promise<void> {
|
||||
const voice = useVoice.getState();
|
||||
voice.setPhase('transcribing');
|
||||
useChat.getState().setHud('thinking');
|
||||
this.playChime(false);
|
||||
const settings = useSettings.getState().settings.voice;
|
||||
try {
|
||||
const text = await transcribeWav(wav.bytes, settings);
|
||||
voice.setTranscript(text);
|
||||
voice.setPhase('off');
|
||||
Log.info('voice', `transcript (${wav.durationSec.toFixed(1)}s): ${text}`);
|
||||
if (text.trim()) {
|
||||
await sendMessage(text, [], 'voice');
|
||||
} else {
|
||||
voice.setError('Transcription vide.');
|
||||
useChat.getState().setHud('idle');
|
||||
if (voice.handsFree) this.scheduleHandsFree(300);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
voice.setError(message);
|
||||
voice.setPhase('off');
|
||||
useChat.getState().setError(message);
|
||||
useChat.getState().setHud('error');
|
||||
Log.error('voice', `transcription failed: ${message}`);
|
||||
setTimeout(() => useChat.getState().setHud('idle'), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
private playChime(up: boolean): void {
|
||||
if (!useSettings.getState().settings.voice.wakeChime) return;
|
||||
try {
|
||||
const ctx = audioBus.context;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
const now = ctx.currentTime;
|
||||
osc.frequency.setValueAtTime(up ? 660 : 880, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(up ? 990 : 550, now + 0.12);
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.08, now + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.16);
|
||||
osc.connect(gain);
|
||||
gain.connect(audioBus.output);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.18);
|
||||
} catch {
|
||||
/* audio unavailable */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const voiceController = new VoiceController();
|
||||
@@ -0,0 +1,106 @@
|
||||
/** PCM utilities: merge, resample and encode 16-bit mono WAV. */
|
||||
|
||||
export function mergeChunks(chunks: Float32Array[], totalLength?: number): Float32Array {
|
||||
const length = totalLength ?? chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Float32Array(length);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (offset + chunk.length > length) {
|
||||
out.set(chunk.subarray(0, length - offset), offset);
|
||||
break;
|
||||
}
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Linear-interpolation resampler with a box pre-filter when downsampling. */
|
||||
export function resample(input: Float32Array, fromRate: number, toRate: number): Float32Array {
|
||||
if (fromRate === toRate || input.length === 0) return input;
|
||||
const ratio = fromRate / toRate;
|
||||
const outLength = Math.max(1, Math.round(input.length / ratio));
|
||||
const out = new Float32Array(outLength);
|
||||
if (ratio > 1) {
|
||||
for (let i = 0; i < outLength; i++) {
|
||||
const start = Math.floor(i * ratio);
|
||||
const end = Math.min(input.length, Math.floor((i + 1) * ratio));
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
sum += input[j];
|
||||
count++;
|
||||
}
|
||||
out[i] = count > 0 ? sum / count : input[Math.min(start, input.length - 1)];
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < outLength; i++) {
|
||||
const pos = i * ratio;
|
||||
const idx = Math.floor(pos);
|
||||
const frac = pos - idx;
|
||||
const a = input[Math.min(idx, input.length - 1)];
|
||||
const b = input[Math.min(idx + 1, input.length - 1)];
|
||||
out[i] = a + (b - a) * frac;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function rms(samples: Float32Array): number {
|
||||
if (samples.length === 0) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i];
|
||||
return Math.sqrt(sum / samples.length);
|
||||
}
|
||||
|
||||
/** Peak-normalise quiet recordings so ASR models get a healthy signal. */
|
||||
export function normalizeGain(samples: Float32Array, targetPeak = 0.9): Float32Array {
|
||||
let peak = 0;
|
||||
for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i]));
|
||||
if (peak === 0 || peak >= targetPeak * 0.8) return samples;
|
||||
const gain = Math.min(8, targetPeak / peak);
|
||||
const out = new Float32Array(samples.length);
|
||||
for (let i = 0; i < samples.length; i++) out[i] = samples[i] * gain;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encodeWav(samples: Float32Array, sampleRate: number): Uint8Array {
|
||||
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
const writeString = (offset: number, text: string) => {
|
||||
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
|
||||
};
|
||||
writeString(0, 'RIFF');
|
||||
view.setUint32(4, 36 + samples.length * 2, true);
|
||||
writeString(8, 'WAVE');
|
||||
writeString(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(36, 'data');
|
||||
view.setUint32(40, samples.length * 2, true);
|
||||
let offset = 44;
|
||||
for (let i = 0; i < samples.length; i++, offset += 2) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
}
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
export interface WavResult {
|
||||
bytes: Uint8Array;
|
||||
sampleRate: number;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
/** Build a 16 kHz mono WAV (the format every OpenAI-compatible ASR accepts) from captured chunks. */
|
||||
export function buildWav16k(chunks: Float32Array[], captureRate: number): WavResult {
|
||||
const merged = mergeChunks(chunks);
|
||||
const resampled = resample(merged, captureRate, 16_000);
|
||||
const normalized = normalizeGain(resampled);
|
||||
return { bytes: encodeWav(normalized, 16_000), sampleRate: 16_000, durationSec: normalized.length / 16_000 };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { create } from 'zustand';
|
||||
import { uid } from '../lib/id';
|
||||
import type { HermesUsage } from '../services/hermes/types';
|
||||
|
||||
export type HudState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'alert' | 'error' | 'success';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
images?: string[];
|
||||
source?: string; // eveflow | telegram | cron | webhook ...
|
||||
timestamp: number;
|
||||
status?: 'streaming' | 'done' | 'error' | 'cancelled';
|
||||
usage?: HermesUsage;
|
||||
transport?: string;
|
||||
jobName?: string;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
kind: 'tool' | 'subagent' | 'system' | 'job';
|
||||
name: string;
|
||||
status: 'running' | 'done' | 'error';
|
||||
detail?: string;
|
||||
output?: string;
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
}
|
||||
|
||||
export interface PendingRequest {
|
||||
id: string; // requestId or synthetic
|
||||
requestId: string;
|
||||
runId: string;
|
||||
kind: 'approval' | 'clarify' | 'input';
|
||||
title: string;
|
||||
description: string;
|
||||
options?: string[];
|
||||
tool?: string;
|
||||
args?: string;
|
||||
secret?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface ChatStore {
|
||||
messages: ChatMessage[];
|
||||
activity: Activity[];
|
||||
pending: PendingRequest[];
|
||||
isSending: boolean;
|
||||
currentRunId: string | null;
|
||||
hud: HudState;
|
||||
hudOverride: HudState | null;
|
||||
error: string | null;
|
||||
draft: string;
|
||||
|
||||
addMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'> & Partial<Pick<ChatMessage, 'id' | 'timestamp'>>) => string;
|
||||
updateMessage: (id: string, patch: Partial<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => void;
|
||||
appendToMessage: (id: string, delta: string, field?: 'content' | 'reasoning') => void;
|
||||
removeMessage: (id: string) => void;
|
||||
clear: () => void;
|
||||
pushActivity: (activity: Omit<Activity, 'id' | 'startedAt'> & Partial<Pick<Activity, 'id' | 'startedAt'>>) => string;
|
||||
finishActivity: (match: { id?: string; name?: string }, patch: Partial<Activity>) => void;
|
||||
addPending: (request: Omit<PendingRequest, 'id' | 'createdAt'>) => void;
|
||||
removePending: (id: string) => void;
|
||||
setSending: (sending: boolean, runId?: string | null) => void;
|
||||
setHud: (state: HudState) => void;
|
||||
setHudOverride: (state: HudState | null) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setDraft: (draft: string) => void;
|
||||
}
|
||||
|
||||
const MAX_MESSAGES = 400;
|
||||
const MAX_ACTIVITY = 120;
|
||||
|
||||
export const useChat = create<ChatStore>((set, get) => ({
|
||||
messages: [],
|
||||
activity: [],
|
||||
pending: [],
|
||||
isSending: false,
|
||||
currentRunId: null,
|
||||
hud: 'idle',
|
||||
hudOverride: null,
|
||||
error: null,
|
||||
draft: '',
|
||||
|
||||
addMessage: (message) => {
|
||||
const id = message.id ?? uid('msg');
|
||||
const entry: ChatMessage = { ...message, id, timestamp: message.timestamp ?? Date.now() };
|
||||
set((s) => ({ messages: [...s.messages.slice(-MAX_MESSAGES), entry] }));
|
||||
return id;
|
||||
},
|
||||
updateMessage: (id, patch) =>
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) => (m.id === id ? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) } : m))
|
||||
})),
|
||||
appendToMessage: (id, delta, field = 'content') =>
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) => (m.id === id ? { ...m, [field]: (m[field] ?? '') + delta } : m))
|
||||
})),
|
||||
removeMessage: (id) => set((s) => ({ messages: s.messages.filter((m) => m.id !== id) })),
|
||||
clear: () => set({ messages: [], activity: [], pending: [], error: null }),
|
||||
|
||||
pushActivity: (activity) => {
|
||||
const id = activity.id ?? uid('act');
|
||||
const entry: Activity = { ...activity, id, startedAt: activity.startedAt ?? Date.now() };
|
||||
set((s) => ({ activity: [...s.activity.slice(-MAX_ACTIVITY), entry] }));
|
||||
return id;
|
||||
},
|
||||
finishActivity: (match, patch) =>
|
||||
set((s) => {
|
||||
const list = [...s.activity];
|
||||
let index = -1;
|
||||
if (match.id) index = list.findIndex((a) => a.id === match.id);
|
||||
if (index === -1 && match.name) {
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
if (list[i].name === match.name && list[i].status === 'running') {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index === -1) return {};
|
||||
list[index] = { ...list[index], endedAt: Date.now(), ...patch };
|
||||
return { activity: list };
|
||||
}),
|
||||
|
||||
addPending: (request) =>
|
||||
set((s) => ({
|
||||
pending: [...s.pending.filter((p) => p.requestId !== request.requestId || !request.requestId), { ...request, id: uid('req'), createdAt: Date.now() }]
|
||||
})),
|
||||
removePending: (id) => set((s) => ({ pending: s.pending.filter((p) => p.id !== id && p.requestId !== id) })),
|
||||
|
||||
setSending: (isSending, runId = null) => set({ isSending, currentRunId: isSending ? runId : null }),
|
||||
setHud: (hud) => {
|
||||
if (get().hud !== hud) set({ hud });
|
||||
},
|
||||
setHudOverride: (hudOverride) => set({ hudOverride }),
|
||||
setError: (error) => set({ error }),
|
||||
setDraft: (draft) => set({ draft })
|
||||
}));
|
||||
@@ -0,0 +1,217 @@
|
||||
import { create } from 'zustand';
|
||||
import type { WebhookStatus } from '../../shared/ipc';
|
||||
import { Log } from '../lib/log';
|
||||
import { persistGet, persistSet } from '../lib/persist';
|
||||
import { HermesClient, jobOutput, jobStatus, resolveTransport, type ResolvedTransport } from '../services/hermes/client';
|
||||
import type {
|
||||
HermesCapabilities,
|
||||
HermesHealth,
|
||||
HermesJob,
|
||||
HermesJobDraft,
|
||||
HermesModel,
|
||||
HermesSession,
|
||||
HermesSkill,
|
||||
HermesToolset
|
||||
} from '../services/hermes/types';
|
||||
import { useSettings } from './settings';
|
||||
|
||||
export type LinkState = 'unknown' | 'checking' | 'online' | 'degraded' | 'offline';
|
||||
|
||||
export interface JobRun {
|
||||
id: string;
|
||||
jobId: string;
|
||||
jobName: string;
|
||||
status: 'ok' | 'failed';
|
||||
output: string;
|
||||
at: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
interface HermesStore {
|
||||
link: LinkState;
|
||||
linkDetail: string;
|
||||
capabilities: HermesCapabilities | null;
|
||||
health: HermesHealth | null;
|
||||
models: HermesModel[];
|
||||
skills: HermesSkill[];
|
||||
toolsets: HermesToolset[];
|
||||
sessions: HermesSession[];
|
||||
jobs: HermesJob[];
|
||||
jobRuns: JobRun[];
|
||||
transport: ResolvedTransport;
|
||||
lastSyncAt: number | null;
|
||||
webhook: WebhookStatus | null;
|
||||
busy: boolean;
|
||||
|
||||
client: () => HermesClient;
|
||||
connect: () => Promise<void>;
|
||||
refreshJobs: () => Promise<void>;
|
||||
refreshSessions: () => Promise<void>;
|
||||
refreshCatalog: () => Promise<void>;
|
||||
refreshWebhook: () => Promise<void>;
|
||||
createJob: (draft: HermesJobDraft) => Promise<void>;
|
||||
updateJob: (id: string, patch: Partial<HermesJobDraft>) => Promise<void>;
|
||||
jobAction: (id: string, action: 'pause' | 'resume' | 'run' | 'delete') => Promise<void>;
|
||||
markRunRead: (id: string) => void;
|
||||
loadCache: () => Promise<void>;
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'eveflow.hermes.cache.v2';
|
||||
const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase());
|
||||
|
||||
function runFromJob(job: HermesJob): JobRun | null {
|
||||
const output = jobOutput(job);
|
||||
const status = String(job.last_status ?? '').toLowerCase();
|
||||
if (!output || !job.last_run_at || !isTerminal(status || 'ok')) return null;
|
||||
const at = String(job.last_run_at);
|
||||
return {
|
||||
id: `${job.id}:${at}`,
|
||||
jobId: job.id,
|
||||
jobName: job.name || job.id,
|
||||
status: status.includes('fail') || status === 'error' ? 'failed' : 'ok',
|
||||
output,
|
||||
at,
|
||||
read: false
|
||||
};
|
||||
}
|
||||
|
||||
export const useHermes = create<HermesStore>((set, get) => ({
|
||||
link: 'unknown',
|
||||
linkDetail: '',
|
||||
capabilities: null,
|
||||
health: null,
|
||||
models: [],
|
||||
skills: [],
|
||||
toolsets: [],
|
||||
sessions: [],
|
||||
jobs: [],
|
||||
jobRuns: [],
|
||||
transport: 'completions',
|
||||
lastSyncAt: null,
|
||||
webhook: null,
|
||||
busy: false,
|
||||
|
||||
client: () => new HermesClient(useSettings.getState().settings.hermes),
|
||||
|
||||
connect: async () => {
|
||||
const config = useSettings.getState().settings.hermes;
|
||||
if (!config.url.trim()) {
|
||||
set({ link: 'offline', linkDetail: 'URL Hermes non configurée' });
|
||||
return;
|
||||
}
|
||||
set({ link: 'checking', linkDetail: '' });
|
||||
const client = get().client();
|
||||
try {
|
||||
const health = await client.health();
|
||||
let capabilities: HermesCapabilities | null = null;
|
||||
try {
|
||||
capabilities = await client.capabilities();
|
||||
} catch (err) {
|
||||
Log.warn('hermes', `capabilities unavailable: ${(err as Error).message}`);
|
||||
}
|
||||
const transport = resolveTransport(config, capabilities);
|
||||
const degraded = String(health.status ?? 'ok').toLowerCase() !== 'ok';
|
||||
set({ health, capabilities, transport, link: degraded ? 'degraded' : 'online', linkDetail: degraded ? `état ${health.status}` : '' });
|
||||
Log.info('hermes', `connected (${transport})`, { status: health.status, model: capabilities?.model });
|
||||
void get().refreshCatalog();
|
||||
void get().refreshJobs();
|
||||
void get().refreshSessions();
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
set({ link: 'offline', linkDetail: message, transport: resolveTransport(config, null) });
|
||||
Log.warn('hermes', `connection failed: ${message}`);
|
||||
}
|
||||
},
|
||||
|
||||
refreshCatalog: async () => {
|
||||
const client = get().client();
|
||||
const [models, skills, toolsets] = await Promise.all([
|
||||
client.models().catch(() => [] as HermesModel[]),
|
||||
client.skills().catch(() => [] as HermesSkill[]),
|
||||
client.toolsets().catch(() => [] as HermesToolset[])
|
||||
]);
|
||||
set({ models, skills, toolsets });
|
||||
},
|
||||
|
||||
refreshSessions: async () => {
|
||||
try {
|
||||
const sessions = await get().client().listSessions();
|
||||
set({ sessions });
|
||||
} catch (err) {
|
||||
Log.debug('hermes', `sessions unavailable: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
|
||||
refreshJobs: async () => {
|
||||
if (!useSettings.getState().settings.hermes.url.trim()) return;
|
||||
try {
|
||||
const jobs = await get().client().listJobs();
|
||||
const previous = get().jobRuns;
|
||||
const known = new Map(previous.map((r) => [r.id, r]));
|
||||
const incoming = jobs.map(runFromJob).filter((r): r is JobRun => !!r).map((r) => ({ ...r, read: known.get(r.id)?.read ?? false }));
|
||||
const merged = [...incoming, ...previous.filter((r) => !incoming.some((i) => i.id === r.id))]
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
.slice(0, 100);
|
||||
set({ jobs, jobRuns: merged, lastSyncAt: Date.now(), link: get().link === 'offline' ? 'online' : get().link });
|
||||
persistSet(CACHE_KEY, { jobs, jobRuns: merged, syncedAt: Date.now() });
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
Log.warn('hermes', `jobs sync failed: ${message}`);
|
||||
if (/HTTP 404/.test(message)) return; // jobs API disabled on this server
|
||||
set({ link: 'offline', linkDetail: message });
|
||||
}
|
||||
},
|
||||
|
||||
refreshWebhook: async () => {
|
||||
const api = window.eveflow;
|
||||
if (!api) return;
|
||||
try {
|
||||
set({ webhook: await api.hermes.webhookStatus() });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
createJob: async (draft) => {
|
||||
set({ busy: true });
|
||||
try {
|
||||
await get().client().createJob(draft);
|
||||
await get().refreshJobs();
|
||||
} finally {
|
||||
set({ busy: false });
|
||||
}
|
||||
},
|
||||
updateJob: async (id, patch) => {
|
||||
set({ busy: true });
|
||||
try {
|
||||
await get().client().updateJob(id, patch);
|
||||
await get().refreshJobs();
|
||||
} finally {
|
||||
set({ busy: false });
|
||||
}
|
||||
},
|
||||
jobAction: async (id, action) => {
|
||||
set({ busy: true });
|
||||
const client = get().client();
|
||||
try {
|
||||
if (action === 'pause') await client.pauseJob(id);
|
||||
else if (action === 'resume') await client.resumeJob(id);
|
||||
else if (action === 'run') await client.runJob(id);
|
||||
else await client.deleteJob(id);
|
||||
await get().refreshJobs();
|
||||
} finally {
|
||||
set({ busy: false });
|
||||
}
|
||||
},
|
||||
markRunRead: (id) => {
|
||||
const jobRuns = get().jobRuns.map((r) => (r.id === id ? { ...r, read: true } : r));
|
||||
set({ jobRuns });
|
||||
persistSet(CACHE_KEY, { jobs: get().jobs, jobRuns, syncedAt: get().lastSyncAt });
|
||||
},
|
||||
loadCache: async () => {
|
||||
const cache = await persistGet<{ jobs?: HermesJob[]; jobRuns?: JobRun[]; syncedAt?: number }>(CACHE_KEY);
|
||||
if (cache) set({ jobs: cache.jobs ?? [], jobRuns: cache.jobRuns ?? [], lastSyncAt: cache.syncedAt ?? null });
|
||||
}
|
||||
}));
|
||||
|
||||
export { jobStatus };
|
||||
@@ -0,0 +1,192 @@
|
||||
import { create } from 'zustand';
|
||||
import { persistGet, persistSet } from '../lib/persist';
|
||||
import { uid } from '../lib/id';
|
||||
import type { HermesConfig } from '../services/hermes/types';
|
||||
import type { SttConfig } from '../services/voice/stt';
|
||||
import type { TtsConfig } from '../services/voice/tts';
|
||||
|
||||
export type HudTheme = 'arc' | 'gold' | 'crimson' | 'emerald';
|
||||
|
||||
export interface VoiceSettings extends SttConfig {
|
||||
/** auto = VAD auto-stop, manual = click to stop */
|
||||
captureMode: 'auto' | 'manual';
|
||||
handsFree: boolean;
|
||||
bargeIn: boolean;
|
||||
micDeviceId: string;
|
||||
silenceMs: number;
|
||||
sensitivity: number; // 1 (low) .. 5 (high)
|
||||
wakeChime: boolean;
|
||||
}
|
||||
|
||||
export interface SpeechSettings extends TtsConfig {
|
||||
autoSpeak: boolean;
|
||||
speakIncoming: boolean;
|
||||
}
|
||||
|
||||
export interface WebhookSettings {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
version: 2;
|
||||
assistantName: string;
|
||||
userName: string;
|
||||
theme: HudTheme;
|
||||
language: string;
|
||||
hermes: HermesConfig;
|
||||
voice: VoiceSettings;
|
||||
speech: SpeechSettings;
|
||||
webhook: WebhookSettings;
|
||||
ui: {
|
||||
showTelemetry: boolean;
|
||||
showReasoning: boolean;
|
||||
reduceMotion: boolean;
|
||||
compactOpacity: number;
|
||||
};
|
||||
hermesSessionId: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
version: 2,
|
||||
assistantName: 'JARVIS',
|
||||
userName: '',
|
||||
theme: 'arc',
|
||||
language: 'fr-FR',
|
||||
hermes: {
|
||||
url: 'http://127.0.0.1:8642',
|
||||
apiKey: '',
|
||||
model: '',
|
||||
sessionKey: 'eveflow-desktop',
|
||||
transport: 'auto',
|
||||
reasoningEffort: '',
|
||||
instructions:
|
||||
"Tu es l'interface vocale EveFlow (style JARVIS). Réponds en français, de façon concise et orale quand la question est simple; utilise le Markdown uniquement pour le contenu structuré (code, listes, tableaux). Les images doivent être des URL http(s) ou des fichiers du dossier partagé.",
|
||||
localTools: true
|
||||
},
|
||||
voice: {
|
||||
provider: 'openai-compatible',
|
||||
apiUrl: 'http://127.0.0.1:8000/v1',
|
||||
apiKey: '',
|
||||
model: 'Qwen/Qwen3-ASR-0.6B',
|
||||
language: 'fr-FR',
|
||||
captureMode: 'auto',
|
||||
handsFree: false,
|
||||
bargeIn: false,
|
||||
micDeviceId: '',
|
||||
silenceMs: 900,
|
||||
sensitivity: 3,
|
||||
wakeChime: true
|
||||
},
|
||||
speech: {
|
||||
provider: 'openai-compatible',
|
||||
apiUrl: 'http://127.0.0.1:8000/v1',
|
||||
apiKey: '',
|
||||
model: 'tts-1',
|
||||
voice: 'alloy',
|
||||
speed: 1.05,
|
||||
format: 'mp3',
|
||||
systemVoice: '',
|
||||
language: 'fr-FR',
|
||||
volume: 1,
|
||||
autoSpeak: true,
|
||||
speakIncoming: true
|
||||
},
|
||||
webhook: { enabled: true, port: 7842, secret: '' },
|
||||
ui: { showTelemetry: true, showReasoning: false, reduceMotion: false, compactOpacity: 0.92 },
|
||||
hermesSessionId: ''
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'eveflow.settings.v2';
|
||||
|
||||
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
|
||||
|
||||
function merge<T extends object>(base: T, patch: DeepPartial<T> | null | undefined): T {
|
||||
if (!patch) return base;
|
||||
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const current = (base as Record<string, unknown>)[key];
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && current && typeof current === 'object' && !Array.isArray(current)) {
|
||||
out[key] = merge(current as object, value as DeepPartial<object>);
|
||||
} else if (value !== undefined) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
/** Import the settings of the 1.x releases (flat keys in the store). */
|
||||
async function migrateLegacy(): Promise<DeepPartial<Settings>> {
|
||||
const patch: DeepPartial<Settings> = { hermes: {}, voice: {}, speech: {} };
|
||||
const legacyConfig = await persistGet<string | Record<string, string>>('eveflow_agent_config');
|
||||
const parsed = typeof legacyConfig === 'string' ? (JSON.parse(legacyConfig) as Record<string, string>) : legacyConfig;
|
||||
if (parsed) {
|
||||
if (parsed.hermesUrl) patch.hermes!.url = parsed.hermesUrl;
|
||||
if (parsed.hermesApiKey) patch.hermes!.apiKey = parsed.hermesApiKey;
|
||||
if (parsed.hermesModel && parsed.hermesModel !== 'hermes-agent') patch.hermes!.model = parsed.hermesModel;
|
||||
if (parsed.hermesSessionKey) patch.hermes!.sessionKey = parsed.hermesSessionKey;
|
||||
}
|
||||
const read = async (key: string) => {
|
||||
const v = await persistGet<string>(key);
|
||||
return typeof v === 'string' ? v : null;
|
||||
};
|
||||
const sttUrl = await read('eveflow_stt_api_url');
|
||||
if (sttUrl) patch.voice!.apiUrl = sttUrl;
|
||||
const sttKey = await read('eveflow_stt_api_key');
|
||||
if (sttKey) patch.voice!.apiKey = sttKey;
|
||||
const sttModel = await read('eveflow_stt_model');
|
||||
if (sttModel) patch.voice!.model = sttModel;
|
||||
const ttsUrl = await read('eveflow_tts_api_url');
|
||||
if (ttsUrl) patch.speech!.apiUrl = ttsUrl;
|
||||
const ttsKey = await read('eveflow_tts_api_key');
|
||||
if (ttsKey) patch.speech!.apiKey = ttsKey;
|
||||
const ttsModel = await read('eveflow_tts_model');
|
||||
if (ttsModel) patch.speech!.model = ttsModel;
|
||||
const ttsProvider = await read('eveflow_tts_provider');
|
||||
if (ttsProvider === 'system' || ttsProvider === 'google-free') patch.speech!.provider = ttsProvider;
|
||||
const sessionId = await read('eveflow_hermes_session_id');
|
||||
if (sessionId) patch.hermesSessionId = sessionId;
|
||||
return patch;
|
||||
}
|
||||
|
||||
interface SettingsStore {
|
||||
settings: Settings;
|
||||
loaded: boolean;
|
||||
load: () => Promise<void>;
|
||||
update: (patch: DeepPartial<Settings>) => void;
|
||||
reset: () => void;
|
||||
setHermesSessionId: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useSettings = create<SettingsStore>((set, get) => ({
|
||||
settings: DEFAULT_SETTINGS,
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
let saved = await persistGet<DeepPartial<Settings>>(STORAGE_KEY);
|
||||
if (!saved) {
|
||||
try {
|
||||
saved = await migrateLegacy();
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
}
|
||||
const merged = merge(DEFAULT_SETTINGS, saved);
|
||||
if (!merged.hermesSessionId) merged.hermesSessionId = uid('eveflow');
|
||||
set({ settings: merged, loaded: true });
|
||||
persistSet(STORAGE_KEY, merged);
|
||||
},
|
||||
update: (patch) => {
|
||||
const next = merge(get().settings, patch);
|
||||
set({ settings: next });
|
||||
persistSet(STORAGE_KEY, next);
|
||||
},
|
||||
reset: () => {
|
||||
const next = { ...DEFAULT_SETTINGS, hermesSessionId: uid('eveflow') };
|
||||
set({ settings: next });
|
||||
persistSet(STORAGE_KEY, next);
|
||||
},
|
||||
setHermesSessionId: (id) => get().update({ hermesSessionId: id })
|
||||
}));
|
||||
|
||||
export const selectSettings = (s: SettingsStore) => s.settings;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
import type { TtsState } from '../services/voice/tts';
|
||||
|
||||
export type ListenPhase = 'off' | 'arming' | 'listening' | 'speech' | 'transcribing';
|
||||
|
||||
interface VoiceStore {
|
||||
phase: ListenPhase;
|
||||
inputLevel: number;
|
||||
tts: TtsState;
|
||||
handsFree: boolean;
|
||||
lastTranscript: string;
|
||||
interim: string;
|
||||
error: string | null;
|
||||
micDevices: Array<{ deviceId: string; label: string }>;
|
||||
setPhase: (phase: ListenPhase) => void;
|
||||
setInputLevel: (level: number) => void;
|
||||
setTts: (state: TtsState) => void;
|
||||
setHandsFree: (on: boolean) => void;
|
||||
setTranscript: (text: string) => void;
|
||||
setInterim: (text: string) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setMicDevices: (devices: Array<{ deviceId: string; label: string }>) => void;
|
||||
}
|
||||
|
||||
export const useVoice = create<VoiceStore>((set) => ({
|
||||
phase: 'off',
|
||||
inputLevel: 0,
|
||||
tts: 'idle',
|
||||
handsFree: false,
|
||||
lastTranscript: '',
|
||||
interim: '',
|
||||
error: null,
|
||||
micDevices: [],
|
||||
setPhase: (phase) => set({ phase }),
|
||||
setInputLevel: (inputLevel) => set({ inputLevel }),
|
||||
setTts: (tts) => set({ tts }),
|
||||
setHandsFree: (handsFree) => set({ handsFree }),
|
||||
setTranscript: (lastTranscript) => set({ lastTranscript }),
|
||||
setInterim: (interim) => set({ interim }),
|
||||
setError: (error) => set({ error }),
|
||||
setMicDevices: (micDevices) => set({ micDevices })
|
||||
}));
|
||||
@@ -0,0 +1,701 @@
|
||||
/* ── Shell ─────────────────────────────────────────────────────────────── */
|
||||
.hud-root {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: 46px 1fr;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(1200px 700px at 50% 40%, rgba(var(--accent-rgb), 0.08), transparent 60%),
|
||||
linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 100%);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
color: var(--ink-1);
|
||||
}
|
||||
|
||||
.hud-root::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(var(--accent-rgb), 0.045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(var(--accent-rgb), 0.045) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.9), transparent 78%);
|
||||
}
|
||||
|
||||
.hud-root::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.012) 0 1px, transparent 1px 3px);
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
.hud-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 1.15fr) minmax(420px, 1fr) minmax(300px, 0.8fr);
|
||||
gap: 14px;
|
||||
padding: 12px 14px 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.hud-body {
|
||||
grid-template-columns: minmax(340px, 1fr) minmax(380px, 1fr);
|
||||
}
|
||||
.hud-body > .ops-column {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hud-column {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Panels ────────────────────────────────────────────────────────────── */
|
||||
.panel {
|
||||
position: relative;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
backdrop-filter: blur(14px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel.bracket::before,
|
||||
.panel.bracket::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-color: var(--accent);
|
||||
border-style: solid;
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
.panel.bracket::before {
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
border-width: 2px 0 0 2px;
|
||||
border-radius: var(--radius) 0 0 0;
|
||||
}
|
||||
.panel.bracket::after {
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
border-width: 0 2px 2px 0;
|
||||
border-radius: 0 0 var(--radius) 0;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.panel-head .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.panel-head .led {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
.panel-head .led.off {
|
||||
background: var(--ink-3);
|
||||
box-shadow: none;
|
||||
}
|
||||
.panel-head .led.warn {
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 10px var(--warn);
|
||||
}
|
||||
.panel-head .led.error {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 10px var(--danger);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
color: var(--ink-0);
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), box-shadow var(--transition), transform var(--transition), border-color var(--transition);
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: rgba(var(--accent-rgb), 0.18);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn.primary {
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.35), rgba(var(--accent-rgb), 0.18));
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn.danger {
|
||||
border-color: rgba(var(--danger-rgb), 0.6);
|
||||
background: rgba(var(--danger-rgb), 0.12);
|
||||
color: #ffd5da;
|
||||
}
|
||||
.btn.danger:hover:not(:disabled) {
|
||||
background: rgba(var(--danger-rgb), 0.25);
|
||||
box-shadow: 0 0 20px rgba(var(--danger-rgb), 0.3);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
border-color: var(--line);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn.small {
|
||||
padding: 4px 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
color: var(--ink-0);
|
||||
border-color: var(--line);
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
}
|
||||
.icon-btn.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--line-strong);
|
||||
background: rgba(var(--accent-rgb), 0.14);
|
||||
box-shadow: inset 0 0 12px rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
.icon-btn.danger:hover {
|
||||
color: var(--danger);
|
||||
border-color: rgba(var(--danger-rgb), 0.5);
|
||||
background: rgba(var(--danger-rgb), 0.12);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Chips ─────────────────────────────────────────────────────────────── */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(var(--accent-rgb), 0.06);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink-3);
|
||||
}
|
||||
.chip.online .dot {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px var(--success);
|
||||
}
|
||||
.chip.checking .dot,
|
||||
.chip.degraded .dot {
|
||||
background: var(--gold);
|
||||
box-shadow: 0 0 8px var(--gold);
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
.chip.offline .dot {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 8px var(--danger);
|
||||
}
|
||||
.chip.accent {
|
||||
color: var(--accent);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
.chip.warn {
|
||||
color: var(--warn);
|
||||
border-color: rgba(255, 171, 64, 0.5);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Top bar ───────────────────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 10px 0 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.06), transparent);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.topbar > * {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.topbar .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.3em;
|
||||
font-size: 13px;
|
||||
color: var(--ink-0);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.topbar .brand .core-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent), inset 0 0 6px var(--accent);
|
||||
}
|
||||
.topbar .brand small {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--ink-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
.topbar .status-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.topbar .status-strip > * {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.topbar .clock {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.topbar .controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.topbar .controls .icon-btn.close:hover {
|
||||
color: #fff;
|
||||
background: rgba(var(--danger-rgb), 0.5);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Core stage ────────────────────────────────────────────────────────── */
|
||||
.core-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.core-stage canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.core-canvas-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.core-caption {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.core-state {
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 14px var(--accent-glow);
|
||||
}
|
||||
.core-state.thinking {
|
||||
color: var(--gold);
|
||||
text-shadow: 0 0 14px rgba(var(--gold-rgb), 0.6);
|
||||
}
|
||||
.core-state.error,
|
||||
.core-state.alert {
|
||||
color: var(--danger);
|
||||
text-shadow: 0 0 14px rgba(var(--danger-rgb), 0.6);
|
||||
}
|
||||
.core-state.success {
|
||||
color: var(--success);
|
||||
}
|
||||
.core-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink-2);
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.core-name {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.5em;
|
||||
padding-left: 0.5em;
|
||||
color: var(--ink-0);
|
||||
text-shadow: 0 0 22px var(--accent-glow);
|
||||
pointer-events: none;
|
||||
}
|
||||
.core-name small {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.35em;
|
||||
color: var(--ink-3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Voice status pill ─────────────────────────────────────────────────── */
|
||||
.voice-error,
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(var(--danger-rgb), 0.45);
|
||||
background: rgba(var(--danger-rgb), 0.1);
|
||||
color: #ffd5da;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.error-banner button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Overlay / modal ───────────────────────────────────────────────────── */
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(2, 6, 12, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
animation: fade-in 160ms ease-out;
|
||||
}
|
||||
.modal {
|
||||
width: min(560px, 92%);
|
||||
max-height: 86%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.7), var(--shadow-glow);
|
||||
animation: rise-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.modal.wide {
|
||||
width: min(820px, 94%);
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Forms ─────────────────────────────────────────────────────────────── */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.field label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.field .hint {
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(2, 8, 14, 0.7);
|
||||
color: var(--ink-0);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
.input:focus,
|
||||
.select:focus,
|
||||
.textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
.textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
}
|
||||
.select option {
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.row.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 14px;
|
||||
}
|
||||
.switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.switch .text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.switch .text small {
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink-3);
|
||||
transition: transform var(--transition), background var(--transition);
|
||||
}
|
||||
.toggle.on {
|
||||
background: rgba(var(--accent-rgb), 0.3);
|
||||
}
|
||||
.toggle.on::after {
|
||||
transform: translateX(18px);
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
.range {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.seg button {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg button.active {
|
||||
background: rgba(var(--accent-rgb), 0.2);
|
||||
color: var(--ink-0);
|
||||
}
|
||||
|
||||
/* ── Misc ──────────────────────────────────────────────────────────────── */
|
||||
.empty {
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
margin: 10px 0;
|
||||
}
|
||||
.kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 4px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1.2s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.pulse {
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
.boot-screen {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-0);
|
||||
color: var(--accent);
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.4em;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
/* ── Transcript ────────────────────────────────────────────────────────── */
|
||||
.chat-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.chat-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 14px 14px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.chat-list.compact {
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 92%;
|
||||
animation: msg-in 220ms ease-out;
|
||||
}
|
||||
@keyframes msg-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
.msg.user {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.msg.assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.msg.system {
|
||||
align-self: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
.msg-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.msg-meta .who {
|
||||
color: var(--accent);
|
||||
}
|
||||
.msg.user .msg-meta .who {
|
||||
color: var(--ink-1);
|
||||
}
|
||||
.msg-meta .src {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.msg-meta .src.telegram {
|
||||
color: #6cc7ff;
|
||||
border-color: rgba(108, 199, 255, 0.5);
|
||||
}
|
||||
.msg-meta .src.cron {
|
||||
color: var(--gold);
|
||||
border-color: rgba(var(--gold-rgb), 0.5);
|
||||
}
|
||||
.msg-meta .src.voice {
|
||||
color: var(--success);
|
||||
border-color: rgba(var(--success-rgb), 0.5);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(var(--accent-rgb), 0.05);
|
||||
color: var(--ink-0);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.55;
|
||||
user-select: text;
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg.user .bubble {
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.22), rgba(var(--accent-rgb), 0.12));
|
||||
border-color: var(--line-strong);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.msg.assistant .bubble {
|
||||
border-bottom-left-radius: 4px;
|
||||
box-shadow: inset 2px 0 0 rgba(var(--accent-rgb), 0.5);
|
||||
}
|
||||
.msg.assistant .bubble.streaming::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
margin-left: 4px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
animation: blink 0.9s steps(2) infinite;
|
||||
}
|
||||
.msg.assistant .bubble.error {
|
||||
border-color: rgba(var(--danger-rgb), 0.5);
|
||||
box-shadow: inset 2px 0 0 var(--danger);
|
||||
color: #ffd5da;
|
||||
}
|
||||
.msg.assistant .bubble.cancelled {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.msg.system .bubble {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
color: var(--ink-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.msg-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.msg-images img,
|
||||
.md img {
|
||||
max-width: 100%;
|
||||
max-height: 260px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.msg-images img {
|
||||
max-height: 140px;
|
||||
}
|
||||
.img-error {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border: 1px dashed rgba(var(--danger-rgb), 0.5);
|
||||
border-radius: 6px;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.reasoning {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 10px;
|
||||
border-left: 2px solid var(--gold);
|
||||
background: rgba(var(--gold-rgb), 0.06);
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.reasoning summary {
|
||||
cursor: pointer;
|
||||
color: var(--gold);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
.msg:hover .msg-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.msg-actions .icon-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
/* ── Markdown ──────────────────────────────────────────────────────────── */
|
||||
.md p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.md p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.md h1,
|
||||
.md h2,
|
||||
.md h3,
|
||||
.md h4 {
|
||||
margin: 12px 0 6px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.md ul,
|
||||
.md ol {
|
||||
margin: 4px 0 8px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
.md li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.md a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--accent);
|
||||
}
|
||||
.md code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
color: var(--ink-0);
|
||||
}
|
||||
.md pre {
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(2, 8, 14, 0.85);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.md pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.md blockquote {
|
||||
margin: 8px 0;
|
||||
padding: 6px 12px;
|
||||
border-left: 2px solid var(--accent);
|
||||
color: var(--ink-2);
|
||||
background: rgba(var(--accent-rgb), 0.05);
|
||||
}
|
||||
.md table {
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.md th,
|
||||
.md td {
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.md th {
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.md hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* ── Command bar ───────────────────────────────────────────────────────── */
|
||||
.command-bar {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 10px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: linear-gradient(180deg, transparent, rgba(var(--accent-rgb), 0.04));
|
||||
}
|
||||
.attachments {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.attachment {
|
||||
position: relative;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-strong);
|
||||
}
|
||||
.attachment img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.attachment button {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.command-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.command-input {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
max-height: 160px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: rgba(2, 8, 14, 0.75);
|
||||
color: var(--ink-0);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
.command-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12), var(--shadow-glow);
|
||||
}
|
||||
.command-input::placeholder {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.command-input.steer {
|
||||
border-color: rgba(var(--gold-rgb), 0.7);
|
||||
}
|
||||
|
||||
.mic-btn,
|
||||
.send-btn {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
color: var(--ink-0);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.mic-btn:hover,
|
||||
.send-btn:hover {
|
||||
background: rgba(var(--accent-rgb), 0.22);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
.send-btn.primary {
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.5), rgba(var(--accent-rgb), 0.25));
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.send-btn.stop {
|
||||
border-color: rgba(var(--danger-rgb), 0.7);
|
||||
background: rgba(var(--danger-rgb), 0.18);
|
||||
color: #ffd5da;
|
||||
}
|
||||
.mic-btn.listening {
|
||||
border-color: var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.28);
|
||||
box-shadow: 0 0 0 calc(4px + var(--level, 0) * 18px) rgba(var(--accent-rgb), 0.18), 0 0 24px var(--accent-glow);
|
||||
}
|
||||
.mic-btn.speech {
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
.mic-btn.transcribing {
|
||||
border-color: var(--gold);
|
||||
color: var(--gold);
|
||||
}
|
||||
.mic-btn.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.command-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.command-foot .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.command-foot .toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--ink-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.command-foot .toggle-btn:hover {
|
||||
border-color: var(--line);
|
||||
color: var(--ink-1);
|
||||
}
|
||||
.command-foot .toggle-btn.on {
|
||||
color: var(--accent);
|
||||
border-color: var(--line-strong);
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
}
|
||||
|
||||
/* ── Pending requests ──────────────────────────────────────────────────── */
|
||||
.request-card {
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.request-card .title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--warn);
|
||||
}
|
||||
.request-card .desc {
|
||||
font-size: 14px;
|
||||
color: var(--ink-0);
|
||||
white-space: pre-wrap;
|
||||
user-select: text;
|
||||
}
|
||||
.request-card pre {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(2, 8, 14, 0.85);
|
||||
border: 1px solid var(--line);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
user-select: text;
|
||||
}
|
||||
.request-card .actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.compact-root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(5, 12, 22, var(--compact-opacity, 0.92)), rgba(2, 6, 12, var(--compact-opacity, 0.92)));
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 30px rgba(var(--accent-rgb), 0.12);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.compact-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 14px;
|
||||
-webkit-app-region: drag;
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--ink-0);
|
||||
}
|
||||
.compact-head > * {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.compact-head .title {
|
||||
flex: 1;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.compact-core {
|
||||
position: relative;
|
||||
height: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.compact-core canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.compact-core .core-caption {
|
||||
bottom: 6px;
|
||||
}
|
||||
.compact-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.compact-body .command-bar {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
.compact-body .command-input {
|
||||
min-height: 40px;
|
||||
font-size: 14px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
.compact-body .bubble {
|
||||
font-size: 13px;
|
||||
padding: 8px 11px;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/* ── Ops panel (tabs) ──────────────────────────────────────────────────── */
|
||||
.ops-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 2px;
|
||||
padding: 4px 6px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ink-3);
|
||||
font-family: var(--font-display);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
.tab:hover {
|
||||
color: var(--ink-1);
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.tab .badge {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.row-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(var(--accent-rgb), 0.04);
|
||||
font-size: 13px;
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
}
|
||||
.row-item:hover {
|
||||
border-color: var(--line-strong);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
}
|
||||
.row-item.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-item .main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.row-item .name {
|
||||
font-weight: 600;
|
||||
color: var(--ink-0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row-item .meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.row-item .desc {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.row-item .actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.row-item .actions .icon-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.status-pill.running {
|
||||
color: var(--gold);
|
||||
border-color: rgba(var(--gold-rgb), 0.6);
|
||||
animation: blink 1.2s infinite;
|
||||
}
|
||||
.status-pill.done,
|
||||
.status-pill.ok,
|
||||
.status-pill.active,
|
||||
.status-pill.online {
|
||||
color: var(--success);
|
||||
border-color: rgba(var(--success-rgb), 0.5);
|
||||
}
|
||||
.status-pill.error,
|
||||
.status-pill.failed,
|
||||
.status-pill.offline {
|
||||
color: var(--danger);
|
||||
border-color: rgba(var(--danger-rgb), 0.5);
|
||||
}
|
||||
.status-pill.paused {
|
||||
color: var(--warn);
|
||||
border-color: rgba(255, 171, 64, 0.5);
|
||||
}
|
||||
|
||||
/* ── Activity feed ─────────────────────────────────────────────────────── */
|
||||
.activity-item {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 6px 4px;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.activity-item .icon {
|
||||
color: var(--ink-3);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.activity-item.running .icon {
|
||||
color: var(--gold);
|
||||
}
|
||||
.activity-item.done .icon {
|
||||
color: var(--success);
|
||||
}
|
||||
.activity-item.error .icon {
|
||||
color: var(--danger);
|
||||
}
|
||||
.activity-item .name {
|
||||
color: var(--ink-0);
|
||||
font-weight: 600;
|
||||
}
|
||||
.activity-item .detail {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.activity-item .time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* ── Telemetry ─────────────────────────────────────────────────────────── */
|
||||
.telemetry {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.gauge svg {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
}
|
||||
.gauge .value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink-0);
|
||||
}
|
||||
.gauge .label {
|
||||
font-size: 9px;
|
||||
}
|
||||
.telemetry-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 14px 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--ink-3);
|
||||
letter-spacing: 0.08em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Jobs editor ───────────────────────────────────────────────────────── */
|
||||
.job-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--accent-rgb), 0.05);
|
||||
}
|
||||
.job-editor .row {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Health grid ───────────────────────────────────────────────────────── */
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.kv dt {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
padding-top: 2px;
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-1);
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.section-title::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
.settings-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 15;
|
||||
width: min(560px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel-strong);
|
||||
border-left: 1px solid var(--line-strong);
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: -30px 0 80px rgba(0, 0, 0, 0.55);
|
||||
animation: slide-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.settings-drawer .panel-head {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.settings-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.settings-nav button {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--ink-3);
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.settings-nav button.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--line-strong);
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
}
|
||||
.settings-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 18px 24px;
|
||||
}
|
||||
.settings-body .card {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--accent-rgb), 0.04);
|
||||
}
|
||||
.test-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.test-result.ok {
|
||||
color: var(--success);
|
||||
border-color: rgba(var(--success-rgb), 0.5);
|
||||
}
|
||||
.test-result.fail {
|
||||
color: var(--danger);
|
||||
border-color: rgba(var(--danger-rgb), 0.5);
|
||||
}
|
||||
.theme-swatches {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.swatch {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 0 0 3px var(--bg-0);
|
||||
}
|
||||
.swatch.active {
|
||||
border-color: #fff;
|
||||
}
|
||||
.level-meter {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.level-meter > div {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--success));
|
||||
transition: width 60ms linear;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@400;500;600;700&family=Share+Tech+Mono&display=swap');
|
||||
|
||||
:root {
|
||||
--font-display: 'Orbitron', 'Rajdhani', 'Segoe UI', sans-serif;
|
||||
--font-ui: 'Rajdhani', 'Segoe UI', 'Helvetica Neue', sans-serif;
|
||||
--font-mono: 'Share Tech Mono', 'Cascadia Mono', 'Consolas', monospace;
|
||||
|
||||
--bg-0: #02060c;
|
||||
--bg-1: #050c16;
|
||||
--bg-2: #081422;
|
||||
--bg-3: #0c1c2e;
|
||||
--ink-0: #e9fbff;
|
||||
--ink-1: #b9e8f5;
|
||||
--ink-2: #7fb6c9;
|
||||
--ink-3: #4d7d91;
|
||||
|
||||
--accent: #34e4ff;
|
||||
--accent-rgb: 52, 228, 255;
|
||||
--accent-soft: rgba(52, 228, 255, 0.14);
|
||||
--accent-line: rgba(52, 228, 255, 0.32);
|
||||
--accent-glow: rgba(52, 228, 255, 0.55);
|
||||
--gold: #ffc35a;
|
||||
--gold-rgb: 255, 195, 90;
|
||||
--danger: #ff5c6c;
|
||||
--danger-rgb: 255, 92, 108;
|
||||
--success: #4ef0a8;
|
||||
--success-rgb: 78, 240, 168;
|
||||
--warn: #ffab40;
|
||||
|
||||
--panel: rgba(6, 16, 28, 0.72);
|
||||
--panel-strong: rgba(6, 16, 28, 0.92);
|
||||
--line: rgba(52, 228, 255, 0.18);
|
||||
--line-strong: rgba(52, 228, 255, 0.42);
|
||||
--radius: 10px;
|
||||
--shadow-glow: 0 0 24px rgba(var(--accent-rgb), 0.25);
|
||||
--transition: 180ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
|
||||
:root[data-theme='gold'] {
|
||||
--accent: #ffc35a;
|
||||
--accent-rgb: 255, 195, 90;
|
||||
--accent-soft: rgba(255, 195, 90, 0.14);
|
||||
--accent-line: rgba(255, 195, 90, 0.32);
|
||||
--accent-glow: rgba(255, 195, 90, 0.55);
|
||||
--line: rgba(255, 195, 90, 0.18);
|
||||
--line-strong: rgba(255, 195, 90, 0.42);
|
||||
--ink-1: #f6e7c8;
|
||||
--ink-2: #c9ad7a;
|
||||
--ink-3: #8a7247;
|
||||
--bg-1: #0d0a05;
|
||||
--bg-2: #171106;
|
||||
--bg-3: #221a0a;
|
||||
--panel: rgba(20, 14, 6, 0.72);
|
||||
--panel-strong: rgba(20, 14, 6, 0.92);
|
||||
}
|
||||
|
||||
:root[data-theme='crimson'] {
|
||||
--accent: #ff5c6c;
|
||||
--accent-rgb: 255, 92, 108;
|
||||
--accent-soft: rgba(255, 92, 108, 0.14);
|
||||
--accent-line: rgba(255, 92, 108, 0.32);
|
||||
--accent-glow: rgba(255, 92, 108, 0.55);
|
||||
--line: rgba(255, 92, 108, 0.18);
|
||||
--line-strong: rgba(255, 92, 108, 0.42);
|
||||
--ink-1: #ffd9dd;
|
||||
--ink-2: #c98d95;
|
||||
--ink-3: #8a4f56;
|
||||
--bg-1: #0d0507;
|
||||
--bg-2: #17080b;
|
||||
--bg-3: #240c11;
|
||||
--panel: rgba(22, 7, 10, 0.72);
|
||||
--panel-strong: rgba(22, 7, 10, 0.92);
|
||||
}
|
||||
|
||||
:root[data-theme='emerald'] {
|
||||
--accent: #4ef0a8;
|
||||
--accent-rgb: 78, 240, 168;
|
||||
--accent-soft: rgba(78, 240, 168, 0.14);
|
||||
--accent-line: rgba(78, 240, 168, 0.32);
|
||||
--accent-glow: rgba(78, 240, 168, 0.55);
|
||||
--line: rgba(78, 240, 168, 0.18);
|
||||
--line-strong: rgba(78, 240, 168, 0.42);
|
||||
--ink-1: #d3f7e6;
|
||||
--ink-2: #86c4a6;
|
||||
--ink-3: #4a7f66;
|
||||
--bg-1: #040d09;
|
||||
--bg-2: #07170f;
|
||||
--bg-3: #0b2217;
|
||||
--panel: rgba(5, 18, 12, 0.72);
|
||||
--panel-strong: rgba(5, 18, 12, 0.92);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 15px;
|
||||
color: var(--ink-1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(var(--accent-rgb), 0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(var(--accent-rgb), 0.45);
|
||||
}
|
||||
|
||||
:root[data-motion='reduce'] * {
|
||||
animation-duration: 0.001s !important;
|
||||
transition-duration: 0.001s !important;
|
||||
}
|
||||
Vendored
+46
-11
@@ -1,14 +1,49 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
electronAPI?: {
|
||||
windowControl: (action: 'minimize' | 'maximize' | 'close') => void;
|
||||
setWindowMode: (mode: 'floating' | 'normal') => void;
|
||||
setCockpitMode: (isOpen: boolean) => 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>;
|
||||
};
|
||||
import type { EveFlowBridge } from '../shared/bridge';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
eveflow?: EveFlowBridge;
|
||||
SpeechRecognition?: SpeechRecognitionConstructor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionAlternative {
|
||||
transcript: string;
|
||||
confidence: number;
|
||||
}
|
||||
interface SpeechRecognitionResult {
|
||||
readonly length: number;
|
||||
readonly isFinal: boolean;
|
||||
[index: number]: SpeechRecognitionAlternative;
|
||||
}
|
||||
interface SpeechRecognitionResultList {
|
||||
readonly length: number;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
}
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
readonly resultIndex: number;
|
||||
readonly results: SpeechRecognitionResultList;
|
||||
}
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
readonly error: string;
|
||||
readonly message: string;
|
||||
}
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
maxAlternatives: number;
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onstart: (() => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
type SpeechRecognitionConstructor = new () => SpeechRecognition;
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in new issue
Block a user