mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Release EveFlow 1.0.3 visual cockpit update
This commit is contained in:
1 parent
17e573911d
commit
6d4499f78f
13 files changed
+2234
-306
No files matched your search
@@ -0,0 +1,56 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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: npm run dist
|
||||||
|
|
||||||
|
- name: Upload Windows artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: EveFlow-Windows-${{ github.ref_name }}
|
||||||
|
path: |
|
||||||
|
out/*.exe
|
||||||
|
out/*.exe.blockmap
|
||||||
|
out/latest.yml
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Publish GitHub release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
out/*.exe
|
||||||
|
out/*.exe.blockmap
|
||||||
|
out/latest.yml
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
out/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.log
|
*.log
|
||||||
@@ -199,10 +199,10 @@ async function loadApp() {
|
|||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1000,
|
width: 1120,
|
||||||
height: 700,
|
height: 860,
|
||||||
minWidth: 400,
|
minWidth: 900,
|
||||||
minHeight: 500,
|
minHeight: 680,
|
||||||
frame: false, // Supprime la barre de titre classique pour un style rétro-futuriste personnalisé
|
frame: false, // Supprime la barre de titre classique pour un style rétro-futuriste personnalisé
|
||||||
transparent: true, // Permet la transparence (fond de fenêtre transparent)
|
transparent: true, // Permet la transparence (fond de fenêtre transparent)
|
||||||
backgroundColor: '#00000000', // Fond transparent
|
backgroundColor: '#00000000', // Fond transparent
|
||||||
@@ -294,14 +294,38 @@ ipcMain.on('set-window-mode', (event, mode) => {
|
|||||||
} else {
|
} else {
|
||||||
// Mode normal : chat complet
|
// Mode normal : chat complet
|
||||||
mainWindow.setAlwaysOnTop(false);
|
mainWindow.setAlwaysOnTop(false);
|
||||||
mainWindow.setMinimumSize(400, 500);
|
mainWindow.setMinimumSize(900, 680);
|
||||||
mainWindow.setSize(1000, 700);
|
mainWindow.setSize(1120, 860);
|
||||||
mainWindow.center();
|
mainWindow.center();
|
||||||
|
|
||||||
event.reply('window-mode-changed', 'normal');
|
event.reply('window-mode-changed', 'normal');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// IPC Handler pour ouvrir/fermer le cockpit sans ecraser Eve.
|
||||||
|
ipcMain.on('set-cockpit-mode', (_event, isOpen) => {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
if (mainWindow.isMaximized()) return;
|
||||||
|
|
||||||
|
const { screen } = require('electron');
|
||||||
|
const display = screen.getDisplayMatching(mainWindow.getBounds());
|
||||||
|
const workArea = display.workArea;
|
||||||
|
const bounds = mainWindow.getBounds();
|
||||||
|
const targetWidth = isOpen ? 1460 : 1120;
|
||||||
|
const nextWidth = Math.min(targetWidth, Math.max(900, workArea.width - 40));
|
||||||
|
const nextHeight = Math.min(Math.max(bounds.height, 820), workArea.height - 40);
|
||||||
|
const nextX = Math.min(bounds.x, workArea.x + workArea.width - nextWidth - 20);
|
||||||
|
const nextY = Math.min(bounds.y, workArea.y + workArea.height - nextHeight - 20);
|
||||||
|
|
||||||
|
mainWindow.setMinimumSize(isOpen ? 1180 : 900, 680);
|
||||||
|
mainWindow.setBounds({
|
||||||
|
x: Math.max(workArea.x + 20, nextX),
|
||||||
|
y: Math.max(workArea.y + 20, nextY),
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight
|
||||||
|
}, true);
|
||||||
|
});
|
||||||
|
|
||||||
// ── IPC Handler : écriture des logs du renderer vers disque ─────────────────
|
// ── IPC Handler : écriture des logs du renderer vers disque ─────────────────
|
||||||
ipcMain.on('write-log', (event, entry) => {
|
ipcMain.on('write-log', (event, entry) => {
|
||||||
writeLogEntry(entry);
|
writeLogEntry(entry);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-three/drei": "^9.105.0",
|
"@react-three/drei": "^9.105.0",
|
||||||
"@react-three/fiber": "^8.16.0",
|
"@react-three/fiber": "^8.16.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"description": "Retro-futuristic Windows companion with 3D Eve bot, local TTS/STT, and multi-agent connectivity",
|
"description": "Retro-futuristic Windows companion with 3D Eve bot, local TTS/STT, and multi-agent connectivity",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { contextBridge, ipcRenderer } = require('electron');
|
|||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
windowControl: (action) => ipcRenderer.send('window-control', action),
|
windowControl: (action) => ipcRenderer.send('window-control', action),
|
||||||
setWindowMode: (mode) => ipcRenderer.send('set-window-mode', mode),
|
setWindowMode: (mode) => ipcRenderer.send('set-window-mode', mode),
|
||||||
|
setCockpitMode: (isOpen) => ipcRenderer.send('set-cockpit-mode', isOpen),
|
||||||
onWindowModeChanged: (callback) => {
|
onWindowModeChanged: (callback) => {
|
||||||
const subscription = (event, mode) => callback(mode);
|
const subscription = (event, mode) => callback(mode);
|
||||||
ipcRenderer.on('window-mode-changed', subscription);
|
ipcRenderer.on('window-mode-changed', subscription);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 621 KiB |
+279
-249
@@ -3,9 +3,10 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import {
|
import {
|
||||||
Send, Mic, MicOff, Settings, MessageSquare, Volume2, VolumeX,
|
Send, Mic, MicOff, Settings, MessageSquare, Volume2, VolumeX,
|
||||||
Sparkles, Cpu, Minimize2, X, AlertCircle, StopCircle, Paperclip,
|
Cpu, Minimize2, X, AlertCircle, StopCircle, Paperclip,
|
||||||
CalendarClock, History, Wrench, Play, Pause, RefreshCw, Trash2, Plus,
|
CalendarClock, History, Wrench, Play, Pause, RefreshCw, Trash2, Plus,
|
||||||
CheckCircle2, WifiOff, Edit3, Save
|
CheckCircle2, WifiOff, Edit3, Save, ChevronUp, ExternalLink,
|
||||||
|
BriefcaseBusiness, AlertTriangle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ThreeCanvas, ToolEvent } from './components/ThreeCanvas';
|
import { ThreeCanvas, ToolEvent } from './components/ThreeCanvas';
|
||||||
import { AudioService } from './services/audioService';
|
import { AudioService } from './services/audioService';
|
||||||
@@ -13,7 +14,6 @@ import { AgentService, AgentConfig, AgentProvider } from './services/agentServic
|
|||||||
import { EmotionService, EveAvatar, EveEmotion } from './services/emotionService';
|
import { EmotionService, EveAvatar, EveEmotion } from './services/emotionService';
|
||||||
import { HermesJob, HermesJobDraft, PollingService } from './services/pollingService';
|
import { HermesJob, HermesJobDraft, PollingService } from './services/pollingService';
|
||||||
import { AppCallbacks } from './services/toolRegistry';
|
import { AppCallbacks } from './services/toolRegistry';
|
||||||
import { version } from '../package.json';
|
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -585,6 +585,7 @@ const HermesCockpitWindow: React.FC<{
|
|||||||
onRun: (job: HermesJob) => void;
|
onRun: (job: HermesJob) => void;
|
||||||
onDelete: (job: HermesJob) => void;
|
onDelete: (job: HermesJob) => void;
|
||||||
onOpenRun: (run: HermesRunRecord) => void;
|
onOpenRun: (run: HermesRunRecord) => void;
|
||||||
|
onClose: () => void;
|
||||||
}> = ({
|
}> = ({
|
||||||
events,
|
events,
|
||||||
isWorking,
|
isWorking,
|
||||||
@@ -606,7 +607,8 @@ const HermesCockpitWindow: React.FC<{
|
|||||||
onResume,
|
onResume,
|
||||||
onRun,
|
onRun,
|
||||||
onDelete,
|
onDelete,
|
||||||
onOpenRun
|
onOpenRun,
|
||||||
|
onClose
|
||||||
}) => {
|
}) => {
|
||||||
const latestEvents = events.slice(-8).reverse();
|
const latestEvents = events.slice(-8).reverse();
|
||||||
const [activeTab, setActiveTab] = useState<HermesCockpitTab>('tools');
|
const [activeTab, setActiveTab] = useState<HermesCockpitTab>('tools');
|
||||||
@@ -726,9 +728,14 @@ const HermesCockpitWindow: React.FC<{
|
|||||||
<div className="tool-screen-header">
|
<div className="tool-screen-header">
|
||||||
<span className={`tool-screen-led ${syncState}`} />
|
<span className={`tool-screen-led ${syncState}`} />
|
||||||
<span>COCKPIT HERMES</span>
|
<span>COCKPIT HERMES</span>
|
||||||
<button className="hermes-icon-btn" onClick={onRefresh} title="Synchroniser Hermes">
|
<div className="hermes-header-actions">
|
||||||
<RefreshCw size={12} />
|
<button className="hermes-icon-btn" onClick={onRefresh} title="Synchroniser Hermes">
|
||||||
</button>
|
<RefreshCw size={12} />
|
||||||
|
</button>
|
||||||
|
<button className="hermes-icon-btn" onClick={onClose} title="Refermer le cockpit Hermes">
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hermes-cockpit-status">
|
<div className="hermes-cockpit-status">
|
||||||
{syncState === 'offline' ? <WifiOff size={12} /> : <CheckCircle2 size={12} />}
|
{syncState === 'offline' ? <WifiOff size={12} /> : <CheckCircle2 size={12} />}
|
||||||
@@ -863,11 +870,12 @@ export const App: React.FC = () => {
|
|||||||
const [currentEmotion, setCurrentEmotion] = useState<EveEmotion>('neutral');
|
const [currentEmotion, setCurrentEmotion] = useState<EveEmotion>('neutral');
|
||||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||||
const [isListening, setIsListening] = useState(false);
|
const [isListening, setIsListening] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted] = useState(false);
|
||||||
|
|
||||||
// Fenêtre & Navigation
|
// Fenêtre & Navigation
|
||||||
const [isFloatingMode, setIsFloatingMode] = useState(false);
|
const [isFloatingMode, setIsFloatingMode] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [showHermesCockpit, setShowHermesCockpit] = useState(false);
|
||||||
|
|
||||||
// Paramètres d'Agents et de Voix (sauvegardés localement)
|
// Paramètres d'Agents et de Voix (sauvegardés localement)
|
||||||
const [agentConfig, setAgentConfig] = useState<AgentConfig>({
|
const [agentConfig, setAgentConfig] = useState<AgentConfig>({
|
||||||
@@ -886,6 +894,12 @@ export const App: React.FC = () => {
|
|||||||
const [sttLang, setSttLang] = useState<string>('fr-FR');
|
const [sttLang, setSttLang] = useState<string>('fr-FR');
|
||||||
const [ttsProvider, setTtsProvider] = useState<'system' | 'google-free'>('google-free');
|
const [ttsProvider, setTtsProvider] = useState<'system' | 'google-free'>('google-free');
|
||||||
const activeAvatar = AVATAR_PROFILES[avatarId];
|
const activeAvatar = AVATAR_PROFILES[avatarId];
|
||||||
|
const hermesUnreadRuns = hermesRunHistory.filter(run => !run.read).length;
|
||||||
|
const hermesSyncLabel = hermesSyncState === 'offline'
|
||||||
|
? 'Hermes offline'
|
||||||
|
: hermesSyncState === 'syncing'
|
||||||
|
? 'Hermes sync'
|
||||||
|
: 'Hermes OK';
|
||||||
const pushToolEvent = (event: Omit<ToolEvent, 'id' | 'ts'>) => {
|
const pushToolEvent = (event: Omit<ToolEvent, 'id' | 'ts'>) => {
|
||||||
setToolEvents(prev => [
|
setToolEvents(prev => [
|
||||||
...prev.slice(-11),
|
...prev.slice(-11),
|
||||||
@@ -1296,6 +1310,17 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleHermesCockpit = () => {
|
||||||
|
const nextOpen = !showHermesCockpit;
|
||||||
|
setShowHermesCockpit(nextOpen);
|
||||||
|
window.electronAPI?.setCockpitMode?.(nextOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeHermesCockpit = () => {
|
||||||
|
setShowHermesCockpit(false);
|
||||||
|
window.electronAPI?.setCockpitMode?.(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSendMessage = async (text: string) => {
|
const handleSendMessage = async (text: string) => {
|
||||||
if (!text.trim() && attachments.length === 0) return;
|
if (!text.trim() && attachments.length === 0) return;
|
||||||
if (isSending) return;
|
if (isSending) return;
|
||||||
@@ -1540,6 +1565,8 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handlePlayLongMessage = (messageId: string, content: string) => {
|
const handlePlayLongMessage = (messageId: string, content: string) => {
|
||||||
if (!audioServiceRef.current) return;
|
if (!audioServiceRef.current) return;
|
||||||
|
|
||||||
@@ -1567,8 +1594,8 @@ export const App: React.FC = () => {
|
|||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
setCurrentEmotion('neutral');
|
setCurrentEmotion('neutral');
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch(err => {
|
||||||
console.error("[TTS] Échec de la lecture audio complète :", err);
|
console.error("Long message audio speak error:", err);
|
||||||
setCurrentlyPlayingMsgId(null);
|
setCurrentlyPlayingMsgId(null);
|
||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
setCurrentEmotion('neutral');
|
setCurrentEmotion('neutral');
|
||||||
@@ -1576,84 +1603,55 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app-container ${isFloatingMode ? 'transparent-bg' : ''}`}>
|
<div className={`app-container ${isFloatingMode ? 'transparent-bg' : ''}`}>
|
||||||
|
|
||||||
{/* 1. APP HEADER */}
|
{/* 1. APP HEADER */}
|
||||||
{!isFloatingMode && (
|
{!isFloatingMode && (
|
||||||
<header className="app-header" style={{ WebkitAppRegion: 'drag' } as any}>
|
<header className="app-header" style={{ WebkitAppRegion: 'drag' } as any}>
|
||||||
<div className="flex-row">
|
<div className="flex-row" style={{ display: 'flex', alignItems: 'center', gap: '6px', WebkitAppRegion: 'no-drag' } as any}>
|
||||||
<div className="h-3 w-3 rounded-full pulsing-led" style={{ width: '12px', height: '12px', borderRadius: '50%', backgroundColor: activeAvatar.accent, boxShadow: `0 0 12px ${activeAvatar.accent}` }}></div>
|
<span className="logo-dot" />
|
||||||
<h1 className="neon-title" style={{ fontFamily: 'var(--font-title)', letterSpacing: '2px', color: 'var(--text-primary)', fontSize: '18px', fontWeight: 800, display: 'flex', alignItems: 'center', gap: '10px', lineHeight: 1 }}>
|
<span className="logo-text">EVEFLOW</span>
|
||||||
EVEFLOW
|
|
||||||
<span className="header-subtitle" style={{ color: 'var(--text-secondary)', fontWeight: 500, fontSize: '14px' }}>// ANALOG SPACE OS</span>
|
|
||||||
<span className="sys-ready-badge" style={{ fontSize: '9px', padding: '3px 10px', backgroundColor: 'var(--accent-light)', color: 'var(--text-neon)', border: '1px solid rgba(0,212,245,0.4)', borderRadius: '20px', fontWeight: 'bold', fontFamily: 'var(--font-mono)' }}>SYS_READY_V{version}</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-row" style={{ WebkitAppRegion: 'no-drag' } as any}>
|
{agentConfig.provider === 'hermes' && (
|
||||||
{/* Statut Agent */}
|
<div className="header-hermes-status" style={{
|
||||||
<div className="flex-row agent-status-badge" style={{ padding: '6px 14px', backgroundColor: 'var(--bg-primary)', fontSize: '11px', fontWeight: '800', color: 'var(--text-neon)', border: '2px solid var(--text-primary)', borderRadius: '20px', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
color: hermesSyncState === 'offline' ? '#ef4444' : '#10b981',
|
||||||
<Cpu style={{ width: '13px', height: '13px', color: 'var(--accent-cyan)' }} />
|
borderColor: hermesSyncState === 'offline' ? 'rgba(239, 68, 68, 0.2)' : 'rgba(0, 242, 255, 0.2)',
|
||||||
<span>[ RECEIVER: {agentConfig.provider} ]</span>
|
WebkitAppRegion: 'no-drag'
|
||||||
|
} as any}>
|
||||||
|
<span className="status-dot" style={{
|
||||||
|
backgroundColor: hermesSyncState === 'offline' ? '#ef4444' : '#10b981',
|
||||||
|
boxShadow: hermesSyncState === 'offline' ? '0 0 10px #ef4444' : '0 0 10px #10b981'
|
||||||
|
}} />
|
||||||
|
<span>{hermesSyncState === 'offline' ? 'Hermes OFFLINE' : 'Hermes OK'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sourdine */}
|
<div className="window-controls" style={{ WebkitAppRegion: 'no-drag' } as any}>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
className="control-btn"
|
||||||
if (audioServiceRef.current && isSpeaking) {
|
|
||||||
audioServiceRef.current.stopSpeaking();
|
|
||||||
setIsSpeaking(false);
|
|
||||||
}
|
|
||||||
setIsMuted(!isMuted);
|
|
||||||
}}
|
|
||||||
className="glow-input"
|
|
||||||
style={{ padding: '8px 12px', width: 'auto', borderRadius: '20px', display: 'flex', alignItems: 'center', cursor: 'pointer', borderColor: isMuted ? '#ef4444' : 'rgba(0,212,245,0.3)', backgroundColor: isMuted ? 'rgba(239,68,68,0.15)' : 'var(--bg-secondary)' }}
|
|
||||||
title={isMuted ? `Activer la voix de ${activeAvatar.name}` : `Couper la voix de ${activeAvatar.name}`}
|
|
||||||
>
|
|
||||||
{isMuted ? <VolumeX style={{ width: '16px', height: '16px', color: '#ef4444' }} /> : <Volume2 style={{ width: '16px', height: '16px', color: 'var(--text-primary)' }} />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Paramètres */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSettings(!showSettings)}
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
className="glow-input"
|
title="Configuration globale"
|
||||||
style={{ padding: '8px 12px', width: 'auto', borderRadius: '20px', display: 'flex', alignItems: 'center', cursor: 'pointer', borderColor: showSettings ? 'var(--accent-cyan)' : 'rgba(0,212,245,0.3)', backgroundColor: showSettings ? 'var(--accent-light)' : 'var(--bg-secondary)' }}
|
|
||||||
title="Paramètres de configuration"
|
|
||||||
>
|
>
|
||||||
<Settings style={{ width: '16px', height: '16px', color: 'var(--text-primary)' }} />
|
<Settings size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Mode Widget Flottant */}
|
|
||||||
<button
|
|
||||||
onClick={toggleWindowMode}
|
|
||||||
className="glow-input"
|
|
||||||
style={{ padding: '8px 12px', width: 'auto', borderRadius: '20px', borderColor: 'rgba(0,212,245,0.3)', backgroundColor: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
|
||||||
title="Activer le mode compagnon flottant"
|
|
||||||
>
|
|
||||||
<Minimize2 style={{ width: '16px', height: '16px', color: 'var(--text-primary)' }} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Electron Controls */}
|
|
||||||
{window.electronAPI && (
|
{window.electronAPI && (
|
||||||
<div className="flex-row" style={{ borderLeft: '2px solid var(--text-primary)', paddingLeft: '12px' }}>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleWindowControl('minimize')}
|
className="control-btn"
|
||||||
className="glow-input"
|
onClick={() => handleWindowControl('minimize')}
|
||||||
style={{ padding: '8px 12px', width: 'auto', cursor: 'pointer', border: 'none', background: 'transparent', color: 'var(--text-primary)', fontWeight: 'bold' }}
|
title="Réduire"
|
||||||
>
|
>
|
||||||
—
|
<Minimize2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleWindowControl('close')}
|
className="control-btn close"
|
||||||
className="glow-input"
|
onClick={() => handleWindowControl('close')}
|
||||||
style={{ padding: '8px 12px', width: 'auto', cursor: 'pointer', border: 'none', background: 'transparent', color: '#ef4444', fontWeight: 'bold' }}
|
title="Fermer"
|
||||||
>
|
>
|
||||||
<X style={{ width: '16px', height: '16px' }} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -1661,130 +1659,165 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
{/* 2. MAIN LAYOUT CONTAINER */}
|
{/* 2. MAIN LAYOUT CONTAINER */}
|
||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
|
|
||||||
{/* CHAT SECTION (De gauche) */}
|
{/* CHAT SECTION (De gauche) */}
|
||||||
{!isFloatingMode && !showSettings && (
|
{!isFloatingMode && !showSettings && (
|
||||||
<section className="chat-section">
|
<section className="chat-section">
|
||||||
<div className="messages-container">
|
{agentConfig.provider === 'hermes' && (
|
||||||
{messages.map((msg) => (
|
<div className="chat-status-bar">
|
||||||
<div
|
<div className={`status-item ${hermesSyncState === 'offline' ? 'red' : 'green'}`}>
|
||||||
key={msg.id}
|
{hermesSyncState === 'offline' ? <WifiOff size={14} /> : <CheckCircle2 size={14} />}
|
||||||
className={`message-row ${msg.role === 'user' ? 'user' : 'assistant'}`}
|
<span>{hermesSyncLabel}</span>
|
||||||
|
</div>
|
||||||
|
<span className="status-separator">|</span>
|
||||||
|
<div className="status-item blue">
|
||||||
|
<BriefcaseBusiness size={14} />
|
||||||
|
<span>{hermesJobs.length} job{hermesJobs.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<span className="status-separator">|</span>
|
||||||
|
<div className={`status-item ${hermesUnreadRuns > 0 ? 'red' : 'blue'}`}>
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span>{hermesUnreadRuns} alerte{hermesUnreadRuns > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="cockpit-btn"
|
||||||
|
onClick={toggleHermesCockpit}
|
||||||
|
title={showHermesCockpit ? 'Refermer le cockpit Hermes' : 'Ouvrir le cockpit Hermes'}
|
||||||
>
|
>
|
||||||
<div className={`message-bubble ${msg.role === 'user' ? 'user' : 'assistant'}`}>
|
<span>Cockpit</span>
|
||||||
{msg.role === 'assistant' ? (
|
<ChevronUp size={12} className={`chevron ${showHermesCockpit ? 'rotated' : ''}`} />
|
||||||
<div className="message-meta" style={{ fontFamily: 'var(--font-mono)', letterSpacing: '1.5px', color: 'var(--text-neon)', display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
|
</button>
|
||||||
<Sparkles style={{ width: '12px', height: '12px', color: 'var(--accent-cyan)', flexShrink: 0 }} />
|
</div>
|
||||||
<span>[TRANSMISSION: {activeAvatar.name.toUpperCase()} // TELEMETRY: {msg.emotion?.toUpperCase() || 'NEUTRAL'}]</span>
|
)}
|
||||||
{msg.source && msg.source !== 'eveflow' && (
|
<div className="messages-container">
|
||||||
<span style={{ fontSize: '9px', padding: '2px 7px', backgroundColor: 'rgba(0,212,245,0.15)', border: '1px solid rgba(0,212,245,0.35)', borderRadius: '10px', color: 'var(--accent-cyan)', fontWeight: 800, letterSpacing: '1px', textTransform: 'uppercase' }}>
|
{messages.map((msg) => {
|
||||||
{msg.source}
|
const isUser = msg.role === 'user';
|
||||||
</span>
|
|
||||||
)}
|
const markdownAndMedia = (
|
||||||
</div>
|
<div className="markdown-content">
|
||||||
) : (
|
<ReactMarkdown
|
||||||
<div className="message-meta" style={{ fontFamily: 'var(--font-mono)', letterSpacing: '1.5px', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
|
remarkPlugins={[remarkGfm]}
|
||||||
<span>[TELECOMMANDE // CONSOLE_INPUT]</span>
|
components={{
|
||||||
{msg.source && msg.source !== 'eveflow' && (
|
img: ({ node, ...props }) => (
|
||||||
<span style={{ fontSize: '9px', padding: '2px 7px', backgroundColor: 'rgba(255,160,0,0.15)', border: '1px solid rgba(255,160,0,0.35)', borderRadius: '10px', color: '#ffb84d', fontWeight: 800, letterSpacing: '1px', textTransform: 'uppercase' }}>
|
<ResilientImage
|
||||||
{msg.source}
|
src={props.src || ''}
|
||||||
</span>
|
alt={props.alt}
|
||||||
)}
|
agentUrl={agentConfig.hermesUrl}
|
||||||
|
provider={agentConfig.provider}
|
||||||
|
hermesApiKey={agentConfig.hermesApiKey}
|
||||||
|
hermesSessionKey={agentConfig.hermesSessionKey}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
a: ({ node, ...props }) => {
|
||||||
|
const isLocal = props.href?.startsWith('file://') || /^[a-zA-Z]:/.test(props.href || '');
|
||||||
|
if (isLocal) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
let cleanPath = props.href || '';
|
||||||
|
if (cleanPath.startsWith('file:///')) {
|
||||||
|
cleanPath = cleanPath.substring(8);
|
||||||
|
} else if (cleanPath.startsWith('file://')) {
|
||||||
|
cleanPath = cleanPath.substring(7);
|
||||||
|
}
|
||||||
|
cleanPath = cleanPath.replace(/\//g, '\\');
|
||||||
|
(window as any).electronAPI?.openLocalFile?.(cleanPath)
|
||||||
|
.catch((err: any) => console.error("Impossible d'ouvrir le fichier :", err));
|
||||||
|
}}
|
||||||
|
className="markdown-link-local"
|
||||||
|
style={{ color: 'var(--text-neon)', textDecoration: 'underline', fontWeight: 'bold' }}
|
||||||
|
title={`Ouvrir le fichier local : ${props.href}`}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <a href={props.href} target="_blank" rel="noopener noreferrer">{props.children}</a>;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preprocessMessageContent(msg.content)}
|
||||||
|
</ReactMarkdown>
|
||||||
|
|
||||||
|
{msg.images && msg.images.length > 0 && (
|
||||||
|
<div className="message-images-gallery" style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{msg.images.map((imgUrl, i) => (
|
||||||
|
<ResilientImage
|
||||||
|
key={i}
|
||||||
|
src={imgUrl}
|
||||||
|
alt="Image jointe"
|
||||||
|
agentUrl={agentConfig.hermesUrl}
|
||||||
|
provider={agentConfig.provider}
|
||||||
|
hermesApiKey={agentConfig.hermesApiKey}
|
||||||
|
hermesSessionKey={agentConfig.hermesSessionKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="markdown-content">
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
components={{
|
|
||||||
img: ({ node, ...props }) => (
|
|
||||||
<ResilientImage
|
|
||||||
src={props.src || ''}
|
|
||||||
alt={props.alt}
|
|
||||||
agentUrl={agentConfig.hermesUrl}
|
|
||||||
provider={agentConfig.provider}
|
|
||||||
hermesApiKey={agentConfig.hermesApiKey}
|
|
||||||
hermesSessionKey={agentConfig.hermesSessionKey}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
a: ({ node, ...props }) => {
|
|
||||||
const isLocal = props.href?.startsWith('file://') || /^[a-zA-Z]:/.test(props.href || '');
|
|
||||||
if (isLocal) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
let cleanPath = props.href || '';
|
|
||||||
if (cleanPath.startsWith('file:///')) {
|
|
||||||
cleanPath = cleanPath.substring(8);
|
|
||||||
} else if (cleanPath.startsWith('file://')) {
|
|
||||||
cleanPath = cleanPath.substring(7);
|
|
||||||
}
|
|
||||||
cleanPath = cleanPath.replace(/\//g, '\\');
|
|
||||||
(window as any).electronAPI?.openLocalFile?.(cleanPath)
|
|
||||||
.catch((err: any) => console.error("Impossible d'ouvrir le fichier :", err));
|
|
||||||
}}
|
|
||||||
className="markdown-link-local"
|
|
||||||
style={{ color: 'var(--text-neon)', textDecoration: 'underline', fontWeight: 'bold' }}
|
|
||||||
title={`Ouvrir le fichier local : ${props.href}`}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <a href={props.href} target="_blank" rel="noopener noreferrer">{props.children}</a>;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{preprocessMessageContent(msg.content)}
|
|
||||||
</ReactMarkdown>
|
|
||||||
|
|
||||||
{msg.images && msg.images.length > 0 && (
|
{!isUser && msg.content.length >= TTS_LONG_TEXT_THRESHOLD && (
|
||||||
<div className="message-images-gallery" style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
<div className="long-message-audio-controls">
|
||||||
{msg.images.map((imgUrl, i) => (
|
<button
|
||||||
<ResilientImage
|
onClick={() => handlePlayLongMessage(msg.id, msg.content)}
|
||||||
key={i}
|
className={`audio-control-btn ${currentlyPlayingMsgId === msg.id ? 'playing' : ''}`}
|
||||||
src={imgUrl}
|
title={currentlyPlayingMsgId === msg.id ? "Arrêter la lecture vocale complète" : "Écouter la lecture vocale complète"}
|
||||||
alt="Image jointe"
|
>
|
||||||
agentUrl={agentConfig.hermesUrl}
|
{currentlyPlayingMsgId === msg.id ? (
|
||||||
provider={agentConfig.provider}
|
<>
|
||||||
hermesApiKey={agentConfig.hermesApiKey}
|
<VolumeX style={{ width: '13px', height: '13px' }} />
|
||||||
hermesSessionKey={agentConfig.hermesSessionKey}
|
<span>[ STOP_AUDIO ]</span>
|
||||||
/>
|
</>
|
||||||
))}
|
) : (
|
||||||
</div>
|
<>
|
||||||
)}
|
<Volume2 style={{ width: '13px', height: '13px' }} />
|
||||||
|
<span>[ LIRE_MESSAGE_COMPLET ]</span>
|
||||||
{msg.role === 'assistant' && msg.content.length >= TTS_LONG_TEXT_THRESHOLD && (
|
</>
|
||||||
<div className="long-message-audio-controls">
|
)}
|
||||||
<button
|
</button>
|
||||||
onClick={() => handlePlayLongMessage(msg.id, msg.content)}
|
</div>
|
||||||
className={`audio-control-btn ${currentlyPlayingMsgId === msg.id ? 'playing' : ''}`}
|
)}
|
||||||
title={currentlyPlayingMsgId === msg.id ? "Arrêter la lecture vocale complète" : "Écouter la lecture vocale complète"}
|
|
||||||
>
|
|
||||||
{currentlyPlayingMsgId === msg.id ? (
|
|
||||||
<>
|
|
||||||
<VolumeX style={{ width: '13px', height: '13px' }} />
|
|
||||||
<span>[ STOP_AUDIO ]</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Volume2 style={{ width: '13px', height: '13px' }} />
|
|
||||||
<span>[ LIRE_MESSAGE_COMPLET ]</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="message-time">
|
|
||||||
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
|
||||||
|
|
||||||
|
|
||||||
|
if (isUser) {
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="message-row user">
|
||||||
|
<div className="message-bubble user">
|
||||||
|
{markdownAndMedia}
|
||||||
|
<div className="message-time">
|
||||||
|
<span>{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||||
|
<span className="double-check">✓✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div key={msg.id} style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
|
||||||
|
<div className="transmission-tag">
|
||||||
|
# [TRANSMISSION: {activeAvatar.name.toUpperCase()} // TELEMETRY: {msg.emotion?.toUpperCase() || 'NEUTRAL'}]
|
||||||
|
{msg.source && msg.source !== 'eveflow' && (
|
||||||
|
<span style={{ marginLeft: '8px', fontSize: '9px', padding: '1px 6px', backgroundColor: 'rgba(0,212,245,0.15)', border: '1px solid rgba(0,212,245,0.35)', borderRadius: '10px', color: 'var(--accent-cyan)', fontWeight: 800, letterSpacing: '1px', textTransform: 'uppercase' }}>
|
||||||
|
{msg.source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="message-row assistant">
|
||||||
|
<div className="avatar-wrapper">
|
||||||
|
<img src="/avatars/eve-round.png" alt={activeAvatar.name} className="avatar-img" />
|
||||||
|
</div>
|
||||||
|
<div className="message-bubble assistant">
|
||||||
|
{markdownAndMedia}
|
||||||
|
<div className="message-time" style={{ color: 'rgba(226, 240, 255, 0.4)', fontSize: '9px', marginTop: '6px', textAlign: 'right' }}>
|
||||||
|
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className="settings-card" style={{ backgroundColor: 'rgba(239,68,68,0.1)', borderColor: 'rgba(239,68,68,0.4)', color: '#ff6b6b', padding: '16px', display: 'flex', flexDirection: 'row', gap: '12px', alignItems: 'flex-start' }}>
|
<div className="settings-card" style={{ backgroundColor: 'rgba(239,68,68,0.1)', borderColor: 'rgba(239,68,68,0.4)', color: '#ff6b6b', padding: '16px', display: 'flex', flexDirection: 'row', gap: '12px', alignItems: 'flex-start' }}>
|
||||||
@@ -1848,7 +1881,7 @@ export const App: React.FC = () => {
|
|||||||
value={inputText}
|
value={inputText}
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage(inputText)}
|
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage(inputText)}
|
||||||
placeholder="Tape ton message ici..."
|
placeholder="Message a Eve..."
|
||||||
className="glow-input"
|
className="glow-input"
|
||||||
disabled={isSending}
|
disabled={isSending}
|
||||||
/>
|
/>
|
||||||
@@ -1857,14 +1890,10 @@ export const App: React.FC = () => {
|
|||||||
{isSpeaking && (
|
{isSpeaking && (
|
||||||
<button
|
<button
|
||||||
onClick={handleStopTTS}
|
onClick={handleStopTTS}
|
||||||
className="glow-input"
|
className="input-circle-btn"
|
||||||
style={{
|
style={{
|
||||||
padding: '12px',
|
|
||||||
width: 'auto',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderColor: '#ef4444',
|
borderColor: '#ef4444',
|
||||||
backgroundColor: 'rgba(239,68,68,0.15)',
|
backgroundColor: 'rgba(239, 68, 68, 0.15)',
|
||||||
flexShrink: 0
|
|
||||||
}}
|
}}
|
||||||
title="Arrêter la lecture vocale"
|
title="Arrêter la lecture vocale"
|
||||||
>
|
>
|
||||||
@@ -1874,42 +1903,35 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="glow-input"
|
className="input-circle-btn"
|
||||||
style={{
|
style={{
|
||||||
padding: '12px',
|
borderColor: attachments.length > 0 ? 'var(--accent-cyan)' : 'rgba(226, 240, 255, 0.12)',
|
||||||
width: 'auto',
|
backgroundColor: attachments.length > 0 ? 'rgba(0, 242, 255, 0.1)' : '#050914',
|
||||||
cursor: 'pointer',
|
|
||||||
borderColor: attachments.length > 0 ? 'var(--accent-cyan)' : 'var(--text-primary)',
|
|
||||||
backgroundColor: attachments.length > 0 ? 'var(--accent-light)' : 'var(--bg-secondary)',
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
}}
|
||||||
title="Ajouter des pièces jointes (Images, Fichiers texte...)"
|
title="Ajouter des pièces jointes (Images, Fichiers texte...)"
|
||||||
>
|
>
|
||||||
<Paperclip style={{ width: '18px', height: '18px', color: attachments.length > 0 ? 'var(--accent-cyan)' : 'var(--text-primary)' }} />
|
<Paperclip style={{ width: '18px', height: '18px', color: attachments.length > 0 ? 'var(--accent-cyan)' : 'var(--text-secondary)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={isListening ? handleStopListening : handleStartVocalRecord}
|
onClick={isListening ? handleStopListening : handleStartVocalRecord}
|
||||||
className="glow-input"
|
className="input-circle-btn"
|
||||||
style={{
|
style={{
|
||||||
padding: '12px',
|
borderColor: isListening ? '#ef4444' : 'rgba(226, 240, 255, 0.12)',
|
||||||
width: 'auto',
|
backgroundColor: isListening ? 'rgba(239, 68, 68, 0.15)' : '#050914'
|
||||||
cursor: 'pointer',
|
|
||||||
borderColor: isListening ? '#ef4444' : 'var(--text-primary)',
|
|
||||||
backgroundColor: isListening ? 'rgba(239,68,68,0.15)' : 'var(--bg-secondary)'
|
|
||||||
}}
|
}}
|
||||||
title={isListening ? "Arrêter l'écoute" : "Démarrer la saisie vocale"}
|
title={isListening ? "Arrêter l'écoute" : "Démarrer la saisie vocale"}
|
||||||
>
|
>
|
||||||
{isListening ? <MicOff style={{ width: '18px', height: '18px', color: '#ef4444' }} /> : <Mic style={{ width: '18px', height: '18px', color: 'var(--text-primary)' }} />}
|
{isListening ? <MicOff style={{ width: '18px', height: '18px', color: '#ef4444' }} /> : <Mic style={{ width: '18px', height: '18px', color: 'var(--text-secondary)' }} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSendMessage(inputText)}
|
onClick={() => handleSendMessage(inputText)}
|
||||||
disabled={(!inputText.trim() && attachments.length === 0) || isSending}
|
disabled={(!inputText.trim() && attachments.length === 0) || isSending}
|
||||||
className="neon-btn"
|
className="input-send-btn"
|
||||||
|
title="Envoyer le message"
|
||||||
>
|
>
|
||||||
<Send style={{ width: '14px', height: '14px' }} />
|
<Send style={{ width: '16px', height: '16px' }} />
|
||||||
<span className="btn-text">Envoyer</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -2165,17 +2187,21 @@ export const App: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* COMPAGNON 3D SECTION (De droite) */}
|
{/* COMPAGNON 3D SECTION (De droite) */}
|
||||||
<section className={`eve-section ${isFloatingMode ? 'floating-widget' : ''}`}>
|
<section className={`eve-section ${isFloatingMode ? 'floating-widget' : ''} ${isListening || isSpeaking || isSending ? 'eve-active' : ''}`}>
|
||||||
<div className={`avatar-ambient-field ${currentEmotion === 'thinking' || isSending ? 'thinking' : 'idle'}`}>
|
{!isFloatingMode && (
|
||||||
<span className="ambient-scanline scanline-a" />
|
<div className="eve-status-pill">
|
||||||
<span className="ambient-scanline scanline-b" />
|
<span className="status-led" />
|
||||||
<span className="ambient-data data-a" />
|
<span>{isListening ? 'Eve ecoute' : isSpeaking ? 'Eve parle' : isSending ? 'Eve reflechit' : 'Eve ecoute'}</span>
|
||||||
<span className="ambient-data data-b" />
|
</div>
|
||||||
<span className="ambient-data data-c" />
|
)}
|
||||||
<span className="ambient-node node-a" />
|
|
||||||
<span className="ambient-node node-b" />
|
{!isFloatingMode && (
|
||||||
<span className="ambient-node node-c" />
|
<button onClick={toggleWindowMode} className="eve-popout-btn" title="Mode Widget flottant">
|
||||||
</div>
|
<ExternalLink size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Header flottant discret pour contrôle en mode Widget */}
|
{/* Header flottant discret pour contrôle en mode Widget */}
|
||||||
{isFloatingMode && (
|
{isFloatingMode && (
|
||||||
@@ -2207,37 +2233,12 @@ export const App: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rendu Canvas 3D */}
|
{/* Rendu Canvas 3D */}
|
||||||
<div style={{ flex: 1, width: '100%', position: 'relative' }}>
|
<div style={{ flex: 1, width: '100%', position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<ThreeCanvas
|
<ThreeCanvas
|
||||||
emotion={currentEmotion}
|
emotion={currentEmotion}
|
||||||
isSpeaking={isSpeaking}
|
isSpeaking={isSpeaking}
|
||||||
avatarId={avatarId}
|
avatarId={avatarId}
|
||||||
/>
|
/>
|
||||||
{agentConfig.provider === 'hermes' && (
|
|
||||||
<HermesCockpitWindow
|
|
||||||
events={toolEvents}
|
|
||||||
isWorking={isSending}
|
|
||||||
jobs={hermesJobs}
|
|
||||||
runs={hermesRunHistory}
|
|
||||||
syncState={hermesSyncState}
|
|
||||||
syncDetail={hermesSyncDetail}
|
|
||||||
lastSyncAt={hermesLastSyncAt}
|
|
||||||
draft={hermesCronDraft}
|
|
||||||
editingJobId={editingHermesJobId}
|
|
||||||
isSaving={isSavingHermesCron}
|
|
||||||
onDraftChange={setHermesCronDraft}
|
|
||||||
onCreate={handleCreateHermesCron}
|
|
||||||
onCancelEdit={handleCancelEditHermesCron}
|
|
||||||
onStartEdit={handleStartEditHermesCron}
|
|
||||||
onUpdate={handleUpdateHermesCron}
|
|
||||||
onRefresh={refreshHermesJobs}
|
|
||||||
onPause={(job) => runHermesCronAction(job, 'pause')}
|
|
||||||
onResume={(job) => runHermesCronAction(job, 'resume')}
|
|
||||||
onRun={(job) => runHermesCronAction(job, 'run')}
|
|
||||||
onDelete={(job) => runHermesCronAction(job, 'delete')}
|
|
||||||
onOpenRun={openHermesRunInChat}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{avatarId !== 'eve' && activeAvatar.assetPath && (
|
{avatarId !== 'eve' && activeAvatar.assetPath && (
|
||||||
<div style={{ position: 'absolute', top: '16px', left: '50%', transform: 'translateX(-50%)', padding: '7px 14px', borderRadius: '18px', backgroundColor: 'var(--bg-secondary)', border: '1px solid rgba(0,212,245,0.25)', boxShadow: '0 0 20px rgba(0,212,245,0.1)', fontSize: '9px', fontFamily: 'var(--font-mono)', fontWeight: 800, letterSpacing: '0.8px', color: 'var(--text-secondary)', textTransform: 'uppercase', pointerEvents: 'none' }}>
|
<div style={{ position: 'absolute', top: '16px', left: '50%', transform: 'translateX(-50%)', padding: '7px 14px', borderRadius: '18px', backgroundColor: 'var(--bg-secondary)', border: '1px solid rgba(0,212,245,0.25)', boxShadow: '0 0 20px rgba(0,212,245,0.1)', fontSize: '9px', fontFamily: 'var(--font-mono)', fontWeight: 800, letterSpacing: '0.8px', color: 'var(--text-secondary)', textTransform: 'uppercase', pointerEvents: 'none' }}>
|
||||||
{activeAvatar.name}: asset premium attendu
|
{activeAvatar.name}: asset premium attendu
|
||||||
@@ -2245,7 +2246,7 @@ export const App: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Badge d'humeur en bas */}
|
{/* Badge d'humeur en bas */}
|
||||||
{!isFloatingMode && (
|
{!isFloatingMode && false && (
|
||||||
<div style={{ position: 'absolute', bottom: '16px', left: '50%', transform: 'translateX(-50%)', padding: '8px 20px', borderRadius: '30px', backgroundColor: 'var(--bg-secondary)', border: '2px solid var(--text-primary)', display: 'flex', alignItems: 'center', gap: '8px', boxShadow: '0 4px 15px rgba(45, 30, 24, 0.08)', fontSize: '12px', fontWeight: '700' }}>
|
<div style={{ position: 'absolute', bottom: '16px', left: '50%', transform: 'translateX(-50%)', padding: '8px 20px', borderRadius: '30px', backgroundColor: 'var(--bg-secondary)', border: '2px solid var(--text-primary)', display: 'flex', alignItems: 'center', gap: '8px', boxShadow: '0 4px 15px rgba(45, 30, 24, 0.08)', fontSize: '12px', fontWeight: '700' }}>
|
||||||
<span className="h-3 w-3 rounded-full pulsing-led" style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: activeAvatar.accent, boxShadow: `0 0 10px ${activeAvatar.accent}` }}></span>
|
<span className="h-3 w-3 rounded-full pulsing-led" style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: activeAvatar.accent, boxShadow: `0 0 10px ${activeAvatar.accent}` }}></span>
|
||||||
<span style={{ color: 'var(--text-secondary)', textTransform: 'uppercase', fontSize: '9px', letterSpacing: '1px', fontFamily: 'var(--font-title)' }}>Humeur:</span>
|
<span style={{ color: 'var(--text-secondary)', textTransform: 'uppercase', fontSize: '9px', letterSpacing: '1px', fontFamily: 'var(--font-title)' }}>Humeur:</span>
|
||||||
@@ -2281,8 +2282,37 @@ export const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{!isFloatingMode && !showSettings && agentConfig.provider === 'hermes' && showHermesCockpit && (
|
||||||
|
<HermesCockpitWindow
|
||||||
|
events={toolEvents}
|
||||||
|
isWorking={isSending}
|
||||||
|
jobs={hermesJobs}
|
||||||
|
runs={hermesRunHistory}
|
||||||
|
syncState={hermesSyncState}
|
||||||
|
syncDetail={hermesSyncDetail}
|
||||||
|
lastSyncAt={hermesLastSyncAt}
|
||||||
|
draft={hermesCronDraft}
|
||||||
|
editingJobId={editingHermesJobId}
|
||||||
|
isSaving={isSavingHermesCron}
|
||||||
|
onDraftChange={setHermesCronDraft}
|
||||||
|
onCreate={handleCreateHermesCron}
|
||||||
|
onCancelEdit={handleCancelEditHermesCron}
|
||||||
|
onStartEdit={handleStartEditHermesCron}
|
||||||
|
onUpdate={handleUpdateHermesCron}
|
||||||
|
onRefresh={refreshHermesJobs}
|
||||||
|
onPause={(job) => runHermesCronAction(job, 'pause')}
|
||||||
|
onResume={(job) => runHermesCronAction(job, 'resume')}
|
||||||
|
onRun={(job) => runHermesCronAction(job, 'run')}
|
||||||
|
onDelete={(job) => runHermesCronAction(job, 'delete')}
|
||||||
|
onOpenRun={openHermesRunInChat}
|
||||||
|
onClose={closeHermesCockpit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ const LoadedGlbAvatar: React.FC<{
|
|||||||
isSpeaking: boolean;
|
isSpeaking: boolean;
|
||||||
}> = ({ path, avatarId, emotion, isSpeaking }) => {
|
}> = ({ path, avatarId, emotion, isSpeaking }) => {
|
||||||
const gltf = useLoader(GLTFLoader, path);
|
const gltf = useLoader(GLTFLoader, path);
|
||||||
const scene = useMemo(() => gltf.scene.clone(true), [gltf.scene]);
|
const scene = useMemo(() => {
|
||||||
|
const cloned = gltf.scene.clone(true);
|
||||||
|
cloned.position.y = 0.27;
|
||||||
|
return cloned;
|
||||||
|
}, [gltf.scene]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const style = EmotionService.getEmotionStyle(emotion, avatarId);
|
const style = EmotionService.getEmotionStyle(emotion, avatarId);
|
||||||
@@ -48,7 +52,7 @@ const LoadedGlbAvatar: React.FC<{
|
|||||||
|
|
||||||
useFrame((state) => {
|
useFrame((state) => {
|
||||||
const t = state.clock.getElapsedTime();
|
const t = state.clock.getElapsedTime();
|
||||||
scene.position.y = Math.sin(t * 1.4) * 0.05 + 0.22;
|
scene.position.y = Math.sin(t * 1.4) * 0.05 + 0.27;
|
||||||
scene.rotation.y = Math.sin(t * 0.45) * 0.05;
|
scene.rotation.y = Math.sin(t * 0.45) * 0.05;
|
||||||
|
|
||||||
if (isSpeaking) {
|
if (isSpeaking) {
|
||||||
|
|||||||
@@ -555,8 +555,8 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
|
|||||||
|
|
||||||
// 1. Animation globale de flottaison (Lévitation magnétique)
|
// 1. Animation globale de flottaison (Lévitation magnétique)
|
||||||
if (modelRef.current) {
|
if (modelRef.current) {
|
||||||
// Oscillation en hauteur stabilisée autour de y = 0.22 (remonté pour un centrage élégant)
|
// Oscillation en hauteur stabilisée autour de y = 0.27 pour mieux occuper la fenetre avatar.
|
||||||
modelRef.current.position.y = Math.sin(t * 1.5) * 0.06 + 0.22;
|
modelRef.current.position.y = Math.sin(t * 1.5) * 0.06 + 0.27;
|
||||||
// Légère rotation sur elle-même (mouvement de veille)
|
// Légère rotation sur elle-même (mouvement de veille)
|
||||||
modelRef.current.rotation.y = Math.sin(t * 0.5) * 0.05;
|
modelRef.current.rotation.y = Math.sin(t * 0.5) * 0.05;
|
||||||
}
|
}
|
||||||
@@ -760,7 +760,7 @@ export const EveModel: React.FC<EveModelProps> = ({ emotion, isSpeaking, avatarI
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={modelRef} scale={1.0} position={[0, -0.28, 0]}>
|
<group ref={modelRef} scale={1.0} position={[0, 0.27, 0]}>
|
||||||
<primitive object={clonedObj} />
|
<primitive object={clonedObj} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { Suspense } from 'react';
|
import React, { Suspense, useRef } from 'react';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas, useFrame } from '@react-three/fiber';
|
||||||
import { OrbitControls } from '@react-three/drei';
|
import { OrbitControls } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
import { AvatarModelLoader } from './AvatarModelLoader';
|
import { AvatarModelLoader } from './AvatarModelLoader';
|
||||||
import { EveEmotion, EveAvatar } from '../services/emotionService';
|
import { EveEmotion, EveAvatar } from '../services/emotionService';
|
||||||
|
|
||||||
@@ -18,11 +19,566 @@ interface ThreeCanvasProps {
|
|||||||
avatarId?: EveAvatar;
|
avatarId?: EveAvatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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' }) => {
|
export const ThreeCanvas: React.FC<ThreeCanvasProps> = ({ emotion, isSpeaking, avatarId = 'eve' }) => {
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [0, 0.05, 6.2], fov: 36 }}
|
camera={{ position: [0, 1.1, 5.8], fov: 36 }}
|
||||||
gl={{ alpha: true, antialias: true, premultipliedAlpha: false }}
|
gl={{ alpha: true, antialias: true, premultipliedAlpha: false }}
|
||||||
onCreated={({ gl }) => {
|
onCreated={({ gl }) => {
|
||||||
gl.setClearColor(0x000000, 0);
|
gl.setClearColor(0x000000, 0);
|
||||||
@@ -41,24 +597,26 @@ export const ThreeCanvas: React.FC<ThreeCanvasProps> = ({ emotion, isSpeaking, a
|
|||||||
color="#e8f4ff"
|
color="#e8f4ff"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cyan atmosphere rim — far enough to avoid harsh specular band on helmet */}
|
{/* Cyan atmosphere rim */}
|
||||||
<pointLight position={[-5, 3, 2]} intensity={1.0} color="#60c8e8" />
|
<pointLight position={[-5, 3, 2]} intensity={1.0} color="#60c8e8" />
|
||||||
|
|
||||||
{/* Desaturated blue fill — subtle depth without coloring the white lacquer */}
|
{/* Desaturated blue fill */}
|
||||||
<pointLight position={[4, -2, 2]} intensity={0.45} color="#8ab4d8" />
|
<pointLight position={[4, -2, 2]} intensity={0.45} color="#8ab4d8" />
|
||||||
|
|
||||||
{/* Soft white back fill to keep geometry readable */}
|
{/* Soft white back fill to keep geometry readable */}
|
||||||
<pointLight position={[0, 3, -4]} intensity={1.0} color="#ffffff" />
|
<pointLight position={[0, 3, -4]} intensity={1.0} color="#ffffff" />
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<TechnicalBackdrop />
|
||||||
|
<HologramProjection emotion={emotion} isSpeaking={isSpeaking} avatarId={avatarId} />
|
||||||
<AvatarModelLoader avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />
|
<AvatarModelLoader avatarId={avatarId} emotion={emotion} isSpeaking={isSpeaking} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{/* OrbitControls calibrated to prevent clipping or losing focus */}
|
{/* OrbitControls calibrated to prevent clipping or losing focus */}
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
enableZoom={true}
|
enableZoom={true}
|
||||||
minDistance={5.2}
|
minDistance={4.8}
|
||||||
maxDistance={7.2}
|
maxDistance={6.8}
|
||||||
enablePan={false}
|
enablePan={false}
|
||||||
autoRotate={false}
|
autoRotate={false}
|
||||||
maxPolarAngle={Math.PI / 1.7}
|
maxPolarAngle={Math.PI / 1.7}
|
||||||
|
|||||||
+1289
-36
File diff suppressed because it is too large.
Load diff
Vendored
+1
@@ -4,6 +4,7 @@ interface Window {
|
|||||||
electronAPI?: {
|
electronAPI?: {
|
||||||
windowControl: (action: 'minimize' | 'maximize' | 'close') => void;
|
windowControl: (action: 'minimize' | 'maximize' | 'close') => void;
|
||||||
setWindowMode: (mode: 'floating' | 'normal') => void;
|
setWindowMode: (mode: 'floating' | 'normal') => void;
|
||||||
|
setCockpitMode: (isOpen: boolean) => void;
|
||||||
onWindowModeChanged: (callback: (mode: 'floating' | 'normal') => void) => () => void;
|
onWindowModeChanged: (callback: (mode: 'floating' | 'normal') => void) => () => void;
|
||||||
onHermesPush: (callback: (event: any) => void) => () => void;
|
onHermesPush: (callback: (event: any) => void) => () => void;
|
||||||
writeLog: (entry: any) => void;
|
writeLog: (entry: any) => void;
|
||||||
|
|||||||
Reference in new issue
Block a user