feat: multi-machine + chat Léa Edge mode app

Multi-machine :
- machine_id auto (hostname_os), configurable via RPA_MACHINE_ID
- Sessions/workflows isolés par machine (dossiers séparés)
- Replay ciblé par machine (pas de fuite cross-machine)
- Endpoint GET /machines pour lister les machines connectées
- Léa affiche la machine source des workflows

Chat Léa systray :
- Edge en mode app (--app=URL) — fenêtre native sans barre d'adresse
- Toggle via menu systray "Discuter avec Léa"
- Fallback navigateur si Edge absent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-17 20:02:45 +01:00
parent 371db69543
commit 8175b39eba

View File

@@ -110,6 +110,25 @@ execution_status = {
} }
command_history: List[Dict[str, Any]] = [] command_history: List[Dict[str, Any]] = []
def _fetch_connected_machines() -> List[Dict[str, Any]]:
"""Récupérer la liste des machines connectées depuis le streaming server.
Appel non-bloquant au endpoint /api/v1/traces/stream/machines.
Retourne une liste vide si le serveur est injoignable.
"""
try:
resp = http_requests.get(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/machines",
timeout=3,
)
if resp.ok:
return resp.json().get("machines", [])
except Exception:
pass
return []
# Répertoire d'upload et chemin de la base de données # Répertoire d'upload et chemin de la base de données
PROJECT_ROOT = Path(__file__).parent.parent PROJECT_ROOT = Path(__file__).parent.parent
UPLOAD_DIR = PROJECT_ROOT / "data" / "uploads" UPLOAD_DIR = PROJECT_ROOT / "data" / "uploads"
@@ -350,23 +369,36 @@ def api_status():
@app.route('/api/workflows') @app.route('/api/workflows')
def api_workflows(): def api_workflows():
"""Liste des workflows (tous répertoires confondus).""" """Liste des workflows (tous répertoires confondus).
Enrichit les workflows avec le machine_id source quand disponible
(attribut _machine_id ajouté par le StreamProcessor).
"""
if not matcher: if not matcher:
return jsonify({"workflows": [], "directories": []}) return jsonify({"workflows": [], "directories": []})
workflows = [] workflows = []
for wf in matcher.get_all_workflows(): for wf in matcher.get_all_workflows():
workflows.append({ wf_data = {
"id": wf.workflow_id, "id": wf.workflow_id,
"name": wf.name, "name": wf.name,
"description": wf.description, "description": wf.description,
"tags": wf.tags, "tags": wf.tags,
"source": wf.source_dir, "source": wf.source_dir,
}) }
# Ajouter le machine_id source si disponible (workflows appris en streaming)
machine_id = getattr(wf, '_machine_id', None)
if machine_id:
wf_data["machine_id"] = machine_id
workflows.append(wf_data)
# Récupérer la liste des machines connectées depuis le streaming server
machines = _fetch_connected_machines()
return jsonify({ return jsonify({
"workflows": workflows, "workflows": workflows,
"directories": matcher.get_directories(), "directories": matcher.get_directories(),
"machines": machines,
}) })
@@ -401,6 +433,17 @@ def api_workflows_refresh():
return jsonify({"success": False, "error": str(e)}) return jsonify({"success": False, "error": str(e)})
@app.route('/api/machines')
def api_machines():
"""Liste des machines connectées au streaming server.
Proxy vers le streaming server pour que le frontend puisse
lister les machines et cibler un replay spécifique.
"""
machines = _fetch_connected_machines()
return jsonify({"machines": machines})
@app.route('/api/search', methods=['POST']) @app.route('/api/search', methods=['POST'])
def api_search(): def api_search():
"""Rechercher des workflows.""" """Rechercher des workflows."""
@@ -1377,12 +1420,21 @@ def handle_copilot_abort():
# Exécution de workflow # Exécution de workflow
# ============================================================================= # =============================================================================
def _try_streaming_server_replay(workflow_id: str, params: Dict[str, Any]) -> Optional[Dict[str, Any]]: def _try_streaming_server_replay(
workflow_id: str,
params: Dict[str, Any],
machine_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
""" """
Tenter d'exécuter un workflow via le streaming server (Agent V1). Tenter d'exécuter un workflow via le streaming server (Agent V1).
POST http://localhost:5005/api/v1/traces/stream/replay POST http://localhost:5005/api/v1/traces/stream/replay
avec workflow_id et params. avec workflow_id, params et optionnellement machine_id (multi-machine).
Args:
workflow_id: Identifiant du workflow à exécuter
params: Paramètres du workflow (variables)
machine_id: Machine cible pour le replay (None = auto-détection)
Returns: Returns:
dict avec le résultat si succès. dict avec le résultat si succès.
@@ -1390,18 +1442,26 @@ def _try_streaming_server_replay(workflow_id: str, params: Dict[str, Any]) -> Op
None si le serveur est injoignable (connexion refusée, timeout). None si le serveur est injoignable (connexion refusée, timeout).
""" """
try: try:
resp = http_requests.post( payload = {
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay",
json={
"workflow_id": workflow_id, "workflow_id": workflow_id,
"session_id": "", # Vide = auto-détection de la session Agent V1 active "session_id": "", # Vide = auto-détection de la session Agent V1 active
"params": params or {}, "params": params or {},
}, }
# Ajouter le machine_id si spécifié (ciblage multi-machine)
if machine_id:
payload["machine_id"] = machine_id
resp = http_requests.post(
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay",
json=payload,
timeout=15, timeout=15,
) )
if resp.status_code == 200: if resp.status_code == 200:
data = resp.json() data = resp.json()
logger.info(f"Workflow {workflow_id} envoyé au streaming server: {data}") logger.info(
f"Workflow {workflow_id} envoyé au streaming server: {data}"
+ (f" (machine={machine_id})" if machine_id else "")
)
return data return data
else: else:
# Le serveur est UP mais refuse — renvoyer l'erreur pour éviter le fallback local # Le serveur est UP mais refuse — renvoyer l'erreur pour éviter le fallback local