chore: ajouter agent_v0/ au tracking git (était un repo embarqué)

Suppression du .git embarqué dans agent_v0/ — le code est maintenant
tracké normalement dans le repo principal.
Inclut : agent_v1 (client), server_v1 (streaming), lea_ui (chat client)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-18 11:12:23 +01:00
parent af83552923
commit ae65be2555
82 changed files with 15616 additions and 0 deletions

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,206 @@
# agent_v1/ui/notifications.py
"""
Gestionnaire de notifications toast natives (Windows/Linux/macOS).
Utilise plyer pour les notifications système, sans dépendance PyQt5.
Remplace les dialogues Qt par des toasts non-bloquants.
Thread-safe avec rate limiting (1 notification / 2 secondes max).
"""
import logging
import threading
import time
from typing import Optional
logger = logging.getLogger(__name__)
# Import conditionnel de plyer — fallback silencieux si absent
try:
from plyer import notification as _plyer_notification
_PLYER_AVAILABLE = True
except ImportError:
_plyer_notification = None
_PLYER_AVAILABLE = False
logger.warning(
"plyer non installé — les notifications toast sont désactivées. "
"Installer avec : pip install plyer"
)
# Nom de l'application affiché dans les toasts
APP_NAME = "Léa"
# Intervalle minimum entre deux notifications (secondes)
RATE_LIMIT_SECONDS = 2
class NotificationManager:
"""
Gestionnaire centralisé de notifications toast.
Thread-safe : peut être appelé depuis n'importe quel thread.
Rate limiting : une seule notification toutes les 2 secondes,
les notifications excédentaires sont ignorées (pas de file d'attente
pour éviter un flood différé).
"""
def __init__(self, icon_path: Optional[str] = None):
"""
Initialise le gestionnaire.
Args:
icon_path: Chemin vers l'icône (.ico/.png) pour les toasts.
None = icône par défaut du système.
"""
self._icon_path = icon_path
self._lock = threading.Lock()
self._last_notification_time: float = 0.0
# ------------------------------------------------------------------ #
# Méthode générique
# ------------------------------------------------------------------ #
def notify(self, title: str, message: str, timeout: int = 5) -> bool:
"""
Affiche une notification toast.
Args:
title: Titre de la notification.
message: Corps du message.
timeout: Durée d'affichage en secondes.
Returns:
True si la notification a été envoyée, False sinon
(plyer absent ou rate limit atteint).
"""
if not _PLYER_AVAILABLE:
logger.debug("Notification ignorée (plyer absent) : %s", title)
return False
with self._lock:
now = time.monotonic()
elapsed = now - self._last_notification_time
if elapsed < RATE_LIMIT_SECONDS:
logger.debug(
"Notification ignorée (rate limit, %.1fs restantes) : %s",
RATE_LIMIT_SECONDS - elapsed,
title,
)
return False
self._last_notification_time = now
# Envoi dans un thread dédié pour ne jamais bloquer l'appelant
thread = threading.Thread(
target=self._send,
args=(title, message, timeout),
daemon=True,
)
thread.start()
return True
def _send(self, title: str, message: str, timeout: int) -> None:
"""Envoi effectif de la notification (exécuté dans un thread dédié)."""
try:
# Windows limite les balloon tips à 256 caractères
if len(title) > 63:
title = title[:60] + "..."
if len(message) > 200:
message = message[:197] + "..."
_plyer_notification.notify(
title=title,
message=message,
app_name=APP_NAME,
app_icon=self._icon_path,
timeout=timeout,
)
except Exception:
logger.exception("Erreur lors de l'envoi de la notification toast")
# ------------------------------------------------------------------ #
# Méthodes métier
# ------------------------------------------------------------------ #
def greet(self) -> bool:
"""Notification de bienvenue au démarrage."""
return self.notify(
title=APP_NAME,
message="Bonjour ! Léa est prête.",
timeout=5,
)
def session_started(self, workflow_name: str) -> bool:
"""Notification de début de session."""
return self.notify(
title=APP_NAME,
message="C'est parti ! Je regarde et je mémorise.",
timeout=5,
)
def session_ended(self, action_count: int) -> bool:
"""Notification de fin de session avec le nombre d'actions."""
return self.notify(
title=APP_NAME,
message=f"C'est noté ! J'ai bien compris les {action_count} étapes.",
timeout=5,
)
def workflow_learned(self, name: str) -> bool:
"""Notification quand une tâche a été apprise."""
return self.notify(
title=APP_NAME,
message=f"J'ai appris '{name}' ! Je peux la refaire quand vous voulez.",
timeout=7,
)
def replay_started(self, workflow_name: str, step_count: int) -> bool:
"""Notification de début de replay."""
return self.notify(
title=APP_NAME,
message=f"Je m'en occupe ! '{workflow_name}' en cours...",
timeout=5,
)
def replay_step(self, current: int, total: int, description: str) -> bool:
"""Notification de progression d'une étape de replay."""
return self.notify(
title=APP_NAME,
message=f"Étape {current}/{total} : {description}",
timeout=3,
)
def replay_finished(self, success: bool, workflow_name: str) -> bool:
"""Notification de fin de replay (succès ou échec)."""
if success:
return self.notify(
title=APP_NAME,
message="C'est fait ! Tout s'est bien passé.",
timeout=5,
)
else:
return self.notify(
title=APP_NAME,
message="Hmm, j'ai eu un souci. Vous pouvez me remontrer ?",
timeout=7,
)
def connection_changed(self, connected: bool, server_host: str) -> bool:
"""Notification de changement d'état de la connexion serveur."""
if connected:
return self.notify(
title=APP_NAME,
message="Connectée au serveur.",
timeout=5,
)
else:
return self.notify(
title=APP_NAME,
message="J'ai perdu la connexion avec le serveur.",
timeout=7,
)
def error(self, message: str) -> bool:
"""Notification d'erreur."""
return self.notify(
title=APP_NAME,
message=f"Oups, un problème : {message}",
timeout=10,
)

