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>
693 lines
25 KiB
Python
693 lines
25 KiB
Python
# 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()
|