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