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