Compare commits

...
29 Commits
Author SHA1 Message Date
Michael 5217eee53a fix: downsample audio to 16kHz to prevent ASR 500 error, add permission check handler, and render errors in compact view (v1.0.8) 2026-06-08 20:44:31 +02:00
Michael 221bb7062c 1.0.7 2026-06-08 20:38:05 +02:00
Michael 57690962e1 fix: implement direct WAV recording to resolve transcription API media decoding errors 2026-06-08 20:37:57 +02:00
Michael cf52a610d4 1.0.6 2026-06-08 19:44:33 +02:00
Michael 3a52cf3ae0 fix: update default STT model to Qwen/Qwen3-ASR-0.6B and implement auto-migration of old values 2026-06-08 19:44:21 +02:00
Michael 5c6c3a9e30 1.0.5 2026-06-08 19:36:07 +02:00
Michael 6955721bdb fix: resolve silent microphone issues by adding permission handlers and UI error notifications 2026-06-08 19:35:58 +02:00
Michael c25875d8be 1.0.4 2026-06-08 19:16:49 +02:00
Michael 7c10d633ca feat: connect Qwen3-ASR-API voice manager and custom OpenAI-compatible TTS support with UI configurations 2026-06-08 19:15:46 +02:00
LogiFlow 115996c7d9 Remove image table from README
Removed the table displaying the main cockpit and compact widget images.
2026-05-22 16:02:19 +02:00
Michael 033b93563d feat: zoom in on 3D avatar in floating widget mode for ultra-high prominence and visual impact 2026-05-21 21:03:44 +02:00
Michael 17d00b085b style(widget): increase widget size to 300x600 and avatar height to 360px 2026-05-21 20:40:22 +02:00
Michael 33cd8156f9 fix(widget): enlarge widget to 300x550, increase avatar to 300px and fix audio/TTS background playback issues 2026-05-21 20:37:47 +02:00
Michael f4cfd6a12d style(widget): enlarge avatar canvas height to 220px and floating window to 300x460 2026-05-21 20:32:00 +02:00
Michael 125322ae07 style(widget): fix border clipping, canvas scaling and layout clipping inside floating widget 2026-05-21 20:29:11 +02:00
Michael 874502daef style(widget): restructure compact widget vertical layout and update genuine screenshots in README.md 2026-05-21 20:26:39 +02:00
Michael 2d4b642184 docs: replace compact widget screenshot with exact 300x400 aspect ratio 2026-05-21 20:20:46 +02:00
Michael 79f757bf41 docs: replace placeholder screenshots with real high-fidelity application screenshots 2026-05-21 20:16:15 +02:00
Michael 81039e659d fix(avatar): resolve broken Eve avatar in production using relative path 2026-05-21 20:14:03 +02:00
Michael 9dc19db942 docs: add comprehensive README.md with high-fidelity UI screenshots 2026-05-21 19:36:30 +02:00
Michael af479df65a fix(v1.0.3): fix startup window display, single instance lock, relative assets and obj packaging 2026-05-21 19:17:07 +02:00
Michael 47fb80602f Stabilize Windows EXE release build 2026-05-21 14:26:15 +02:00
Michael d2d8fe4f7c Disable Windows release auto signing 2026-05-21 14:17:35 +02:00
Michael 6d4499f78f Release EveFlow 1.0.3 visual cockpit update 2026-05-21 14:11:19 +02:00
Michael 17e573911d Add Hermes cron synchronization cockpit 2026-05-21 10:03:41 +02:00
Michael 1caa738dfb improve hermes visuals and tool monitor 2026-05-21 08:30:39 +02:00
LogiFlow 9f03a47078 Merge pull request #2 from R0m1k3/improve/avatar-visuals
refactor(settings): remove avatar selector and minimax integration, b…
2026-05-20 22:24:55 +02:00
Michael ba859aa78c refactor(settings): remove avatar selector and minimax integration, bump version to 1.0.2 2026-05-20 22:21:49 +02:00
LogiFlow 15458a2fbd Merge pull request #1 from R0m1k3/improve/avatar-visuals
feat: implement file sharing, SVG image rendering, avatar emotional a…
2026-05-20 16:13:48 +02:00
22 changed files with 8033 additions and 849 deletions