View File

@@ -0,0 +1,190 @@
# agent_v1/ui/shared_state.py
"""
Etat partage entre le systray et le chat Lea. Thread-safe.
Point central de verite pour l'etat de l'agent :
- Enregistrement en cours (oui/non, nom de la tache)
- Replay en cours
- Compteur d'actions
Les deux composants UI (SmartTrayV1 et ChatWindow) lisent et ecrivent
dans cet objet. Chaque changement notifie tous les listeners enregistres.
"""
from __future__ import annotations
import logging
import threading
from typing import Any, Callable, List, Optional
logger = logging.getLogger(__name__)
class AgentState:
"""Etat partage entre le systray et le chat Lea. Thread-safe."""
def __init__(self) -> None:
self._lock = threading.Lock()
# Etat d'enregistrement
self._recording = False
self._recording_name = ""
self._actions_count = 0
# Etat de replay
self._replay_active = False
# Callbacks de demarrage/arret de session (relies au moteur agent)
self._on_start: Optional[Callable[[str], None]] = None
self._on_stop: Optional[Callable[[], None]] = None
# Listeners notifies a chaque changement d'etat
self._listeners: List[Callable[["AgentState"], None]] = []
# ------------------------------------------------------------------
# Proprietes en lecture seule (thread-safe)
# ------------------------------------------------------------------
@property
def is_recording(self) -> bool:
with self._lock:
return self._recording
@property
def recording_name(self) -> str:
with self._lock:
return self._recording_name
@property
def actions_count(self) -> int:
with self._lock:
return self._actions_count
@property
def is_replay_active(self) -> bool:
with self._lock:
return self._replay_active
# ------------------------------------------------------------------
# Mutations (thread-safe, notifient les listeners)
# ------------------------------------------------------------------
def start_recording(self, name: str) -> None:
"""Demarre un enregistrement (appele depuis systray OU chat).
Appelle le callback on_start si defini, puis notifie les listeners.
"""
with self._lock:
if self._recording:
logger.warning("Enregistrement deja en cours, ignore")
return
self._recording = True
self._recording_name = name
self._actions_count = 0
on_start = self._on_start
logger.info("Enregistrement demarre : %s", name)
# Appeler le callback moteur (hors du lock pour eviter deadlock)
if on_start is not None:
try:
on_start(name)
except Exception as e:
logger.error("Erreur demarrage session : %s", e)
# Annuler l'enregistrement si le moteur echoue
with self._lock:
self._recording = False
self._recording_name = ""
self._notify_listeners()
raise
self._notify_listeners()
def stop_recording(self) -> None:
"""Arrete l'enregistrement (appele depuis systray OU chat).
Appelle le callback on_stop si defini, puis notifie les listeners.
"""
with self._lock:
if not self._recording:
logger.debug("Pas d'enregistrement en cours, ignore")
return
self._recording = False
name = self._recording_name
count = self._actions_count
on_stop = self._on_stop
logger.info("Enregistrement arrete : %s (%d actions)", name, count)
# Appeler le callback moteur
if on_stop is not None:
try:
on_stop()
except Exception as e:
logger.error("Erreur arret session : %s", e)
self._notify_listeners()
def update_actions_count(self, count: int) -> None:
"""Met a jour le compteur d'actions (appele par le moteur agent)."""
with self._lock:
self._actions_count = count
self._notify_listeners()
def set_replay_active(self, active: bool) -> None:
"""Active ou desactive le mode replay."""
with self._lock:
if self._replay_active == active:
return
self._replay_active = active
logger.info("Replay %s", "actif" if active else "termine")
self._notify_listeners()
# ------------------------------------------------------------------
# Enregistrement des callbacks et listeners
# ------------------------------------------------------------------
def set_on_start(self, callback: Callable[[str], None]) -> None:
"""Definit le callback appele quand un enregistrement demarre.
Ce callback est le pont vers le moteur agent (AgentV1.start_session).
"""
with self._lock:
self._on_start = callback
def set_on_stop(self, callback: Callable[[], None]) -> None:
"""Definit le callback appele quand un enregistrement s'arrete.
Ce callback est le pont vers le moteur agent (AgentV1.stop_session).
"""
with self._lock:
self._on_stop = callback
def on_change(self, callback: Callable[["AgentState"], None]) -> None:
"""Enregistre un listener notifie a chaque changement d'etat.
Les listeners sont appeles dans un thread separe pour ne pas
bloquer l'appelant.
"""
with self._lock:
self._listeners.append(callback)
# ------------------------------------------------------------------
# Notification interne
# ------------------------------------------------------------------
def _notify_listeners(self) -> None:
"""Notifie tous les listeners enregistres du changement d'etat."""
with self._lock:
listeners = list(self._listeners)
for listener in listeners:
try:
# Appel dans un thread pour ne pas bloquer
threading.Thread(
target=listener,
args=(self,),
daemon=True,
).start()
except Exception as e:
logger.error("Erreur notification listener : %s", e)

