pyproject.toml devient le point d'entree unique de configuration des outils
(pytest.ini supprime, sa config y est deplacee). Les dependances restent dans
requirements.txt/requirements-dev.txt (deja utilises par setup.bat) plutot que
dupliquees ici : ce projet se distribue par PyInstaller, pas par "pip install".
ruff : configure puis passe sur tout app/ + tests/ (154 corrections
automatiques, purement mecaniques - tri des imports, imports multiples sur une
ligne separes, alias depreciees) suivies de 7 corrections manuelles ciblees :
- app/ssh_connection.py : `raise ... from None` pour le refus explicite de
passphrase (ce n'est pas un echec a diagnostiquer).
- app/controllers/workspace_manager.py : zip() sans strict= documente en
commentaire (deliberement tolerant a un workspace legerement perime).
- tests/test_ai_redaction.py : nom de variable ambigu renomme.
- E702 (construction compacte "widget = X(); widget.setY(...)" dans
sftp_panel.py/tunnel_dialog.py) et E402 (quelques tests qui importent un
symbole juste avant la section qui l'utilise) : ignores explicitement, ce
sont des styles deliberes preexistants, pas des oublis.
`ruff check app main.py tests` est desormais propre.
pyright : mode "basic" (le code n'a jamais ete type). Plus de 200 signalements
sur ~300 etaient le meme faux positif connu de l'ecosysteme PySide6 (acces
plat aux enums Qt, ex. Qt.UserRole, valide a l'execution mais que les stubs ne
reconnaissent que sous leur forme imbriquee) : desactive avec justification en
commentaire. Reste un socle de 77 signalements reels (surtout des acces
Optional non filtres dans app/ui/tabs.py et app/ssh_connection.py) : non
corrige dans cette phase - resserrer le typage la ou pyright a raison est un
travail a part, pas une consequence automatique de l'ajout de l'outil. La CI
le lance en mode informatif (continue-on-error) tant que ce socle n'est pas
resorbe.
.gitea/workflows/ci.yml : lint (ruff, bloquant), tests (pytest, bloquant),
typecheck (pyright, informatif), build (PyInstaller, sur main uniquement,
artefact du dossier portable). Necessite un runner Gitea Actions Windows
enregistre - PySide6/pywinpty/Credential Manager/ACL NTFS sont Windows
uniquement, comme l'application elle-meme ; non verifiable depuis ce poste.
Suite complete revalidee apres coup (33/33) pour confirmer qu'aucune des 154
corrections automatiques n'a modifie de comportement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
179 lines
7.0 KiB
Python
179 lines
7.0 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 (
|
|
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", "<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)
|