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>
161 lines
6.9 KiB
Python
161 lines
6.9 KiB
Python
"""Integration de l'assistant IA dans un onglet terminal : menu (active/desactive
|
|
selon la selection), panneau lateral cree a la demande dans le splitter (meme
|
|
mecanisme que le panneau SFTP), et generation de commande (jamais executee
|
|
automatiquement, avec avertissement si dangereuse).
|
|
|
|
LocalTerminalConnection.start() est neutralisee comme dans test_snippets.py :
|
|
aucun sous-processus reel, send() depose seulement les octets dans une file."""
|
|
import sys, pathlib
|
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
|
|
|
from unittest.mock import patch
|
|
|
|
from PySide6.QtCore import QCoreApplication
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
from app.local_terminal import LocalTerminalConnection
|
|
from app.ai.provider import AIProvider
|
|
from app.dialogs.ai_prompts import SEND
|
|
from app.ui.ai_panel import AIPanel
|
|
from app.ui.tabs import LocalTerminalTabWidget
|
|
|
|
app = QApplication.instance() or QApplication(sys.argv)
|
|
|
|
NOTICE_OK = patch("app.ui.ai_panel.ensure_ai_notice_acknowledged", return_value=True)
|
|
SEND_ASIS = patch("app.ui.ai_panel.confirm_sensitive_content", return_value=SEND)
|
|
PROMPTS_OK_IN_TABS = patch("app.ui.tabs.ensure_ai_notice_acknowledged", return_value=True)
|
|
SEND_ASIS_IN_TABS = patch("app.ui.tabs.confirm_sensitive_content", return_value=SEND)
|
|
|
|
|
|
class FakeProvider(AIProvider):
|
|
def __init__(self, reply="reponse"):
|
|
self.reply = reply
|
|
|
|
def chat(self, messages, *, temperature=None):
|
|
return self.reply
|
|
|
|
|
|
def pump(ms=3000):
|
|
import time
|
|
deadline = time.time() + ms / 1000
|
|
while time.time() < deadline:
|
|
QCoreApplication.processEvents()
|
|
time.sleep(0.01)
|
|
|
|
|
|
def make_tab(ai_provider_factory=None):
|
|
return LocalTerminalTabWidget(ai_provider_factory=ai_provider_factory)
|
|
|
|
|
|
print("=== 1. Menu IA : actions de selection desactivees sans selection ===")
|
|
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
|
tab = make_tab()
|
|
assert not tab.terminal.has_selection()
|
|
menu = tab._build_ai_menu()
|
|
labels = [a.text() for a in menu.actions() if a.text()]
|
|
print(" actions:", labels)
|
|
assert "Expliquer cette sortie" in labels
|
|
assert "Generer une commande..." in labels
|
|
for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."):
|
|
action = next(a for a in menu.actions() if a.text() == text)
|
|
assert not action.isEnabled(), f"{text} devrait etre desactive sans selection"
|
|
generate_action = next(a for a in menu.actions() if a.text() == "Generer une commande...")
|
|
assert generate_action.isEnabled(), "Generer une commande ne necessite pas de selection"
|
|
tab.close_connection()
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 2. Menu IA : actions de selection activees des qu'il y a une selection ===")
|
|
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
|
tab = make_tab()
|
|
tab.terminal.feed(b"erreur : connection refused\r\n")
|
|
tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 0), (10, 0)
|
|
assert tab.terminal.has_selection()
|
|
menu = tab._build_ai_menu()
|
|
for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."):
|
|
action = next(a for a in menu.actions() if a.text() == text)
|
|
assert action.isEnabled(), f"{text} devrait etre active avec une selection"
|
|
tab.close_connection()
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 3. 'Expliquer cette sortie' : cree le panneau IA dans le splitter et lance la requete ===")
|
|
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
|
provider = FakeProvider(reply="Ceci indique une connexion refusee.")
|
|
tab = make_tab(ai_provider_factory=lambda: provider)
|
|
tab.terminal.feed(b"erreur : connection refused\r\n")
|
|
tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 0), (10, 0)
|
|
assert tab.ai_panel is None
|
|
with NOTICE_OK, SEND_ASIS:
|
|
tab._ai_explain()
|
|
pump()
|
|
assert isinstance(tab.ai_panel, AIPanel)
|
|
assert tab.splitter.indexOf(tab.ai_panel) >= 0
|
|
assert "connexion refusee" in tab.ai_panel.conversation.toPlainText().lower()
|
|
tab.close_connection()
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 4. Basculer l'affichage du panneau IA (menu) ===")
|
|
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
|
tab = make_tab(ai_provider_factory=lambda: FakeProvider())
|
|
tab.show() # isVisible() se compose avec les ancetres : il en faut un reellement affiche
|
|
tab._toggle_ai_panel() # premiere fois : cree + affiche
|
|
assert tab.ai_panel is not None and tab.ai_panel.isVisible()
|
|
tab._toggle_ai_panel() # deuxieme fois : masque
|
|
assert not tab.ai_panel.isVisible()
|
|
tab.close_connection()
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 5. Generer une commande : jamais auto-execute, insertion sans Entree ===")
|
|
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
|
provider = FakeProvider(reply="Pour lister les gros fichiers :\n\n```bash\nfind / -size +100M\n```")
|
|
tab = make_tab(ai_provider_factory=lambda: provider)
|
|
with PROMPTS_OK_IN_TABS, SEND_ASIS_IN_TABS, \
|
|
patch("app.ui.tabs.QInputDialog.getMultiLineText", return_value=("trouver les gros fichiers", True)), \
|
|
patch("app.ui.tabs.AICommandDialog.exec", lambda self: (setattr(self, "insert_requested", True), 1)[1]):
|
|
tab._ai_generate_command()
|
|
pump()
|
|
sent = tab.connection._out_queue.get_nowait()
|
|
print(" commande inseree:", sent)
|
|
assert sent == b"find / -size +100M"
|
|
assert not sent.endswith(b"\r") and not sent.endswith(b"\n")
|
|
assert tab.ai_btn.isEnabled() and tab.ai_btn.text() == "🤖 Assistant IA"
|
|
tab.close_connection()
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 6. Generer une commande : annulee par l'utilisateur -> rien envoye ===")
|
|
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
|
provider = FakeProvider(reply="```bash\nrm -rf /\n```")
|
|
tab = make_tab(ai_provider_factory=lambda: provider)
|
|
with patch("app.ui.tabs.QInputDialog.getMultiLineText", return_value=("", False)):
|
|
tab._ai_generate_command()
|
|
assert tab.connection._out_queue.empty()
|
|
tab.close_connection()
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 7. Sans fournisseur configure : message clair, pas de boite de saisie ===")
|
|
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
|
tab = make_tab(ai_provider_factory=lambda: None)
|
|
with patch("app.ui.tabs.QInputDialog.getMultiLineText") as mocked_input, \
|
|
patch("app.ui.tabs.QMessageBox.information") as mocked_info:
|
|
tab._ai_generate_command()
|
|
assert not mocked_input.called
|
|
assert mocked_info.called
|
|
tab.close_connection()
|
|
print(" OK")
|
|
|
|
print()
|
|
print("=== 8. Fermeture de l'onglet : le panneau IA est arrete proprement ===")
|
|
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
|
tab = make_tab(ai_provider_factory=lambda: FakeProvider())
|
|
tab._toggle_ai_panel()
|
|
tab.close_connection() # ne doit pas lever, ni bloquer indefiniment
|
|
print(" OK")
|
|
|
|
print()
|
|
print("AI TAB INTEGRATION : TOUS LES TESTS OK")
|