Files
pyssh-client/app/dialogs/ai_settings_dialog.py
Florian f5d99584fd
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
Assistant IA : liste deroulante de fournisseurs (dont Ollama, LM Studio)
Retour utilisateur : le champ "Fournisseur" en simple texte libre ne donnait
aucune indication de ce qui est possible, notamment pour l'IA locale
(Ollama, LM Studio), alors que d'autres preferent un service cloud.

Remplace par une QComboBox editable : une liste de raccourcis usuels (OpenAI,
OpenRouter, Mistral AI, Ollama local, LM Studio local, Personnalise...) qui
pre-remplit l'URL de base au choix - sans jamais empecher de saisir librement
un autre fournisseur/proxy compatible OpenAI, conformement a l'exigence
d'origine de ne rien figer en dur. Le modele reste toujours en saisie libre,
chaque installation Ollama/LM Studio ayant ses propres modeles telecharges.

Bonus cible : choisir un prereglage local (Ollama/LM Studio, detecte via
localhost/127.0.0.1 dans l'URL) met a jour l'indication du champ cle API pour
signaler qu'elle n'est generalement pas necessaire.

tests/test_ai_settings_dialog.py (8 verifications, nouveau fichier - aucun
test ne couvrait encore AISettingsDialog elle-meme) : presence d'Ollama/LM
Studio dans la liste, pre-remplissage de l'URL par prereglage, "Personnalise"
qui ne touche pas une URL deja saisie, persistance d'un nom de fournisseur
personnalise a travers un enregistrement puis une reouverture. Suite complete
revalidee (34/34) et ruff toujours propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:00:25 +02:00

214 lines
8.7 KiB
Python

"""Configuration de l'assistant IA (optionnel, desactive par defaut) : aucune
liste figee de MODELES, juste un nom de modele libre compatible avec l'API
OpenAI. Le fournisseur, lui, propose une liste deroulante de raccourcis usuels
(cloud et LLM local via Ollama/LM Studio) qui pre-remplissent l'URL de base -
mais reste modifiable librement ("Personnalise...") pour tout autre service
compatible OpenAI."""
from __future__ import annotations
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
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
# (nom affiche, URL de base) - un point de depart, pas une liste fermee : le
# champ reste editable, pour tout fournisseur/proxy/LLM local compatible avec
# l'API OpenAI mais absent d'ici.
PROVIDER_PRESETS: list[tuple[str, str]] = [
("OpenAI", "https://api.openai.com/v1"),
("OpenRouter", "https://openrouter.ai/api/v1"),
("Mistral AI", "https://api.mistral.ai/v1"),
("Ollama (local)", "http://localhost:11434/v1"),
("LM Studio (local)", "http://localhost:1234/v1"),
("Personnalise...", ""),
]
def _looks_local(base_url: str) -> bool:
return "localhost" in base_url or "127.0.0.1" in base_url
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_combo = QComboBox()
self.provider_combo.setEditable(True)
self.provider_combo.setToolTip(
"Choisissez un raccourci (pre-remplit l'URL de base) ou saisissez "
"librement le nom de tout autre fournisseur/proxy compatible OpenAI."
)
for label, url in PROVIDER_PRESETS:
self.provider_combo.addItem(label, url)
self.provider_combo.setCurrentText(settings.provider_name)
# activated (et non currentIndexChanged) : ne se declenche que sur un
# choix explicite dans la liste, jamais pendant la saisie libre d'un
# nom de fournisseur personnalise.
self.provider_combo.activated.connect(self._on_provider_preset_chosen)
form.addRow("Fournisseur :", self.provider_combo)
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, llama3.2, qwen2.5-coder...")
form.addRow("Modele :", self.model_edit)
self._credential_store_available = self.credential_store.is_available()
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_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)
# ---------- fournisseur ----------
def _on_provider_preset_chosen(self, index: int) -> None:
_label, url = PROVIDER_PRESETS[index]
if url:
self.base_url_edit.setText(url)
if not self._credential_store_available:
return
if _looks_local(url):
self.api_key_edit.setPlaceholderText("Generalement inutile pour un LLM local")
else:
self.api_key_edit.setPlaceholderText("Laisser vide si le fournisseur n'en requiert pas")
# ---------- lecture du formulaire ----------
def _form_settings(self) -> AISettings:
return AISettings(
enabled=self.enabled_check.isChecked(),
provider_name=self.provider_combo.currentText().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)