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:
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user