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>
1128 lines
39 KiB
Python
1128 lines
39 KiB
Python
# agent_v1/ui/chat_window.py
|
|
"""
|
|
Fenetre de chat Lea integree au systray — version tkinter native.
|
|
|
|
Remplace l'approche Edge browser par une vraie fenetre tkinter integree.
|
|
Design professionnel, theme clair, ancree en bas a droite de l'ecran.
|
|
Tourne dans son propre thread daemon pour ne pas bloquer pystray.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Any, Callable, Dict, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Theme — palette professionnelle claire
|
|
# ---------------------------------------------------------------------------
|
|
BG_COLOR = "#FAFBFC" # Fond principal
|
|
HEADER_BG = "#2563EB" # Bleu en-tete
|
|
HEADER_FG = "#FFFFFF" # Texte en-tete blanc
|
|
MSG_LEA_BG = "#EFF6FF" # Fond messages Lea (bleu clair)
|
|
MSG_USER_BG = "#E8F5E9" # Fond messages utilisateur (vert plus marque)
|
|
MSG_LEA_FG = "#1E40AF" # Texte Lea (bleu fonce)
|
|
MSG_USER_FG = "#1B5E20" # Texte utilisateur (vert plus fonce, meilleur contraste)
|
|
TIMESTAMP_FG = "#9CA3AF" # Horodatages gris
|
|
BTN_BG = "#3B82F6" # Boutons bleu
|
|
BTN_HOVER_BG = "#2563EB" # Boutons hover
|
|
BTN_FG = "#FFFFFF" # Texte boutons blanc
|
|
INPUT_BG = "#FFFFFF" # Champ de saisie blanc
|
|
INPUT_FG = "#1F2937" # Texte saisie noir
|
|
BORDER_COLOR = "#E5E7EB" # Bordures gris clair
|
|
STATUS_CONNECTED = "#22C55E" # Vert connecte
|
|
STATUS_DISCONNECTED = "#EF4444" # Rouge deconnecte
|
|
QUICK_BTN_BG = "#F3F4F6" # Fond boutons rapides
|
|
QUICK_BTN_FG = "#374151" # Texte boutons rapides
|
|
QUICK_BTN_HOVER = "#E5E7EB" # Hover boutons rapides
|
|
SCROLLBAR_BG = "#E5E7EB" # Fond scrollbar
|
|
SCROLLBAR_FG = "#9CA3AF" # Curseur scrollbar
|
|
MSG_BORDER_COLOR = "#D1D5DB" # Bordure subtile des bulles de messages
|
|
|
|
# Dimensions — confortables
|
|
WIN_WIDTH = 600
|
|
WIN_HEIGHT = 800
|
|
MARGIN = 14
|
|
MSG_WRAP_WIDTH = WIN_WIDTH - 90
|
|
|
|
# Tailles de police — bien lisibles
|
|
FONT_TITLE = ("Segoe UI", 15, "bold")
|
|
FONT_MSG = ("Segoe UI", 13)
|
|
FONT_MSG_ITALIC = ("Segoe UI", 12, "italic")
|
|
FONT_TIMESTAMP = ("Segoe UI", 10)
|
|
FONT_SYSTEM = ("Segoe UI", 10, "italic")
|
|
FONT_QUICK_BTN = ("Segoe UI", 11)
|
|
FONT_INPUT = ("Segoe UI", 13)
|
|
FONT_STATUS = ("Segoe UI", 10)
|
|
FONT_CLOSE_BTN = ("Segoe UI", 13)
|
|
FONT_SEND_BTN = ("Segoe UI", 13)
|
|
FONT_RESIZE_GRIP = ("Segoe UI", 10)
|
|
|
|
|
|
class ChatWindow:
|
|
"""Fenetre de chat Lea en tkinter natif.
|
|
|
|
Tourne dans un thread daemon independant. Thread-safe via root.after().
|
|
Interface compatible avec l'ancien ChatWindow (toggle, show, hide, destroy).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
server_client: Optional[Any] = None,
|
|
on_start_callback: Optional[Callable[[str], None]] = None,
|
|
server_host: str = "localhost",
|
|
chat_port: int = 5004,
|
|
shared_state: Optional[Any] = None,
|
|
) -> None:
|
|
self._server_client = server_client
|
|
self._on_start_callback = on_start_callback
|
|
self._server_host = server_host
|
|
self._chat_port = chat_port
|
|
|
|
# Etat partage avec le systray (source de verite unique)
|
|
self._shared_state = shared_state
|
|
|
|
# Etat
|
|
self._visible = False
|
|
self._destroyed = False
|
|
self._root = None
|
|
self._ready = threading.Event()
|
|
self._messages = [] # historique local
|
|
|
|
# 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)
|
|
|
|
# Demarrer tkinter dans un thread daemon
|
|
self._thread = threading.Thread(
|
|
target=self._run_tk_loop,
|
|
daemon=True,
|
|
name="chat-window-tk",
|
|
)
|
|
self._thread.start()
|
|
|
|
# Attendre que la fenetre soit prete (max 5s)
|
|
self._ready.wait(timeout=5.0)
|
|
logger.info("ChatWindow tkinter initialisee")
|
|
|
|
# ======================================================================
|
|
# Interface publique (thread-safe)
|
|
# ======================================================================
|
|
|
|
def toggle(self) -> None:
|
|
"""Afficher/masquer la fenetre de chat."""
|
|
if self._destroyed or self._root is None:
|
|
return
|
|
if self._visible:
|
|
self.hide()
|
|
else:
|
|
self.show()
|
|
|
|
def show(self) -> None:
|
|
"""Afficher la fenetre."""
|
|
if self._destroyed or self._root is None:
|
|
return
|
|
self._root.after(0, self._do_show)
|
|
|
|
def hide(self) -> None:
|
|
"""Masquer la fenetre (sans la detruire)."""
|
|
if self._destroyed or self._root is None:
|
|
return
|
|
self._root.after(0, self._do_hide)
|
|
|
|
def is_visible(self) -> bool:
|
|
"""Verifie si la fenetre est affichee."""
|
|
return self._visible
|
|
|
|
def destroy(self) -> None:
|
|
"""Fermer definitivement la fenetre et arreter le thread."""
|
|
if self._destroyed:
|
|
return
|
|
self._destroyed = True
|
|
if self._root is not None:
|
|
try:
|
|
self._root.after(0, self._do_destroy)
|
|
except Exception:
|
|
pass
|
|
|
|
def update_server_client(self, server_client: Any) -> None:
|
|
"""Mettre a jour le client serveur (appele si cree apres la fenetre)."""
|
|
self._server_client = server_client
|
|
|
|
def _on_shared_state_change(self, state) -> None:
|
|
"""Callback appele quand l'etat partage change (depuis le systray ou ailleurs).
|
|
|
|
Affiche un message dans le chat si un enregistrement demarre ou s'arrete
|
|
depuis le systray.
|
|
"""
|
|
if self._root is None or self._destroyed:
|
|
return
|
|
|
|
# Detecter la transition enregistrement demarre (depuis le systray)
|
|
if state.is_recording and not getattr(self, "_last_known_recording", False):
|
|
name = state.recording_name
|
|
self._add_lea_message(
|
|
f"Enregistrement en cours : \u00ab {name} \u00bb\n"
|
|
"Montrez-moi les \u00e9tapes, j'observe !"
|
|
)
|
|
|
|
# Detecter la transition enregistrement arrete (depuis le systray)
|
|
if not state.is_recording and getattr(self, "_last_known_recording", False):
|
|
count = state.actions_count
|
|
self._add_lea_message(
|
|
f"C'est not\u00e9 ! J'ai m\u00e9moris\u00e9 {count} actions."
|
|
)
|
|
|
|
# Detecter la transition replay
|
|
if state.is_replay_active and not getattr(self, "_last_known_replay", False):
|
|
self._add_lea_message("Replay en cours...")
|
|
|
|
if not state.is_replay_active and getattr(self, "_last_known_replay", False):
|
|
self._add_lea_message("Replay termin\u00e9. C'est fait !")
|
|
|
|
# Memoriser l'etat pour detecter les transitions
|
|
self._last_known_recording = state.is_recording
|
|
self._last_known_replay = state.is_replay_active
|
|
|
|
# ======================================================================
|
|
# Construction de la fenetre (thread tkinter)
|
|
# ======================================================================
|
|
|
|
def _run_tk_loop(self) -> None:
|
|
"""Boucle principale tkinter dans un thread daemon."""
|
|
try:
|
|
# Activer le DPI awareness sur Windows AVANT de créer Tk()
|
|
# Sans ça, tkinter rend tout minuscule sur les écrans haute résolution
|
|
try:
|
|
import ctypes
|
|
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
|
except Exception:
|
|
pass
|
|
|
|
import tkinter as tk
|
|
from tkinter import font as tkfont
|
|
|
|
self._tk = tk
|
|
root = tk.Tk()
|
|
self._root = root
|
|
|
|
# Appliquer le scaling DPI de Windows à tkinter
|
|
try:
|
|
dpi = root.winfo_fpixels('1i')
|
|
scale_factor = dpi / 72.0
|
|
root.tk.call('tk', 'scaling', scale_factor)
|
|
except Exception:
|
|
pass
|
|
|
|
# Fenetre avec barre de titre native (redimensionnable par l'utilisateur)
|
|
root.title("Léa — Assistante")
|
|
root.configure(bg=BG_COLOR)
|
|
root.attributes('-topmost', True)
|
|
root.minsize(400, 500)
|
|
|
|
# Taille et position (bas-droite de l'ecran)
|
|
screen_w = root.winfo_screenwidth()
|
|
screen_h = root.winfo_screenheight()
|
|
x = screen_w - WIN_WIDTH - 12
|
|
y = screen_h - WIN_HEIGHT - 50
|
|
root.geometry(f"{WIN_WIDTH}x{WIN_HEIGHT}+{x}+{y}")
|
|
|
|
# Intercepter la fermeture (X) pour masquer au lieu de fermer
|
|
root.protocol("WM_DELETE_WINDOW", self._do_hide)
|
|
|
|
# Taille minimum pour eviter une fenetre trop petite
|
|
root.minsize(350, 450)
|
|
|
|
# Ombre / bordure simulee
|
|
root.configure(highlightbackground="#CBD5E1", highlightthickness=1)
|
|
|
|
# Construire les widgets
|
|
self._build_header(root)
|
|
self._build_status_bar(root)
|
|
self._build_messages_area(root)
|
|
self._build_quick_actions(root)
|
|
self._build_input_area(root)
|
|
self._build_resize_grip(root)
|
|
|
|
# Message d'accueil
|
|
self._add_lea_message(
|
|
"Bonjour ! Je suis L\u00e9a.\n"
|
|
"Je peux apprendre vos t\u00e2ches r\u00e9p\u00e9titives "
|
|
"et les refaire \u00e0 votre place.\n"
|
|
"Que puis-je faire pour vous ?"
|
|
)
|
|
|
|
# Demarrer masquee
|
|
root.withdraw()
|
|
self._visible = False
|
|
|
|
# Verification connexion periodique
|
|
self._check_connection_status()
|
|
|
|
# Signaler que la fenetre est prete
|
|
self._ready.set()
|
|
|
|
# Boucle tkinter
|
|
root.mainloop()
|
|
|
|
except Exception as e:
|
|
logger.error("Erreur initialisation ChatWindow tkinter : %s", e)
|
|
self._ready.set()
|
|
|
|
def _build_header(self, root) -> None:
|
|
"""Barre de titre personnalisee (draggable)."""
|
|
tk = self._tk
|
|
|
|
header = tk.Frame(root, bg=HEADER_BG, height=44)
|
|
header.pack(fill=tk.X, side=tk.TOP)
|
|
header.pack_propagate(False)
|
|
|
|
# Titre — police agrandie
|
|
title_label = tk.Label(
|
|
header,
|
|
text="L\u00e9a \u2014 Assistante",
|
|
bg=HEADER_BG,
|
|
fg=HEADER_FG,
|
|
font=FONT_TITLE,
|
|
anchor="w",
|
|
padx=12,
|
|
)
|
|
title_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
|
|
# Bouton fermer
|
|
close_btn = tk.Label(
|
|
header,
|
|
text="\u2715",
|
|
bg=HEADER_BG,
|
|
fg=HEADER_FG,
|
|
font=FONT_CLOSE_BTN,
|
|
padx=14,
|
|
cursor="hand2",
|
|
)
|
|
close_btn.pack(side=tk.RIGHT)
|
|
close_btn.bind("<Button-1>", lambda e: self.hide())
|
|
close_btn.bind("<Enter>", lambda e: close_btn.configure(bg="#DC2626"))
|
|
close_btn.bind("<Leave>", lambda e: close_btn.configure(bg=HEADER_BG))
|
|
|
|
# Drag support (deplacer la fenetre)
|
|
self._drag_data = {"x": 0, "y": 0}
|
|
header.bind("<Button-1>", self._on_drag_start)
|
|
header.bind("<B1-Motion>", self._on_drag_motion)
|
|
title_label.bind("<Button-1>", self._on_drag_start)
|
|
title_label.bind("<B1-Motion>", self._on_drag_motion)
|
|
|
|
def _build_status_bar(self, root) -> None:
|
|
"""Barre de statut avec indicateur de connexion."""
|
|
tk = self._tk
|
|
|
|
status_frame = tk.Frame(root, bg="#F8FAFC", height=26)
|
|
status_frame.pack(fill=tk.X, side=tk.TOP)
|
|
status_frame.pack_propagate(False)
|
|
|
|
# Separateur
|
|
tk.Frame(root, bg=BORDER_COLOR, height=1).pack(fill=tk.X, side=tk.TOP)
|
|
|
|
# Indicateur de connexion (point colore)
|
|
self._status_dot = tk.Label(
|
|
status_frame,
|
|
text="\u25CF",
|
|
fg=STATUS_DISCONNECTED,
|
|
bg="#F8FAFC",
|
|
font=FONT_STATUS,
|
|
padx=8,
|
|
)
|
|
self._status_dot.pack(side=tk.LEFT)
|
|
|
|
self._status_label = tk.Label(
|
|
status_frame,
|
|
text="D\u00e9connect\u00e9e",
|
|
bg="#F8FAFC",
|
|
fg=TIMESTAMP_FG,
|
|
font=FONT_STATUS,
|
|
)
|
|
self._status_label.pack(side=tk.LEFT)
|
|
|
|
def _build_messages_area(self, root) -> None:
|
|
"""Zone de messages scrollable."""
|
|
tk = self._tk
|
|
|
|
# Conteneur avec scrollbar
|
|
msg_container = tk.Frame(root, bg=BG_COLOR)
|
|
msg_container.pack(fill=tk.BOTH, expand=True, side=tk.TOP)
|
|
|
|
# Canvas + Scrollbar pour le scroll
|
|
self._canvas = tk.Canvas(
|
|
msg_container,
|
|
bg=BG_COLOR,
|
|
highlightthickness=0,
|
|
bd=0,
|
|
)
|
|
|
|
scrollbar = tk.Scrollbar(
|
|
msg_container,
|
|
orient=tk.VERTICAL,
|
|
command=self._canvas.yview,
|
|
bg=SCROLLBAR_BG,
|
|
troughcolor=BG_COLOR,
|
|
width=10,
|
|
)
|
|
|
|
self._canvas.configure(yscrollcommand=scrollbar.set)
|
|
|
|
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
|
|
# Frame interne scrollable
|
|
self._msg_frame = tk.Frame(self._canvas, bg=BG_COLOR)
|
|
self._msg_frame_id = self._canvas.create_window(
|
|
(0, 0),
|
|
window=self._msg_frame,
|
|
anchor=tk.NW,
|
|
width=WIN_WIDTH - 22, # largeur moins scrollbar
|
|
)
|
|
|
|
# Bindings pour le scroll
|
|
self._msg_frame.bind(
|
|
"<Configure>",
|
|
lambda e: self._canvas.configure(scrollregion=self._canvas.bbox("all")),
|
|
)
|
|
self._canvas.bind(
|
|
"<Configure>",
|
|
lambda e: self._canvas.itemconfig(self._msg_frame_id, width=e.width),
|
|
)
|
|
# Molette souris
|
|
self._canvas.bind_all(
|
|
"<MouseWheel>",
|
|
lambda e: self._canvas.yview_scroll(int(-1 * (e.delta / 120)), "units"),
|
|
)
|
|
|
|
def _build_quick_actions(self, root) -> None:
|
|
"""Barre de boutons d'actions rapides."""
|
|
tk = self._tk
|
|
|
|
# Separateur
|
|
tk.Frame(root, bg=BORDER_COLOR, height=1).pack(fill=tk.X, side=tk.TOP)
|
|
|
|
actions_frame = tk.Frame(root, bg="#F8FAFC", height=48)
|
|
actions_frame.pack(fill=tk.X, side=tk.TOP, padx=0, pady=0)
|
|
actions_frame.pack_propagate(False)
|
|
|
|
buttons = [
|
|
("\U0001f393 Apprenez-moi", self._on_quick_record),
|
|
("\u25b6\ufe0f Lancer", self._on_quick_tasks),
|
|
("\U0001f4ca Donn\u00e9es", self._on_quick_import),
|
|
("\u23f9\ufe0f Arr\u00eater", self._on_quick_stop),
|
|
("\u2753 Aide", self._on_quick_help),
|
|
]
|
|
|
|
for text, cmd in buttons:
|
|
btn = tk.Label(
|
|
actions_frame,
|
|
text=text,
|
|
bg=QUICK_BTN_BG,
|
|
fg=QUICK_BTN_FG,
|
|
font=FONT_QUICK_BTN,
|
|
padx=8,
|
|
pady=5,
|
|
cursor="hand2",
|
|
relief=tk.FLAT,
|
|
)
|
|
btn.pack(side=tk.LEFT, padx=4, pady=5)
|
|
btn.bind("<Button-1>", lambda e, c=cmd: c())
|
|
btn.bind("<Enter>", lambda e, b=btn: b.configure(bg=QUICK_BTN_HOVER))
|
|
btn.bind("<Leave>", lambda e, b=btn: b.configure(bg=QUICK_BTN_BG))
|
|
|
|
# Separateur
|
|
tk.Frame(root, bg=BORDER_COLOR, height=1).pack(fill=tk.X, side=tk.TOP)
|
|
|
|
def _build_input_area(self, root) -> None:
|
|
"""Zone de saisie avec bouton envoyer."""
|
|
tk = self._tk
|
|
|
|
input_frame = tk.Frame(root, bg=BG_COLOR, height=48)
|
|
input_frame.pack(fill=tk.X, side=tk.BOTTOM, padx=MARGIN, pady=(MARGIN, 4))
|
|
input_frame.pack_propagate(False)
|
|
|
|
# Bouton joindre un fichier (📎)
|
|
attach_btn = tk.Label(
|
|
input_frame,
|
|
text="\U0001f4ce",
|
|
bg=BG_COLOR,
|
|
fg=BTN_BG,
|
|
font=FONT_SEND_BTN,
|
|
padx=6,
|
|
pady=3,
|
|
cursor="hand2",
|
|
)
|
|
attach_btn.pack(side=tk.LEFT, padx=(0, 4))
|
|
attach_btn.bind("<Button-1>", lambda e: self._on_attach_file())
|
|
attach_btn.bind("<Enter>", lambda e: attach_btn.configure(fg=BTN_HOVER_BG))
|
|
attach_btn.bind("<Leave>", lambda e: attach_btn.configure(fg=BTN_BG))
|
|
|
|
# Champ de saisie — police agrandie
|
|
self._input_var = tk.StringVar()
|
|
self._input_entry = tk.Entry(
|
|
input_frame,
|
|
textvariable=self._input_var,
|
|
bg=INPUT_BG,
|
|
fg=INPUT_FG,
|
|
font=FONT_INPUT,
|
|
relief=tk.FLAT,
|
|
bd=0,
|
|
highlightthickness=1,
|
|
highlightbackground=BORDER_COLOR,
|
|
highlightcolor=BTN_BG,
|
|
insertbackground=INPUT_FG,
|
|
)
|
|
self._input_entry.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 8))
|
|
self._input_entry.insert(0, "")
|
|
self._input_entry.bind("<Return>", lambda e: self._on_send())
|
|
|
|
# Placeholder
|
|
self._input_entry.insert(0, "Votre message...")
|
|
self._input_entry.configure(fg=TIMESTAMP_FG)
|
|
self._input_entry.bind("<FocusIn>", self._on_input_focus_in)
|
|
self._input_entry.bind("<FocusOut>", self._on_input_focus_out)
|
|
|
|
# Bouton envoyer
|
|
send_btn = tk.Label(
|
|
input_frame,
|
|
text="\u27A4",
|
|
bg=BTN_BG,
|
|
fg=BTN_FG,
|
|
font=FONT_SEND_BTN,
|
|
padx=12,
|
|
pady=3,
|
|
cursor="hand2",
|
|
)
|
|
send_btn.pack(side=tk.RIGHT)
|
|
send_btn.bind("<Button-1>", lambda e: self._on_send())
|
|
send_btn.bind("<Enter>", lambda e: send_btn.configure(bg=BTN_HOVER_BG))
|
|
send_btn.bind("<Leave>", lambda e: send_btn.configure(bg=BTN_BG))
|
|
|
|
def _build_resize_grip(self, root) -> None:
|
|
"""Poignee de redimensionnement en bas a droite de la fenetre."""
|
|
tk = self._tk
|
|
|
|
grip_frame = tk.Frame(root, bg=BG_COLOR, height=16)
|
|
grip_frame.pack(fill=tk.X, side=tk.BOTTOM)
|
|
|
|
# Symbole de grip aligne a droite
|
|
self._resize_grip = tk.Label(
|
|
grip_frame,
|
|
text="\u25E2", # triangle bas-droite
|
|
bg=BG_COLOR,
|
|
fg="#B0B8C4",
|
|
font=FONT_RESIZE_GRIP,
|
|
cursor="bottom_right_corner",
|
|
padx=4,
|
|
)
|
|
self._resize_grip.pack(side=tk.RIGHT, anchor=tk.SE)
|
|
|
|
# Donnees de resize
|
|
self._resize_data = {"x": 0, "y": 0, "w": 0, "h": 0}
|
|
self._resize_grip.bind("<Button-1>", self._on_resize_start)
|
|
self._resize_grip.bind("<B1-Motion>", self._on_resize_motion)
|
|
|
|
# ======================================================================
|
|
# Placeholder input
|
|
# ======================================================================
|
|
|
|
def _on_input_focus_in(self, event) -> None:
|
|
"""Retire le placeholder quand le champ prend le focus."""
|
|
if self._input_entry.get() == "Votre message...":
|
|
self._input_entry.delete(0, self._tk.END)
|
|
self._input_entry.configure(fg=INPUT_FG)
|
|
|
|
def _on_input_focus_out(self, event) -> None:
|
|
"""Remet le placeholder si le champ est vide."""
|
|
if not self._input_entry.get().strip():
|
|
self._input_entry.delete(0, self._tk.END)
|
|
self._input_entry.insert(0, "Votre message...")
|
|
self._input_entry.configure(fg=TIMESTAMP_FG)
|
|
|
|
# ======================================================================
|
|
# Drag (deplacer la fenetre)
|
|
# ======================================================================
|
|
|
|
def _on_drag_start(self, event) -> None:
|
|
self._drag_data["x"] = event.x
|
|
self._drag_data["y"] = event.y
|
|
|
|
def _on_drag_motion(self, event) -> None:
|
|
if self._root is None:
|
|
return
|
|
dx = event.x - self._drag_data["x"]
|
|
dy = event.y - self._drag_data["y"]
|
|
x = self._root.winfo_x() + dx
|
|
y = self._root.winfo_y() + dy
|
|
self._root.geometry(f"+{x}+{y}")
|
|
|
|
# ======================================================================
|
|
# Resize (redimensionner la fenetre via le grip)
|
|
# ======================================================================
|
|
|
|
def _on_resize_start(self, event) -> None:
|
|
"""Debut du redimensionnement — memorise la position et la taille."""
|
|
if self._root is None:
|
|
return
|
|
self._resize_data["x"] = event.x_root
|
|
self._resize_data["y"] = event.y_root
|
|
self._resize_data["w"] = self._root.winfo_width()
|
|
self._resize_data["h"] = self._root.winfo_height()
|
|
|
|
def _on_resize_motion(self, event) -> None:
|
|
"""Redimensionne la fenetre en suivant le curseur."""
|
|
if self._root is None:
|
|
return
|
|
dx = event.x_root - self._resize_data["x"]
|
|
dy = event.y_root - self._resize_data["y"]
|
|
new_w = max(350, self._resize_data["w"] + dx)
|
|
new_h = max(450, self._resize_data["h"] + dy)
|
|
x = self._root.winfo_x()
|
|
y = self._root.winfo_y()
|
|
self._root.geometry(f"{new_w}x{new_h}+{x}+{y}")
|
|
|
|
# ======================================================================
|
|
# Actions (thread tkinter)
|
|
# ======================================================================
|
|
|
|
def _do_show(self) -> None:
|
|
"""Affiche la fenetre (appele dans le thread tkinter)."""
|
|
if self._root is None:
|
|
return
|
|
self._root.deiconify()
|
|
self._root.lift()
|
|
self._root.focus_force()
|
|
self._input_entry.focus_set()
|
|
self._visible = True
|
|
|
|
def _do_hide(self) -> None:
|
|
"""Masque la fenetre (appele dans le thread tkinter)."""
|
|
if self._root is None:
|
|
return
|
|
self._root.withdraw()
|
|
self._visible = False
|
|
|
|
def _do_destroy(self) -> None:
|
|
"""Detruit la fenetre (appele dans le thread tkinter)."""
|
|
if self._root is not None:
|
|
try:
|
|
self._root.quit()
|
|
self._root.destroy()
|
|
except Exception:
|
|
pass
|
|
self._root = None
|
|
self._visible = False
|
|
|
|
# ======================================================================
|
|
# Ajout de messages dans la zone de chat
|
|
# ======================================================================
|
|
|
|
def _add_lea_message(self, text: str) -> None:
|
|
"""Ajoute un message de Lea (cote gauche, fond bleu)."""
|
|
if self._root is None:
|
|
return
|
|
self._root.after(0, lambda: self._render_message(
|
|
sender="lea",
|
|
text=text,
|
|
bg=MSG_LEA_BG,
|
|
fg=MSG_LEA_FG,
|
|
prefix="\U0001f4ac ",
|
|
))
|
|
|
|
def _add_user_message(self, text: str) -> None:
|
|
"""Ajoute un message utilisateur (cote droit, fond vert)."""
|
|
if self._root is None:
|
|
return
|
|
self._root.after(0, lambda: self._render_message(
|
|
sender="user",
|
|
text=text,
|
|
bg=MSG_USER_BG,
|
|
fg=MSG_USER_FG,
|
|
prefix="",
|
|
))
|
|
|
|
def _add_system_message(self, text: str) -> None:
|
|
"""Ajoute un message systeme (centre, gris)."""
|
|
if self._root is None:
|
|
return
|
|
self._root.after(0, lambda: self._render_message(
|
|
sender="system",
|
|
text=text,
|
|
bg=BG_COLOR,
|
|
fg=TIMESTAMP_FG,
|
|
prefix="",
|
|
))
|
|
|
|
def _render_message(
|
|
self, sender: str, text: str, bg: str, fg: str, prefix: str
|
|
) -> None:
|
|
"""Rendu d'un message dans la zone de chat (thread tkinter)."""
|
|
tk = self._tk
|
|
if self._msg_frame is None:
|
|
return
|
|
|
|
now = datetime.now().strftime("%H:%M")
|
|
|
|
# Conteneur du message — espacement vertical accru
|
|
msg_container = tk.Frame(self._msg_frame, bg=BG_COLOR)
|
|
msg_container.pack(fill=tk.X, padx=MARGIN, pady=4)
|
|
|
|
if sender == "system":
|
|
# Message systeme centre
|
|
label = tk.Label(
|
|
msg_container,
|
|
text=text,
|
|
bg=bg,
|
|
fg=fg,
|
|
font=FONT_SYSTEM,
|
|
wraplength=MSG_WRAP_WIDTH,
|
|
)
|
|
label.pack(anchor=tk.CENTER)
|
|
elif sender == "user":
|
|
# Message utilisateur a droite — bulle avec bordure subtile
|
|
inner = tk.Frame(
|
|
msg_container,
|
|
bg=bg,
|
|
padx=12,
|
|
pady=8,
|
|
highlightbackground=MSG_BORDER_COLOR,
|
|
highlightthickness=1,
|
|
)
|
|
inner.pack(anchor=tk.E, padx=(50, 0))
|
|
|
|
# Horodatage
|
|
tk.Label(
|
|
inner,
|
|
text=f"Vous \u2022 {now}",
|
|
bg=bg,
|
|
fg=TIMESTAMP_FG,
|
|
font=FONT_TIMESTAMP,
|
|
anchor=tk.E,
|
|
).pack(fill=tk.X, anchor=tk.E)
|
|
|
|
# Texte — police agrandie
|
|
tk.Label(
|
|
inner,
|
|
text=text,
|
|
bg=bg,
|
|
fg=fg,
|
|
font=FONT_MSG,
|
|
wraplength=MSG_WRAP_WIDTH - 40,
|
|
justify=tk.LEFT,
|
|
anchor=tk.W,
|
|
).pack(fill=tk.X, anchor=tk.W, pady=(3, 0))
|
|
else:
|
|
# Message Lea a gauche — bulle avec bordure subtile
|
|
inner = tk.Frame(
|
|
msg_container,
|
|
bg=bg,
|
|
padx=12,
|
|
pady=8,
|
|
highlightbackground=MSG_BORDER_COLOR,
|
|
highlightthickness=1,
|
|
)
|
|
inner.pack(anchor=tk.W, padx=(0, 50))
|
|
|
|
# Horodatage
|
|
tk.Label(
|
|
inner,
|
|
text=f"L\u00e9a \u2022 {now}",
|
|
bg=bg,
|
|
fg=TIMESTAMP_FG,
|
|
font=FONT_TIMESTAMP,
|
|
anchor=tk.W,
|
|
).pack(fill=tk.X, anchor=tk.W)
|
|
|
|
# Texte — police agrandie
|
|
tk.Label(
|
|
inner,
|
|
text=f"{prefix}{text}",
|
|
bg=bg,
|
|
fg=fg,
|
|
font=FONT_MSG,
|
|
wraplength=MSG_WRAP_WIDTH - 40,
|
|
justify=tk.LEFT,
|
|
anchor=tk.W,
|
|
).pack(fill=tk.X, anchor=tk.W, pady=(3, 0))
|
|
|
|
# Stocker dans l'historique
|
|
self._messages.append({"sender": sender, "text": text, "time": now})
|
|
|
|
# Scroll vers le bas
|
|
self._scroll_to_bottom()
|
|
|
|
def _scroll_to_bottom(self) -> None:
|
|
"""Scrolle la zone de messages vers le bas."""
|
|
if self._canvas is not None:
|
|
self._canvas.update_idletasks()
|
|
self._canvas.yview_moveto(1.0)
|
|
|
|
def _show_typing_indicator(self) -> None:
|
|
"""Affiche un indicateur 'Lea reflechit...'."""
|
|
if self._root is None:
|
|
return
|
|
self._root.after(0, self._do_show_typing)
|
|
|
|
def _do_show_typing(self) -> None:
|
|
"""Affiche l'indicateur dans le thread tkinter."""
|
|
tk = self._tk
|
|
if self._msg_frame is None:
|
|
return
|
|
|
|
self._typing_frame = tk.Frame(self._msg_frame, bg=BG_COLOR)
|
|
self._typing_frame.pack(fill=tk.X, padx=MARGIN, pady=4)
|
|
|
|
inner = tk.Frame(
|
|
self._typing_frame,
|
|
bg=MSG_LEA_BG,
|
|
padx=12,
|
|
pady=8,
|
|
highlightbackground=MSG_BORDER_COLOR,
|
|
highlightthickness=1,
|
|
)
|
|
inner.pack(anchor=tk.W, padx=(0, 50))
|
|
|
|
tk.Label(
|
|
inner,
|
|
text="L\u00e9a r\u00e9fl\u00e9chit...",
|
|
bg=MSG_LEA_BG,
|
|
fg=TIMESTAMP_FG,
|
|
font=FONT_MSG_ITALIC,
|
|
).pack(anchor=tk.W)
|
|
|
|
self._scroll_to_bottom()
|
|
|
|
def _remove_typing_indicator(self) -> None:
|
|
"""Retire l'indicateur de frappe."""
|
|
if self._root is None:
|
|
return
|
|
self._root.after(0, self._do_remove_typing)
|
|
|
|
def _do_remove_typing(self) -> None:
|
|
"""Retire l'indicateur dans le thread tkinter."""
|
|
if hasattr(self, "_typing_frame") and self._typing_frame is not None:
|
|
try:
|
|
self._typing_frame.destroy()
|
|
except Exception:
|
|
pass
|
|
self._typing_frame = None
|
|
|
|
# ======================================================================
|
|
# Envoi de messages
|
|
# ======================================================================
|
|
|
|
def _on_send(self) -> None:
|
|
"""Envoie le message saisi par l'utilisateur."""
|
|
text = self._input_var.get().strip()
|
|
if not text or text == "Votre message...":
|
|
return
|
|
|
|
# Vider le champ
|
|
self._input_var.set("")
|
|
self._input_entry.configure(fg=INPUT_FG)
|
|
|
|
# Afficher le message utilisateur
|
|
self._add_user_message(text)
|
|
|
|
# Envoyer au serveur dans un thread
|
|
threading.Thread(
|
|
target=self._send_to_server,
|
|
args=(text,),
|
|
daemon=True,
|
|
).start()
|
|
|
|
def _send_to_server(self, message: str) -> None:
|
|
"""Envoie le message au serveur et affiche la reponse (thread reseau)."""
|
|
self._show_typing_indicator()
|
|
|
|
try:
|
|
if self._server_client is None:
|
|
self._remove_typing_indicator()
|
|
self._add_lea_message(
|
|
"Je ne suis pas encore connect\u00e9e au serveur.\n"
|
|
"V\u00e9rifiez la connexion r\u00e9seau."
|
|
)
|
|
return
|
|
|
|
response = self._server_client.send_chat_message(message)
|
|
|
|
self._remove_typing_indicator()
|
|
|
|
if response is None:
|
|
self._add_lea_message(
|
|
"\u26a0 Connexion perdue. Impossible de joindre le serveur."
|
|
)
|
|
return
|
|
|
|
# Extraire le message de la reponse
|
|
resp = response.get("response", {})
|
|
if isinstance(resp, dict):
|
|
text = resp.get("message", str(resp))
|
|
else:
|
|
text = str(resp)
|
|
|
|
if text:
|
|
self._add_lea_message(text)
|
|
|
|
# Traiter les suggestions
|
|
suggestions = []
|
|
if isinstance(resp, dict):
|
|
suggestions = resp.get("suggestions", [])
|
|
if suggestions:
|
|
suggestions_text = "\n".join(
|
|
f"\u2022 {s}" for s in suggestions
|
|
)
|
|
self._add_system_message(f"Suggestions :\n{suggestions_text}")
|
|
|
|
except Exception as e:
|
|
self._remove_typing_indicator()
|
|
logger.error("Erreur envoi message : %s", e)
|
|
self._add_lea_message(
|
|
f"\u26a0 Erreur : {e}"
|
|
)
|
|
|
|
# ======================================================================
|
|
# Actions rapides
|
|
# ======================================================================
|
|
|
|
def _on_quick_record(self) -> None:
|
|
"""Bouton Apprenez-moi — lance une session d'enregistrement."""
|
|
# Verifier si un enregistrement est deja en cours
|
|
if self._shared_state is not None and self._shared_state.is_recording:
|
|
self._add_lea_message(
|
|
"Un enregistrement est d\u00e9j\u00e0 en cours.\n"
|
|
"Cliquez \u00ab Arr\u00eater \u00bb pour terminer d'abord."
|
|
)
|
|
return
|
|
|
|
can_record = (
|
|
self._shared_state is not None
|
|
or self._on_start_callback is not None
|
|
)
|
|
if can_record:
|
|
self._add_system_message("Pr\u00e9paration de l'apprentissage...")
|
|
# Demander le nom dans un thread (dialogue tkinter)
|
|
threading.Thread(target=self._do_quick_record, daemon=True).start()
|
|
else:
|
|
self._add_lea_message(
|
|
"L'apprentissage n'est pas disponible pour le moment. "
|
|
"Essayez depuis le menu de la barre des t\u00e2ches."
|
|
)
|
|
|
|
def _do_quick_record(self) -> None:
|
|
"""Demande le nom de la t\u00e2che et lance l'enregistrement."""
|
|
import tkinter as tk
|
|
from tkinter import simpledialog
|
|
|
|
# Creer un dialogue ephemere
|
|
tmp_root = tk.Tk()
|
|
tmp_root.withdraw()
|
|
tmp_root.attributes('-topmost', True)
|
|
name = simpledialog.askstring(
|
|
"Nouvelle t\u00e2che",
|
|
"D\u00e9crivez cette t\u00e2che en quelques mots :",
|
|
initialvalue="",
|
|
parent=tmp_root,
|
|
)
|
|
tmp_root.destroy()
|
|
|
|
if name and name.strip():
|
|
name = name.strip()
|
|
self._add_lea_message(
|
|
f"C'est parti ! Montrez-moi comment faire \u00ab {name} \u00bb."
|
|
)
|
|
try:
|
|
# Utiliser l'etat partage si disponible (synchronise le systray)
|
|
if self._shared_state is not None:
|
|
self._shared_state.start_recording(name)
|
|
elif self._on_start_callback is not None:
|
|
self._on_start_callback(name)
|
|
except Exception as e:
|
|
self._add_lea_message(f"Oups, un probl\u00e8me : {e}")
|
|
|
|
def _on_quick_tasks(self) -> None:
|
|
"""Bouton Lancer — demande ce que L\u00e9a sait faire."""
|
|
self._add_user_message("Qu'est-ce que vous savez faire ?")
|
|
threading.Thread(
|
|
target=self._send_to_server,
|
|
args=("qu'est-ce que tu sais faire ?",),
|
|
daemon=True,
|
|
).start()
|
|
|
|
def _on_quick_import(self) -> None:
|
|
"""Bouton Donn\u00e9es — affiche les tables / imports."""
|
|
self._add_user_message("Montrez-moi les donn\u00e9es")
|
|
threading.Thread(
|
|
target=self._send_to_server,
|
|
args=("montre les tables",),
|
|
daemon=True,
|
|
).start()
|
|
|
|
def _on_quick_stop(self) -> None:
|
|
"""Bouton Arr\u00eater — arr\u00eate l'enregistrement ou le replay en cours.
|
|
|
|
Verifie l'etat partage et agit en consequence :
|
|
1. Si enregistrement en cours → arrete via shared_state
|
|
2. Si replay en cours → arrete via shared_state
|
|
3. Sinon → informe l'utilisateur qu'il n'y a rien a arreter
|
|
"""
|
|
if self._shared_state is not None:
|
|
if self._shared_state.is_recording:
|
|
name = self._shared_state.recording_name
|
|
count = self._shared_state.actions_count
|
|
self._shared_state.stop_recording()
|
|
self._add_lea_message(
|
|
f"Enregistrement arr\u00eat\u00e9.\n"
|
|
f"J'ai m\u00e9moris\u00e9 {count} actions pour "
|
|
f"\u00ab {name} \u00bb. C'est not\u00e9 !"
|
|
)
|
|
return
|
|
|
|
if self._shared_state.is_replay_active:
|
|
self._shared_state.set_replay_active(False)
|
|
self._add_lea_message("Replay arr\u00eat\u00e9.")
|
|
return
|
|
|
|
# Rien a arreter
|
|
self._add_lea_message(
|
|
"Il n'y a rien en cours \u00e0 arr\u00eater.\n"
|
|
"Cliquez \u00ab Apprenez-moi \u00bb pour d\u00e9marrer."
|
|
)
|
|
|
|
def _on_quick_help(self) -> None:
|
|
"""Bouton Aide — demande l'aide."""
|
|
self._add_user_message("Aide")
|
|
threading.Thread(
|
|
target=self._send_to_server,
|
|
args=("aide",),
|
|
daemon=True,
|
|
).start()
|
|
|
|
# ======================================================================
|
|
# Envoi de fichier
|
|
# ======================================================================
|
|
|
|
def _on_attach_file(self) -> None:
|
|
"""Ouvre un dialogue pour selectionner un fichier a envoyer."""
|
|
from tkinter import filedialog
|
|
|
|
filepath = filedialog.askopenfilename(
|
|
title="Choisir un fichier",
|
|
filetypes=[
|
|
("Tableurs", "*.xlsx *.xls *.csv"),
|
|
("PDF", "*.pdf"),
|
|
("Tous", "*.*"),
|
|
],
|
|
parent=self._root,
|
|
)
|
|
if filepath:
|
|
self._add_user_message(f"\U0001f4ce {os.path.basename(filepath)}")
|
|
threading.Thread(
|
|
target=self._upload_file,
|
|
args=(filepath,),
|
|
daemon=True,
|
|
).start()
|
|
|
|
def _upload_file(self, filepath: str) -> None:
|
|
"""Envoie le fichier au serveur et affiche la reponse (thread reseau)."""
|
|
self._show_typing_indicator()
|
|
|
|
try:
|
|
if self._server_client is None:
|
|
self._remove_typing_indicator()
|
|
self._add_lea_message(
|
|
"Je ne suis pas encore connect\u00e9e au serveur.\n"
|
|
"V\u00e9rifiez la connexion r\u00e9seau."
|
|
)
|
|
return
|
|
|
|
import requests
|
|
|
|
# Determiner l'URL de base du serveur chat
|
|
host = self._server_host
|
|
port = self._chat_port
|
|
url = f"http://{host}:{port}/api/chat/upload"
|
|
|
|
filename = os.path.basename(filepath)
|
|
|
|
with open(filepath, "rb") as f:
|
|
files = {"file": (filename, f)}
|
|
resp = requests.post(url, files=files, timeout=60)
|
|
|
|
self._remove_typing_indicator()
|
|
|
|
if resp.ok:
|
|
data = resp.json()
|
|
# Construire un message de reponse lisible
|
|
msg_parts = []
|
|
if data.get("filename"):
|
|
msg_parts.append(f"Fichier **{data['filename']}** re\u00e7u !")
|
|
|
|
preview = data.get("preview", {})
|
|
if preview:
|
|
rows = preview.get("total_rows", "?")
|
|
cols = preview.get("columns", [])
|
|
cols_str = ", ".join(cols[:8])
|
|
if len(cols) > 8:
|
|
cols_str += f"... (+{len(cols) - 8})"
|
|
msg_parts.append(f"{rows} lignes, colonnes : {cols_str}")
|
|
|
|
table_name = data.get("table_name", "")
|
|
if table_name:
|
|
msg_parts.append(
|
|
f"Je cr\u00e9e la table '{table_name}' ?"
|
|
)
|
|
|
|
if data.get("message"):
|
|
msg_parts.append(str(data["message"]))
|
|
|
|
message = "\n".join(msg_parts) if msg_parts else "Fichier envoy\u00e9 !"
|
|
self._add_lea_message(message)
|
|
else:
|
|
error = "Erreur serveur"
|
|
try:
|
|
error = resp.json().get("error", error)
|
|
except Exception:
|
|
pass
|
|
self._add_lea_message(f"\u26a0 {error}")
|
|
|
|
except Exception as e:
|
|
self._remove_typing_indicator()
|
|
logger.error("Erreur upload fichier : %s", e)
|
|
self._add_lea_message(f"\u26a0 Erreur d'envoi : {e}")
|
|
|
|
# ======================================================================
|
|
# Verification de la connexion
|
|
# ======================================================================
|
|
|
|
def _check_connection_status(self) -> None:
|
|
"""Verifie l'etat de connexion et met a jour l'indicateur."""
|
|
if self._destroyed or self._root is None:
|
|
return
|
|
|
|
try:
|
|
connected = False
|
|
if self._server_client is not None:
|
|
connected = getattr(self._server_client, "connected", False)
|
|
|
|
if connected:
|
|
self._status_dot.configure(fg=STATUS_CONNECTED)
|
|
self._status_label.configure(text="Connect\u00e9e")
|
|
else:
|
|
self._status_dot.configure(fg=STATUS_DISCONNECTED)
|
|
self._status_label.configure(text="D\u00e9connect\u00e9e")
|
|
except Exception:
|
|
pass
|
|
|
|
# Reverifier toutes les 10 secondes
|
|
if not self._destroyed and self._root is not None:
|
|
try:
|
|
self._root.after(10000, self._check_connection_status)
|
|
except Exception:
|
|
pass
|