feat: capture Windows auto-détection OS, chat Léa agrandi, UX améliorée

- Capture auto : détecte OS navigateur → capture Windows ou Linux
- Timer capture utilise aussi la smart capture
- Heartbeat background permanent (même sans session)
- Tri screenshots par date (plus de vieilles captures)
- Chat Léa : 450x650, polices 11pt, redimensionnable, meilleur contraste
- Bouton Exécuter : "Linux" + "Windows" avec feedback visuel
- Délai 5s avant replay Windows (temps de réduire le navigateur)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-17 23:03:53 +01:00
parent 8175b39eba
commit 4e217e30dd
4 changed files with 81 additions and 58 deletions

View File

@@ -136,20 +136,23 @@ def capture_windows():
project_root = Path(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) project_root = Path(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
live_dir = project_root / "data" / "training" / "live_sessions" live_dir = project_root / "data" / "training" / "live_sessions"
# Trouver la session la plus récente # Chercher aussi dans les sous-dossiers machine (multi-machine)
sessions = sorted(live_dir.glob("sess_*/shots"), key=lambda p: p.parent.name, reverse=True) import time as _time
if not sessions:
return jsonify({'error': 'Aucune session Windows trouvée'}), 404
# Chercher le screenshot plein écran le plus récent (full ou heartbeat, pas les crops) # Chercher le screenshot le plus récent dans TOUS les dossiers
latest_shot = None all_shots = []
for session_shots in sessions[:3]: for pattern in ["bg_*/shots/heartbeat_*.png", "sess_*/shots/heartbeat_*.png",
shots = [s for s in session_shots.glob("*.png") "*/bg_*/shots/heartbeat_*.png", "bg_*/shots/shot_*_full.png",
if "full" in s.name or "heartbeat" in s.name or "focus" in s.name] "sess_*/shots/shot_*_full.png"]:
if shots: all_shots.extend(live_dir.glob(pattern))
shots.sort(key=lambda p: p.stat().st_mtime, reverse=True)
latest_shot = shots[0] if not all_shots:
break return jsonify({'error': 'Aucun screenshot Windows. Lancez l\'agent V1 sur le PC cible.'}), 404
# Trier par date de modification (le plus récent en premier)
all_shots.sort(key=lambda p: p.stat().st_mtime, reverse=True)
latest_shot = all_shots[0]
age_seconds = _time.time() - latest_shot.stat().st_mtime
if not latest_shot: if not latest_shot:
return jsonify({'error': 'Aucun screenshot Windows disponible'}), 404 return jsonify({'error': 'Aucun screenshot Windows disponible'}), 404
@@ -169,6 +172,8 @@ def capture_windows():
'source': 'windows', 'source': 'windows',
'file': str(latest_shot.name), 'file': str(latest_shot.name),
'session': latest_shot.parent.parent.name, 'session': latest_shot.parent.parent.name,
'age_seconds': round(age_seconds, 1),
'fresh': age_seconds < 30,
}) })
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500

View File

