Files
pyssh-client/app/ui/ai_panel.py
Florian 0b2ff38bfd Assistant IA optionnel (analyse de sortie, diagnostic, generation de commande)
Nouvelle couche app/ai/ (provider OpenAI-compatible via urllib, redaction de
secrets, detection de commandes dangereuses, parametres, worker QThread) et son
integration dans chaque onglet terminal via un bouton "Assistant IA" (memes
conventions que le bouton Snippets existant) :

- Expliquer cette sortie / Diagnostiquer cette erreur / Proposer une solution /
  Poser une question... : utilisent la selection courante du terminal, affichees
  dans un panneau lateral (memoire uniquement, jamais journalise) reutilisant le
  splitter comme le panneau SFTP.
- Generer une commande : ne s'execute jamais automatiquement (Copier / Inserer
  dans le terminal sans Entree / Annuler), avec avertissement si la commande
  proposee correspond a un motif destructeur connu.
- Avant tout envoi : avis unique (persist) puis detection de secrets evidents
  (mot de passe, cle, jeton) avec choix de masquer, envoyer tel quel ou annuler.

Desactive par defaut, sans liste de fournisseur figee (URL + modele libres,
compatible OpenAI/OpenRouter/LLM local). La cle API passe exclusivement par
CredentialStore (jamais dans settings.json ni journalisee). Parametrable via
le nouveau menu "Assistant IA > Parametres..." (bouton "Tester la connexion").

Bug trouve par les tests avant commit : un panneau nouvellement insere dans un
QSplitter deja visible est affiche automatiquement par Qt, ce qui inversait le
premier clic sur "Afficher le panneau IA" ; corrige en le masquant explicitement
a sa creation.

9 nouveaux fichiers de test (provider, redaction, dangers, parametres, worker,
confirmations de securite, dialogue de commande, panneau, integration onglet) :
39 verifications, toutes au vert. Suite complete (31 fichiers) executee sans
regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 05:48:13 +02:00

172 lines
6.9 KiB
Python

"""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 (
QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QLabel, QLineEdit, QApplication,
)
from app.ai.service import AIRequestWorker, KIND_ASK
from app.ai.redaction import redact
from app.dialogs.ai_prompts import ensure_ai_notice_acknowledged, confirm_sensitive_content, MASK, CANCEL
_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", "<br>")
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("<i>Assistant IA non configure (menu Assistant IA > Parametres...).</i>")
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"<b>{html.escape(label)}</b><br>{_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"<div>{_escape(text)}</div>")
def _on_error(self, generation: int, message: str) -> None:
if generation != self._generation:
return
self.busy_label.clear()
self.conversation.append(f"<span style='color:#c0392b;'>Erreur : {_escape(message)}</span>")
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)