From f5d99584fd58e47afa5f20781a17965cbdaee665 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 5 Sep 2026 08:00:25 +0200 Subject: [PATCH] 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 --- README.md | 10 ++- app/dialogs/ai_settings_dialog.py | 60 ++++++++++++-- tests/test_ai_settings_dialog.py | 127 ++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 tests/test_ai_settings_dialog.py diff --git a/README.md b/README.md index 7b18671..7978103 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,13 @@ Une aide ponctuelle pour l'administration systeme, pas un pilote automatique : * n'est jamais executee automatiquement**. - **Desactive par defaut**, et sans le moindre fournisseur impose : compatible avec toute API de - type OpenAI (OpenAI, OpenRouter, un proxy interne, ou un modele local exposant la meme API) — - vous indiquez l'URL et le modele, il n'y a pas de liste figee. Se configure depuis le menu - "Assistant IA > Parametres...", avec un bouton "Tester la connexion" + type OpenAI. Se configure depuis le menu "Assistant IA > Parametres...", avec une liste + deroulante de raccourcis usuels — OpenAI, OpenRouter, Mistral AI, mais aussi **Ollama** et + **LM Studio** pour faire tourner un modele **entierement en local** — qui pre-remplit l'URL de + base, et un champ "Personnalise..." pour tout autre service compatible OpenAI (proxy interne, + autre LLM local...). Le modele, lui, reste toujours en saisie libre (pas de liste figee, chaque + installation Ollama/LM Studio ayant ses propres modeles telecharges). Bouton "Tester la + connexion" pour verifier la configuration avant de l'utiliser - Depuis un onglet terminal, le bouton "🤖 Assistant IA" permet, sur la selection courante : **Expliquer cette sortie**, **Diagnostiquer cette erreur**, **Proposer une solution**, ou **Poser une question** en y ajoutant la selection comme contexte. Les reponses s'affichent diff --git a/app/dialogs/ai_settings_dialog.py b/app/dialogs/ai_settings_dialog.py index a65d13f..2674d97 100644 --- a/app/dialogs/ai_settings_dialog.py +++ b/app/dialogs/ai_settings_dialog.py @@ -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(), diff --git a/tests/test_ai_settings_dialog.py b/tests/test_ai_settings_dialog.py new file mode 100644 index 0000000..b039b10 --- /dev/null +++ b/tests/test_ai_settings_dialog.py @@ -0,0 +1,127 @@ +"""AISettingsDialog : liste deroulante de fournisseurs (raccourcis usuels, +dont des LLM locaux comme Ollama/LM Studio) qui pre-remplit l'URL de base sans +jamais empecher de saisir un fournisseur personnalise.""" +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import tempfile +from pathlib import Path +from unittest.mock import patch + +from PySide6.QtWidgets import QApplication + +import app.app_settings as app_settings +from app.ai.settings import load_ai_settings +from app.credentials import CredentialStore +from app.dialogs.ai_settings_dialog import PROVIDER_PRESETS, AISettingsDialog + +app = QApplication.instance() or QApplication(sys.argv) + + +class FakeCredentialStore(CredentialStore): + def __init__(self): + self._data = {} + + def is_available(self): + return True + + def set_secret(self, key, secret): + self._data[key] = secret + return True + + def get_secret(self, key): + return self._data.get(key) + + def delete_secret(self, key): + self._data.pop(key, None) + + +def select_preset(dialog, label: str) -> int: + """Simule un choix reel dans la liste (pas une saisie libre) : Qt met a + jour l'index courant ET emet `activated` lors d'un clic - setCurrentIndex() + seul n'emet pas `activated`, il faut donc reproduire les deux.""" + index = dialog.provider_combo.findText(label) + assert index >= 0, f"prereglage introuvable : {label}" + dialog.provider_combo.setCurrentIndex(index) + dialog.provider_combo.activated.emit(index) + return index + + +tmp = Path(tempfile.mkdtemp()) +settings_redirect = patch.object(app_settings, "SETTINGS_FILE", tmp / "settings.json") +settings_redirect.start() + +print("=== 1. La liste propose des fournisseurs cloud ET des LLM locaux ===") +store = FakeCredentialStore() +dialog = AISettingsDialog(credential_store=store) +labels = [dialog.provider_combo.itemText(i) for i in range(dialog.provider_combo.count())] +print(" fournisseurs proposes:", labels) +assert "OpenAI" in labels +assert "Ollama (local)" in labels +assert "LM Studio (local)" in labels +assert "Personnalise..." in labels +print(" OK") + +print() +print("=== 2. Choisir 'Ollama (local)' pre-remplit l'URL de base ===") +dialog.base_url_edit.setText("") +select_preset(dialog, "Ollama (local)") +print(" URL de base:", dialog.base_url_edit.text()) +assert dialog.base_url_edit.text() == "http://localhost:11434/v1" +print(" OK") + +print() +print("=== 3. ... et signale que la cle API n'est generalement pas necessaire ===") +print(" placeholder cle API:", dialog.api_key_edit.placeholderText()) +assert "inutile" in dialog.api_key_edit.placeholderText().lower() +print(" OK") + +print() +print("=== 4. Choisir un fournisseur cloud restaure le message par defaut pour la cle API ===") +select_preset(dialog, "OpenAI") +assert dialog.base_url_edit.text() == "https://api.openai.com/v1" +print(" placeholder cle API:", dialog.api_key_edit.placeholderText()) +assert "inutile" not in dialog.api_key_edit.placeholderText().lower() +print(" OK") + +print() +print("=== 5. 'Personnalise...' ne touche pas a une URL de base deja saisie ===") +dialog.base_url_edit.setText("https://mon-proxy-interne.example.com/v1") +select_preset(dialog, "Personnalise...") +print(" URL de base:", dialog.base_url_edit.text()) +assert dialog.base_url_edit.text() == "https://mon-proxy-interne.example.com/v1" +print(" OK") + +print() +print("=== 6. Le champ reste editable : un nom personnalise est bien conserve a l'enregistrement ===") +dialog.provider_combo.setCurrentText("Mon LLM interne") +dialog.model_edit.setText("mon-modele") +dialog._on_accept() +reloaded = load_ai_settings() +print(" fournisseur enregistre:", reloaded.provider_name) +assert reloaded.provider_name == "Mon LLM interne" +assert reloaded.base_url == "https://mon-proxy-interne.example.com/v1" +print(" OK") + +print() +print("=== 7. Rouvrir le dialogue recharge le fournisseur personnalise precedemment saisi ===") +dialog2 = AISettingsDialog(credential_store=store) +print(" fournisseur affiche:", dialog2.provider_combo.currentText()) +assert dialog2.provider_combo.currentText() == "Mon LLM interne" +print(" OK") + +print() +print("=== 8. Les prereglages restent coherents avec la liste affichee (pas de doublon, tous non vides) ===") +seen = set() +for label, _url in PROVIDER_PRESETS: + assert label not in seen, f"doublon dans PROVIDER_PRESETS : {label}" + seen.add(label) + assert label.strip(), "un prereglage ne doit jamais avoir un nom vide" +print(" OK") + +settings_redirect.stop() + +print() +print("AI SETTINGS DIALOG : TOUS LES TESTS OK")