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>
251 lines
7.9 KiB
Python
251 lines
7.9 KiB
Python
# agent_v0/lea_ui/chat_widget.py
|
|
"""
|
|
Widget de chat pour l'interface Lea.
|
|
|
|
Affiche les messages avec des bulles :
|
|
- Utilisateur a droite (fond indigo)
|
|
- Lea a gauche (fond blanc)
|
|
|
|
Communique avec le serveur Linux via LeaServerClient.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import List, Optional
|
|
|
|
from PyQt5.QtCore import (
|
|
QPropertyAnimation,
|
|
QSize,
|
|
Qt,
|
|
QTimer,
|
|
pyqtSignal,
|
|
pyqtSlot,
|
|
)
|
|
from PyQt5.QtGui import QColor, QFont, QPainter, QPainterPath, QPen
|
|
from PyQt5.QtWidgets import (
|
|
QFrame,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QLineEdit,
|
|
QPushButton,
|
|
QScrollArea,
|
|
QSizePolicy,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from . import styles
|
|
|
|
logger = logging.getLogger("lea_ui.chat")
|
|
|
|
|
|
class ChatBubble(QFrame):
|
|
"""Bulle de message individuelle."""
|
|
|
|
def __init__(
|
|
self,
|
|
text: str,
|
|
is_user: bool = False,
|
|
parent: Optional[QWidget] = None,
|
|
) -> None:
|
|
super().__init__(parent)
|
|
self._is_user = is_user
|
|
|
|
# Style de la bulle
|
|
if is_user:
|
|
bg_color = styles.COLOR_BUBBLE_USER
|
|
text_color = styles.COLOR_TEXT_ON_ACCENT
|
|
align = Qt.AlignRight
|
|
else:
|
|
bg_color = styles.COLOR_BUBBLE_LEA
|
|
text_color = styles.COLOR_TEXT
|
|
align = Qt.AlignLeft
|
|
|
|
self.setStyleSheet(f"""
|
|
QFrame {{
|
|
background-color: {bg_color};
|
|
border-radius: {styles.BUBBLE_RADIUS}px;
|
|
padding: {styles.PADDING}px;
|
|
border: {"none" if is_user else f"1px solid {styles.COLOR_BORDER}"};
|
|
}}
|
|
""")
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(
|
|
styles.PADDING, styles.PADDING // 2,
|
|
styles.PADDING, styles.PADDING // 2,
|
|
)
|
|
|
|
label = QLabel(text)
|
|
label.setWordWrap(True)
|
|
label.setFont(QFont(styles.FONT_FAMILY, styles.FONT_SIZE_NORMAL))
|
|
label.setStyleSheet(f"color: {text_color}; background: transparent; border: none;")
|
|
label.setTextFormat(Qt.RichText)
|
|
label.setOpenExternalLinks(True)
|
|
layout.addWidget(label)
|
|
|
|
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
|
|
self.setMaximumWidth(280)
|
|
|
|
|
|
class ChatWidget(QWidget):
|
|
"""Widget de chat complet avec zone de messages et champ de saisie.
|
|
|
|
Signals :
|
|
message_sent(str) : emis quand l'utilisateur envoie un message
|
|
"""
|
|
|
|
message_sent = pyqtSignal(str)
|
|
|
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
|
super().__init__(parent)
|
|
self._messages: List[dict] = []
|
|
self._setup_ui()
|
|
|
|
def _setup_ui(self) -> None:
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
|
|
# Zone de messages (scrollable)
|
|
self._scroll_area = QScrollArea()
|
|
self._scroll_area.setWidgetResizable(True)
|
|
self._scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
self._scroll_area.setStyleSheet(styles.CHAT_AREA_STYLE)
|
|
|
|
self._messages_container = QWidget()
|
|
self._messages_container.setObjectName("ChatContainer")
|
|
self._messages_layout = QVBoxLayout(self._messages_container)
|
|
self._messages_layout.setContentsMargins(
|
|
styles.PADDING, styles.PADDING,
|
|
styles.PADDING, styles.PADDING,
|
|
)
|
|
self._messages_layout.setSpacing(styles.SPACING)
|
|
self._messages_layout.addStretch()
|
|
|
|
self._scroll_area.setWidget(self._messages_container)
|
|
layout.addWidget(self._scroll_area, stretch=1)
|
|
|
|
# Separateur
|
|
sep = QFrame()
|
|
sep.setFrameShape(QFrame.HLine)
|
|
sep.setStyleSheet(f"background-color: {styles.COLOR_BORDER}; max-height: 1px;")
|
|
layout.addWidget(sep)
|
|
|
|
# Zone de saisie
|
|
input_layout = QHBoxLayout()
|
|
input_layout.setContentsMargins(
|
|
styles.PADDING, styles.SPACING,
|
|
styles.PADDING, styles.SPACING,
|
|
)
|
|
input_layout.setSpacing(styles.SPACING)
|
|
|
|
self._input = QLineEdit()
|
|
self._input.setObjectName("ChatInput")
|
|
self._input.setPlaceholderText("Ecrivez un message...")
|
|
self._input.setStyleSheet(styles.INPUT_STYLE)
|
|
self._input.returnPressed.connect(self._on_send)
|
|
input_layout.addWidget(self._input, stretch=1)
|
|
|
|
self._send_btn = QPushButton("Envoyer")
|
|
self._send_btn.setObjectName("SendButton")
|
|
self._send_btn.setStyleSheet(styles.SEND_BUTTON_STYLE)
|
|
self._send_btn.setCursor(Qt.PointingHandCursor)
|
|
self._send_btn.clicked.connect(self._on_send)
|
|
input_layout.addWidget(self._send_btn)
|
|
|
|
layout.addLayout(input_layout)
|
|
|
|
def _on_send(self) -> None:
|
|
"""Envoyer le message saisi."""
|
|
text = self._input.text().strip()
|
|
if not text:
|
|
return
|
|
|
|
self._input.clear()
|
|
self.add_user_message(text)
|
|
self.message_sent.emit(text)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API publique
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def add_user_message(self, text: str) -> None:
|
|
"""Ajouter un message utilisateur (bulle a droite)."""
|
|
self._add_bubble(text, is_user=True)
|
|
|
|
def add_lea_message(self, text: str) -> None:
|
|
"""Ajouter un message de Lea (bulle a gauche)."""
|
|
self._add_bubble(text, is_user=False)
|
|
|
|
def add_system_message(self, text: str) -> None:
|
|
"""Ajouter un message systeme (centre, discret)."""
|
|
label = QLabel(text)
|
|
label.setFont(QFont(styles.FONT_FAMILY, styles.FONT_SIZE_SMALL))
|
|
label.setStyleSheet(
|
|
f"color: {styles.COLOR_TEXT_SECONDARY}; "
|
|
f"background: transparent; padding: 4px;"
|
|
)
|
|
label.setAlignment(Qt.AlignCenter)
|
|
label.setWordWrap(True)
|
|
|
|
# Inserer avant le stretch final
|
|
count = self._messages_layout.count()
|
|
self._messages_layout.insertWidget(count - 1, label)
|
|
self._scroll_to_bottom()
|
|
|
|
def set_input_enabled(self, enabled: bool) -> None:
|
|
"""Activer/desactiver la saisie (pendant le chargement)."""
|
|
self._input.setEnabled(enabled)
|
|
self._send_btn.setEnabled(enabled)
|
|
if not enabled:
|
|
self._input.setPlaceholderText("Lea reflechit...")
|
|
else:
|
|
self._input.setPlaceholderText("Ecrivez un message...")
|
|
|
|
def clear_messages(self) -> None:
|
|
"""Effacer tous les messages."""
|
|
while self._messages_layout.count() > 1:
|
|
item = self._messages_layout.takeAt(0)
|
|
widget = item.widget()
|
|
if widget:
|
|
widget.deleteLater()
|
|
self._messages = []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _add_bubble(self, text: str, is_user: bool) -> None:
|
|
"""Ajouter une bulle au conteneur de messages."""
|
|
bubble = ChatBubble(text, is_user=is_user)
|
|
|
|
# Conteneur d'alignement
|
|
row = QHBoxLayout()
|
|
row.setContentsMargins(0, 0, 0, 0)
|
|
if is_user:
|
|
row.addStretch()
|
|
row.addWidget(bubble)
|
|
else:
|
|
row.addWidget(bubble)
|
|
row.addStretch()
|
|
|
|
# Inserer avant le stretch final
|
|
count = self._messages_layout.count()
|
|
wrapper = QWidget()
|
|
wrapper.setLayout(row)
|
|
wrapper.setStyleSheet("background: transparent;")
|
|
self._messages_layout.insertWidget(count - 1, wrapper)
|
|
|
|
self._messages.append({"text": text, "is_user": is_user})
|
|
self._scroll_to_bottom()
|
|
|
|
def _scroll_to_bottom(self) -> None:
|
|
"""Scroller vers le bas apres l'ajout d'un message."""
|
|
QTimer.singleShot(50, lambda: (
|
|
self._scroll_area.verticalScrollBar().setValue(
|
|
self._scroll_area.verticalScrollBar().maximum()
|
|
)
|
|
))
|