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>
214 lines
8.7 KiB
Python
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)
|