mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5217eee53a | ||
|
|
221bb7062c | ||
|
|
57690962e1 | ||
|
|
cf52a610d4 | ||
|
|
3a52cf3ae0 | ||
|
|
5c6c3a9e30 | ||
|
|
6955721bdb | ||
|
|
c25875d8be | ||
|
|
7c10d633ca | ||
|
|
115996c7d9 | ||
|
|
033b93563d | ||
|
|
17d00b085b | ||
|
|
33cd8156f9 | ||
|
|
f4cfd6a12d | ||
|
|
125322ae07 | ||
|
|
874502daef | ||
|
|
2d4b642184 | ||
|
|
79f757bf41 | ||
|
|
81039e659d | ||
|
|
9dc19db942 | ||
|
|
af479df65a | ||
|
|
47fb80602f | ||
|
|
d2d8fe4f7c | ||
|
|
6d4499f78f | ||
|
|
17e573911d | ||
|
|
1caa738dfb | ||
|
|
9f03a47078 | ||
|
|
15458a2fbd |
No files matched your search
@@ -0,0 +1,60 @@
|
|||||||
|
name: Windows EXE
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
name: Build Windows installer
|
||||||
|
runs-on: windows-latest
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build Windows EXE
|
||||||
|
run: npx electron-builder --win --publish never
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Upload Windows artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: EveFlow-Windows-${{ github.ref_name }}
|
||||||
|
path: |
|
||||||
|
out/*.exe
|
||||||
|
out/*.exe.blockmap
|
||||||
|
out/latest.yml
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Publish GitHub release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
out/*.exe
|
||||||
|
out/*.exe.blockmap
|
||||||
|
out/latest.yml
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
out/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.log
|
*.log
|
||||||
File renamed without changes.
@@ -0,0 +1,83 @@
|
|||||||
|
# 🚀 EveFlow — Compagnon de Bureau Rétro-Futuriste 3D
|
||||||
|
|
||||||
|
[](https://github.com/R0m1k3/EveFlow)
|
||||||
|
[](https://github.com/R0m1k3/EveFlow/releases/tag/v1.0.8)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
**EveFlow** est un compagnon de bureau Windows immersif haut de gamme, combinant une esthétique cyberpunk rétro-futuriste soignée et des technologies d'intelligence artificielle avancées. Il intègre un assistant virtuel en 3D nommé **Eve**, animé en temps réel avec des expressions émotionnelles dynamiques et synchronisé avec des services de synthèse vocale (TTS/STT) locaux et des architectures multi-agents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📸 Aperçu de l'Interface
|
||||||
|
|
||||||
|
Découvrez le cockpit de contrôle immersif et le mode widget compact d'EveFlow :
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Fonctionnalités Clés
|
||||||
|
|
||||||
|
* **🤖 Avatar 3D Eve Interactif** : Un modèle 3D animé sous WebGL avec des shaders personnalisés pour ses yeux LED. Ses expressions (joie, tristesse, réflexion, colère, surprise, neutre), son clignotement de paupières et ses mouvements de bras s'adaptent de manière organique selon le contexte et la parole.
|
||||||
|
* **🌌 Interface Transparent Glassmorphic Premium** : Fenêtre sans bordure avec un effet de verre dépoli haut de gamme, s'intégrant magnifiquement à votre bureau Windows sans perturber votre espace de travail.
|
||||||
|
* **🔄 Deux Modes d'Affichage Dynamiques** :
|
||||||
|
* **Mode Cockpit** : Interface complète de discussion avec panneau de commande, historique des messages, affichage des logs et télémétrie matérielle avancée.
|
||||||
|
* **Mode Flottant (Widget)** : Une carte compacte translucide toujours au premier plan, plaçant l'avatar d'Eve directement dans le coin de votre écran pour une compagnie discrète.
|
||||||
|
* **🔌 Serveur Webhook Intégré (Hermes)** : Port d'écoute webhook local (`7842`) permettant la réception d'événements et de notifications poussés en temps réel par des agents tiers (comme Telegram ou des scripts d'automatisation).
|
||||||
|
* **🖥️ Télémétrie CPU en Temps Réel** : Suivi des performances de votre ordinateur (fréquence CPU et température thermique calculée selon la charge réelle) directement intégré sous forme d'afficheurs analogiques virtuels futuristes.
|
||||||
|
* **🔒 Sécurité Zero Trust** : Isolation du contexte Chromium activée (`contextIsolation`), interdiction de l'intégration directe de Node dans le renderer et vérification stricte des extensions de fichiers lors des opérations de lecture/écriture de fichiers partagés via IPC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Stack Technique
|
||||||
|
|
||||||
|
* **Framework Applicatif** : [Electron](https://www.electronjs.org/) (Zéro Trust)
|
||||||
|
* **Frontend** : [React](https://react.dev/) + [Vite](https://vitejs.dev/) + [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
* **Moteur 3D** : [Three.js](https://threejs.org/) via [@react-three/fiber](https://github.com/pmndrs/react-three-fiber) et [@react-three/drei](https://github.com/pmndrs/drei)
|
||||||
|
* **Serveur Webhook** : Node.js `http` (Port `7842`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Installation & Lancement en Développement
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
* [Node.js](https://nodejs.org/) (Version 20 recommandée)
|
||||||
|
* [Git](https://git-scm.com/)
|
||||||
|
|
||||||
|
### Étapes d'installation
|
||||||
|
1. **Cloner le dépôt** :
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/R0m1k3/EveFlow.git
|
||||||
|
cd EveFlow
|
||||||
|
```
|
||||||
|
2. **Installer les dépendances** :
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
3. **Lancer l'application en mode développement** :
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
*Cette commande démarre le serveur de développement Vite à l'adresse `http://localhost:5173` puis lance Electron en mode connecté.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Compilation & Packaging (Production)
|
||||||
|
|
||||||
|
Pour générer l'exécutable d'installation Windows (`.exe`) autonome :
|
||||||
|
|
||||||
|
1. **Compiler le bundle frontend** :
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
2. **Générer le package d'installation Windows** :
|
||||||
|
```bash
|
||||||
|
npm run dist
|
||||||
|
```
|
||||||
|
*L'exécutable d'installation NSIS (`EveFlow Setup 1.0.8.exe`) et la build décompressée seront générés dans le dossier `./out/`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 Licence
|
||||||
|
|
||||||
|
Ce projet est sous licence MIT. Consultez le fichier [LICENSE](LICENSE) pour plus de détails.
|
||||||
@@ -1,10 +1,29 @@
|
|||||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
const { app, BrowserWindow, ipcMain, session } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
|
||||||
|
// Autoriser le chargement de fichiers locaux via fetch sous le protocole file://
|
||||||
|
app.commandLine.appendSwitch('disable-web-security');
|
||||||
|
app.commandLine.appendSwitch('allow-file-access-from-files');
|
||||||
|
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
|
|
||||||
|
// ── Single Instance Lock ───────────────────────────────────────────────────
|
||||||
|
const gotTheLock = app.requestSingleInstanceLock();
|
||||||
|
if (!gotTheLock) {
|
||||||
|
app.quit();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Logging Setup ───────────────────────────────────────────────────────────
|
// ── Logging Setup ───────────────────────────────────────────────────────────
|
||||||
function getLogPath() {
|
function getLogPath() {
|
||||||
const userDataDir = app.getPath('userData');
|
const userDataDir = app.getPath('userData');
|
||||||
@@ -42,34 +61,55 @@ let webhookServer = null;
|
|||||||
// Handles multiple formats so EveFlow mirrors both sides of Telegram conversations.
|
// Handles multiple formats so EveFlow mirrors both sides of Telegram conversations.
|
||||||
function normalizeHermesEvents(raw) {
|
function normalizeHermesEvents(raw) {
|
||||||
const src = raw.platform || raw.source || 'webhook';
|
const src = raw.platform || raw.source || 'webhook';
|
||||||
|
const collectImages = (obj) => {
|
||||||
|
const images = [];
|
||||||
|
const push = (value) => {
|
||||||
|
if (!value) return;
|
||||||
|
if (typeof value === 'string') images.push(value);
|
||||||
|
else if (typeof value === 'object') push(value.url || value.href || value.path || value.file || value.file_url || value.media_url);
|
||||||
|
};
|
||||||
|
for (const key of ['image', 'image_url', 'photo', 'media', 'url', 'file', 'file_url', 'media_url']) {
|
||||||
|
push(obj?.[key]);
|
||||||
|
}
|
||||||
|
for (const key of ['images', 'photos', 'media_urls', 'attachments', 'files']) {
|
||||||
|
const value = obj?.[key];
|
||||||
|
if (Array.isArray(value)) value.forEach(push);
|
||||||
|
else push(value);
|
||||||
|
}
|
||||||
|
return [...new Set(images)];
|
||||||
|
};
|
||||||
|
const withImages = (event, obj) => {
|
||||||
|
const images = collectImages(obj);
|
||||||
|
return images.length ? { ...event, images } : event;
|
||||||
|
};
|
||||||
|
|
||||||
// Format A — { type:'message', payload:{ text, role?, platform? } }
|
// Format A — { type:'message', payload:{ text, role?, platform? } }
|
||||||
if (raw.type === 'message' && raw.payload?.text) {
|
if (raw.type === 'message' && raw.payload?.text) {
|
||||||
return [{
|
return [withImages({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
role: raw.payload.role || 'assistant',
|
role: raw.payload.role || 'assistant',
|
||||||
text: raw.payload.text,
|
text: raw.payload.text,
|
||||||
source: raw.payload.platform || src
|
source: raw.payload.platform || src
|
||||||
}];
|
}, raw.payload)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format B — run completed: { event:'run.completed', input?, output, platform? }
|
// Format B — run completed: { event:'run.completed', input?, output, platform? }
|
||||||
if ((raw.event === 'run.completed' || raw.type === 'run.completed') && raw.output) {
|
if ((raw.event === 'run.completed' || raw.type === 'run.completed') && raw.output) {
|
||||||
const events = [];
|
const events = [];
|
||||||
if (raw.input) events.push({ type: 'message', role: 'user', text: raw.input, source: src });
|
if (raw.input) events.push({ type: 'message', role: 'user', text: raw.input, source: src });
|
||||||
events.push({ type: 'message', role: 'assistant', text: raw.output, source: src });
|
events.push(withImages({ type: 'message', role: 'assistant', text: raw.output, source: src }, raw));
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format C — direct: { role, text, source? }
|
// Format C — direct: { role, text, source? }
|
||||||
if (raw.role && raw.text) {
|
if (raw.role && raw.text) {
|
||||||
return [{ type: 'message', role: raw.role, text: raw.text, source: src }];
|
return [withImages({ type: 'message', role: raw.role, text: raw.text, source: src }, raw)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format D — legacy: { type:'user_message'|'assistant_message', text }
|
// Format D — legacy: { type:'user_message'|'assistant_message', text }
|
||||||
if (raw.text && raw.type) {
|
if (raw.text && raw.type) {
|
||||||
const role = raw.type.includes('user') ? 'user' : 'assistant';
|
const role = raw.type.includes('user') ? 'user' : 'assistant';
|
||||||
return [{ type: 'message', role, text: raw.text, source: src }];
|
return [withImages({ type: 'message', role, text: raw.text, source: src }, raw)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passthrough — let renderer decide
|
// Passthrough — let renderer decide
|
||||||
@@ -178,23 +218,34 @@ async function loadApp() {
|
|||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1000,
|
width: 1120,
|
||||||
height: 700,
|
height: 860,
|
||||||
minWidth: 400,
|
minWidth: 900,
|
||||||
minHeight: 500,
|
minHeight: 680,
|
||||||
frame: false, // Supprime la barre de titre classique pour un style rétro-futuriste personnalisé
|
frame: false, // Supprime la barre de titre classique pour un style rétro-futuriste personnalisé
|
||||||
transparent: true, // Permet la transparence (fond de fenêtre transparent)
|
transparent: true, // Permet la transparence (fond de fenêtre transparent)
|
||||||
backgroundColor: '#00000000', // Fond transparent
|
backgroundColor: '#00000000', // Fond transparent
|
||||||
hasShadow: true,
|
hasShadow: true,
|
||||||
|
show: false, // Cache la fenêtre au démarrage pour éviter les problèmes visuels de DWM
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
contextIsolation: true, // Sécurité Zero Trust active
|
contextIsolation: true, // Sécurité Zero Trust active
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
webSecurity: false, // Permet les requêtes HTTP vers API locales/LAN
|
webSecurity: false, // Permet les requêtes HTTP vers API locales/LAN
|
||||||
allowRunningInsecureContent: true, // Autorise HTTP depuis le renderer (API locales)
|
allowRunningInsecureContent: true, // Autorise HTTP depuis le renderer (API locales)
|
||||||
|
backgroundThrottling: false, // Empêche Chromium de suspendre les processus audio/TTS en arrière-plan
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
// Petit délai de sécurité (80ms) pour garantir la synchronisation DWM sous Windows
|
||||||
|
setTimeout(() => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.show();
|
||||||
|
}
|
||||||
|
}, 80);
|
||||||
|
});
|
||||||
|
|
||||||
loadApp().catch((error) => {
|
loadApp().catch((error) => {
|
||||||
console.error("Echec du chargement de l'application.", error);
|
console.error("Echec du chargement de l'application.", error);
|
||||||
loadFallbackPage("Une erreur inattendue a interrompu le chargement de l'interface.");
|
loadFallbackPage("Une erreur inattendue a interrompu le chargement de l'interface.");
|
||||||
@@ -206,6 +257,31 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
// Configurer l'intercepteur de requêtes pour Google TTS pour contourner le blocage HTTP 403 (User-Agent Electron)
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
|
{ urls: ['https://translate.google.com/*'] },
|
||||||
|
(details, callback) => {
|
||||||
|
details.requestHeaders['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
||||||
|
details.requestHeaders['Referer'] = 'https://translate.google.com/';
|
||||||
|
callback({ cancel: false, requestHeaders: details.requestHeaders });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Autoriser l'accès aux périphériques de capture (microphone) sous Electron
|
||||||
|
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||||
|
if (permission === 'media') {
|
||||||
|
return callback(true);
|
||||||
|
}
|
||||||
|
callback(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
|
||||||
|
if (permission === 'media') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
startWebhookServer();
|
startWebhookServer();
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
@@ -247,30 +323,54 @@ ipcMain.on('window-control', (event, action) => {
|
|||||||
ipcMain.on('set-window-mode', (event, mode) => {
|
ipcMain.on('set-window-mode', (event, mode) => {
|
||||||
if (!mainWindow) return;
|
if (!mainWindow) return;
|
||||||
if (mode === 'floating') {
|
if (mode === 'floating') {
|
||||||
// Mode flottant compact : 320x450
|
// Mode flottant compact : 300x600
|
||||||
mainWindow.setResizable(true);
|
mainWindow.setResizable(true);
|
||||||
mainWindow.setMinimumSize(250, 350);
|
mainWindow.setMinimumSize(250, 350);
|
||||||
mainWindow.setSize(300, 400);
|
mainWindow.setSize(300, 600);
|
||||||
mainWindow.setAlwaysOnTop(true, 'screen-saver');
|
mainWindow.setAlwaysOnTop(true, 'screen-saver');
|
||||||
|
|
||||||
// Positionner dans le coin inférieur droit de l'écran principal
|
// Positionner dans le coin inférieur droit de l'écran principal
|
||||||
const { screen } = require('electron');
|
const { screen } = require('electron');
|
||||||
const primaryDisplay = screen.getPrimaryDisplay();
|
const primaryDisplay = screen.getPrimaryDisplay();
|
||||||
const { width, height } = primaryDisplay.workAreaSize;
|
const { width, height } = primaryDisplay.workAreaSize;
|
||||||
mainWindow.setPosition(width - 320, height - 420);
|
mainWindow.setPosition(width - 320, height - 620);
|
||||||
|
|
||||||
event.reply('window-mode-changed', 'floating');
|
event.reply('window-mode-changed', 'floating');
|
||||||
} else {
|
} else {
|
||||||
// Mode normal : chat complet
|
// Mode normal : chat complet
|
||||||
mainWindow.setAlwaysOnTop(false);
|
mainWindow.setAlwaysOnTop(false);
|
||||||
mainWindow.setMinimumSize(400, 500);
|
mainWindow.setMinimumSize(900, 680);
|
||||||
mainWindow.setSize(1000, 700);
|
mainWindow.setSize(1120, 860);
|
||||||
mainWindow.center();
|
mainWindow.center();
|
||||||
|
|
||||||
event.reply('window-mode-changed', 'normal');
|
event.reply('window-mode-changed', 'normal');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// IPC Handler pour ouvrir/fermer le cockpit sans ecraser Eve.
|
||||||
|
ipcMain.on('set-cockpit-mode', (_event, isOpen) => {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
if (mainWindow.isMaximized()) return;
|
||||||
|
|
||||||
|
const { screen } = require('electron');
|
||||||
|
const display = screen.getDisplayMatching(mainWindow.getBounds());
|
||||||
|
const workArea = display.workArea;
|
||||||
|
const bounds = mainWindow.getBounds();
|
||||||
|
const targetWidth = isOpen ? 1460 : 1120;
|
||||||
|
const nextWidth = Math.min(targetWidth, Math.max(900, workArea.width - 40));
|
||||||
|
const nextHeight = Math.min(Math.max(bounds.height, 820), workArea.height - 40);
|
||||||
|
const nextX = Math.min(bounds.x, workArea.x + workArea.width - nextWidth - 20);
|
||||||
|
const nextY = Math.min(bounds.y, workArea.y + workArea.height - nextHeight - 20);
|
||||||
|
|
||||||
|
mainWindow.setMinimumSize(isOpen ? 1180 : 900, 680);
|
||||||
|
mainWindow.setBounds({
|
||||||
|
x: Math.max(workArea.x + 20, nextX),
|
||||||
|
y: Math.max(workArea.y + 20, nextY),
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight
|
||||||
|
}, true);
|
||||||
|
});
|
||||||
|
|
||||||
// ── IPC Handler : écriture des logs du renderer vers disque ─────────────────
|
// ── IPC Handler : écriture des logs du renderer vers disque ─────────────────
|
||||||
ipcMain.on('write-log', (event, entry) => {
|
ipcMain.on('write-log', (event, entry) => {
|
||||||
writeLogEntry(entry);
|
writeLogEntry(entry);
|
||||||
@@ -373,6 +473,108 @@ ipcMain.handle('read-local-file', async (_event, filePath) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handler pour charger une image distante via Node.js (contourne CORS et les restrictions du navigateur Electron)
|
||||||
|
// Retourne une Data URL base64 pour un affichage garanti sans flickering ni erreur CORS
|
||||||
|
ipcMain.handle('fetch-remote-image', async (_event, url, extraHeaders = {}) => {
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `fetch-remote-image: ${url}` });
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
if (!url || typeof url !== 'string' || (!url.startsWith('http://') && !url.startsWith('https://'))) {
|
||||||
|
const err = `URL invalide : ${url}`;
|
||||||
|
writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: err });
|
||||||
|
reject(new Error(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = url.startsWith('https://') ? https : http;
|
||||||
|
let timeoutId;
|
||||||
|
let isSettled = false;
|
||||||
|
const settleReject = (err) => {
|
||||||
|
if (isSettled) return;
|
||||||
|
isSettled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
const settleResolve = (value) => {
|
||||||
|
if (isSettled) return;
|
||||||
|
isSettled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = client.get(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
'Accept': 'image/*, */*',
|
||||||
|
...extraHeaders,
|
||||||
|
},
|
||||||
|
}, (res) => {
|
||||||
|
writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `HTTP ${res.statusCode} — Content-Type: ${res.headers['content-type']} — URL: ${url}` });
|
||||||
|
|
||||||
|
// Gérer les redirects HTTP 301/302
|
||||||
|
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
|
||||||
|
writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `Redirect → ${res.headers.location}` });
|
||||||
|
// Suivre le redirect récursivement
|
||||||
|
const newUrl = res.headers.location;
|
||||||
|
const newClient = newUrl.startsWith('https://') ? https : http;
|
||||||
|
newClient.get(newUrl, { headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'image/*' } }, (res2) => {
|
||||||
|
if (res2.statusCode !== 200) { reject(new Error(`HTTP ${res2.statusCode} après redirect`)); return; }
|
||||||
|
const chunks2 = [];
|
||||||
|
res2.on('data', c => chunks2.push(c));
|
||||||
|
res2.on('end', () => {
|
||||||
|
const buf = Buffer.concat(chunks2);
|
||||||
|
const ct = ((res2.headers['content-type'] || 'image/jpeg').split(';')[0]).trim();
|
||||||
|
settleResolve(`data:${ct};base64,${buf.toString('base64')}`);
|
||||||
|
});
|
||||||
|
}).on('error', settleReject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
const err = `HTTP ${res.statusCode} pour ${url}`;
|
||||||
|
writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: err });
|
||||||
|
res.resume();
|
||||||
|
settleReject(new Error(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', chunk => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const buf = Buffer.concat(chunks);
|
||||||
|
const ct = ((res.headers['content-type'] || 'image/jpeg').split(';')[0]).trim();
|
||||||
|
const dataUrl = `data:${ct};base64,${buf.toString('base64')}`;
|
||||||
|
writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `Image chargée OK — ${buf.length} octets — ${ct}` });
|
||||||
|
settleResolve(dataUrl);
|
||||||
|
} catch (e) {
|
||||||
|
settleReject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on('error', settleReject);
|
||||||
|
});
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
req.destroy();
|
||||||
|
const err = `Timeout (12s) pour ${url}`;
|
||||||
|
writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: err });
|
||||||
|
settleReject(new Error(err));
|
||||||
|
}, 12000);
|
||||||
|
req.on('error', (err) => {
|
||||||
|
writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: `Erreur réseau: ${err.message} — ${url}` });
|
||||||
|
settleReject(err);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: `Exception: ${err.message}` });
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── CPU Telemetry Helper ───────────────────────────────────────────────────
|
// ── CPU Telemetry Helper ───────────────────────────────────────────────────
|
||||||
function getCpuLoad() {
|
function getCpuLoad() {
|
||||||
const cpus = os.cpus();
|
const cpus = os.cpus();
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "1.0.0",
|
"version": "1.0.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "1.0.0",
|
"version": "1.0.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-three/drei": "^9.105.0",
|
"@react-three/drei": "^9.105.0",
|
||||||
"@react-three/fiber": "^8.16.0",
|
"@react-three/fiber": "^8.16.0",
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "1.0.2",
|
"version": "1.0.8",
|
||||||
"description": "Retro-futuristic Windows companion with 3D Eve bot, local TTS/STT, and multi-agent connectivity",
|
"description": "Retro-futuristic Windows companion with 3D Eve bot, local TTS/STT, and multi-agent connectivity",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"appId": "com.romik3.eveflow",
|
"appId": "com.romik3.eveflow",
|
||||||
"productName": "EveFlow",
|
"productName": "EveFlow",
|
||||||
"copyright": "Copyright © 2026 R0m1k3",
|
"copyright": "Copyright © 2026 R0m1k3",
|
||||||
|
"asar": false,
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "out"
|
"output": "out"
|
||||||
},
|
},
|
||||||
@@ -25,7 +26,9 @@
|
|||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
"requestedExecutionLevel": "asInvoker"
|
"requestedExecutionLevel": "asInvoker",
|
||||||
|
"signAndEditExecutable": false,
|
||||||
|
"verifyUpdateCodeSignature": false
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { contextBridge, ipcRenderer } = require('electron');
|
|||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
windowControl: (action) => ipcRenderer.send('window-control', action),
|
windowControl: (action) => ipcRenderer.send('window-control', action),
|
||||||
setWindowMode: (mode) => ipcRenderer.send('set-window-mode', mode),
|
setWindowMode: (mode) => ipcRenderer.send('set-window-mode', mode),
|
||||||
|
setCockpitMode: (isOpen) => ipcRenderer.send('set-cockpit-mode', isOpen),
|
||||||
onWindowModeChanged: (callback) => {
|
onWindowModeChanged: (callback) => {
|
||||||
const subscription = (event, mode) => callback(mode);
|
const subscription = (event, mode) => callback(mode);
|
||||||
ipcRenderer.on('window-mode-changed', subscription);
|
ipcRenderer.on('window-mode-changed', subscription);
|
||||||
@@ -28,4 +29,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
writeSharedFile: (filename, content, isBase64) => ipcRenderer.invoke('write-shared-file', filename, content, isBase64),
|
writeSharedFile: (filename, content, isBase64) => ipcRenderer.invoke('write-shared-file', filename, content, isBase64),
|
||||||
// Ouverture de fichier local
|
// Ouverture de fichier local
|
||||||
openLocalFile: (filePath) => ipcRenderer.invoke('open-local-file', filePath),
|
openLocalFile: (filePath) => ipcRenderer.invoke('open-local-file', filePath),
|
||||||
|
// Chargement d'une image distante via Node.js (contourne CORS \u2014 retourne une Data URL base64)
|
||||||
|
fetchRemoteImage: (url, extraHeaders = {}) => ipcRenderer.invoke('fetch-remote-image', url, extraHeaders),
|
||||||
});
|
});
|
||||||
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 621 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const paths = [
|
||||||
|
path.join(__dirname, 'public', 'Eve', 'EVE.obj'),
|
||||||
|
path.join(__dirname, 'Eve', 'EVE.obj')
|
||||||
|
];
|
||||||
|
|
||||||
|
paths.forEach(p => {
|
||||||
|
const newPath = p + '.bin';
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
fs.renameSync(p, newPath);
|
||||||
|
console.log(`Renamed: ${p} -> ${newPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Not found: ${p}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
+1481
-364
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,7 @@ interface AvatarModelLoaderProps {
|
|||||||
|
|
||||||
type AvatarManifest = Partial<Record<Exclude<EveAvatar, 'eve'>, string | null>>;
|
type AvatarManifest = Partial<Record<Exclude<EveAvatar, 'eve'>, string | null>>;
|
||||||
|
|
||||||
const AVATAR_MANIFEST_PATH = '/avatars/avatar-manifest.json';
|
const AVATAR_MANIFEST_PATH = './avatars/avatar-manifest.json';
|
||||||
|
|
||||||
const LoadedGlbAvatar: React.FC<{
|
const LoadedGlbAvatar: React.FC<{
|
||||||
path: string;
|
path: string;
|
||||||
@@ -22,7 +22,11 @@ const LoadedGlbAvatar: React.FC<{
|
|||||||
isSpeaking: boolean;
|
isSpeaking: boolean;
|
||||||
}> = ({ path, avatarId, emotion, isSpeaking }) => {
|
}> = ({ path, avatarId, emotion, isSpeaking }) => {
|
||||||
const gltf = useLoader(GLTFLoader, path);
|
const gltf = useLoader(GLTFLoader, path);
|
||||||
const scene = useMemo(() => gltf.scene.clone(true), [gltf.scene]);
|
const scene = useMemo(() => {
|
||||||
|
const cloned = gltf.scene.clone(true);
|
||||||
|
cloned.position.y = 0.27;
|
||||||
|
return cloned;
|
||||||
|
}, [gltf.scene]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const style = EmotionService.getEmotionStyle(emotion, avatarId);
|
const style = EmotionService.getEmotionStyle(emotion, avatarId);
|
||||||
@@ -48,7 +52,7 @@ const LoadedGlbAvatar: React.FC<{
|
|||||||
|
|
||||||
useFrame((state) => {
|
useFrame((state) => {
|
||||||
const t = state.clock.getElapsedTime();
|
const t = state.clock.getElapsedTime();
|
||||||
scene.position.y = Math.sin(t * 1.4) * 0.05 + 0.22;
|
scene.position.y = Math.sin(t * 1.4) * 0.05 + 0.27;
|
||||||
scene.rotation.y = Math.sin(t * 0.45) * 0.05;
|
scene.rotation.y = Math.sin(t * 0.45) * 0.05;
|
||||||
|
|
||||||
if (isSpeaking) {
|
if (isSpeaking) {
|
||||||
@@ -58,7 +62,7 @@ const LoadedGlbAvatar: React.FC<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return <primitive object={scene} scale={1} />;
|
return <primitive object={scene} scale={0.48} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AvatarModelLoader: React.FC<AvatarModelLoaderProps> = ({ avatarId, emotion, isSpeaking }) => {
|
export const AvatarModelLoader: React.FC<AvatarModelLoaderProps> = ({ avatarId, emotion, isSpeaking }) => {
|
||||||
@@ -77,8 +81,16 @@ export const AvatarModelLoader: React.FC<AvatarModelLoaderProps> = ({ avatarId,
|
|||||||
})
|
})
|
||||||
.then((manifest) => {
|
.then((manifest) => {
|
||||||
if (cancelled || !manifest) return;
|
if (cancelled || !manifest) return;
|
||||||
const nextAssetPath = manifest[avatarId];
|
let nextAssetPath = manifest[avatarId];
|
||||||
setAssetPath(typeof nextAssetPath === 'string' && nextAssetPath.trim() ? nextAssetPath : null);
|
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(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setAssetPath(null);
|
if (!cancelled) setAssetPath(null);
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
|
|||||||
|
|
||||||
// Charger les matériaux et le modèle OBJ depuis le répertoire public
|
// Charger les matériaux et le modèle OBJ depuis le répertoire public
|
||||||
const materials = useLoader(MTLLoader, './Eve/EVE.mtl');
|
const materials = useLoader(MTLLoader, './Eve/EVE.mtl');
|
||||||
const obj = useLoader(OBJLoader, './Eve/EVE.obj', (loader) => {
|
const obj = useLoader(OBJLoader, './Eve/EVE.obj.bin', (loader) => {
|
||||||
materials.preload();
|
materials.preload();
|
||||||
loader.setMaterials(materials);
|
loader.setMaterials(materials);
|
||||||
});
|
});
|
||||||
@@ -452,8 +452,8 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
|
|||||||
const center = new THREE.Vector3();
|
const center = new THREE.Vector3();
|
||||||
localBox.getCenter(center);
|
localBox.getCenter(center);
|
||||||
|
|
||||||
// Normalize height to exactly 2.4 units in scene space
|
// Normalize height conservatively so the avatar stays inside the right panel.
|
||||||
const targetHeight = 2.4;
|
const targetHeight = 1.18;
|
||||||
const scaleFactor = targetHeight / (size.y || 1);
|
const scaleFactor = targetHeight / (size.y || 1);
|
||||||
clonedObj.scale.setScalar(scaleFactor);
|
clonedObj.scale.setScalar(scaleFactor);
|
||||||
|
|
||||||
@@ -555,8 +555,8 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
|
|||||||
|
|
||||||
// 1. Animation globale de flottaison (Lévitation magnétique)
|
// 1. Animation globale de flottaison (Lévitation magnétique)
|
||||||
if (modelRef.current) {
|
if (modelRef.current) {
|
||||||
// Oscillation en hauteur stabilisée autour de y = 0.22 (remonté pour un centrage élégant)
|
// 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.22;
|
modelRef.current.position.y = Math.sin(t * 1.5) * 0.06 + 0.27;
|
||||||
// Légère rotation sur elle-même (mouvement de veille)
|
// Légère rotation sur elle-même (mouvement de veille)
|
||||||
modelRef.current.rotation.y = Math.sin(t * 0.5) * 0.05;
|
modelRef.current.rotation.y = Math.sin(t * 0.5) * 0.05;
|
||||||
}
|
}
|
||||||
@@ -760,7 +760,7 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={modelRef} scale={1.0} position={[0, 0.22, 0]}>
|
<group ref={modelRef} scale={1.0} position={[0, 0.27, 0]}>
|
||||||
<primitive object={clonedObj} />
|
<primitive object={clonedObj} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|||||||
+580
-10
@@ -1,23 +1,591 @@
|
|||||||
import React, { Suspense } from 'react';
|
import React, { Suspense, useRef } from 'react';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas, useFrame } from '@react-three/fiber';
|
||||||
import { OrbitControls } from '@react-three/drei';
|
import { OrbitControls } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
import { AvatarModelLoader } from './AvatarModelLoader';
|
import { AvatarModelLoader } from './AvatarModelLoader';
|
||||||
import { EveEmotion, EveAvatar } from '../services/emotionService';
|
import { EveEmotion, EveAvatar } from '../services/emotionService';
|
||||||
|
|
||||||
|
export interface ToolEvent {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
status: 'idle' | 'running' | 'done' | 'error';
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ThreeCanvasProps {
|
interface ThreeCanvasProps {
|
||||||
emotion: EveEmotion;
|
emotion: EveEmotion;
|
||||||
isSpeaking: boolean;
|
isSpeaking: boolean;
|
||||||
avatarId?: EveAvatar;
|
avatarId?: EveAvatar;
|
||||||
|
isFloatingMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThreeCanvas: React.FC<ThreeCanvasProps> = ({ emotion, isSpeaking, avatarId = 'eve' }) => {
|
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 (
|
return (
|
||||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [0, 0, 4.6], fov: 45 }}
|
camera={{ position: isFloatingMode ? [0, 0.72, 3.7] : [0, 1.1, 5.8], fov: isFloatingMode ? 30 : 36 }}
|
||||||
gl={{ alpha: true, antialias: true }}
|
gl={{ alpha: true, antialias: true, premultipliedAlpha: false }}
|
||||||
|
onCreated={({ gl }) => {
|
||||||
|
gl.setClearColor(0x000000, 0);
|
||||||
|
}}
|
||||||
dpr={[1, 2]}
|
dpr={[1, 2]}
|
||||||
style={{ background: 'transparent' }}
|
style={{ background: 'transparent', backgroundColor: 'transparent' }}
|
||||||
>
|
>
|
||||||
{/* Soft ambient — dark space base */}
|
{/* Soft ambient — dark space base */}
|
||||||
<ambientLight intensity={0.6} color="#b0c8e8" />
|
<ambientLight intensity={0.6} color="#b0c8e8" />
|
||||||
@@ -30,24 +598,26 @@ export const ThreeCanvas: React.FC<ThreeCanvasProps> = ({ emotion, isSpeaking, a
|
|||||||
color="#e8f4ff"
|
color="#e8f4ff"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cyan atmosphere rim — far enough to avoid harsh specular band on helmet */}
|
{/* Cyan atmosphere rim */}
|
||||||
<pointLight position={[-5, 3, 2]} intensity={1.0} color="#60c8e8" />
|
<pointLight position={[-5, 3, 2]} intensity={1.0} color="#60c8e8" />
|
||||||
|
|
||||||
{/* Desaturated blue fill — subtle depth without coloring the white lacquer */}
|
{/* Desaturated blue fill */}
|
||||||
<pointLight position={[4, -2, 2]} intensity={0.45} color="#8ab4d8" />
|
<pointLight position={[4, -2, 2]} intensity={0.45} color="#8ab4d8" />
|
||||||
|
|
||||||
{/* Soft white back fill to keep geometry readable */}
|
{/* Soft white back fill to keep geometry readable */}
|
||||||
<pointLight position={[0, 3, -4]} intensity={1.0} color="#ffffff" />
|
<pointLight position={[0, 3, -4]} intensity={1.0} color="#ffffff" />
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<TechnicalBackdrop />
|
||||||
|
<HologramProjection emotion={emotion} isSpeaking={isSpeaking} avatarId={avatarId} />
|
||||||
<AvatarModelLoader avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />
|
<AvatarModelLoader avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{/* OrbitControls calibrated to prevent clipping or losing focus */}
|
{/* OrbitControls calibrated to prevent clipping or losing focus */}
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
enableZoom={true}
|
enableZoom={true}
|
||||||
minDistance={2.2}
|
minDistance={isFloatingMode ? 2.4 : 4.8}
|
||||||
maxDistance={5.5}
|
maxDistance={isFloatingMode ? 4.7 : 6.8}
|
||||||
enablePan={false}
|
enablePan={false}
|
||||||
autoRotate={false}
|
autoRotate={false}
|
||||||
maxPolarAngle={Math.PI / 1.7}
|
maxPolarAngle={Math.PI / 1.7}
|
||||||
|
|||||||
+1972
-36
File diff suppressed because it is too large.
Load diff
@@ -47,7 +47,7 @@ export class AgentService {
|
|||||||
avatarId: EveAvatar = 'eve',
|
avatarId: EveAvatar = 'eve',
|
||||||
callbacks?: AppCallbacks,
|
callbacks?: AppCallbacks,
|
||||||
onToken?: (token: string) => void,
|
onToken?: (token: string) => void,
|
||||||
onToolCall?: (toolName: string) => void,
|
onToolCall?: (toolName: string, detail?: string, status?: 'running' | 'done' | 'error') => void,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
onSessionIdChange?: (newSessionId: string) => void,
|
onSessionIdChange?: (newSessionId: string) => void,
|
||||||
images?: string[]
|
images?: string[]
|
||||||
@@ -63,7 +63,18 @@ export class AgentService {
|
|||||||
// inject the avatar character prompt as before.
|
// inject the avatar character prompt as before.
|
||||||
let updatedHistory: { role: 'user' | 'assistant' | 'system'; content: string }[];
|
let updatedHistory: { role: 'user' | 'assistant' | 'system'; content: string }[];
|
||||||
if (config.provider === 'hermes') {
|
if (config.provider === 'hermes') {
|
||||||
updatedHistory = history.map(h => ({ role: h.role as 'user' | 'assistant' | 'system', content: h.content }));
|
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 {
|
} else {
|
||||||
const systemPrompt = this.getSystemPromptForAvatar(avatarId);
|
const systemPrompt = this.getSystemPromptForAvatar(avatarId);
|
||||||
updatedHistory = [
|
updatedHistory = [
|
||||||
@@ -138,7 +149,7 @@ export class AgentService {
|
|||||||
config: AgentConfig,
|
config: AgentConfig,
|
||||||
callbacks?: AppCallbacks,
|
callbacks?: AppCallbacks,
|
||||||
onToken?: (token: string) => void,
|
onToken?: (token: string) => void,
|
||||||
onToolCall?: (toolName: string) => void,
|
onToolCall?: (toolName: string, detail?: string, status?: 'running' | 'done' | 'error') => void,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
onSessionIdChange?: (newSessionId: string) => void,
|
onSessionIdChange?: (newSessionId: string) => void,
|
||||||
images?: string[]
|
images?: string[]
|
||||||
@@ -261,7 +272,7 @@ export class AgentService {
|
|||||||
for (const tc of assistantMsg.tool_calls) {
|
for (const tc of assistantMsg.tool_calls) {
|
||||||
const toolName: string = tc.function?.name || 'unknown';
|
const toolName: string = tc.function?.name || 'unknown';
|
||||||
LogService.info('HermesAgent', `Tool call: ${toolName}`, tc.function?.arguments);
|
LogService.info('HermesAgent', `Tool call: ${toolName}`, tc.function?.arguments);
|
||||||
onToolCall?.(toolName);
|
onToolCall?.(toolName, tc.function?.arguments, 'running');
|
||||||
|
|
||||||
const result = await executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks!);
|
const result = await executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks!);
|
||||||
messages.push(result);
|
messages.push(result);
|
||||||
@@ -295,7 +306,7 @@ export class AgentService {
|
|||||||
onToken: (token: string) => void,
|
onToken: (token: string) => void,
|
||||||
messages: any[],
|
messages: any[],
|
||||||
callbacks: AppCallbacks | undefined,
|
callbacks: AppCallbacks | undefined,
|
||||||
onToolCall: ((name: string) => void) | undefined,
|
onToolCall: ((name: string, detail?: string, status?: 'running' | 'done' | 'error') => void) | undefined,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
model: string
|
model: string
|
||||||
@@ -305,6 +316,7 @@ export class AgentService {
|
|||||||
let fullContent = '';
|
let fullContent = '';
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
const pendingToolCalls: Record<string, any> = {};
|
const pendingToolCalls: Record<string, any> = {};
|
||||||
|
let sseEvent = 'message';
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -315,12 +327,34 @@ export class AgentService {
|
|||||||
buffer = lines.pop() ?? '';
|
buffer = lines.pop() ?? '';
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) {
|
||||||
|
sseEvent = line.slice(7).trim() || 'message';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!line.startsWith('data: ')) continue;
|
if (!line.startsWith('data: ')) continue;
|
||||||
const jsonStr = line.slice(6).trim();
|
const jsonStr = line.slice(6).trim();
|
||||||
if (jsonStr === '[DONE]') break;
|
if (jsonStr === '[DONE]') {
|
||||||
|
sseEvent = 'message';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chunk = JSON.parse(jsonStr);
|
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 delta = chunk.choices?.[0]?.delta;
|
||||||
const finishReason = chunk.choices?.[0]?.finish_reason;
|
const finishReason = chunk.choices?.[0]?.finish_reason;
|
||||||
|
|
||||||
@@ -348,7 +382,7 @@ export class AgentService {
|
|||||||
for (const tc of Object.values(pendingToolCalls) as any[]) {
|
for (const tc of Object.values(pendingToolCalls) as any[]) {
|
||||||
const toolName = tc.function?.name;
|
const toolName = tc.function?.name;
|
||||||
LogService.info('HermesAgent', `[stream] Tool call: ${toolName}`);
|
LogService.info('HermesAgent', `[stream] Tool call: ${toolName}`);
|
||||||
onToolCall?.(toolName);
|
onToolCall?.(toolName, tc.function?.arguments, 'running');
|
||||||
const result = await executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks);
|
const result = await executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks);
|
||||||
messages.push(result);
|
messages.push(result);
|
||||||
}
|
}
|
||||||
|
|||||||
+686
-49
@@ -54,12 +54,98 @@ export class AudioService {
|
|||||||
public ttsSynth: SpeechSynthesis;
|
public ttsSynth: SpeechSynthesis;
|
||||||
private recognition: any = null;
|
private recognition: any = null;
|
||||||
private isListeningActive = false;
|
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() {
|
constructor() {
|
||||||
this.ttsSynth = window.speechSynthesis;
|
this.ttsSynth = window.speechSynthesis;
|
||||||
this.initSTT();
|
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)
|
// Initialisation de la reconnaissance vocale locale et gratuite (Web Speech API)
|
||||||
private initSTT() {
|
private initSTT() {
|
||||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||||
@@ -148,9 +234,15 @@ export class AudioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _cleanForTTS(raw: string): string {
|
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
|
// 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)
|
// Remplacer les puces de liste (ex: - item, * item) par une pause douce (virgule)
|
||||||
let formatted = raw.replace(/(?:\r?\n)\s*[-*+]\s+/g, ', ');
|
let formatted = noCode.replace(/(?:\r?\n)\s*[-*+]\s+/g, ', ');
|
||||||
|
|
||||||
// Remplacer les deux-points suivis d'un saut de ligne par un point
|
// Remplacer les deux-points suivis d'un saut de ligne par un point
|
||||||
formatted = formatted.replace(/:\s*(?:\r?\n)/g, '. ');
|
formatted = formatted.replace(/:\s*(?:\r?\n)/g, '. ');
|
||||||
@@ -218,40 +310,577 @@ export class AudioService {
|
|||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synthèse Vocale complète (annule et relit tout le texte)
|
// Moteur Google TTS gratuit en ligne avec découpage intelligent
|
||||||
public speak(text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1): Promise<void> {
|
private async speakGoogleFree(text: string, rate: number): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
const cleanText = this._cleanForTTS(text);
|
||||||
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
|
if (!cleanText.trim()) return;
|
||||||
this.ttsSynth.cancel();
|
|
||||||
const u = this._makeUtterance(text, voiceName, rate, pitch);
|
const chunks: string[] = [];
|
||||||
u.onend = () => resolve();
|
let currentChunk = '';
|
||||||
u.onerror = (e) => reject(e);
|
|
||||||
this.ttsSynth.speak(u);
|
// 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajoute une phrase à la file TTS sans annuler la lecture en cours
|
// File de traitement asynchrone des phrases de synthèse vocale (Zero Trust & Zero Dependency)
|
||||||
// Utilisé pour le streaming phrase par phrase (démarre l'audio dès la 1ère phrase)
|
private async processQueue(): Promise<void> {
|
||||||
public queueSentence(text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1): void {
|
if (this.isPlayingQueue) return;
|
||||||
if (!this.ttsSynth) 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();
|
const clean = text.trim();
|
||||||
if (!clean) return;
|
if (!clean) return;
|
||||||
const u = this._makeUtterance(clean, voiceName, rate, pitch);
|
this.audioQueue.push({ text: clean, voiceName, rate, pitch, provider });
|
||||||
if (u.text.trim()) {
|
this.processQueue();
|
||||||
this.ttsSynth.speak(u); // s'enchaîne automatiquement à la file SpeechSynthesis
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vide la file TTS (fin du streaming ou interruption utilisateur)
|
// Vide la file TTS (fin du streaming ou interruption utilisateur)
|
||||||
public flushQueue(): void {
|
public flushQueue(): void {
|
||||||
|
this.audioQueue = [];
|
||||||
|
this.isPlayingQueue = false;
|
||||||
if (this.ttsSynth) this.ttsSynth.cancel();
|
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
|
// Arrêter immédiatement de parler et vider la file
|
||||||
public stopSpeaking() {
|
public stopSpeaking() {
|
||||||
|
this.audioQueue = [];
|
||||||
|
this.isPlayingQueue = false;
|
||||||
if (this.ttsSynth) {
|
if (this.ttsSynth) {
|
||||||
this.ttsSynth.cancel();
|
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)
|
// Lancer l'écoute vocale (STT - Speech-To-Text)
|
||||||
@@ -262,48 +891,56 @@ export class AudioService {
|
|||||||
onError: (err: string) => void,
|
onError: (err: string) => void,
|
||||||
lang: string = 'fr-FR'
|
lang: string = 'fr-FR'
|
||||||
) {
|
) {
|
||||||
if (!this.recognition) {
|
if (this.sttProvider === 'qwen3-asr') {
|
||||||
onError("STT non supporté");
|
this.startListeningAPI(onResult, onStart, onEnd, onError, lang);
|
||||||
return;
|
} else {
|
||||||
}
|
if (!this.recognition) {
|
||||||
|
onError("STT non supporté par ce navigateur");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isListeningActive) {
|
if (this.isListeningActive) {
|
||||||
this.recognition.stop();
|
this.recognition.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.recognition.lang = lang;
|
this.recognition.lang = lang;
|
||||||
this.isListeningActive = true;
|
this.isListeningActive = true;
|
||||||
|
|
||||||
this.recognition.onstart = () => {
|
this.recognition.onstart = () => {
|
||||||
onStart();
|
onStart();
|
||||||
};
|
};
|
||||||
|
|
||||||
this.recognition.onresult = (event: any) => {
|
this.recognition.onresult = (event: any) => {
|
||||||
const resultText = event.results[0][0].transcript;
|
const resultText = event.results[0][0].transcript;
|
||||||
onResult(resultText);
|
onResult(resultText);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.recognition.onerror = (event: any) => {
|
this.recognition.onerror = (event: any) => {
|
||||||
onError(event.error);
|
onError(event.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.recognition.onend = () => {
|
this.recognition.onend = () => {
|
||||||
this.isListeningActive = false;
|
this.isListeningActive = false;
|
||||||
onEnd();
|
onEnd();
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.recognition.start();
|
this.recognition.start();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
onError(e.message || "Erreur de démarrage de l'écoute");
|
onError(e.message || "Erreur de démarrage de l'écoute");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrêter l'écoute vocale
|
// Arrêter l'écoute vocale
|
||||||
public stopListening() {
|
public stopListening() {
|
||||||
if (this.recognition && this.isListeningActive) {
|
if (this.sttProvider === 'qwen3-asr') {
|
||||||
this.recognition.stop();
|
this.stopListeningAPI();
|
||||||
this.isListeningActive = false;
|
} else {
|
||||||
|
if (this.recognition && this.isListeningActive) {
|
||||||
|
this.recognition.stop();
|
||||||
|
this.isListeningActive = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+163
-54
@@ -1,112 +1,221 @@
|
|||||||
/**
|
/**
|
||||||
* PollingService — Periodically polls Hermes Agent /api/jobs endpoint
|
* PollingService — synchronizes Hermes /api/jobs for scheduled work.
|
||||||
* to receive proactive notifications from the AI (scheduled tasks, alerts).
|
|
||||||
*
|
*
|
||||||
* Runs as a background timer. Calls the provided callback whenever a
|
* Hermes remains the source of truth. EveFlow polls as a client, keeps a local
|
||||||
* newly completed job is detected.
|
* display cache, and uses the same service for CRUD actions on remote jobs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { LogService } from './logService';
|
import { LogService } from './logService';
|
||||||
|
|
||||||
|
export type HermesJobStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'scheduled'
|
||||||
|
| 'paused'
|
||||||
|
| 'running'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'ok'
|
||||||
|
| string;
|
||||||
|
|
||||||
export interface HermesJob {
|
export interface HermesJob {
|
||||||
id: string;
|
id: string;
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
name?: string;
|
||||||
|
prompt?: string;
|
||||||
|
schedule?: string | { kind?: string; expr?: string; display?: string };
|
||||||
|
status?: HermesJobStatus;
|
||||||
|
state?: HermesJobStatus;
|
||||||
|
enabled?: boolean;
|
||||||
result?: string;
|
result?: string;
|
||||||
|
output?: string;
|
||||||
|
last_result?: string;
|
||||||
|
last_output?: string;
|
||||||
|
error?: string;
|
||||||
|
last_error?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
next_run_at?: string;
|
||||||
|
last_run_at?: string;
|
||||||
completed_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 type JobHandler = (job: HermesJob) => void;
|
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 {
|
export class PollingService {
|
||||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
private seenJobIds: Set<string> = new Set();
|
|
||||||
private hermesUrl: string = '';
|
private hermesUrl: string = '';
|
||||||
private hermesApiKey: string = '';
|
private hermesApiKey: string = '';
|
||||||
private onJobCompleted: JobHandler | null = null;
|
private onSync: JobsSyncHandler | null = null;
|
||||||
|
private onStatus: SyncStatusHandler | null = null;
|
||||||
private intervalMs: number;
|
private intervalMs: number;
|
||||||
|
|
||||||
constructor(intervalMs: number = 30_000) {
|
constructor(intervalMs: number = 30_000) {
|
||||||
this.intervalMs = intervalMs;
|
this.intervalMs = intervalMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public start(
|
||||||
* Start polling Hermes Agent for completed jobs.
|
hermesUrl: string,
|
||||||
*
|
apiKey: string,
|
||||||
* @param hermesUrl - Base URL of the Hermes server
|
onSync: JobsSyncHandler,
|
||||||
* @param apiKey - Bearer token for authentication
|
onStatus?: SyncStatusHandler
|
||||||
* @param onCompleted - Callback fired when a new completed job is detected
|
): void {
|
||||||
*/
|
this.stop();
|
||||||
public start(hermesUrl: string, apiKey: string, onCompleted: JobHandler): void {
|
|
||||||
this.stop(); // clear any existing interval
|
|
||||||
this.hermesUrl = hermesUrl;
|
this.hermesUrl = hermesUrl;
|
||||||
this.hermesApiKey = apiKey;
|
this.hermesApiKey = apiKey;
|
||||||
this.onJobCompleted = onCompleted;
|
this.onSync = onSync;
|
||||||
|
this.onStatus = onStatus || null;
|
||||||
|
|
||||||
LogService.info('PollingService', `Démarrage polling Hermes toutes les ${this.intervalMs / 1000}s`, { hermesUrl });
|
LogService.info('PollingService', `Synchronisation Hermes jobs toutes les ${this.intervalMs / 1000}s`, { hermesUrl });
|
||||||
|
|
||||||
// Fire once immediately, then on interval
|
this.sync();
|
||||||
this.poll();
|
this.intervalId = setInterval(() => this.sync(), this.intervalMs);
|
||||||
this.intervalId = setInterval(() => this.poll(), this.intervalMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop the polling loop. */
|
|
||||||
public stop(): void {
|
public stop(): void {
|
||||||
if (this.intervalId !== null) {
|
if (this.intervalId !== null) {
|
||||||
clearInterval(this.intervalId);
|
clearInterval(this.intervalId);
|
||||||
this.intervalId = null;
|
this.intervalId = null;
|
||||||
LogService.info('PollingService', 'Polling Hermes arrêté.');
|
LogService.info('PollingService', 'Synchronisation Hermes jobs arrêtée.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if currently polling. */
|
|
||||||
public get isActive(): boolean {
|
public get isActive(): boolean {
|
||||||
return this.intervalId !== null;
|
return this.intervalId !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async poll(): Promise<void> {
|
public async refresh(): Promise<HermesSyncSnapshot> {
|
||||||
if (!this.hermesUrl || !this.onJobCompleted) return;
|
return this.fetchJobs();
|
||||||
|
}
|
||||||
|
|
||||||
const base = this.hermesUrl.replace(/\/$/, '').replace(/\/v1(\/.*)?$/, '');
|
public async createJob(draft: HermesJobDraft): Promise<HermesJob> {
|
||||||
const endpoint = `${base}/api/jobs`;
|
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 abortCtrl = new AbortController();
|
||||||
const timeoutId = setTimeout(() => abortCtrl.abort(), 10_000); // 10s max
|
const timeoutId = setTimeout(() => abortCtrl.abort(), 12_000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (this.hermesApiKey) headers['Authorization'] = `Bearer ${this.hermesApiKey.trim()}`;
|
if (this.hermesApiKey) headers.Authorization = `Bearer ${this.hermesApiKey.trim()}`;
|
||||||
|
|
||||||
const response = await fetch(endpoint, { method: 'GET', headers, signal: abortCtrl.signal });
|
const response = await fetch(endpoint, {
|
||||||
|
...init,
|
||||||
|
headers: { ...headers, ...(init.headers || {}) },
|
||||||
|
signal: abortCtrl.signal
|
||||||
|
});
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
LogService.warn('PollingService', `Erreur HTTP /api/jobs: ${response.status}`);
|
const raw = await response.text().catch(() => '');
|
||||||
return;
|
throw new Error(`HTTP ${response.status}${raw ? `: ${raw.slice(0, 180)}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobs: HermesJob[] = await response.json();
|
if (response.status === 204) return null;
|
||||||
|
const text = await response.text();
|
||||||
for (const job of jobs) {
|
return text ? JSON.parse(text) : null;
|
||||||
if (job.status === 'completed' && job.id && !this.seenJobIds.has(job.id)) {
|
|
||||||
this.seenJobIds.add(job.id);
|
|
||||||
LogService.info('PollingService', `Nouveau job complété: ${job.id}`, { result: job.result?.slice(0, 200) });
|
|
||||||
this.onJobCompleted(job);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Évite une fuite mémoire sur les sessions longues
|
|
||||||
if (this.seenJobIds.size > 500) {
|
|
||||||
const arr = [...this.seenJobIds];
|
|
||||||
this.seenJobIds = new Set(arr.slice(-250));
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if ((e as any).name === 'AbortError') {
|
if (e?.name === 'AbortError') throw new Error('Timeout Hermes /api/jobs');
|
||||||
LogService.warn('PollingService', 'Poll timeout (10s) — serveur Hermes trop lent ou injoignable');
|
throw e;
|
||||||
return;
|
|
||||||
}
|
|
||||||
LogService.warn('PollingService', `Exception lors du poll: ${e.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+1
@@ -4,6 +4,7 @@ interface Window {
|
|||||||
electronAPI?: {
|
electronAPI?: {
|
||||||
windowControl: (action: 'minimize' | 'maximize' | 'close') => void;
|
windowControl: (action: 'minimize' | 'maximize' | 'close') => void;
|
||||||
setWindowMode: (mode: 'floating' | 'normal') => void;
|
setWindowMode: (mode: 'floating' | 'normal') => void;
|
||||||
|
setCockpitMode: (isOpen: boolean) => void;
|
||||||
onWindowModeChanged: (callback: (mode: 'floating' | 'normal') => void) => () => void;
|
onWindowModeChanged: (callback: (mode: 'floating' | 'normal') => void) => () => void;
|
||||||
onHermesPush: (callback: (event: any) => void) => () => void;
|
onHermesPush: (callback: (event: any) => void) => () => void;
|
||||||
writeLog: (entry: any) => void;
|
writeLog: (entry: any) => void;
|
||||||
|
|||||||
Reference in new issue
Block a user