@@ -879,6 +879,16 @@ def execute_windows():
if not data: if not data:
return jsonify({'error': 'Aucune donnée'}), 400 return jsonify({'error': 'Aucune donnée'}), 400
# Injecter un délai de 5s avant la première action
# pour laisser le temps à l'utilisateur de réduire le navigateur
if 'actions' in data and data['actions']:
data['actions'].insert(0, {
'type': 'wait',
'action_id': 'wait_before_start',
'parameters': {'duration_ms': 5000},
'text': '',
})
# Mapper les types VWB → types executor Windows # Mapper les types VWB → types executor Windows
TYPE_MAP = { TYPE_MAP = {
'click_anchor': 'click', 'click_anchor': 'click',

View File

@@ -111,9 +111,30 @@ export default function CapturePanel({
} }
}; };
// Capture intelligente — auto-détection OS
const doSmartCapture = async () => {
const isWindows = navigator.platform?.includes('Win') || navigator.userAgent?.includes('Windows');
if (isWindows) {
try {
const resp = await fetch('/api/screen-capture/capture-windows', { method: 'POST' });
const data = await resp.json();
if (data.image) {
setCurrentCapture({
screenshot_base64: data.image,
width: data.width,
height: data.height,
source: 'windows',
} as any);
}
} catch {}
} else {
onCapture();
}
};
const handleTimerCapture = () => { const handleTimerCapture = () => {
if (timerSeconds === 0) { if (timerSeconds === 0) {
onCapture(); doSmartCapture();
return; return;
} }
@@ -127,7 +148,7 @@ export default function CapturePanel({
} else { } else {
clearInterval(interval); clearInterval(interval);
setCountdown(null); setCountdown(null);
onCapture(); doSmartCapture();
} }
}, 1000); }, 1000);
}; };
@@ -145,33 +166,10 @@ export default function CapturePanel({
<div className="capture-panel"> <div className="capture-panel">
<h3>Capture</h3> <h3>Capture</h3>
{/* Contrôles de capture */} {/* Capture — auto-détection OS navigateur */}
<div className="capture-controls"> <div className="capture-controls">
<button onClick={onCapture} disabled={countdown !== null}> <button disabled={countdown !== null} onClick={doSmartCapture}>
Capturer 📸 Capturer
</button>
<button
onClick={async () => {
try {
const resp = await fetch('/api/screen-capture/capture-windows', { method: 'POST' });
const data = await resp.json();
if (data.image) {
const fakeCapture = {
screenshot_base64: data.image,
width: data.width,
height: data.height,
source: 'windows',
};
setCurrentCapture(fakeCapture as any);
}
} catch (err) {
console.error('Capture Windows échouée:', err);
}
}}
title="Capture le dernier écran du PC Windows"
style={{ fontSize: '12px' }}
>
🖥 Windows
</button> </button>
<select value={timerSeconds} onChange={(e) => setTimerSeconds(Number(e.target.value))}> <select value={timerSeconds} onChange={(e) => setTimerSeconds(Number(e.target.value))}>
<option value="0">Immédiat</option> <option value="0">Immédiat</option>

View File

@@ -1,4 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { useState } from 'react';
import type { Execution } from '../types'; import type { Execution } from '../types';
interface Props { interface Props {
@@ -9,20 +10,19 @@ interface Props {
export default function ExecutionControls({ execution, onStart, onStop }: Props) { export default function ExecutionControls({ execution, onStart, onStop }: Props) {
const isRunning = execution?.status === 'running' || execution?.status === 'paused'; const isRunning = execution?.status === 'running' || execution?.status === 'paused';
const [windowsStatus, setWindowsStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const handleExecuteWindows = async () => { const handleExecuteWindows = async () => {
setWindowsStatus('sending');
try { try {
// Récupérer le workflow actif depuis l'état de la session
const stateResp = await fetch('/api/v3/session/state'); const stateResp = await fetch('/api/v3/session/state');
const state = await stateResp.json(); const state = await stateResp.json();
let workflowId = state?.session?.active_workflow_id; let workflowId = state?.session?.active_workflow_id;
let steps = state?.workflow?.steps || []; let steps = state?.workflow?.steps || [];
// Si pas de workflow actif, essayer de charger le premier disponible
if (!steps.length && state?.workflows_list?.length) { if (!steps.length && state?.workflows_list?.length) {
const firstWf = state.workflows_list[0]; const firstWf = state.workflows_list[0];
workflowId = firstWf.id; workflowId = firstWf.id;
// Charger les étapes du workflow
const wfResp = await fetch(`/api/v3/workflow/${firstWf.id}`); const wfResp = await fetch(`/api/v3/workflow/${firstWf.id}`);
const wfData = await wfResp.json(); const wfData = await wfResp.json();
steps = wfData?.steps || wfData?.workflow?.steps || []; steps = wfData?.steps || wfData?.workflow?.steps || [];
@@ -30,10 +30,10 @@ export default function ExecutionControls({ execution, onStart, onStop }: Props)
if (!steps.length) { if (!steps.length) {
alert('Aucune étape dans le workflow. Sélectionnez un workflow d\'abord.'); alert('Aucune étape dans le workflow. Sélectionnez un workflow d\'abord.');
setWindowsStatus('idle');
return; return;
} }
// Via le proxy Vite (/api → port 5002)
const resp = await fetch('/api/v3/execute-windows', { const resp = await fetch('/api/v3/execute-windows', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -52,36 +52,50 @@ export default function ExecutionControls({ execution, onStart, onStop }: Props)
const result = await resp.json(); const result = await resp.json();
if (result.replay_id) { if (result.replay_id) {
alert(`Replay lancé sur Windows ! ID: ${result.replay_id}`); setWindowsStatus('sent');
alert('Replay lancé ! Réduisez cette fenêtre maintenant.\nLes actions commenceront dans 5 secondes.');
setTimeout(() => setWindowsStatus('idle'), 5000);
} else { } else {
alert(`Erreur: ${result.error || JSON.stringify(result)}`); alert(`Erreur: ${result.error || JSON.stringify(result)}`);
setWindowsStatus('error');
setTimeout(() => setWindowsStatus('idle'), 3000);
} }
} catch (err) { } catch (err) {
alert(`Erreur connexion streaming server: ${err}`); alert(`Erreur connexion: ${err}`);
setWindowsStatus('error');
setTimeout(() => setWindowsStatus('idle'), 3000);
} }
}; };
return ( return (
<div className="execution-controls"> <div className="execution-controls">
{!isRunning ? ( {!isRunning ? (
<div style={{ display: 'flex', gap: '4px' }}> <div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<button className="btn-start" onClick={onStart}> <button className="btn-start" onClick={onStart} title="Exécuter sur cet écran (Linux)">
Exécuter 🐧 Linux
</button> </button>
<button <button
className="btn-start" className="btn-start"
onClick={handleExecuteWindows} onClick={handleExecuteWindows}
style={{ background: '#0078d4', fontSize: '12px' }} disabled={windowsStatus === 'sending'}
title="Envoyer les actions au PC Windows via le streaming server" style={{
background: windowsStatus === 'sent' ? '#22c55e' : windowsStatus === 'error' ? '#ef4444' : '#0078d4',
fontSize: '12px',
opacity: windowsStatus === 'sending' ? 0.6 : 1,
}}
title="Exécuter sur le PC Windows distant"
> >
🖥 Windows {windowsStatus === 'sending' ? '⏳ Envoi...' :
windowsStatus === 'sent' ? '✅ Lancé !' :
windowsStatus === 'error' ? '❌ Erreur' :
'🖥️ Windows'}
</button> </button>
</div> </div>
) : ( ) : (
<> <>
<div className="exec-status"> <div className="exec-status">
<span className={`status-badge ${execution?.status}`}> <span className={`status-badge ${execution?.status}`}>
{execution?.status === 'running' ? 'En cours' : 'En pause'} {execution?.status === 'running' ? 'En cours' : '⏸️ En pause'}
</span> </span>
<span className="exec-progress"> <span className="exec-progress">
{execution?.completed_steps}/{execution?.total_steps} {execution?.completed_steps}/{execution?.total_steps}
@@ -93,10 +107,6 @@ export default function ExecutionControls({ execution, onStart, onStop }: Props)
</> </>
)} )}
{execution?.status === 'completed' && (
<span className="status-badge completed">Terminé</span>
)}
{execution?.status === 'error' && ( {execution?.status === 'error' && (
<span className="status-badge error" title={execution.error_message}> <span className="status-badge error" title={execution.error_message}>
Erreur Erreur