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:
0
agent_v0/agent_v1/ui/__init__.py
Normal file
0
agent_v0/agent_v1/ui/__init__.py
Normal file
1127
agent_v0/agent_v1/ui/chat_window.py
Normal file
1127
agent_v0/agent_v1/ui/chat_window.py
Normal file
File diff suppressed because it is too large
Load Diff
206
agent_v0/agent_v1/ui/notifications.py
Normal file
206
agent_v0/agent_v1/ui/notifications.py
Normal 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,
|
||||
)
|
||||
190
agent_v0/agent_v1/ui/shared_state.py
Normal file
190
agent_v0/agent_v1/ui/shared_state.py
Normal 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)
|
||||
692
agent_v0/agent_v1/ui/smart_tray.py
Normal file
692
agent_v0/agent_v1/ui/smart_tray.py
Normal 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()
|
||||
Reference in New Issue
Block a user