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>
This commit is contained in:
171
app/ui/ai_panel.py
Normal file
171
app/ui/ai_panel.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""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)
|
||||
173
app/ui/tabs.py
173
app/ui/tabs.py
@@ -15,7 +15,7 @@ from PySide6.QtCore import Qt, Signal, QTimer
|
||||
from PySide6.QtGui import QKeySequence, QShortcut
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter,
|
||||
QFrame, QLineEdit, QFileDialog, QMenu,
|
||||
QFrame, QLineEdit, QFileDialog, QMenu, QInputDialog,
|
||||
)
|
||||
|
||||
from app.session_manager import Session, AUTH_KEY, AUTH_PASSWORD
|
||||
@@ -30,6 +30,12 @@ from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_hos
|
||||
from app.services.ssh_target import detect_ssh_target
|
||||
from app.services.reconnect import compute_backoff_delay
|
||||
from app.snippets import Snippet, SnippetStore
|
||||
from app.ai.service import KIND_EXPLAIN, KIND_DIAGNOSE, KIND_SOLUTION, KIND_GENERATE, AIRequestWorker
|
||||
from app.ai.danger import check_dangerous
|
||||
from app.ai.redaction import redact
|
||||
from app.ui.ai_panel import AIPanel
|
||||
from app.dialogs.ai_command_dialog import AICommandDialog
|
||||
from app.dialogs.ai_prompts import ensure_ai_notice_acknowledged, confirm_sensitive_content, MASK, CANCEL
|
||||
|
||||
# sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles
|
||||
ANSI_ESCAPE_RE = re.compile(
|
||||
@@ -48,11 +54,15 @@ class BaseTerminalTab(QWidget):
|
||||
|
||||
def __init__(
|
||||
self, initial_status: str, snippet_store: SnippetStore | None = None,
|
||||
broadcast=None, parent=None,
|
||||
broadcast=None, ai_provider_factory=None, credential_store=None, parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.snippet_store = snippet_store
|
||||
self.broadcast = broadcast
|
||||
self.ai_provider_factory = ai_provider_factory
|
||||
self.credential_store = credential_store
|
||||
self.ai_panel: AIPanel | None = None
|
||||
self._ai_command_worker: AIRequestWorker | None = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -82,6 +92,13 @@ class BaseTerminalTab(QWidget):
|
||||
self.snippets_btn.clicked.connect(self._show_snippets_menu)
|
||||
self.bar.addWidget(self.snippets_btn)
|
||||
|
||||
self.ai_btn = QToolButton()
|
||||
self.ai_btn.setText("🤖 Assistant IA")
|
||||
self.ai_btn.setToolTip("Analyser la sortie selectionnee ou generer une commande (optionnel)")
|
||||
self.ai_btn.setPopupMode(QToolButton.InstantPopup)
|
||||
self.ai_btn.clicked.connect(self._show_ai_menu)
|
||||
self.bar.addWidget(self.ai_btn)
|
||||
|
||||
self.log_btn = QToolButton()
|
||||
self.log_btn.setText("⏺ Journal")
|
||||
self.log_btn.setToolTip("Enregistrer la session dans un fichier")
|
||||
@@ -269,6 +286,141 @@ class BaseTerminalTab(QWidget):
|
||||
elif self.connection:
|
||||
self.connection.send(data)
|
||||
|
||||
# ---------- assistant IA (optionnel) ----------
|
||||
def _show_ai_menu(self) -> None:
|
||||
menu = self._build_ai_menu()
|
||||
menu.exec(self.ai_btn.mapToGlobal(self.ai_btn.rect().bottomLeft()))
|
||||
|
||||
def _build_ai_menu(self) -> QMenu:
|
||||
menu = QMenu(self)
|
||||
has_selection = self.terminal.has_selection()
|
||||
|
||||
explain_action = menu.addAction("Expliquer cette sortie")
|
||||
explain_action.setEnabled(has_selection)
|
||||
explain_action.triggered.connect(self._ai_explain)
|
||||
|
||||
diagnose_action = menu.addAction("Diagnostiquer cette erreur")
|
||||
diagnose_action.setEnabled(has_selection)
|
||||
diagnose_action.triggered.connect(self._ai_diagnose)
|
||||
|
||||
solution_action = menu.addAction("Proposer une solution")
|
||||
solution_action.setEnabled(has_selection)
|
||||
solution_action.triggered.connect(self._ai_solution)
|
||||
|
||||
ask_action = menu.addAction("Poser une question...")
|
||||
ask_action.setEnabled(has_selection)
|
||||
ask_action.triggered.connect(self._ai_ask)
|
||||
|
||||
if not has_selection:
|
||||
for action in (explain_action, diagnose_action, solution_action, ask_action):
|
||||
action.setToolTip("Selectionnez d'abord du texte dans le terminal.")
|
||||
|
||||
menu.addSeparator()
|
||||
generate_action = menu.addAction("Generer une commande...")
|
||||
generate_action.triggered.connect(self._ai_generate_command)
|
||||
|
||||
menu.addSeparator()
|
||||
panel_visible = self.ai_panel is not None and self.ai_panel.isVisible()
|
||||
toggle_action = menu.addAction("Masquer le panneau IA" if panel_visible else "Afficher le panneau IA")
|
||||
toggle_action.triggered.connect(self._toggle_ai_panel)
|
||||
|
||||
menu.addSeparator()
|
||||
settings_action = menu.addAction("Parametres de l'assistant IA...")
|
||||
settings_action.triggered.connect(self._open_ai_settings)
|
||||
return menu
|
||||
|
||||
def _ensure_ai_panel(self) -> AIPanel:
|
||||
if self.ai_panel is None:
|
||||
self.ai_panel = AIPanel(self.ai_provider_factory)
|
||||
self.splitter.addWidget(self.ai_panel)
|
||||
# cree masque : sans cela, Qt affiche automatiquement un widget
|
||||
# fraichement ajoute a un splitter deja visible, ce qui romprait
|
||||
# l'etat "ferme par defaut" attendu du panneau.
|
||||
self.ai_panel.hide()
|
||||
return self.ai_panel
|
||||
|
||||
def _toggle_ai_panel(self) -> None:
|
||||
panel = self._ensure_ai_panel()
|
||||
panel.setVisible(not panel.isVisible())
|
||||
|
||||
def _open_ai_settings(self) -> None:
|
||||
from app.dialogs.ai_settings_dialog import AISettingsDialog
|
||||
AISettingsDialog(credential_store=self.credential_store, parent=self).exec()
|
||||
|
||||
def _ai_explain(self) -> None:
|
||||
self._run_ai_selection_action(KIND_EXPLAIN, "Expliquer cette sortie")
|
||||
|
||||
def _ai_diagnose(self) -> None:
|
||||
self._run_ai_selection_action(KIND_DIAGNOSE, "Diagnostiquer cette erreur")
|
||||
|
||||
def _ai_solution(self) -> None:
|
||||
self._run_ai_selection_action(KIND_SOLUTION, "Proposer une solution")
|
||||
|
||||
def _run_ai_selection_action(self, kind: str, label: str) -> None:
|
||||
selection = self.terminal.selected_text()
|
||||
if not selection:
|
||||
return
|
||||
panel = self._ensure_ai_panel()
|
||||
panel.run_request(kind, selection, label=label)
|
||||
|
||||
def _ai_ask(self) -> None:
|
||||
selection = self.terminal.selected_text()
|
||||
if not selection:
|
||||
return
|
||||
panel = self._ensure_ai_panel()
|
||||
panel.prompt_question(context=selection)
|
||||
|
||||
def _ai_generate_command(self) -> None:
|
||||
if self._ai_command_worker is not None and self._ai_command_worker.isRunning():
|
||||
return
|
||||
provider = self.ai_provider_factory() if self.ai_provider_factory else None
|
||||
if provider is None:
|
||||
QMessageBox.information(
|
||||
self, "Assistant IA",
|
||||
"L'assistant IA n'est pas configure (menu Assistant IA > Parametres de l'assistant IA...).",
|
||||
)
|
||||
return
|
||||
request, ok = QInputDialog.getMultiLineText(
|
||||
self, "Generer une commande", "Decrivez ce que vous voulez accomplir :",
|
||||
)
|
||||
if not ok or not request.strip():
|
||||
return
|
||||
request = request.strip()
|
||||
if not ensure_ai_notice_acknowledged(self):
|
||||
return
|
||||
decision = confirm_sensitive_content(self, request)
|
||||
if decision == CANCEL:
|
||||
return
|
||||
if decision == MASK:
|
||||
request = redact(request)
|
||||
|
||||
self.ai_btn.setEnabled(False)
|
||||
self.ai_btn.setText("🤖 Generation...")
|
||||
self._ai_command_worker = AIRequestWorker(provider, KIND_GENERATE, request)
|
||||
self._ai_command_worker.finished_ok.connect(self._on_ai_command_ok)
|
||||
self._ai_command_worker.finished_error.connect(self._on_ai_command_error)
|
||||
self._ai_command_worker.start()
|
||||
|
||||
def _reset_ai_generate_button(self) -> None:
|
||||
self.ai_btn.setEnabled(True)
|
||||
self.ai_btn.setText("🤖 Assistant IA")
|
||||
|
||||
def _on_ai_command_ok(self, response: str) -> None:
|
||||
self._reset_ai_generate_button()
|
||||
dangerous = [m.label for m in check_dangerous(response)]
|
||||
dialog = AICommandDialog(response, dangerous_labels=dangerous, parent=self)
|
||||
if dialog.exec() and dialog.insert_requested and dialog.command:
|
||||
self.insert_snippet_text(dialog.command)
|
||||
|
||||
def _on_ai_command_error(self, message: str) -> None:
|
||||
self._reset_ai_generate_button()
|
||||
QMessageBox.critical(self, "Assistant IA", message)
|
||||
|
||||
def insert_snippet_text(self, text: str) -> None:
|
||||
"""N'envoie jamais Entree : le texte est ecrit, a l'utilisateur de valider."""
|
||||
if self.connection:
|
||||
self.connection.send(text.encode("utf-8"))
|
||||
|
||||
# ---------- recherche dans l'historique ----------
|
||||
def open_search(self) -> None:
|
||||
self.search_bar.show()
|
||||
@@ -290,6 +442,10 @@ class BaseTerminalTab(QWidget):
|
||||
def close_connection(self) -> None:
|
||||
self._closing = True # ne jamais reconnecter automatiquement une fermeture voulue
|
||||
self.stop_logging()
|
||||
if self.ai_panel is not None:
|
||||
self.ai_panel.shutdown()
|
||||
if self._ai_command_worker is not None and self._ai_command_worker.isRunning():
|
||||
self._ai_command_worker.wait(2000)
|
||||
if self.connection:
|
||||
self.connection.close_connection()
|
||||
self.connection.wait(2000)
|
||||
@@ -300,11 +456,12 @@ class SessionTabWidget(BaseTerminalTab):
|
||||
|
||||
def __init__(
|
||||
self, session: Session, secret: str, bastion_target=None, bastion_password: str = "",
|
||||
snippet_store=None, broadcast=None, parent=None,
|
||||
snippet_store=None, broadcast=None, ai_provider_factory=None, credential_store=None, parent=None,
|
||||
):
|
||||
super().__init__(
|
||||
f"○ Connexion a {session.username}@{session.host}...",
|
||||
snippet_store=snippet_store, broadcast=broadcast, parent=parent,
|
||||
snippet_store=snippet_store, broadcast=broadcast,
|
||||
ai_provider_factory=ai_provider_factory, credential_store=credential_store, parent=parent,
|
||||
)
|
||||
self.session = session
|
||||
self._bastion_target = bastion_target
|
||||
@@ -594,10 +751,14 @@ class LocalTerminalTabWidget(BaseTerminalTab):
|
||||
|
||||
sftp_requested = Signal(object) # cible ssh detectee (ou None)
|
||||
|
||||
def __init__(self, shell: str = SHELL_POWERSHELL, snippet_store=None, broadcast=None, parent=None):
|
||||
def __init__(
|
||||
self, shell: str = SHELL_POWERSHELL, snippet_store=None, broadcast=None,
|
||||
ai_provider_factory=None, credential_store=None, parent=None,
|
||||
):
|
||||
super().__init__(
|
||||
f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...",
|
||||
snippet_store=snippet_store, broadcast=broadcast, parent=parent,
|
||||
snippet_store=snippet_store, broadcast=broadcast,
|
||||
ai_provider_factory=ai_provider_factory, credential_store=credential_store, parent=parent,
|
||||
)
|
||||
self.shell = shell
|
||||
|
||||
|
||||
Reference in New Issue
Block a user