"""Panneau lateral de l'assistant IA : conversation en memoire uniquement (jamais persistee, jamais journalisee), une requete a la fois. Point d'entree unique (`run_request`) pour toutes les actions IA d'un onglet : c'est ici, et seulement ici, que se fait la verification de securite (avis unique + detection de secrets) avant tout envoi au fournisseur configure. """ from __future__ import annotations import html from PySide6.QtWidgets import ( QApplication, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextEdit, QVBoxLayout, QWidget, ) from app.ai.redaction import redact from app.ai.service import KIND_ASK, AIRequestWorker from app.dialogs.ai_prompts import CANCEL, MASK, confirm_sensitive_content, ensure_ai_notice_acknowledged _KIND_LABELS = { "explain": "Expliquer cette sortie", "diagnose": "Diagnostiquer cette erreur", "solution": "Proposer une solution", "ask": "Question", } def _escape(text: str) -> str: return html.escape(text).replace("\n", "
") class AIPanel(QWidget): """`provider_factory` est un callable sans argument renvoyant un AIProvider pret a l'emploi, ou None si l'assistant est desactive/non configure - rappele a chaque requete pour refleter immediatement un changement de parametres, sans avoir a rouvrir l'onglet.""" def __init__(self, provider_factory, parent=None): super().__init__(parent) self.provider_factory = provider_factory self._worker: AIRequestWorker | None = None self._generation = 0 self._last_response = "" self._pending_context = "" layout = QVBoxLayout(self) layout.setContentsMargins(6, 6, 6, 6) header = QHBoxLayout() header.addWidget(QLabel("🤖 Assistant IA"), 1) close_btn = QPushButton("✕") close_btn.setFixedWidth(28) close_btn.setToolTip("Masquer le panneau") close_btn.clicked.connect(self.hide) header.addWidget(close_btn) layout.addLayout(header) self.conversation = QTextEdit() self.conversation.setReadOnly(True) layout.addWidget(self.conversation, 1) self.busy_label = QLabel("") self.busy_label.setObjectName("statusLabel") layout.addWidget(self.busy_label) self.question_row = QWidget() question_layout = QHBoxLayout(self.question_row) question_layout.setContentsMargins(0, 0, 0, 0) self.question_edit = QLineEdit() self.question_edit.setPlaceholderText("Votre question...") self.question_edit.returnPressed.connect(self._send_question) send_btn = QPushButton("Envoyer") send_btn.clicked.connect(self._send_question) question_layout.addWidget(self.question_edit, 1) question_layout.addWidget(send_btn) self.question_row.hide() layout.addWidget(self.question_row) btn_row = QHBoxLayout() self.copy_btn = QPushButton("Copier reponse") self.new_question_btn = QPushButton("Nouvelle question") self.clear_btn = QPushButton("Effacer la conversation") self.copy_btn.clicked.connect(self._copy_last_response) self.new_question_btn.clicked.connect(lambda: self.prompt_question("")) self.clear_btn.clicked.connect(self.clear_conversation) for b in (self.copy_btn, self.new_question_btn, self.clear_btn): btn_row.addWidget(b) layout.addLayout(btn_row) # ---------- conversation ---------- def clear_conversation(self) -> None: self._generation += 1 # toute reponse en vol devient perimee et sera ignoree self.conversation.clear() self._last_response = "" self.busy_label.clear() self.question_row.hide() def _copy_last_response(self) -> None: if self._last_response: QApplication.clipboard().setText(self._last_response) # ---------- question libre ---------- def prompt_question(self, context: str = "") -> None: """Affiche le panneau et la zone de saisie d'une question. `context` est un texte optionnel (ex : selection du terminal) joint a la question, propose a la meme verification de secrets avant l'envoi.""" self.show() self._pending_context = context self.question_row.show() self.question_edit.setFocus() def _send_question(self) -> None: question = self.question_edit.text().strip() if not question: return self.question_edit.clear() self.question_row.hide() context, self._pending_context = self._pending_context, "" self.run_request(KIND_ASK, question, context=context, label=_KIND_LABELS["ask"]) # ---------- requetes ---------- def run_request(self, kind: str, payload: str, context: str = "", label: str = "") -> None: if self._worker is not None and self._worker.isRunning(): self.busy_label.setText("Une requete est deja en cours, patientez...") return provider = self.provider_factory() if self.provider_factory else None if provider is None: self.show() self.conversation.append("Assistant IA non configure (menu Assistant IA > Parametres...).") return if not ensure_ai_notice_acknowledged(self): return to_check = payload + ("\n" + context if context else "") decision = confirm_sensitive_content(self, to_check) if decision == CANCEL: return if decision == MASK: payload = redact(payload) context = redact(context) if context else context self.show() label = label or _KIND_LABELS.get(kind, kind) self.conversation.append(f"{html.escape(label)}
{_escape(payload)}") self.busy_label.setText("Interrogation du fournisseur IA...") generation = self._generation self._worker = AIRequestWorker(provider, kind, payload, context=context) self._worker.finished_ok.connect(lambda text: self._on_ok(generation, text)) self._worker.finished_error.connect(lambda message: self._on_error(generation, message)) self._worker.start() def _on_ok(self, generation: int, text: str) -> None: if generation != self._generation: return self.busy_label.clear() self._last_response = text self.conversation.append(f"
{_escape(text)}
") def _on_error(self, generation: int, message: str) -> None: if generation != self._generation: return self.busy_label.clear() self.conversation.append(f"Erreur : {_escape(message)}") def shutdown(self) -> None: """A appeler avant la destruction du panneau (fermeture de l'onglet) : une requete deja en vol devient perimee (voir `_generation`) et son thread dispose d'un court delai pour se terminer proprement.""" self._generation += 1 if self._worker is not None and self._worker.isRunning(): self._worker.wait(2000)