Compare commits
3 Commits
0bcfddbbc4
...
83be93e121
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83be93e121 | ||
|
|
f5c33477f0 | ||
|
|
b1a3aa16f1 |
@@ -319,7 +319,22 @@ class AgentV1:
|
|||||||
if img_hash != self._last_heartbeat_hash:
|
if img_hash != self._last_heartbeat_hash:
|
||||||
self._last_heartbeat_hash = img_hash
|
self._last_heartbeat_hash = img_hash
|
||||||
self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}")
|
self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}")
|
||||||
self.streamer.push_event({"type": "heartbeat", "image": full_path, "timestamp": time.time(), "machine_id": self.machine_id})
|
heartbeat_event = {
|
||||||
|
"type": "heartbeat",
|
||||||
|
"image": full_path,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"machine_id": self.machine_id,
|
||||||
|
}
|
||||||
|
# QW1 — enrichissement multi-écrans (monitor_index + monitors_geometry)
|
||||||
|
# Additif, fallback gracieux : sans cet enrichissement, le serveur
|
||||||
|
# ne reçoit l'info qu'au moment des clics, donc QW1 ne s'active
|
||||||
|
# pas en continu sur poste Windows multi-écrans.
|
||||||
|
try:
|
||||||
|
from .vision.capturer import _enrich_with_monitor_info
|
||||||
|
_enrich_with_monitor_info(heartbeat_event)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("QW1 enrichissement heartbeat échoué: %s", e)
|
||||||
|
self.streamer.push_event(heartbeat_event)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Heartbeat error: {e}")
|
logger.error(f"Heartbeat error: {e}")
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ Stratégie en cascade :
|
|||||||
Émet sur le bus lea:* l'event monitor_routed avec la source de la décision.
|
Émet sur le bus lea:* l'event monitor_routed avec la source de la décision.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MonitorTarget:
|
class MonitorTarget:
|
||||||
@@ -76,6 +79,10 @@ def resolve_target_monitor(
|
|||||||
if m is not None:
|
if m is not None:
|
||||||
return _to_target(m, source="action")
|
return _to_target(m, source="action")
|
||||||
# Index invalide → on tombe sur le fallback focus
|
# Index invalide → on tombe sur le fallback focus
|
||||||
|
logger.warning(
|
||||||
|
"[BUS] lea:monitor_invalid_index requested=%d available_idx=%s",
|
||||||
|
int(explicit_idx), [g.get("idx") for g in geometry],
|
||||||
|
)
|
||||||
|
|
||||||
# 2. Fallback focus actif
|
# 2. Fallback focus actif
|
||||||
focused_idx = session_state.get("last_focused_monitor")
|
focused_idx = session_state.get("last_focused_monitor")
|
||||||
@@ -83,6 +90,10 @@ def resolve_target_monitor(
|
|||||||
m = _find_monitor(geometry, int(focused_idx))
|
m = _find_monitor(geometry, int(focused_idx))
|
||||||
if m is not None:
|
if m is not None:
|
||||||
return _to_target(m, source="focus")
|
return _to_target(m, source="focus")
|
||||||
|
logger.warning(
|
||||||
|
"[BUS] lea:monitor_unavailable focused_idx=%d available_idx=%s",
|
||||||
|
int(focused_idx), [g.get("idx") for g in geometry],
|
||||||
|
)
|
||||||
|
|
||||||
# 3. Fallback composite (backward compat — comportement actuel mss.monitors[0])
|
# 3. Fallback composite (backward compat — comportement actuel mss.monitors[0])
|
||||||
return _COMPOSITE_FALLBACK
|
return _COMPOSITE_FALLBACK
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ le replay continue avec uniquement les déclaratifs (fallback safe).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import io
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -77,7 +76,7 @@ def build_pause_payload(
|
|||||||
existing_labels=[c["label"] for c in checks],
|
existing_labels=[c["label"] for c in checks],
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("safety_checks LLM exception (%s) — fallback safe", e)
|
logger.warning("[BUS] lea:safety_checks_llm_failed reason=exception detail=%s", e)
|
||||||
additional = []
|
additional = []
|
||||||
|
|
||||||
for a in additional:
|
for a in additional:
|
||||||
@@ -159,21 +158,21 @@ Réponds UNIQUEMENT en JSON :
|
|||||||
timeout=timeout_s,
|
timeout=timeout_s,
|
||||||
)
|
)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.warning("safety_checks LLM HTTP %s", response.status_code)
|
logger.warning("[BUS] lea:safety_checks_llm_failed reason=http_status detail=%s", response.status_code)
|
||||||
return []
|
return []
|
||||||
text = response.json().get("response", "").strip()
|
text = response.json().get("response", "").strip()
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
logger.warning("safety_checks LLM timeout (%ss)", timeout_s)
|
logger.warning("[BUS] lea:safety_checks_llm_failed reason=timeout detail=%ss", timeout_s)
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("safety_checks LLM erreur réseau: %s", e)
|
logger.warning("[BUS] lea:safety_checks_llm_failed reason=network detail=%s", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# format=json garantit normalement du JSON valide
|
# format=json garantit normalement du JSON valide
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(text)
|
parsed = json.loads(text)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.warning("safety_checks LLM JSON invalide (%s) — fallback safe", e)
|
logger.warning("[BUS] lea:safety_checks_llm_failed reason=json_decode detail=%s", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
additional = parsed.get("additional_checks") or []
|
additional = parsed.get("additional_checks") or []
|
||||||
|
|||||||
@@ -1155,3 +1155,34 @@ def replay_resume_proxy():
|
|||||||
'detail': f'Streaming server non disponible ({streaming_url})'}), 502
|
'detail': f'Streaming server non disponible ({streaming_url})'}), 502
|
||||||
except req.RequestException as e:
|
except req.RequestException as e:
|
||||||
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502
|
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# QW4 — Proxy GET /api/v3/replay/state/<replay_id> → streaming /replay/{id}
|
||||||
|
# Forward Bearer token vers le serveur streaming.
|
||||||
|
# Permet à App.tsx de récupérer le state du replay actif (Agent V1 Windows)
|
||||||
|
# pour afficher PauseDialog quand status = paused_need_help avec safety_checks.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@api_v3_bp.route('/replay/state/<replay_id>', methods=['GET'])
|
||||||
|
def replay_state_proxy(replay_id):
|
||||||
|
"""Proxy QW4 vers le serveur streaming pour récupérer le state replay actif."""
|
||||||
|
import requests as req
|
||||||
|
|
||||||
|
streaming_url = os.environ.get('RPA_STREAMING_URL', 'http://localhost:5005')
|
||||||
|
token = os.environ.get('RPA_API_TOKEN', '')
|
||||||
|
headers = {}
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'Bearer {token}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = req.get(
|
||||||
|
f'{streaming_url}/api/v1/traces/stream/replay/{replay_id}',
|
||||||
|
headers=headers,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
return resp.content, resp.status_code, {'Content-Type': 'application/json'}
|
||||||
|
except req.ConnectionError:
|
||||||
|
return jsonify({'error': 'streaming_unreachable',
|
||||||
|
'detail': f'Streaming server non disponible ({streaming_url})'}), 502
|
||||||
|
except req.RequestException as e:
|
||||||
|
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ function App() {
|
|||||||
const [healingCandidates, setHealingCandidates] = useState<any[]>([]);
|
const [healingCandidates, setHealingCandidates] = useState<any[]>([]);
|
||||||
const [healingStepInfo, setHealingStepInfo] = useState<any>(null);
|
const [healingStepInfo, setHealingStepInfo] = useState<any>(null);
|
||||||
|
|
||||||
|
// QW4 — Replay streaming Windows en cours (Agent V1 distant).
|
||||||
|
// Quand un replay distant est lancé via ExecutionControls "→ Windows",
|
||||||
|
// ExecutionControls appelle setStreamingReplayId(replay_id) et un useEffect
|
||||||
|
// poll /api/v3/replay/state/<id> pour fusionner safety_checks + pause_*
|
||||||
|
// dans appState.execution → PauseDialog s'affiche.
|
||||||
|
const [streamingReplayId, setStreamingReplayId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Charger l'état initial
|
// Charger l'état initial
|
||||||
const loadState = useCallback(async () => {
|
const loadState = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +130,62 @@ function App() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isExecutionRunning, loadState]);
|
}, [isExecutionRunning, loadState]);
|
||||||
|
|
||||||
|
// QW4 — Polling state replay streaming (Agent V1 Windows distant)
|
||||||
|
// Tourne dès qu'un replay distant a été lancé. Récupère safety_checks,
|
||||||
|
// pause_message, pause_reason et les fusionne dans appState.execution
|
||||||
|
// pour que PauseDialog s'affiche quand status = paused_need_help.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!streamingReplayId) return;
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const pollReplay = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/v3/replay/state/${streamingReplayId}`);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const state = await resp.json();
|
||||||
|
if (stopped) return;
|
||||||
|
|
||||||
|
// Fusionner dans appState.execution sans écraser le reste.
|
||||||
|
setAppState(prev => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const prevExec = prev.execution || {
|
||||||
|
id: streamingReplayId,
|
||||||
|
workflow_id: prev.session?.active_workflow_id || '',
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
current_step_index: 0,
|
||||||
|
completed_steps: 0,
|
||||||
|
failed_steps: 0,
|
||||||
|
total_steps: 0,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
execution: {
|
||||||
|
...prevExec,
|
||||||
|
status: state.status || prevExec.status,
|
||||||
|
pause_message: state.pause_message || state.message,
|
||||||
|
pause_reason: state.pause_reason,
|
||||||
|
safety_checks: state.safety_checks || [],
|
||||||
|
replay_id: streamingReplayId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stopper le polling si le replay est terminé / annulé.
|
||||||
|
if (state.status && ['completed', 'error', 'cancelled'].includes(state.status)) {
|
||||||
|
setStreamingReplayId(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore (le serveur streaming peut être momentanément indispo)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tick immédiat puis toutes les 1s.
|
||||||
|
pollReplay();
|
||||||
|
const interval = setInterval(pollReplay, 1000);
|
||||||
|
return () => { stopped = true; clearInterval(interval); };
|
||||||
|
}, [streamingReplayId]);
|
||||||
|
|
||||||
// Convertir les étapes en nœuds React Flow
|
// Convertir les étapes en nœuds React Flow
|
||||||
// Les edges ne sont générées automatiquement que lors du premier chargement
|
// Les edges ne sont générées automatiquement que lors du premier chargement
|
||||||
// d'un workflow. Ensuite, les connexions manuelles de l'utilisateur sont préservées.
|
// d'un workflow. Ensuite, les connexions manuelles de l'utilisateur sont préservées.
|
||||||
@@ -452,6 +515,7 @@ function App() {
|
|||||||
execution={appState?.execution || null}
|
execution={appState?.execution || null}
|
||||||
onStart={handleStartExecution}
|
onStart={handleStartExecution}
|
||||||
onStop={handleStopExecution}
|
onStop={handleStopExecution}
|
||||||
|
onWindowsReplayStarted={(replayId) => setStreamingReplayId(replayId)}
|
||||||
/>
|
/>
|
||||||
<ConfidenceDashboard
|
<ConfidenceDashboard
|
||||||
isExecutionRunning={isExecutionRunning}
|
isExecutionRunning={isExecutionRunning}
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ interface Props {
|
|||||||
execution: Execution | null;
|
execution: Execution | null;
|
||||||
onStart: () => void;
|
onStart: () => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
|
// QW4 — Notifie App.tsx quand un replay streaming Windows est lancé,
|
||||||
|
// pour qu'il poll /api/v3/replay/state/<id> et affiche PauseDialog au besoin.
|
||||||
|
onWindowsReplayStarted?: (replayId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExecutionControls({ execution, onStart, onStop }: Props) {
|
export default function ExecutionControls({ execution, onStart, onStop, onWindowsReplayStarted }: 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 [windowsStatus, setWindowsStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||||
|
|
||||||
@@ -56,6 +59,11 @@ 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) {
|
||||||
setWindowsStatus('sent');
|
setWindowsStatus('sent');
|
||||||
|
// QW4 — propage le replay_id à App.tsx pour activer le polling
|
||||||
|
// /api/v3/replay/state/<id> (PauseDialog si paused_need_help).
|
||||||
|
if (onWindowsReplayStarted) {
|
||||||
|
try { onWindowsReplayStarted(result.replay_id); } catch {}
|
||||||
|
}
|
||||||
alert('Replay lancé ! Réduisez cette fenêtre maintenant.\nLes actions commenceront dans 5 secondes.');
|
alert('Replay lancé ! Réduisez cette fenêtre maintenant.\nLes actions commenceront dans 5 secondes.');
|
||||||
setTimeout(() => setWindowsStatus('idle'), 5000);
|
setTimeout(() => setWindowsStatus('idle'), 5000);
|
||||||
} else {
|
} else {
|
||||||
@@ -75,9 +83,27 @@ export default function ExecutionControls({ execution, onStart, onStop }: Props)
|
|||||||
{!isRunning ? (
|
{!isRunning ? (
|
||||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
{userOS === 'linux' ? (
|
{userOS === 'linux' ? (
|
||||||
<button className="btn-start" onClick={onStart} title="Exécuter sur cet écran">
|
<>
|
||||||
Exécuter
|
<button className="btn-start" onClick={onStart} title="Exécuter sur cet écran (Linux local)">
|
||||||
</button>
|
Exécuter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-start"
|
||||||
|
onClick={handleExecuteWindows}
|
||||||
|
disabled={windowsStatus === 'sending'}
|
||||||
|
style={{
|
||||||
|
background: windowsStatus === 'sent' ? '#22c55e' : windowsStatus === 'error' ? '#ef4444' : '#0078d4',
|
||||||
|
fontSize: '12px',
|
||||||
|
opacity: windowsStatus === 'sending' ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
title="Exécuter sur le PC Windows (Agent V1)"
|
||||||
|
>
|
||||||
|
{windowsStatus === 'sending' ? 'Envoi...' :
|
||||||
|
windowsStatus === 'sent' ? 'Lancé !' :
|
||||||
|
windowsStatus === 'error' ? 'Erreur' :
|
||||||
|
'→ Windows'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="btn-start"
|
className="btn-start"
|
||||||
|
|||||||
Reference in New Issue
Block a user