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

@@ -109,9 +109,13 @@ Une aide ponctuelle pour l'administration systeme, pas un pilote automatique : *
n'est jamais executee automatiquement**. n'est jamais executee automatiquement**.
- **Desactive par defaut**, et sans le moindre fournisseur impose : compatible avec toute API de - **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) — type OpenAI. Se configure depuis le menu "Assistant IA > Parametres...", avec une liste
vous indiquez l'URL et le modele, il n'y a pas de liste figee. Se configure depuis le menu deroulante de raccourcis usuels — OpenAI, OpenRouter, Mistral AI, mais aussi **Ollama** et
"Assistant IA > Parametres...", avec un bouton "Tester la connexion" **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 : - Depuis un onglet terminal, le bouton "🤖 Assistant IA" permet, sur la selection courante :
**Expliquer cette sortie**, **Diagnostiquer cette erreur**, **Proposer une solution**, ou **Expliquer cette sortie**, **Diagnostiquer cette erreur**, **Proposer une solution**, ou
**Poser une question** en y ajoutant la selection comme contexte. Les reponses s'affichent **Poser une question** en y ajoutant la selection comme contexte. Les reponses s'affichent

View File

@@ -1,10 +1,14 @@
"""Configuration de l'assistant IA (optionnel, desactive par defaut) : aucune """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 liste figee de MODELES, juste un nom de modele libre compatible avec l'API
compatibles avec l'API OpenAI (OpenAI, OpenRouter, proxy interne, LLM local...).""" 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 __future__ import annotations
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QDoubleSpinBox, 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.ai.settings import AISettings, load_ai_settings, load_api_key, save_ai_settings, save_api_key
from app.credentials import CredentialStore, NullCredentialStore 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): class AISettingsDialog(QDialog):
def __init__(self, credential_store: CredentialStore | None = None, parent=None): def __init__(self, credential_store: CredentialStore | None = None, parent=None):
@@ -44,22 +64,34 @@ class AISettingsDialog(QDialog):
) )
form.addRow("", self.enabled_check) form.addRow("", self.enabled_check)
self.provider_name_edit = QLineEdit(settings.provider_name) self.provider_combo = QComboBox()
self.provider_name_edit.setPlaceholderText("ex : OpenAI, OpenRouter, LLM local...") self.provider_combo.setEditable(True)
form.addRow("Fournisseur :", self.provider_name_edit) 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 = QLineEdit(settings.base_url)
self.base_url_edit.setPlaceholderText("https://api.openai.com/v1") self.base_url_edit.setPlaceholderText("https://api.openai.com/v1")
form.addRow("URL de base :", self.base_url_edit) form.addRow("URL de base :", self.base_url_edit)
self.model_edit = QLineEdit(settings.model) 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) 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 = QLineEdit(api_key)
self.api_key_edit.setEchoMode(QLineEdit.Password) self.api_key_edit.setEchoMode(QLineEdit.Password)
self.api_key_edit.setPlaceholderText("Laisser vide si le fournisseur n'en requiert pas") 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.setEnabled(False)
self.api_key_edit.setPlaceholderText("Aucun trousseau securise disponible sur ce systeme") self.api_key_edit.setPlaceholderText("Aucun trousseau securise disponible sur ce systeme")
form.addRow("Cle API :", self.api_key_edit) form.addRow("Cle API :", self.api_key_edit)
@@ -101,11 +133,23 @@ class AISettingsDialog(QDialog):
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
layout.addWidget(buttons) 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 ---------- # ---------- lecture du formulaire ----------
def _form_settings(self) -> AISettings: def _form_settings(self) -> AISettings:
return AISettings( return AISettings(
enabled=self.enabled_check.isChecked(), 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(), base_url=self.base_url_edit.text().strip(),
model=self.model_edit.text().strip(), model=self.model_edit.text().strip(),
timeout=self.timeout_spin.value(), timeout=self.timeout_spin.value(),

View File

@@ -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")