No files matched your search

+60
View File
@@ -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
View File
@@ -1,5 +1,6 @@
node_modules/
dist/
out/
.env
.env.local
*.log
View File
File renamed without changes.
+83
View File
@@ -0,0 +1,83 @@
# 🚀 EveFlow — Compagnon de Bureau Rétro-Futuriste 3D
[![Windows Version](https://img.shields.io/badge/OS-Windows-blue.svg?style=flat-square&logo=windows)](https://github.com/R0m1k3/EveFlow)
[![Version](https://img.shields.io/badge/version-1.0.8-brightgreen.svg?style=flat-square)](https://github.com/R0m1k3/EveFlow/releases/tag/v1.0.8)
[![License](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat-square)](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.
+217 -15
View File
@@ -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,31 @@ 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 });
}
);
// 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();
createWindow();
@@ -247,30 +323,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 +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 ───────────────────────────────────────────────────
function getCpuLoad() {
const cpus = os.cpus();
+2539 -2
View File
File diff suppressed because it is too large. Load diff
+31 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eveflow",
"version": "1.0.0",
"version": "1.0.8",
"description": "Retro-futuristic Windows companion with 3D Eve bot, local TTS/STT, and multi-agent connectivity",
"main": "main.js",
"scripts": {
@@ -8,7 +8,35 @@
"build": "tsc && vite build",
"preview": "vite preview",
"electron": "electron .",
"start": "concurrently -k \"npm run dev -- --host 127.0.0.1 --port 5173 --strictPort\" \"wait-on http://localhost:5173 && npm run electron\""
"start": "concurrently -k \"npm run dev -- --host 127.0.0.1 --port 5173 --strictPort\" \"wait-on http://localhost:5173 && npm run electron\"",
"dist": "electron-builder --win"
},
"build": {
"appId": "com.romik3.eveflow",
"productName": "EveFlow",
"copyright": "Copyright © 2026 R0m1k3",
"asar": false,
"directories": {
"output": "out"
},
"files": [
"main.js",
"preload.js",
"dist/**/*"
],
"win": {
"target": "nsis",
"requestedExecutionLevel": "asInvoker",
"signAndEditExecutable": false,
"verifyUpdateCodeSignature": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "EveFlow"
}
},
"dependencies": {
"@react-three/drei": "^9.105.0",
@@ -27,6 +55,7 @@
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.2.2",
"electron": "^30.0.1",
"electron-builder": "^26.8.1",
"typescript": "^5.4.2",
"vite": "^5.2.2",
"wait-on": "^7.2.0"
+3
View File
@@ -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

+17
View File
@@ -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}`);
}
});
+1486 -541
View File
File diff suppressed because it is too large. Load diff
+18 -6
View File
@@ -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);
+6 -6
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+42 -116
View File
@@ -2,7 +2,7 @@ import { EveAvatar } from './emotionService';
import { LogService } from './logService';
import { HERMES_TOOLS, executeTool, AppCallbacks } from './toolRegistry';
export type AgentProvider = 'ollama' | 'hermes' | 'minimax';
export type AgentProvider = 'ollama' | 'hermes';
export interface AgentConfig {
provider: AgentProvider;
@@ -12,9 +12,6 @@ export interface AgentConfig {
hermesModel: string;
hermesApiKey: string;
hermesSessionKey: string;
minimaxApiKey: string;
minimaxGroupId: string;
minimaxModel: string;
}
export class AgentService {
@@ -50,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[]
@@ -66,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: ![alt](https://...).',
'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 = [
@@ -80,8 +88,6 @@ export class AgentService {
return this.sendToOllama(message, updatedHistory, config.ollamaUrl, config.ollamaModel, images);
case 'hermes':
return this.sendToHermes(message, updatedHistory, config, callbacks, onToken, onToolCall, sessionId, onSessionIdChange, images);
case 'minimax':
return this.sendToMinimax(message, history, config, avatarId);
default:
throw new Error("Fournisseur d'agent non reconnu");
}
@@ -143,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[]
@@ -266,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);
@@ -300,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
@@ -310,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();
@@ -320,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;
@@ -353,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);
}
@@ -378,107 +407,4 @@ export class AgentService {
return fullContent || "Désolé, je n'ai pas reçu de réponse de Hermes Agent.";
}
// ── Connecteur Minimax (API Externe) ─────────────────────────────────────
private static async sendToMinimax(
message: string,
history: { role: 'user' | 'assistant'; content: string }[],
config: AgentConfig,
avatarId: EveAvatar = 'eve'
): Promise<string> {
const { minimaxApiKey, minimaxGroupId, minimaxModel } = config;
if (!minimaxApiKey) {
throw new Error("Clé d'API Minimax manquante dans les paramètres.");
}
const systemPrompt = this.getSystemPromptForAvatar(avatarId);
const formattedMessages = [
{ role: 'system', content: systemPrompt },
...history.map(h => ({ role: h.role, content: h.content })),
{ role: 'user', content: message }
];
const url = 'https://api.minimax.chat/v1/chat/completions';
const headers: Record<string, string> = {
'Authorization': `Bearer ${minimaxApiKey.trim()}`,
'Content-Type': 'application/json'
};
if (minimaxGroupId) headers['GroupId'] = minimaxGroupId.trim();
LogService.info('Minimax', `POST ${url}`, {
model: minimaxModel,
hasGroupId: !!minimaxGroupId,
historyLength: history.length
});
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ model: minimaxModel || 'abab6.5-chat', messages: formattedMessages, temperature: 0.7 })
});
LogService.info('Minimax', `HTTP ${response.status} ${response.statusText}`);
if (!response.ok) {
const rawErr = await response.text().catch(() => '(no body)');
LogService.error('Minimax', `Réponse non-OK`, { status: response.status, body: rawErr });
let errorMsg = `Erreur HTTP Minimax: ${response.status}`;
try { const p = JSON.parse(rawErr); errorMsg = `Minimax API Error: ${p.base_resp?.status_msg || p.error?.message || response.status}`; } catch {}
throw new Error(errorMsg);
}
const data = await response.json();
LogService.debug('Minimax', 'Réponse reçue', { base_resp: data.base_resp, replyPreview: data.choices?.[0]?.message?.content?.slice(0, 200) });
if (data.base_resp && data.base_resp.status_code !== 0) {
throw new Error(`Minimax API Error: ${data.base_resp.status_msg}`);
}
return data.choices?.[0]?.message?.content || "Désolé, je n'ai pas reçu de réponse de Minimax.";
} catch (e: any) {
LogService.error('Minimax', `Exception lors de la requête`, { message: e.message, stack: e.stack });
throw new Error(`Échec de connexion à Minimax (${e.message || e})`);
}
}
// ── Récupérer la liste des modèles Minimax ───────────────────────────────
public static async getMinimaxModels(apiKey: string, groupId?: string): Promise<string[]> {
if (!apiKey) throw new Error("Clé d'API Minimax requise pour lister les modèles.");
const url = 'https://api.minimax.chat/v1/models';
const headers: Record<string, string> = {
'Authorization': `Bearer ${apiKey.trim()}`,
'Content-Type': 'application/json'
};
if (groupId) headers['GroupId'] = groupId.trim();
LogService.info('Minimax', `GET ${url}`, { hasGroupId: !!groupId });
try {
const response = await fetch(url, { method: 'GET', headers });
LogService.info('Minimax', `GET /models → HTTP ${response.status}`);
if (!response.ok) {
const rawErr = await response.text().catch(() => '(no body)');
LogService.error('Minimax', `Erreur /models`, { status: response.status, body: rawErr });
let errorMsg = `Erreur HTTP Minimax (${response.status})`;
try { const p = JSON.parse(rawErr); errorMsg = `Minimax API Error: ${p.base_resp?.status_msg || p.error?.message || response.status}`; } catch {}
throw new Error(errorMsg);
}
const data = await response.json();
LogService.debug('Minimax', 'Liste modèles reçue', data);
if (data && Array.isArray(data.data)) return data.data.map((m: any) => m.id);
if (data && Array.isArray(data.models)) return data.models.map((m: any) => m.model_name || m.id || m);
if (Array.isArray(data)) return data.map((m: any) => m.id || m);
throw new Error("Format de réponse non standard");
} catch (e: any) {
LogService.error('Minimax', `Exception /models`, { message: e.message });
throw new Error(`Échec de récupération des modèles Minimax: ${e.message || e}`);
}
}
}
+814 -61
View File
@@ -4,16 +4,148 @@ export interface VoiceOption {
voice: SpeechSynthesisVoice;
}
// Dictionnaire phonétique local pour corriger la prononciation des acronymes et anglicismes en français
const PHONETIC_DICTIONARY: Record<string, string> = {
'api': 'a-pé-i',
'cors': 'korss',
'svg': 'ess-vé-gé',
'cpu': 'cé-pé-u',
'fps': 'eff-pé-ess',
'tts': 'té-té-ess',
'ui': 'u-i',
'ai': 'a-i',
'ia': 'i-a',
'json': 'dji-zone',
'url': 'u-err-el',
'html': 'ach-té-em-el',
'css': 'cé-ess-ess',
'js': 'ji-ess',
'typescript': 'taïpe-skript',
'javascript': 'djava-skript',
'electron': 'élèktron',
'react': 'ri-akt',
'github': 'gitte-ub',
'git': 'gitte',
'setup': 'sétup',
'c++': 'cé-plus-plus',
'node.js': 'node-j-s',
'nodejs': 'node-j-s',
'database': 'deïta-beïss',
'token': 'tokène',
'cli': 'cé-el-i',
'npm': 'en-pé-em',
'npx': 'en-pé-ix',
'vite': 'vite',
'vscode': 'vé-ess-code',
'vs code': 'vé-ess-code',
'windows': 'windoze',
'macos': 'mak-os',
'linux': 'linux',
'docker': 'dokeur',
'cyberpunk': 'saïbeur-pounk',
'avatar': 'avatar',
'chat': 'tchat',
'prompt': 'prompte',
'welcome': 'ouel-kome',
'ipc': 'i-pé-cé'
};
export class AudioService {
public ttsSynth: SpeechSynthesis;
private recognition: any = null;
private isListeningActive = false;
private currentAudioElement: HTMLAudioElement | null = null;
private audioQueue: { text: string; voiceName?: string; rate: number; pitch: number; provider: 'system' | 'google-free' | 'openai-tts' }[] = [];
private isPlayingQueue = false;
private activeReject: ((err: any) => void) | null = null;
// ASR/STT & TTS Custom API settings
private sttProvider: 'browser' | 'qwen3-asr' = 'browser';
private sttApiUrl = 'http://127.0.0.1:8000/v1';
private sttApiKey = '';
private sttModel = 'Qwen/Qwen3-ASR-0.6B';
// Propriétés pour l'enregistrement WAV direct
private audioContext: AudioContext | null = null;
private scriptProcessor: ScriptProcessorNode | null = null;
private micStream: MediaStream | null = null;
private leftChannel: Float32Array[] = [];
private recordingLength = 0;
private sampleRate = 44100;
// Callbacks pour l'écoute
private activeOnResult: ((text: string) => void) | null = null;
private activeOnEnd: (() => void) | null = null;
private activeOnError: ((err: string) => void) | null = null;
private activeLang = 'fr-FR';
private ttsApiUrl = 'http://127.0.0.1:8000/v1';
private ttsApiKey = '';
private ttsModel = 'tts-1';
public get isSpeakingActive(): boolean {
return this.isPlayingQueue || this.audioQueue.length > 0;
}
private logError(tag: string, message: string, data?: any) {
console.error(`[${tag}] ${message}`, data);
if ((window as any).electronAPI?.writeLog) {
(window as any).electronAPI.writeLog({
ts: new Date().toISOString(),
level: 'ERROR',
tag,
message,
data: data ? { message: data.message, stack: data.stack, ...data } : undefined
});
}
}
private logWarn(tag: string, message: string, data?: any) {
console.warn(`[${tag}] ${message}`, data);
if ((window as any).electronAPI?.writeLog) {
(window as any).electronAPI.writeLog({
ts: new Date().toISOString(),
level: 'WARN',
tag,
message,
data: data ? { message: data.message, stack: data.stack, ...data } : undefined
});
}
}
private logInfo(tag: string, message: string, data?: any) {
console.log(`[${tag}] ${message}`, data);
if ((window as any).electronAPI?.writeLog) {
(window as any).electronAPI.writeLog({
ts: new Date().toISOString(),
level: 'INFO',
tag,
message,
data: data ? { message: data.message, stack: data.stack, ...data } : undefined
});
}
}
constructor() {
this.ttsSynth = window.speechSynthesis;
this.initSTT();
}
// Permet de mettre à jour dynamiquement la configuration de la reconnaissance vocale
public updateSTTConfig(provider: 'browser' | 'qwen3-asr', apiUrl: string, apiKey: string, model: string) {
this.sttProvider = provider;
this.sttApiUrl = apiUrl;
this.sttApiKey = apiKey;
this.sttModel = model;
}
// Permet de mettre à jour dynamiquement la configuration du lecteur de voix personnalisé
public updateTTSConfig(apiUrl: string, apiKey: string, model: string) {
this.ttsApiUrl = apiUrl;
this.ttsApiKey = apiKey;
this.ttsModel = model;
}
// Initialisation de la reconnaissance vocale locale et gratuite (Web Speech API)
private initSTT() {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
@@ -27,10 +159,52 @@ export class AudioService {
}
}
// Calculer un score de qualité pour trier les voix locales
private _getVoiceScore(v: SpeechSynthesisVoice): number {
const lang = v.lang.toLowerCase();
const name = v.name.toLowerCase();
if (!lang.startsWith('fr')) return 0;
let score = 100; // Base français
if (name.includes('google') || name.includes('online')) {
score += 50;
}
if (name.includes('natural') || name.includes('neural')) {
score += 40;
}
if (name.includes('mobile')) {
score += 30;
}
if (name.includes('julie') || name.includes('paul')) {
score += 20;
}
if (name.includes('hortense')) {
score += 10;
}
return score;
}
// Récupérer les voix françaises et globales disponibles
public getVoices(): VoiceOption[] {
if (!this.ttsSynth) return [];
return this.ttsSynth.getVoices().map(v => ({
const allVoices = this.ttsSynth.getVoices();
// Trier pour mettre les meilleures voix françaises en haut
const sorted = [...allVoices].sort((a, b) => {
const scoreA = this._getVoiceScore(a);
const scoreB = this._getVoiceScore(b);
if (scoreA !== scoreB) {
return scoreB - scoreA;
}
return a.name.localeCompare(b.name);
});
return sorted.map(v => ({
name: v.name,
lang: v.lang,
voice: v
@@ -40,34 +214,64 @@ export class AudioService {
private _resolveVoice(voiceName?: string): SpeechSynthesisVoice | undefined {
const voices = this.ttsSynth.getVoices();
if (voiceName) return voices.find(v => v.name === voiceName);
return voices.find(v => v.lang.startsWith('fr') && v.name.toLowerCase().includes('google'))
|| voices.find(v => v.lang.startsWith('fr'));
const frVoices = voices.filter(v => v.lang.toLowerCase().startsWith('fr'));
if (frVoices.length === 0) return voices.find(v => v.lang.startsWith('en'));
// Sélectionner la voix française locale avec le meilleur score
return frVoices.sort((a, b) => this._getVoiceScore(b) - this._getVoiceScore(a))[0];
}
private _applyPhoneticCorrections(text: string): string {
let corrected = text;
for (const [key, replacement] of Object.entries(PHONETIC_DICTIONARY)) {
const escapedKey = key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
// Regex recherchant le mot technique avec des limites Unicode ou de ponctuations
const regex = new RegExp(`(?<=^|\\s|\\p{P})${escapedKey}(?=$|\\s|\\p{P})`, 'giu');
corrected = corrected.replace(regex, replacement);
}
return corrected;
}
private _cleanForTTS(raw: string): string {
// 1. Extraire le texte des liens Markdown [Texte](url). Gère aussi les parenthèses dans les URLs
let cleaned = raw.replace(/\[([^\]]+)\]\((?:[^)(]+|\([^)(]*\))*\)/g, '$1');
// 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, '');
// 2. Supprimer toutes les URLs nues (http, https, ftp, file) complexes ou simples
// 0b. Supprimer complètement les blocs de code Markdown (```code```) car ils ne sont pas prononçables oralement
let noCode = noMedia.replace(/```[\s\S]*?```/g, ' ');
// 1. Améliorer la ponctuation Markdown pour forcer des pauses de diction naturelles
// Remplacer les puces de liste (ex: - item, * item) par une pause douce (virgule)
let formatted = noCode.replace(/(?:\r?\n)\s*[-*+]\s+/g, ', ');
// Remplacer les deux-points suivis d'un saut de ligne par un point
formatted = formatted.replace(/:\s*(?:\r?\n)/g, '. ');
// Remplacer les doubles sauts de ligne par des points (pauses de paragraphes)
formatted = formatted.replace(/(?:\r?\n){2,}/g, '. ');
// Remplacer les sauts de ligne simples restant par des espaces
formatted = formatted.replace(/(?:\r?\n)/g, ' ');
// 2. Extraire le texte des liens Markdown [Texte](url)
let cleaned = formatted.replace(/\[([^\]]+)\]\((?:[^)(]+|\([^)(]*\))*\)/g, '$1');
// 3. Supprimer toutes les URLs nues (http, https, ftp, file)
cleaned = cleaned.replace(/(?:https?|ftp|file):\/\/\S+/gi, '');
// 3. Supprimer les adresses commençant par www.
// 4. Supprimer les adresses www.
cleaned = cleaned.replace(/www\.\S+/gi, '');
// 4. Supprimer tout mot technique contenant des chemins de fichiers, adresses IP ou segments techniques
// (ex: C:\chemin, localhost:8080, v1/chat/completions)
// 5. Filtrer les mots techniques complexes (chemins de fichiers, adresses IP)
const words = cleaned.split(/\s+/);
cleaned = words
.filter(word => {
// Exclure les mots qui ressemblent à des chemins ou contiennent des slashes / backslashes
if (word.includes('/') || word.includes('\\')) {
return false;
}
// Exclure les hôtes avec port (ex: localhost:8080, 127.0.0.1:8642)
if (/(?:[a-zA-Z0-9-]+\.[a-zA-Z]{2,6}|localhost|[\d.]+):\d+/.test(word)) {
return false;
}
// Exclure les adresses IP nues
if (/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/.test(word)) {
return false;
}
@@ -75,7 +279,8 @@ export class AudioService {
})
.join(' ');
return cleaned
// 6. Nettoyer les caractères Markdown, Emojis et symboles non prononçables
cleaned = cleaned
// Emojis et symboles Unicode
.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FEFF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA9F}]/gu, '')
// Markdown restant : **, *, _, __, #, `, ~
@@ -89,6 +294,9 @@ export class AudioService {
// Espaces multiples
.replace(/\s{2,}/g, ' ')
.trim();
// 7. Appliquer les corrections phonétiques locales
return this._applyPhoneticCorrections(cleaned);
}
private _makeUtterance(text: string, voiceName?: string, rate = 1.0, pitch = 1.1): SpeechSynthesisUtterance {
@@ -102,40 +310,577 @@ 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);
});
});
}
}
// 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
// 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 if (item.provider === 'openai-tts') {
await this.speakCustomAPI(item.text, item.voiceName, item.rate);
} else {
await new Promise<void>((resolve, reject) => {
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
const u = this._makeUtterance(item.text, item.voiceName, item.rate, item.pitch);
u.onend = () => resolve();
u.onerror = (e: any) => {
if (e && (e.error === 'interrupted' || e.error === 'canceled')) {
reject(new Error("playback_stopped"));
} else {
reject(new Error("Erreur de diction système"));
}
};
this.ttsSynth.speak(u);
});
}
} catch (err: any) {
if (err?.message === 'playback_stopped') {
break;
}
this.logWarn("AudioService", "Échec de la lecture de la phrase dans la file", err);
if (item.provider === 'google-free' || item.provider === 'openai-tts') {
// Repli automatique transparent sur la voix locale hors-ligne pour ce segment précis en cas de défaillance
try {
await new Promise<void>((resolve, reject) => {
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
const u = this._makeUtterance(item.text, item.voiceName, item.rate, item.pitch);
u.onend = () => resolve();
u.onerror = (e: any) => {
if (e && (e.error === 'interrupted' || e.error === 'canceled')) {
reject(new Error("playback_stopped"));
} else {
reject(new Error("Erreur de diction système"));
}
};
this.ttsSynth.speak(u);
});
} catch (fallbackErr: any) {
if (fallbackErr?.message === 'playback_stopped') {
break;
}
this.logError("AudioService", "Échec également du repli système", fallbackErr);
}
}
}
}
this.isPlayingQueue = false;
}
// Synthèse Vocale complète (annule et relit tout le texte)
public async speak(
text: string,
voiceName?: string,
rate: number = 1.0,
pitch: number = 1.1,
provider: 'system' | 'google-free' | 'openai-tts' = 'google-free'
): Promise<void> {
this.stopSpeaking();
this.queueSentence(text, voiceName, rate, pitch, provider);
// Attendre la fin complète de la lecture pour résoudre la promesse
while (this.isPlayingQueue || this.audioQueue.length > 0) {
await new Promise(resolve => setTimeout(resolve, 50));
}
}
// Ajoute une phrase à la file de lecture vocale pour un enchaînement fluide en temps réel (streaming)
public queueSentence(
text: string,
voiceName?: string,
rate: number = 1.0,
pitch: number = 1.1,
provider: 'system' | 'google-free' | 'openai-tts' = 'google-free'
): void {
const clean = text.trim();
if (!clean) return;
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;
}
}
// 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)
@@ -146,48 +891,56 @@ export class AudioService {
onError: (err: string) => void,
lang: string = 'fr-FR'
) {
if (!this.recognition) {
onError("STT non supporté");
return;
}
if (this.sttProvider === 'qwen3-asr') {
this.startListeningAPI(onResult, onStart, onEnd, onError, lang);
} else {
if (!this.recognition) {
onError("STT non supporté par ce navigateur");
return;
}
if (this.isListeningActive) {
this.recognition.stop();
}
if (this.isListeningActive) {
this.recognition.stop();
}
this.recognition.lang = lang;
this.isListeningActive = true;
this.recognition.lang = lang;
this.isListeningActive = true;
this.recognition.onstart = () => {
onStart();
};
this.recognition.onstart = () => {
onStart();
};
this.recognition.onresult = (event: any) => {
const resultText = event.results[0][0].transcript;
onResult(resultText);
};
this.recognition.onresult = (event: any) => {
const resultText = event.results[0][0].transcript;
onResult(resultText);
};
this.recognition.onerror = (event: any) => {
onError(event.error);
};
this.recognition.onerror = (event: any) => {
onError(event.error);
};
this.recognition.onend = () => {
this.isListeningActive = false;
onEnd();
};
this.recognition.onend = () => {
this.isListeningActive = false;
onEnd();
};
try {
this.recognition.start();
} catch (e: any) {
onError(e.message || "Erreur de démarrage de l'écoute");
try {
this.recognition.start();
} catch (e: any) {
onError(e.message || "Erreur de démarrage de l'écoute");
}
}
}
// Arrêter l'écoute vocale
public stopListening() {
if (this.recognition && this.isListeningActive) {
this.recognition.stop();
this.isListeningActive = false;
if (this.sttProvider === 'qwen3-asr') {
this.stopListeningAPI();
} else {
if (this.recognition && this.isListeningActive) {
this.recognition.stop();
this.isListeningActive = false;
}
}
}
}
+163 -54
View File
@@ -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;
}
}
}
+1
View File
@@ -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;