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:
161
app/dialogs/ai_settings_dialog.py
Normal file
161
app/dialogs/ai_settings_dialog.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Configuration de l'assistant IA (optionnel, desactive par defaut) : aucune
|
||||
liste figee de fournisseurs/modeles, juste une URL de base et un nom de modele
|
||||
compatibles avec l'API OpenAI (OpenAI, OpenRouter, proxy interne, LLM local...)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QLineEdit, QCheckBox, QDoubleSpinBox,
|
||||
QPushButton, QDialogButtonBox, QMessageBox, QLabel,
|
||||
)
|
||||
|
||||
from app.ai.settings import AISettings, load_ai_settings, save_ai_settings, load_api_key, save_api_key
|
||||
from app.ai.provider import OpenAICompatibleProvider
|
||||
from app.ai.service import KIND_TEST, AIRequestWorker
|
||||
from app.credentials import CredentialStore, NullCredentialStore
|
||||
|
||||
|
||||
class AISettingsDialog(QDialog):
|
||||
def __init__(self, credential_store: CredentialStore | None = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.credential_store = credential_store or NullCredentialStore()
|
||||
self._test_worker: AIRequestWorker | None = None
|
||||
self.setWindowTitle("Assistant IA")
|
||||
self.setMinimumWidth(460)
|
||||
|
||||
settings = load_ai_settings()
|
||||
api_key = load_api_key(self.credential_store)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
form = QFormLayout()
|
||||
|
||||
self.enabled_check = QCheckBox("Activer l'assistant IA")
|
||||
self.enabled_check.setChecked(settings.enabled)
|
||||
self.enabled_check.setToolTip(
|
||||
"Desactive par defaut. Aucune donnee n'est envoyee a un service externe "
|
||||
"tant que cette case n'est pas cochee et qu'un modele n'est pas configure."
|
||||
)
|
||||
form.addRow("", self.enabled_check)
|
||||
|
||||
self.provider_name_edit = QLineEdit(settings.provider_name)
|
||||
self.provider_name_edit.setPlaceholderText("ex : OpenAI, OpenRouter, LLM local...")
|
||||
form.addRow("Fournisseur :", self.provider_name_edit)
|
||||
|
||||
self.base_url_edit = QLineEdit(settings.base_url)
|
||||
self.base_url_edit.setPlaceholderText("https://api.openai.com/v1")
|
||||
form.addRow("URL de base :", self.base_url_edit)
|
||||
|
||||
self.model_edit = QLineEdit(settings.model)
|
||||
self.model_edit.setPlaceholderText("ex : gpt-4o-mini")
|
||||
form.addRow("Modele :", self.model_edit)
|
||||
|
||||
self.api_key_edit = QLineEdit(api_key)
|
||||
self.api_key_edit.setEchoMode(QLineEdit.Password)
|
||||
self.api_key_edit.setPlaceholderText("Laisser vide si le fournisseur n'en requiert pas")
|
||||
if not self.credential_store.is_available():
|
||||
self.api_key_edit.setEnabled(False)
|
||||
self.api_key_edit.setPlaceholderText("Aucun trousseau securise disponible sur ce systeme")
|
||||
form.addRow("Cle API :", self.api_key_edit)
|
||||
|
||||
self.timeout_spin = QDoubleSpinBox()
|
||||
self.timeout_spin.setRange(1.0, 600.0)
|
||||
self.timeout_spin.setSuffix(" s")
|
||||
self.timeout_spin.setValue(settings.timeout)
|
||||
form.addRow("Delai d'attente :", self.timeout_spin)
|
||||
|
||||
self.temperature_spin = QDoubleSpinBox()
|
||||
self.temperature_spin.setRange(0.0, 2.0)
|
||||
self.temperature_spin.setSingleStep(0.1)
|
||||
self.temperature_spin.setValue(settings.temperature)
|
||||
form.addRow("Temperature :", self.temperature_spin)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
note = QLabel(
|
||||
"La cle API est memorisee dans le trousseau securise du systeme, jamais "
|
||||
"dans un fichier de configuration. L'assistant est entierement optionnel : "
|
||||
"aucune fonctionnalite existante n'en depend."
|
||||
)
|
||||
note.setWordWrap(True)
|
||||
note.setObjectName("statusLabel")
|
||||
layout.addWidget(note)
|
||||
|
||||
test_row = QVBoxLayout()
|
||||
self.test_btn = QPushButton("Tester la connexion")
|
||||
self.test_btn.clicked.connect(self._test_connection)
|
||||
self.test_status = QLabel("")
|
||||
self.test_status.setWordWrap(True)
|
||||
test_row.addWidget(self.test_btn)
|
||||
test_row.addWidget(self.test_status)
|
||||
layout.addLayout(test_row)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self._on_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
# ---------- lecture du formulaire ----------
|
||||
def _form_settings(self) -> AISettings:
|
||||
return AISettings(
|
||||
enabled=self.enabled_check.isChecked(),
|
||||
provider_name=self.provider_name_edit.text().strip() or "OpenAI",
|
||||
base_url=self.base_url_edit.text().strip(),
|
||||
model=self.model_edit.text().strip(),
|
||||
timeout=self.timeout_spin.value(),
|
||||
temperature=self.temperature_spin.value(),
|
||||
)
|
||||
|
||||
def _build_provider(self) -> OpenAICompatibleProvider | None:
|
||||
settings = self._form_settings()
|
||||
if not settings.base_url or not settings.model:
|
||||
QMessageBox.warning(
|
||||
self, "Configuration incomplete",
|
||||
"Renseignez au moins l'URL de base et le modele avant de tester.",
|
||||
)
|
||||
return None
|
||||
return OpenAICompatibleProvider(
|
||||
base_url=settings.base_url,
|
||||
api_key=self.api_key_edit.text().strip(),
|
||||
model=settings.model,
|
||||
timeout=settings.timeout,
|
||||
temperature=settings.temperature,
|
||||
)
|
||||
|
||||
# ---------- test de connexion ----------
|
||||
def _test_connection(self) -> None:
|
||||
if self._test_worker is not None and self._test_worker.isRunning():
|
||||
return
|
||||
provider = self._build_provider()
|
||||
if provider is None:
|
||||
return
|
||||
self.test_btn.setEnabled(False)
|
||||
self.test_status.setText("Test en cours...")
|
||||
self._test_worker = AIRequestWorker(provider, KIND_TEST, "")
|
||||
self._test_worker.finished_ok.connect(self._on_test_ok)
|
||||
self._test_worker.finished_error.connect(self._on_test_error)
|
||||
self._test_worker.start()
|
||||
|
||||
def _on_test_ok(self, _text: str) -> None:
|
||||
self.test_btn.setEnabled(True)
|
||||
self.test_status.setText("✓ Connexion reussie.")
|
||||
|
||||
def _on_test_error(self, message: str) -> None:
|
||||
self.test_btn.setEnabled(True)
|
||||
self.test_status.setText(f"✗ Echec : {message}")
|
||||
|
||||
# ---------- validation ----------
|
||||
def _on_accept(self) -> None:
|
||||
settings = self._form_settings()
|
||||
if settings.enabled and (not settings.base_url or not settings.model):
|
||||
QMessageBox.warning(
|
||||
self, "Configuration incomplete",
|
||||
"Renseignez au moins l'URL de base et le modele pour activer l'assistant IA.",
|
||||
)
|
||||
return
|
||||
save_ai_settings(settings)
|
||||
save_api_key(self.credential_store, self.api_key_edit.text().strip())
|
||||
self.accept()
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
if self._test_worker is not None and self._test_worker.isRunning():
|
||||
self._test_worker.wait(2000)
|
||||
super().closeEvent(event)
|
||||
Reference in New Issue
Block a user