View File

@@ -0,0 +1,692 @@
# agent_v1/ui/smart_tray.py
"""
Tray intelligent pour Agent V1 — remplace tray.py (plus de PyQt5).
Utilise pystray pour l'icone systray et tkinter (stdlib) pour les dialogues.
Communication serveur via LeaServerClient (chat:5004, streaming:5005).
Notifications via NotificationManager (module parallele).
Fenetre de chat Lea integree via ChatWindow (pywebview).
Architecture de threads :
- Thread principal : boucle pystray (icon.run)
- Thread daemon : verification connexion serveur (toutes les 30s)
- Thread daemon : rafraichissement cache workflows (toutes les 5 min)
- Thread daemon : pywebview (fenetre de chat Lea)
- Thread daemon : hotkey global Ctrl+Shift+L (si keyboard disponible)
- Threads ephemeres : dialogues tkinter (chaque dialogue cree son propre Tk())
"""
from __future__ import annotations
import logging
import os
import threading
import time
from typing import Any, Callable, Dict, List, Optional
from PIL import Image, ImageDraw
import pystray
from pystray import MenuItem as item
from .notifications import NotificationManager
from .shared_state import AgentState
logger = logging.getLogger(__name__)
# Intervalles (secondes)
_CONNECTION_CHECK_INTERVAL = 30
_WORKFLOW_CACHE_TTL = 300 # 5 minutes
# ---------------------------------------------------------------------------
# Helpers tkinter (sans PyQt5)
# ---------------------------------------------------------------------------
def _ask_string(title: str, prompt: str, default: str = "") -> Optional[str]:
"""Dialogue de saisie texte via tkinter (sans PyQt5).
Cree une instance Tk() ephemere, affiche le dialogue, puis la detruit.
Compatible avec la boucle pystray (pas de mainloop persistant).
"""
import tkinter as tk
from tkinter import simpledialog
root = tk.Tk()
root.withdraw()
root.attributes('-topmost', True)
result = simpledialog.askstring(title, prompt, initialvalue=default, parent=root)
root.destroy()
return result
def _show_info(title: str, message: str) -> None:
"""Affiche une boite d'information via tkinter (sans PyQt5)."""
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.withdraw()
root.attributes('-topmost', True)
messagebox.showinfo(title, message, parent=root)
root.destroy()
# ---------------------------------------------------------------------------
# SmartTrayV1
# ---------------------------------------------------------------------------
class SmartTrayV1:
"""Tray systeme intelligent pour Agent V1.
Remplace TrayAppV1 (PyQt5) par pystray + tkinter.
Meme interface constructeur pour compatibilite avec main.py.
"""
def __init__(
self,
on_start_callback: Callable[[str], None],
on_stop_callback: Callable[[], None],
server_client: Optional[Any] = None,
chat_window: Optional[Any] = None,
machine_id: str = "default",
shared_state: Optional[AgentState] = None,
) -> None:
self.on_start = on_start_callback
self.on_stop = on_stop_callback
self.server_client = server_client
self.machine_id = machine_id # Identifiant machine (multi-machine)
# Fenetre de chat Lea (pywebview)
self._chat_window = chat_window
# Etat partage avec le chat (source de verite unique)
self._shared_state = shared_state
# Etat interne (synchronise avec shared_state si disponible)
self.icon: Optional[pystray.Icon] = None
self.is_recording = False
self.actions_count = 0
# Etat connexion serveur
self._connected = False
self._replay_active = False
# Cache workflows
self._workflows: List[Dict[str, Any]] = []
self._workflows_lock = threading.Lock()
self._workflows_last_fetch: float = 0.0
# Verrous
self._state_lock = threading.Lock()
self._stop_event = threading.Event()
# Notifications
self._notifier = NotificationManager()
# Icones d'etat (cercles colores)
self.icons = {
"idle": self._create_circle_icon("gray"),
"recording": self._create_circle_icon("red"),
"connected": self._create_circle_icon("green"),
"disconnected": self._create_circle_icon("orange"),
"replay": self._create_circle_icon("blue"),
}
# Enregistrer le callback de changement de connexion sur le client
if self.server_client is not None:
self.server_client.set_on_connection_change(self._on_connection_change)
# S'abonner aux changements de l'etat partage
if self._shared_state is not None:
self._shared_state.on_change(self._on_shared_state_change)
logger.info("SmartTrayV1 initialise")
# ------------------------------------------------------------------
# Icones
# ------------------------------------------------------------------
@staticmethod
def _create_circle_icon(color: str) -> Image.Image:
"""Genere une icone circulaire simple mais propre."""
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
draw.ellipse((4, 4, 60, 60), fill=color, outline="white", width=2)
return img
def _current_icon(self) -> Image.Image:
"""Retourne l'icone correspondant a l'etat courant."""
if self._replay_active:
return self.icons["replay"]
if self.is_recording:
return self.icons["recording"]
if self._connected:
return self.icons["connected"]
if self.server_client is not None:
return self.icons["disconnected"]
return self.icons["idle"]
def _update_icon(self) -> None:
"""Met a jour l'icone et le menu du tray."""
if self.icon is not None:
self.icon.icon = self._current_icon()
self.icon.update_menu()
# ------------------------------------------------------------------
# Menu dynamique
# ------------------------------------------------------------------
def _get_menu_items(self):
"""Retourne les items du menu (appele a chaque ouverture du menu)."""
# Ligne de statut (féminin : Léa est connectée/déconnectée)
if self.is_recording:
status_text = "\U0001f534 Apprentissage en cours..."
elif self._connected:
status_text = "\U0001f7e2 Connect\u00e9e"
else:
status_text = "\U0001f534 D\u00e9connect\u00e9e"
# Compteur d'actions (visible uniquement en enregistrement)
actions_text = f"\U0001f4ca {self.actions_count} \u00e9tapes m\u00e9moris\u00e9es"
# Sous-menu workflows
workflow_items = self._build_workflow_submenu()
# Ligne d'identification machine (toujours visible)
machine_text = f"\U0001f4bb {self.machine_id}"
items = [
# --- Identite machine ---
item(machine_text, lambda: None, enabled=False),
# --- Statut ---
item(status_text, lambda: None, enabled=False),
item(
actions_text,
lambda: None,
enabled=False,
visible=lambda _i: self.is_recording,
),
pystray.Menu.SEPARATOR,
# --- Actions session ---
item(
"\U0001f393 Apprenez-moi une t\u00e2che",
self._on_start_session,
visible=lambda _i: not self.is_recording,
),
item(
"\u23f9\ufe0f C'est termin\u00e9",
self._on_stop_session,
visible=lambda _i: self.is_recording,
),
pystray.Menu.SEPARATOR,
# --- Workflows ---
item(
"\U0001f4cb Mes t\u00e2ches",
pystray.Menu(*workflow_items) if workflow_items else pystray.Menu(
item("(aucune t\u00e2che apprise)", lambda: None, enabled=False),
),
visible=lambda _i: self.server_client is not None,
),
item(
"\U0001f504 Actualiser",
self._on_refresh_workflows,
visible=lambda _i: self.server_client is not None,
),
pystray.Menu.SEPARATOR,
# --- Chat ---
item(
"\U0001f4ac Discuter avec L\u00e9a",
self._on_toggle_chat,
visible=lambda _i: self._chat_window is not None,
),
pystray.Menu.SEPARATOR,
# --- Utilitaires ---
item("\U0001f4c2 Mes fichiers", self._on_open_folder),
item("\u274c Quitter L\u00e9a", self._on_quit),
]
return items
@staticmethod
def _human_workflow_name(wf: Dict[str, Any]) -> str:
"""Retourne un nom lisible pour un workflow.
Priorite :
1. Champ 'display_name' (nom humain saisi par l'utilisateur)
2. Champ 'name' ou 'workflow_name'
3. Fallback : "Tache du <date>"
"""
# Nom humain explicite (nouveau champ)
display = wf.get("display_name", "").strip()
if display:
return display
# Nom technique existant
name = wf.get("name", wf.get("workflow_name", "")).strip()
if name:
return name
# Fallback avec date de creation
created = wf.get("created_at", wf.get("timestamp", ""))
if created:
# Extraire juste la date (format ISO ou timestamp)
try:
from datetime import datetime
if isinstance(created, (int, float)):
dt = datetime.fromtimestamp(created)
else:
dt = datetime.fromisoformat(str(created).replace("Z", "+00:00"))
return f"T\u00e2che du {dt.strftime('%d %B')}"
except Exception:
pass
return "T\u00e2che sans nom"
def _build_workflow_submenu(self) -> List[pystray.MenuItem]:
"""Construit la liste des workflows comme items de sous-menu."""
with self._workflows_lock:
workflows = list(self._workflows)
if not workflows:
return [item("(aucune t\u00e2che apprise)", lambda: None, enabled=False)]
items = []
for wf in workflows:
wf_name = self._human_workflow_name(wf)
wf_id = wf.get("id", wf.get("workflow_id", ""))
# Creer une closure avec les bonnes valeurs
items.append(
item(wf_name, self._make_replay_callback(wf_id, wf_name))
)
return items
def _make_replay_callback(
self, workflow_id: str, workflow_name: str
) -> Callable:
"""Cree un callback de lancement de replay pour un workflow donne."""
def _callback(_icon=None, _item=None):
self._launch_replay(workflow_id, workflow_name)
return _callback
# ------------------------------------------------------------------
# Actions utilisateur
# ------------------------------------------------------------------
def _on_shared_state_change(self, state: AgentState) -> None:
"""Callback appele quand l'etat partage change (depuis le chat ou ailleurs).
Met a jour l'etat local du systray pour refleter le changement.
"""
with self._state_lock:
self.is_recording = state.is_recording
self.actions_count = state.actions_count
self._replay_active = state.is_replay_active
self._update_icon()
def _on_start_session(self, _icon=None, _item=None) -> None:
"""Demande le nom de la t\u00e2che et demarre la session."""
# Dialogue tkinter dans un thread dedie
def _dialog():
name = _ask_string(
"Nouvelle t\u00e2che",
"D\u00e9crivez la t\u00e2che \u00e0 apprendre :",
default="",
)
if name and name.strip():
name = name.strip()
# Utiliser l'etat partage si disponible
if self._shared_state is not None:
try:
self._shared_state.start_recording(name)
except Exception as e:
self._notifier.notify("L\u00e9a", f"Oups : {e}")
return
else:
# Fallback sans etat partage
with self._state_lock:
self.is_recording = True
self.actions_count = 0
self._update_icon()
self.on_start(name)
self._notifier.notify(
"L\u00e9a",
"C'est parti ! Montrez-moi comment faire.",
)
threading.Thread(target=_dialog, daemon=True).start()
def _on_stop_session(self, _icon=None, _item=None) -> None:
"""Termine la session en cours et envoie les donnees."""
count = self.actions_count
# Utiliser l'etat partage si disponible
if self._shared_state is not None:
self._shared_state.stop_recording()
else:
with self._state_lock:
self.is_recording = False
self._update_icon()
self.on_stop()
self._notifier.notify(
"L\u00e9a",
f"Merci ! J'ai bien m\u00e9moris\u00e9 vos {count} actions.",
)
def _on_refresh_workflows(self, _icon=None, _item=None) -> None:
"""Rafraichit la liste des workflows depuis le serveur."""
threading.Thread(target=self._fetch_workflows, daemon=True).start()
def _on_ask_server(self, _icon=None, _item=None) -> None:
"""Envoie 'Que dois-je faire ?' au serveur et affiche la reponse."""
def _ask():
if self.server_client is None:
return
response = self.server_client.send_chat_message(
"Que dois-je faire maintenant ?"
)
if response:
# L'API renvoie {"response": {"message": "..."}} ou {"response": "..."}
resp = response.get("response", {})
if isinstance(resp, dict):
text = resp.get("message", str(resp))
else:
text = str(resp)
self._notifier.notify("Léa", text)
else:
self._notifier.notify(
"Erreur",
"Impossible de contacter le serveur.",
)
threading.Thread(target=_ask, daemon=True).start()
def _on_toggle_chat(self, _icon=None, _item=None) -> None:
"""Affiche ou masque la fenetre de chat Lea (pywebview)."""
if self._chat_window is None:
return
def _toggle():
try:
self._chat_window.toggle()
except Exception as e:
logger.error("Erreur toggle chat : %s", e)
self._notifier.notify(
"Erreur Chat",
f"Impossible d'ouvrir le chat : {e}",
)
threading.Thread(target=_toggle, daemon=True).start()
def _launch_replay(self, workflow_id: str, workflow_name: str) -> None:
"""Lance le replay d'un workflow."""
def _replay():
if self.server_client is None:
return
with self._state_lock:
self._replay_active = True
self._update_icon()
self._notifier.notify(
"L\u00e9a",
f"Je m'en occupe ! '{workflow_name}' en cours...",
)
try:
import requests
resp = requests.post(
f"{self.server_client._stream_base}/api/v1/traces/stream/replay/start",
json={"workflow_id": workflow_id},
timeout=10,
)
if resp.ok:
logger.info("Replay demarre pour workflow %s", workflow_id)
else:
self._notifier.notify(
"L\u00e9a",
"Hmm, le serveur a refus\u00e9. R\u00e9essayons plus tard.",
)
except Exception as e:
logger.error("Erreur lancement replay : %s", e)
self._notifier.notify(
"L\u00e9a",
f"Oups, un probl\u00e8me : {e}",
)
finally:
with self._state_lock:
self._replay_active = False
self._update_icon()
threading.Thread(target=_replay, daemon=True).start()
def _on_open_folder(self, _icon=None, _item=None) -> None:
"""Ouvre le dossier des sessions dans l'explorateur de fichiers."""
from ..config import SESSIONS_ROOT
sessions_path = str(SESSIONS_ROOT)
if os.name == "nt":
os.startfile(sessions_path)
else:
os.system(f'xdg-open "{sessions_path}"')
def _on_quit(self, _icon=None, _item=None) -> None:
"""Arrete proprement l'agent et quitte."""
logger.info("Arret demande par l'utilisateur")
# Arreter la session si en cours
if self.is_recording:
self.on_stop()
# Signaler l'arret aux threads de fond
self._stop_event.set()
# Fermer la fenetre de chat si ouverte
if self._chat_window is not None:
try:
self._chat_window.destroy()
except Exception as e:
logger.debug("Erreur fermeture chat : %s", e)
# Arreter le hotkey global si actif
self._stop_hotkey()
# Arreter le client serveur si present
if self.server_client is not None:
self.server_client.shutdown()
# Arreter l'icone pystray
if self.icon is not None:
self.icon.stop()
# ------------------------------------------------------------------
# Verification connexion serveur (thread daemon)
# ------------------------------------------------------------------
def _connection_checker_loop(self) -> None:
"""Verifie la connexion au serveur toutes les 30 secondes."""
logger.info("Thread de verification connexion demarre")
while not self._stop_event.is_set():
if self.server_client is not None:
try:
was_connected = self._connected
self._connected = self.server_client.check_connection()
if self._connected != was_connected:
self._update_icon()
# La notification est geree par _on_connection_change
except Exception as e:
logger.error("Erreur verification connexion : %s", e)
self._stop_event.wait(timeout=_CONNECTION_CHECK_INTERVAL)
logger.info("Thread de verification connexion arrete")
def _on_connection_change(self, connected: bool) -> None:
"""Callback appelee par LeaServerClient quand l'etat de connexion change."""
with self._state_lock:
self._connected = connected
self._update_icon()
if connected:
self._notifier.notify(
"L\u00e9a",
"Connect\u00e9e au serveur.",
)
# Rafraichir les taches a la connexion
threading.Thread(target=self._fetch_workflows, daemon=True).start()
else:
self._notifier.notify(
"L\u00e9a",
"J'ai perdu la connexion avec le serveur.",
)
# ------------------------------------------------------------------
# Cache workflows (thread daemon)
# ------------------------------------------------------------------
def _workflow_cache_loop(self) -> None:
"""Rafraichit le cache des workflows toutes les 5 minutes."""
logger.info("Thread de cache workflows demarre")
while not self._stop_event.is_set():
if self.server_client is not None and self._connected:
self._fetch_workflows()
self._stop_event.wait(timeout=_WORKFLOW_CACHE_TTL)
logger.info("Thread de cache workflows arrete")
def _fetch_workflows(self) -> None:
"""Recupere la liste des workflows depuis le serveur."""
if self.server_client is None:
return
try:
workflows = self.server_client.list_workflows()
with self._workflows_lock:
self._workflows = workflows
self._workflows_last_fetch = time.time()
logger.debug(
"Cache workflows mis a jour : %d workflows", len(workflows)
)
# Forcer la reconstruction du menu
self._update_icon()
except Exception as e:
logger.error("Erreur recuperation workflows : %s", e)
# ------------------------------------------------------------------
# Mise a jour du compteur (compatibilite main.py)
# ------------------------------------------------------------------
def update_stats(self, count: int) -> None:
"""Met a jour le compteur d'actions en temps reel dans le menu."""
with self._state_lock:
self.actions_count = count
if self.icon is not None:
self.icon.update_menu()
def set_replay_active(self, active: bool) -> None:
"""Signale qu'un replay est en cours (appele depuis main.py)."""
with self._state_lock:
self._replay_active = active
self._update_icon()
if active:
self._notifier.notify("L\u00e9a", "Je m'en occupe...")
else:
self._notifier.notify("L\u00e9a", "C'est fait !")
# ------------------------------------------------------------------
# Hotkey global Ctrl+Shift+L (toggle chat)
# ------------------------------------------------------------------
_hotkey_hook = None # reference pour pouvoir le retirer
def _start_hotkey(self) -> None:
"""Enregistre le raccourci global Ctrl+Shift+L pour ouvrir le chat.
Utilise la librairie 'keyboard' si disponible.
Silencieux si elle n'est pas installee (pas critique).
"""
if self._chat_window is None:
return
try:
import keyboard
self._hotkey_hook = keyboard.add_hotkey(
"ctrl+shift+l",
self._on_toggle_chat,
suppress=False,
)
logger.info("Hotkey Ctrl+Shift+L enregistre pour le chat Lea")
except ImportError:
logger.debug(
"keyboard non installe — hotkey Ctrl+Shift+L desactive. "
"Installer avec : pip install keyboard"
)
except Exception as e:
logger.warning("Impossible d'enregistrer le hotkey : %s", e)
def _stop_hotkey(self) -> None:
"""Retire le raccourci global."""
if self._hotkey_hook is not None:
try:
import keyboard
keyboard.remove_hotkey(self._hotkey_hook)
self._hotkey_hook = None
logger.debug("Hotkey Ctrl+Shift+L retire")
except Exception:
pass
# ------------------------------------------------------------------
# Point d'entree
# ------------------------------------------------------------------
def run(self) -> None:
"""Demarre le tray, les threads de fond, et entre dans la boucle principale."""
# Notification d'accueil (avec identifiant machine)
self._notifier.notify(
"L\u00e9a",
f"Bonjour ! L\u00e9a est pr\u00eate.",
)
# Enregistrer le hotkey global Ctrl+Shift+L (toggle chat)
self._start_hotkey()
# Tooltip avec identifiant machine pour le multi-machine
tray_title = f"Agent V1 - {self.machine_id}"
# Menu statique — reconstruit via _update_icon() quand l'état change
self.icon = pystray.Icon(
"AgentV1",
self._current_icon(),
tray_title,
menu=pystray.Menu(*self._get_menu_items()),
)
# Demarrer le thread de verification connexion
if self.server_client is not None:
conn_thread = threading.Thread(
target=self._connection_checker_loop,
daemon=True,
name="smart-tray-conn-check",
)
conn_thread.start()
# Demarrer le thread de cache workflows
wf_thread = threading.Thread(
target=self._workflow_cache_loop,
daemon=True,
name="smart-tray-wf-cache",
)
wf_thread.start()
# Premiere verification immediate
threading.Thread(
target=self._fetch_workflows, daemon=True
).start()
# Boucle principale pystray (bloquante)
logger.info("SmartTrayV1 demarre — entree dans la boucle pystray")
self.icon.run()