Files
pyssh-client/app/dialogs/ai_settings_dialog.py
Florian c39ef9c038 Qualite : pyproject.toml (ruff/pytest/pyright) + CI Gitea (lint/tests/build)
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>
2026-09-05 07:46:50 +02:00

170 lines
6.8 KiB
Python

"""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 (
QCheckBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QVBoxLayout,
)
from app.ai.provider import OpenAICompatibleProvider
from app.ai.service import KIND_TEST, AIRequestWorker
from app.ai.settings import AISettings, load_ai_settings, load_api_key, save_ai_settings, save_api_key
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)