Assistant IA : liste deroulante de fournisseurs (dont Ollama, LM Studio)
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

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>
This commit is contained in:
Florian
2026-09-05 08:00:25 +02:00
parent 6cceb3f3b2
commit f5d99584fd
3 changed files with 186 additions and 11 deletions

View File

@@ -1,10 +1,14 @@
"""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...)."""
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,
@@ -21,6 +25,22 @@ 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):
@@ -44,22 +64,34 @@ class AISettingsDialog(QDialog):
)
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.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")
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.is_available():
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)
@@ -101,11 +133,23 @@ class AISettingsDialog(QDialog):
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_name_edit.text().strip() or "OpenAI",
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(),