mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/
|
||||
dist/
|
||||
out/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
File renamed without changes.
@@ -0,0 +1,92 @@
|
||||
# 🚀 EveFlow — Compagnon de Bureau Rétro-Futuriste 3D
|
||||
|
||||
[](https://github.com/R0m1k3/EveFlow)
|
||||
[](https://github.com/R0m1k3/EveFlow/releases/tag/v1.0.3)
|
||||
[](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 :
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>Cockpit Principal (Mode Discussion & Télémétrie)</strong></td>
|
||||
<td align="center"><strong>Widget Flottant (Mode Compact Transparent)</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="public/screenshots/cockpit_view.png" width="500" alt="EveFlow Cockpit View"/></td>
|
||||
<td><img src="public/screenshots/compact_widget.png" width="300" alt="EveFlow Compact Widget"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 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.3.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 fs = require('fs');
|
||||
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;
|
||||
|
||||
// ── 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 ───────────────────────────────────────────────────────────
|
||||
function getLogPath() {
|
||||
const userDataDir = app.getPath('userData');
|
||||
@@ -42,34 +61,55 @@ let webhookServer = null;
|
||||
// Handles multiple formats so EveFlow mirrors both sides of Telegram conversations.
|
||||
function normalizeHermesEvents(raw) {
|
||||
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? } }
|
||||
if (raw.type === 'message' && raw.payload?.text) {
|
||||
return [{
|
||||
return [withImages({
|
||||
type: 'message',
|
||||
role: raw.payload.role || 'assistant',
|
||||
text: raw.payload.text,
|
||||
source: raw.payload.platform || src
|
||||
}];
|
||||
}, raw.payload)];
|
||||
}
|
||||
|
||||
// Format B — run completed: { event:'run.completed', input?, output, platform? }
|
||||
if ((raw.event === 'run.completed' || raw.type === 'run.completed') && raw.output) {
|
||||
const events = [];
|
||||
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;
|
||||
}
|
||||
|
||||
// Format C — direct: { role, text, source? }
|
||||
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 }
|
||||
if (raw.text && raw.type) {
|
||||
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
|
||||
@@ -178,23 +218,34 @@ async function loadApp() {
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 400,
|
||||
minHeight: 500,
|
||||
width: 1120,
|
||||
height: 860,
|
||||
minWidth: 900,
|
||||
minHeight: 680,
|
||||
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)
|
||||
backgroundColor: '#00000000', // Fond transparent
|
||||
hasShadow: true,
|
||||
show: false, // Cache la fenêtre au démarrage pour éviter les problèmes visuels de DWM
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true, // Sécurité Zero Trust active
|
||||
nodeIntegration: false,
|
||||
webSecurity: false, // Permet les requêtes HTTP vers API locales/LAN
|
||||
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) => {
|
||||
console.error("Echec du chargement de l'application.", error);
|
||||
loadFallbackPage("Une erreur inattendue a interrompu le chargement de l'interface.");
|
||||
@@ -206,6 +257,16 @@ function createWindow() {
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
);
|
||||
|
||||
startWebhookServer();
|
||||
createWindow();
|
||||
|
||||
@@ -247,30 +308,54 @@ ipcMain.on('window-control', (event, action) => {
|
||||
ipcMain.on('set-window-mode', (event, mode) => {
|
||||
if (!mainWindow) return;
|
||||
if (mode === 'floating') {
|
||||
// Mode flottant compact : 320x450
|
||||
// Mode flottant compact : 300x600
|
||||
mainWindow.setResizable(true);
|
||||
mainWindow.setMinimumSize(250, 350);
|
||||
mainWindow.setSize(300, 400);
|
||||
mainWindow.setSize(300, 600);
|
||||
mainWindow.setAlwaysOnTop(true, 'screen-saver');
|
||||
|
||||
// Positionner dans le coin inférieur droit de l'écran principal
|
||||
const { screen } = require('electron');
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { width, height } = primaryDisplay.workAreaSize;
|
||||
mainWindow.setPosition(width - 320, height - 420);
|
||||
mainWindow.setPosition(width - 320, height - 620);
|
||||
|
||||
event.reply('window-mode-changed', 'floating');
|
||||
} else {
|
||||
// Mode normal : chat complet
|
||||
mainWindow.setAlwaysOnTop(false);
|
||||
mainWindow.setMinimumSize(400, 500);
|
||||
mainWindow.setSize(1000, 700);
|
||||
mainWindow.setMinimumSize(900, 680);
|
||||
mainWindow.setSize(1120, 860);
|
||||
mainWindow.center();
|
||||
|
||||
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 ─────────────────
|
||||
ipcMain.on('write-log', (event, entry) => {
|
||||
writeLogEntry(entry);
|
||||
@@ -373,6 +458,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 ───────────────────────────────────────────────────
|
||||
function getCpuLoad() {
|
||||
const cpus = os.cpus();
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "eveflow",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.105.0",
|
||||
"@react-three/fiber": "^8.16.0",
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "Retro-futuristic Windows companion with 3D Eve bot, local TTS/STT, and multi-agent connectivity",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"appId": "com.romik3.eveflow",
|
||||
"productName": "EveFlow",
|
||||
"copyright": "Copyright © 2026 R0m1k3",
|
||||
"asar": false,
|
||||
"directories": {
|
||||
"output": "out"
|
||||
},
|
||||
@@ -25,7 +26,9 @@
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"requestedExecutionLevel": "asInvoker"
|
||||
"requestedExecutionLevel": "asInvoker",
|
||||
"signAndEditExecutable": false,
|
||||
"verifyUpdateCodeSignature": false
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
@@ -4,6 +4,7 @@ const { contextBridge, ipcRenderer } = require('electron');
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
windowControl: (action) => ipcRenderer.send('window-control', action),
|
||||
setWindowMode: (mode) => ipcRenderer.send('set-window-mode', mode),
|
||||
setCockpitMode: (isOpen) => ipcRenderer.send('set-cockpit-mode', isOpen),
|
||||
onWindowModeChanged: (callback) => {
|
||||
const subscription = (event, mode) => callback(mode);
|
||||
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),
|
||||
// Ouverture de fichier local
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
+1201
-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>>;
|
||||
|
||||
const AVATAR_MANIFEST_PATH = '/avatars/avatar-manifest.json';
|
||||
const AVATAR_MANIFEST_PATH = './avatars/avatar-manifest.json';
|
||||
|
||||
const LoadedGlbAvatar: React.FC<{
|
||||
path: string;
|
||||
@@ -22,7 +22,11 @@ const LoadedGlbAvatar: React.FC<{
|
||||
isSpeaking: boolean;
|
||||
}> = ({ path, avatarId, emotion, isSpeaking }) => {
|
||||
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(() => {
|
||||
const style = EmotionService.getEmotionStyle(emotion, avatarId);
|
||||
@@ -48,7 +52,7 @@ const LoadedGlbAvatar: React.FC<{
|
||||
|
||||
useFrame((state) => {
|
||||
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;
|
||||
|
||||
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 }) => {
|
||||
@@ -77,8 +81,16 @@ export const AvatarModelLoader: React.FC<AvatarModelLoaderProps> = ({ avatarId,
|
||||
})
|
||||
.then((manifest) => {
|
||||
if (cancelled || !manifest) return;
|
||||
const nextAssetPath = manifest[avatarId];
|
||||
setAssetPath(typeof nextAssetPath === 'string' && nextAssetPath.trim() ? nextAssetPath : null);
|
||||
let nextAssetPath = manifest[avatarId];
|
||||
if (typeof nextAssetPath === 'string' && nextAssetPath.trim()) {
|
||||
// Si le chemin commence par un slash, on le rend relatif pour fonctionner sous le protocole file:// en production
|
||||
if (nextAssetPath.startsWith('/')) {
|
||||
nextAssetPath = '.' + nextAssetPath;
|
||||
}
|
||||
setAssetPath(nextAssetPath);
|
||||
} else {
|
||||
setAssetPath(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAssetPath(null);
|
||||
|
||||
@@ -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
|
||||
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();
|
||||
loader.setMaterials(materials);
|
||||
});
|
||||
@@ -452,8 +452,8 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
|
||||
const center = new THREE.Vector3();
|
||||
localBox.getCenter(center);
|
||||
|
||||
// Normalize height to exactly 2.4 units in scene space
|
||||
const targetHeight = 2.4;
|
||||
// Normalize height conservatively so the avatar stays inside the right panel.
|
||||
const targetHeight = 1.18;
|
||||
const scaleFactor = targetHeight / (size.y || 1);
|
||||
clonedObj.scale.setScalar(scaleFactor);
|
||||
|
||||
@@ -555,8 +555,8 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
|
||||
|
||||
// 1. Animation globale de flottaison (Lévitation magnétique)
|
||||
if (modelRef.current) {
|
||||
// Oscillation en hauteur stabilisée autour de y = 0.22 (remonté pour un centrage élégant)
|
||||
modelRef.current.position.y = Math.sin(t * 1.5) * 0.06 + 0.22;
|
||||
// Oscillation en hauteur stabilisée autour de y = 0.27 pour mieux occuper la fenetre avatar.
|
||||
modelRef.current.position.y = Math.sin(t * 1.5) * 0.06 + 0.27;
|
||||
// Légère rotation sur elle-même (mouvement de veille)
|
||||
modelRef.current.rotation.y = Math.sin(t * 0.5) * 0.05;
|
||||
}
|
||||
@@ -760,7 +760,7 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
|
||||
});
|
||||
|
||||
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} />
|
||||
</group>
|
||||
);
|
||||
|
||||
+580
-10
@@ -1,23 +1,591 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import React, { Suspense, useRef } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { AvatarModelLoader } from './AvatarModelLoader';
|
||||
import { EveEmotion, EveAvatar } from '../services/emotionService';
|
||||
|
||||
export interface ToolEvent {
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
status: 'idle' | 'running' | 'done' | 'error';
|
||||
ts: number;
|
||||
}
|
||||
|
||||
interface ThreeCanvasProps {
|
||||
emotion: EveEmotion;
|
||||
isSpeaking: boolean;
|
||||
avatarId?: EveAvatar;
|
||||
isFloatingMode?: boolean;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 4.6], fov: 45 }}
|
||||
gl={{ alpha: true, antialias: true }}
|
||||
camera={{ position: isFloatingMode ? [0, 0.72, 3.7] : [0, 1.1, 5.8], fov: isFloatingMode ? 30 : 36 }}
|
||||
gl={{ alpha: true, antialias: true, premultipliedAlpha: false }}
|
||||
onCreated={({ gl }) => {
|
||||
gl.setClearColor(0x000000, 0);
|
||||
}}
|
||||
dpr={[1, 2]}
|
||||
style={{ background: 'transparent' }}
|
||||
style={{ background: 'transparent', backgroundColor: 'transparent' }}
|
||||
>
|
||||
{/* Soft ambient — dark space base */}
|
||||
<ambientLight intensity={0.6} color="#b0c8e8" />
|
||||
@@ -30,24 +598,26 @@ export const ThreeCanvas: React.FC<ThreeCanvasProps> = ({ emotion, isSpeaking, a
|
||||
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" />
|
||||
|
||||
{/* Desaturated blue fill — subtle depth without coloring the white lacquer */}
|
||||
{/* Desaturated blue fill */}
|
||||
<pointLight position={[4, -2, 2]} intensity={0.45} color="#8ab4d8" />
|
||||
|
||||
{/* Soft white back fill to keep geometry readable */}
|
||||
<pointLight position={[0, 3, -4]} intensity={1.0} color="#ffffff" />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<TechnicalBackdrop />
|
||||
<HologramProjection emotion={emotion} isSpeaking={isSpeaking} avatarId={avatarId} />
|
||||
<AvatarModelLoader avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />
|
||||
</Suspense>
|
||||
|
||||
{/* OrbitControls calibrated to prevent clipping or losing focus */}
|
||||
<OrbitControls
|
||||
enableZoom={true}
|
||||
minDistance={2.2}
|
||||
maxDistance={5.5}
|
||||
minDistance={isFloatingMode ? 2.4 : 4.8}
|
||||
maxDistance={isFloatingMode ? 4.7 : 6.8}
|
||||
enablePan={false}
|
||||
autoRotate={false}
|
||||
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',
|
||||
callbacks?: AppCallbacks,
|
||||
onToken?: (token: string) => void,
|
||||
onToolCall?: (toolName: string) => void,
|
||||
onToolCall?: (toolName: string, detail?: string, status?: 'running' | 'done' | 'error') => void,
|
||||
sessionId?: string,
|
||||
onSessionIdChange?: (newSessionId: string) => void,
|
||||
images?: string[]
|
||||
@@ -63,7 +63,18 @@ export class AgentService {
|
||||
// inject the avatar character prompt as before.
|
||||
let updatedHistory: { role: 'user' | 'assistant' | 'system'; content: string }[];
|
||||
if (config.provider === 'hermes') {
|
||||
updatedHistory = history.map(h => ({ role: h.role as 'user' | 'assistant' | 'system', content: h.content }));
|
||||
updatedHistory = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: [
|
||||
'Platform: EveFlow desktop chat, connected through the OpenAI-compatible API, not a Hermes messaging gateway.',
|
||||
'Do not use MEDIA:/path tags for images or files here: those are only intercepted by Telegram/Discord/Slack gateways and local paths are not web-accessible in EveFlow.',
|
||||
'When showing an internet image, return a normal Markdown image with a public http(s) URL: .',
|
||||
'When creating a diagram or SVG for EveFlow, use the generate_shared_image tool so the app writes it locally and can display it.'
|
||||
].join(' ')
|
||||
},
|
||||
...history.map(h => ({ role: h.role as 'user' | 'assistant' | 'system', content: h.content }))
|
||||
];
|
||||
} else {
|
||||
const systemPrompt = this.getSystemPromptForAvatar(avatarId);
|
||||
updatedHistory = [
|
||||
@@ -138,7 +149,7 @@ export class AgentService {
|
||||
config: AgentConfig,
|
||||
callbacks?: AppCallbacks,
|
||||
onToken?: (token: string) => void,
|
||||
onToolCall?: (toolName: string) => void,
|
||||
onToolCall?: (toolName: string, detail?: string, status?: 'running' | 'done' | 'error') => void,
|
||||
sessionId?: string,
|
||||
onSessionIdChange?: (newSessionId: string) => void,
|
||||
images?: string[]
|
||||
@@ -261,7 +272,7 @@ export class AgentService {
|
||||
for (const tc of assistantMsg.tool_calls) {
|
||||
const toolName: string = tc.function?.name || 'unknown';
|
||||
LogService.info('HermesAgent', `Tool call: ${toolName}`, tc.function?.arguments);
|
||||
onToolCall?.(toolName);
|
||||
onToolCall?.(toolName, tc.function?.arguments, 'running');
|
||||
|
||||
const result = await executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks!);
|
||||
messages.push(result);
|
||||
@@ -295,7 +306,7 @@ export class AgentService {
|
||||
onToken: (token: string) => void,
|
||||
messages: any[],
|
||||
callbacks: AppCallbacks | undefined,
|
||||
onToolCall: ((name: string) => void) | undefined,
|
||||
onToolCall: ((name: string, detail?: string, status?: 'running' | 'done' | 'error') => void) | undefined,
|
||||
endpoint: string,
|
||||
headers: Record<string, string>,
|
||||
model: string
|
||||
@@ -305,6 +316,7 @@ export class AgentService {
|
||||
let fullContent = '';
|
||||
let buffer = '';
|
||||
const pendingToolCalls: Record<string, any> = {};
|
||||
let sseEvent = 'message';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -315,12 +327,34 @@ export class AgentService {
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
sseEvent = line.slice(7).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const jsonStr = line.slice(6).trim();
|
||||
if (jsonStr === '[DONE]') break;
|
||||
if (jsonStr === '[DONE]') {
|
||||
sseEvent = 'message';
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(jsonStr);
|
||||
|
||||
if (sseEvent === 'hermes.tool.progress') {
|
||||
const tool = String(chunk.tool || chunk.name || chunk.label || 'outil');
|
||||
const label = String(chunk.label || chunk.preview || tool);
|
||||
const emoji = chunk.emoji ? `${chunk.emoji} ` : '';
|
||||
const detail = [chunk.preview, chunk.args, chunk.skill, chunk.toolset]
|
||||
.filter(Boolean)
|
||||
.map((value: any) => typeof value === 'string' ? value : JSON.stringify(value))
|
||||
.join(' | ');
|
||||
onToolCall?.(`${emoji}${label}`, detail || tool, 'running');
|
||||
sseEvent = 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = chunk.choices?.[0]?.delta;
|
||||
const finishReason = chunk.choices?.[0]?.finish_reason;
|
||||
|
||||
@@ -348,7 +382,7 @@ export class AgentService {
|
||||
for (const tc of Object.values(pendingToolCalls) as any[]) {
|
||||
const toolName = tc.function?.name;
|
||||
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);
|
||||
messages.push(result);
|
||||
}
|
||||
|
||||
+229
-20
@@ -54,6 +54,40 @@ export class AudioService {
|
||||
public ttsSynth: SpeechSynthesis;
|
||||
private recognition: any = null;
|
||||
private isListeningActive = false;
|
||||
private currentAudioElement: HTMLAudioElement | null = null;
|
||||
private audioQueue: { text: string; voiceName?: string; rate: number; pitch: number; provider: 'system' | 'google-free' }[] = [];
|
||||
private isPlayingQueue = false;
|
||||
private activeReject: ((err: any) => void) | null = null;
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.ttsSynth = window.speechSynthesis;
|
||||
@@ -148,9 +182,15 @@ export class AudioService {
|
||||
}
|
||||
|
||||
private _cleanForTTS(raw: string): string {
|
||||
// 0a. Supprimer les tokens MEDIA: (chemins de fichiers envoyés par le bot) pour ne pas les lire à voix haute
|
||||
let noMedia = raw.replace(/MEDIA:\s*\S+/g, '');
|
||||
|
||||
// 0b. Supprimer complètement les blocs de code Markdown (```code```) car ils ne sont pas prononçables oralement
|
||||
let noCode = noMedia.replace(/```[\s\S]*?```/g, ' ');
|
||||
|
||||
// 1. Améliorer la ponctuation Markdown pour forcer des pauses de diction naturelles
|
||||
// Remplacer les puces de liste (ex: - item, * item) par une pause douce (virgule)
|
||||
let formatted = 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
|
||||
formatted = formatted.replace(/:\s*(?:\r?\n)/g, '. ');
|
||||
@@ -218,40 +258,209 @@ export class AudioService {
|
||||
return u;
|
||||
}
|
||||
|
||||
// Synthèse Vocale complète (annule et relit tout le texte)
|
||||
public speak(text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
|
||||
this.ttsSynth.cancel();
|
||||
const u = this._makeUtterance(text, voiceName, rate, pitch);
|
||||
u.onend = () => resolve();
|
||||
u.onerror = (e) => reject(e);
|
||||
this.ttsSynth.speak(u);
|
||||
});
|
||||
// Moteur Google TTS gratuit en ligne avec découpage intelligent
|
||||
private async speakGoogleFree(text: string, rate: number): Promise<void> {
|
||||
const cleanText = this._cleanForTTS(text);
|
||||
if (!cleanText.trim()) return;
|
||||
|
||||
const chunks: string[] = [];
|
||||
let currentChunk = '';
|
||||
|
||||
// Regex pour couper en phrases
|
||||
const sentences = cleanText.match(/[^.!?]+[.!?]*|.+/g) || [cleanText];
|
||||
for (const sentence of sentences) {
|
||||
if ((currentChunk + sentence).length > 180) {
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk.trim());
|
||||
currentChunk = '';
|
||||
}
|
||||
if (sentence.length > 180) {
|
||||
const words = sentence.split(/\s+/);
|
||||
let temp = '';
|
||||
for (const word of words) {
|
||||
if ((temp + ' ' + word).length > 180) {
|
||||
chunks.push(temp.trim());
|
||||
temp = word;
|
||||
} else {
|
||||
temp += (temp ? ' ' : '') + word;
|
||||
}
|
||||
}
|
||||
if (temp) currentChunk = temp;
|
||||
} else {
|
||||
currentChunk = sentence;
|
||||
}
|
||||
} else {
|
||||
currentChunk += (currentChunk ? ' ' : '') + sentence;
|
||||
}
|
||||
}
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk.trim());
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (!chunk) continue;
|
||||
const url = `https://translate.google.com/translate_tts?ie=UTF-8&tl=fr&client=tw-ob&q=${encodeURIComponent(chunk)}`;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const audio = new Audio(url);
|
||||
this.currentAudioElement = audio;
|
||||
audio.playbackRate = rate;
|
||||
this.activeReject = reject;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
audio.pause();
|
||||
reject(new Error("playback_stopped"));
|
||||
}, 15000);
|
||||
|
||||
audio.onended = () => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
resolve();
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
const code = audio.error ? audio.error.code : 'UNKNOWN';
|
||||
const msg = audio.error ? audio.error.message : 'No message';
|
||||
reject(new Error(`Erreur de chargement Google TTS (code: ${code}, msg: ${msg})`));
|
||||
};
|
||||
|
||||
audio.play().catch((err) => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ajoute une phrase à la file TTS sans annuler la lecture en cours
|
||||
// Utilisé pour le streaming phrase par phrase (démarre l'audio dès la 1ère phrase)
|
||||
public queueSentence(text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1): void {
|
||||
if (!this.ttsSynth) return;
|
||||
// File de traitement asynchrone des phrases de synthèse vocale (Zero Trust & Zero Dependency)
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isPlayingQueue) return;
|
||||
this.isPlayingQueue = true;
|
||||
|
||||
while (this.audioQueue.length > 0) {
|
||||
const item = this.audioQueue.shift();
|
||||
if (!item) continue;
|
||||
|
||||
try {
|
||||
if (item.provider === 'google-free') {
|
||||
await this.speakGoogleFree(item.text, item.rate);
|
||||
} else {
|
||||
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') {
|
||||
// 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' = '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' = 'google-free'
|
||||
): void {
|
||||
const clean = text.trim();
|
||||
if (!clean) return;
|
||||
const u = this._makeUtterance(clean, voiceName, rate, pitch);
|
||||
if (u.text.trim()) {
|
||||
this.ttsSynth.speak(u); // s'enchaîne automatiquement à la file SpeechSynthesis
|
||||
}
|
||||
this.audioQueue.push({ text: clean, voiceName, rate, pitch, provider });
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
// Vide la file TTS (fin du streaming ou interruption utilisateur)
|
||||
public flushQueue(): void {
|
||||
this.audioQueue = [];
|
||||
this.isPlayingQueue = false;
|
||||
if (this.ttsSynth) this.ttsSynth.cancel();
|
||||
if (this.currentAudioElement) {
|
||||
this.currentAudioElement.pause();
|
||||
this.currentAudioElement = null;
|
||||
}
|
||||
if (this.activeReject) {
|
||||
this.activeReject(new Error("playback_stopped"));
|
||||
this.activeReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Arrêter immédiatement de parler
|
||||
// Arrêter immédiatement de parler et vider la file
|
||||
public stopSpeaking() {
|
||||
this.audioQueue = [];
|
||||
this.isPlayingQueue = false;
|
||||
if (this.ttsSynth) {
|
||||
this.ttsSynth.cancel();
|
||||
}
|
||||
if (this.currentAudioElement) {
|
||||
this.currentAudioElement.pause();
|
||||
this.currentAudioElement = null;
|
||||
}
|
||||
if (this.activeReject) {
|
||||
this.activeReject(new Error("playback_stopped"));
|
||||
this.activeReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Lancer l'écoute vocale (STT - Speech-To-Text)
|
||||
|
||||
+163
-54
@@ -1,112 +1,221 @@
|
||||
/**
|
||||
* PollingService — Periodically polls Hermes Agent /api/jobs endpoint
|
||||
* to receive proactive notifications from the AI (scheduled tasks, alerts).
|
||||
* PollingService — synchronizes Hermes /api/jobs for scheduled work.
|
||||
*
|
||||
* Runs as a background timer. Calls the provided callback whenever a
|
||||
* newly completed job is detected.
|
||||
* Hermes remains the source of truth. EveFlow polls as a client, keeps a local
|
||||
* display cache, and uses the same service for CRUD actions on remote jobs.
|
||||
*/
|
||||
|
||||
import { LogService } from './logService';
|
||||
|
||||
export type HermesJobStatus =
|
||||
| 'pending'
|
||||
| 'scheduled'
|
||||
| 'paused'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'ok'
|
||||
| string;
|
||||
|
||||
export interface HermesJob {
|
||||
id: string;
|
||||
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;
|
||||
output?: string;
|
||||
last_result?: string;
|
||||
last_output?: string;
|
||||
error?: string;
|
||||
last_error?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
next_run_at?: string;
|
||||
last_run_at?: string;
|
||||
completed_at?: string;
|
||||
last_status?: string;
|
||||
repeat?: { times?: number | null; completed?: number };
|
||||
skills?: string[];
|
||||
deliver?: string;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export 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 {
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private seenJobIds: Set<string> = new Set();
|
||||
private hermesUrl: string = '';
|
||||
private hermesApiKey: string = '';
|
||||
private onJobCompleted: JobHandler | null = null;
|
||||
private onSync: JobsSyncHandler | null = null;
|
||||
private onStatus: SyncStatusHandler | null = null;
|
||||
private intervalMs: number;
|
||||
|
||||
constructor(intervalMs: number = 30_000) {
|
||||
this.intervalMs = intervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling Hermes Agent for completed jobs.
|
||||
*
|
||||
* @param hermesUrl - Base URL of the Hermes server
|
||||
* @param apiKey - Bearer token for authentication
|
||||
* @param onCompleted - Callback fired when a new completed job is detected
|
||||
*/
|
||||
public start(hermesUrl: string, apiKey: string, onCompleted: JobHandler): void {
|
||||
this.stop(); // clear any existing interval
|
||||
public start(
|
||||
hermesUrl: string,
|
||||
apiKey: string,
|
||||
onSync: JobsSyncHandler,
|
||||
onStatus?: SyncStatusHandler
|
||||
): void {
|
||||
this.stop();
|
||||
this.hermesUrl = hermesUrl;
|
||||
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.poll();
|
||||
this.intervalId = setInterval(() => this.poll(), this.intervalMs);
|
||||
this.sync();
|
||||
this.intervalId = setInterval(() => this.sync(), this.intervalMs);
|
||||
}
|
||||
|
||||
/** Stop the polling loop. */
|
||||
public stop(): void {
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
LogService.info('PollingService', 'Polling Hermes arrêté.');
|
||||
LogService.info('PollingService', 'Synchronisation Hermes jobs arrêtée.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if currently polling. */
|
||||
public get isActive(): boolean {
|
||||
return this.intervalId !== null;
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (!this.hermesUrl || !this.onJobCompleted) return;
|
||||
public async refresh(): Promise<HermesSyncSnapshot> {
|
||||
return this.fetchJobs();
|
||||
}
|
||||
|
||||
const base = this.hermesUrl.replace(/\/$/, '').replace(/\/v1(\/.*)?$/, '');
|
||||
const endpoint = `${base}/api/jobs`;
|
||||
public async createJob(draft: HermesJobDraft): Promise<HermesJob> {
|
||||
return this.requestJob('', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: draft.name,
|
||||
schedule: draft.schedule,
|
||||
prompt: draft.prompt,
|
||||
...(draft.deliver ? { deliver: draft.deliver } : {})
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
public async updateJob(jobId: string, patch: Partial<HermesJobDraft>): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch)
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteJob(jobId: string): Promise<void> {
|
||||
await this.requestRaw(`/${encodeURIComponent(jobId)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
public async pauseJob(jobId: string): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}/pause`, { method: 'POST' });
|
||||
}
|
||||
|
||||
public async resumeJob(jobId: string): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}/resume`, { method: 'POST' });
|
||||
}
|
||||
|
||||
public async runJob(jobId: string): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}/run`, { method: 'POST' });
|
||||
}
|
||||
|
||||
private async sync(): Promise<void> {
|
||||
if (!this.hermesUrl || !this.onSync) return;
|
||||
|
||||
try {
|
||||
this.onStatus?.('syncing');
|
||||
const snapshot = await this.fetchJobs();
|
||||
this.onSync(snapshot);
|
||||
this.onStatus?.('online');
|
||||
} catch (e: any) {
|
||||
const message = e?.message || String(e);
|
||||
LogService.warn('PollingService', `Synchronisation Hermes jobs impossible: ${message}`);
|
||||
this.onStatus?.('offline', message);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchJobs(): Promise<HermesSyncSnapshot> {
|
||||
const payload = await this.requestRaw('');
|
||||
return {
|
||||
jobs: parseJobsPayload(payload),
|
||||
syncedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private async requestJob(path: string, init: RequestInit): Promise<HermesJob> {
|
||||
const payload = await this.requestRaw(path, init);
|
||||
if (payload && typeof payload === 'object' && 'job' in payload) {
|
||||
return (payload as { job: HermesJob }).job;
|
||||
}
|
||||
return payload as HermesJob;
|
||||
}
|
||||
|
||||
private async requestRaw(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
if (!this.hermesUrl) throw new Error('URL Hermes manquante');
|
||||
|
||||
const base = normalizeBaseUrl(this.hermesUrl);
|
||||
const endpoint = `${base}/api/jobs${path}`;
|
||||
const abortCtrl = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortCtrl.abort(), 10_000); // 10s max
|
||||
const timeoutId = setTimeout(() => abortCtrl.abort(), 12_000);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.hermesApiKey) headers['Authorization'] = `Bearer ${this.hermesApiKey.trim()}`;
|
||||
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);
|
||||
|
||||
if (!response.ok) {
|
||||
LogService.warn('PollingService', `Erreur HTTP /api/jobs: ${response.status}`);
|
||||
return;
|
||||
const raw = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status}${raw ? `: ${raw.slice(0, 180)}` : ''}`);
|
||||
}
|
||||
|
||||
const jobs: HermesJob[] = await response.json();
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === 'completed' && job.id && !this.seenJobIds.has(job.id)) {
|
||||
this.seenJobIds.add(job.id);
|
||||
LogService.info('PollingService', `Nouveau job complété: ${job.id}`, { result: job.result?.slice(0, 200) });
|
||||
this.onJobCompleted(job);
|
||||
}
|
||||
}
|
||||
|
||||
// Évite une fuite mémoire sur les sessions longues
|
||||
if (this.seenJobIds.size > 500) {
|
||||
const arr = [...this.seenJobIds];
|
||||
this.seenJobIds = new Set(arr.slice(-250));
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch (e: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if ((e as any).name === 'AbortError') {
|
||||
LogService.warn('PollingService', 'Poll timeout (10s) — serveur Hermes trop lent ou injoignable');
|
||||
return;
|
||||
}
|
||||
LogService.warn('PollingService', `Exception lors du poll: ${e.message}`);
|
||||
if (e?.name === 'AbortError') throw new Error('Timeout Hermes /api/jobs');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -4,6 +4,7 @@ interface Window {
|
||||
electronAPI?: {
|
||||
windowControl: (action: 'minimize' | 'maximize' | 'close') => void;
|
||||
setWindowMode: (mode: 'floating' | 'normal') => void;
|
||||
setCockpitMode: (isOpen: boolean) => void;
|
||||
onWindowModeChanged: (callback: (mode: 'floating' | 'normal') => void) => () => void;
|
||||
onHermesPush: (callback: (event: any) => void) => () => void;
|
||||
writeLog: (entry: any) => void;
|
||||
|
||||
Reference in new issue
Block a user