Assistant IA optionnel (analyse de sortie, diagnostic, generation de commande)

Nouvelle couche app/ai/ (provider OpenAI-compatible via urllib, redaction de
secrets, detection de commandes dangereuses, parametres, worker QThread) et son
integration dans chaque onglet terminal via un bouton "Assistant IA" (memes
conventions que le bouton Snippets existant) :

- Expliquer cette sortie / Diagnostiquer cette erreur / Proposer une solution /
  Poser une question... : utilisent la selection courante du terminal, affichees
  dans un panneau lateral (memoire uniquement, jamais journalise) reutilisant le
  splitter comme le panneau SFTP.
- Generer une commande : ne s'execute jamais automatiquement (Copier / Inserer
  dans le terminal sans Entree / Annuler), avec avertissement si la commande
  proposee correspond a un motif destructeur connu.
- Avant tout envoi : avis unique (persist) puis detection de secrets evidents
  (mot de passe, cle, jeton) avec choix de masquer, envoyer tel quel ou annuler.

Desactive par defaut, sans liste de fournisseur figee (URL + modele libres,
compatible OpenAI/OpenRouter/LLM local). La cle API passe exclusivement par
CredentialStore (jamais dans settings.json ni journalisee). Parametrable via
le nouveau menu "Assistant IA > Parametres..." (bouton "Tester la connexion").

Bug trouve par les tests avant commit : un panneau nouvellement insere dans un
QSplitter deja visible est affiche automatiquement par Qt, ce qui inversait le
premier clic sur "Afficher le panneau IA" ; corrige en le masquant explicitement
a sa creation.

9 nouveaux fichiers de test (provider, redaction, dangers, parametres, worker,
confirmations de securite, dialogue de commande, panneau, integration onglet) :
39 verifications, toutes au vert. Suite complete (31 fichiers) executee sans
regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Florian
2026-09-05 05:48:13 +02:00
parent 9216f636bd
commit 0b2ff38bfd
22 changed files with 2081 additions and 8 deletions

0
app/ai/__init__.py Normal file
View File

37
app/ai/danger.py Normal file
View File

@@ -0,0 +1,37 @@
"""Detection locale simple de commandes potentiellement destructives, proposees
par l'assistant IA. N'a pas besoin d'etre parfaite : sert a afficher un
avertissement, jamais a bloquer ou executer quoi que ce soit automatiquement."""
from __future__ import annotations
import re
from dataclasses import dataclass
_DANGEROUS_PATTERNS: list[tuple[str, re.Pattern]] = [
("rm -rf (suppression recursive forcee)", re.compile(r"\brm\s+(-\w*\s+)*-\w*[rf]\w*[rf]\w*\b", re.IGNORECASE)),
("mkfs (formatage)", re.compile(r"\bmkfs(\.\w+)?\b", re.IGNORECASE)),
("dd (ecriture bas niveau sur un disque)", re.compile(r"\bdd\s+.*\bif=", re.IGNORECASE | re.DOTALL)),
("wipefs (effacement des signatures de systeme de fichiers)", re.compile(r"\bwipefs\b", re.IGNORECASE)),
("shutdown (extinction)", re.compile(r"\bshutdown\b", re.IGNORECASE)),
("reboot (redemarrage)", re.compile(r"\breboot\b", re.IGNORECASE)),
("iptables : purge des regles", re.compile(r"\biptables\b.*(-F|--flush)\b", re.IGNORECASE)),
("nft : purge des regles", re.compile(r"\bnft\b.*\bflush\b", re.IGNORECASE)),
("DROP DATABASE", re.compile(r"\bDROP\s+DATABASE\b", re.IGNORECASE)),
("DELETE FROM sans WHERE", re.compile(r"\bDELETE\s+FROM\s+\S+\s*;", re.IGNORECASE)),
("docker system prune (suppression en masse)", re.compile(r"\bdocker\s+system\s+prune\b", re.IGNORECASE)),
("zpool destroy", re.compile(r"\bzpool\s+destroy\b", re.IGNORECASE)),
("lvremove (suppression de volume logique)", re.compile(r"\blvremove\b", re.IGNORECASE)),
("vgremove (suppression de groupe de volumes)", re.compile(r"\bvgremove\b", re.IGNORECASE)),
]
@dataclass
class DangerMatch:
label: str
def check_dangerous(command: str) -> list[DangerMatch]:
return [DangerMatch(label) for label, pattern in _DANGEROUS_PATTERNS if pattern.search(command)]
def is_dangerous(command: str) -> bool:
return bool(check_dangerous(command))

154
app/ai/provider.py Normal file
View File

@@ -0,0 +1,154 @@
"""Abstraction du fournisseur IA : une API compatible OpenAI (endpoint chat
completions), avec URL de base et modele configurables - pas de liste de
fournisseurs ou de modeles figee en dur, pour supporter OpenAI, OpenRouter, un
proxy interne ou un LLM local exposant la meme API.
AIProvider porte les 4 usages metier (explain_output/diagnose/generate_command/
ask), tous construits au-dessus de chat(), seule methode que doit implementer
un fournisseur concret (OpenAICompatibleProvider)."""
from __future__ import annotations
import abc
import json
import urllib.error
import urllib.request
class AIError(Exception):
"""Message deja comprehensible, pret a etre affiche a l'utilisateur."""
_SYSTEM_PROMPT_TROUBLESHOOT = """Tu es un assistant specialise en administration systeme, Linux, Windows,
reseau, SSH, Docker, virtualisation et troubleshooting.
Analyse la sortie terminal fournie par l'utilisateur.
Explique :
1. ce que signifie la sortie ;
2. la cause probable ;
3. les verifications recommandees ;
4. les commandes eventuellement utiles.
Ne suppose jamais qu'une commande a ete executee.
Ne pretends jamais connaitre des elements qui ne sont pas presents.
Signale explicitement les hypotheses."""
_SYSTEM_PROMPT_DIAGNOSE = _SYSTEM_PROMPT_TROUBLESHOOT + """
Structure ta reponse ainsi :
Cause probable
Explication
Verifications
Commandes proposees
Niveau de risque"""
_SYSTEM_PROMPT_COMMAND = """Tu es un assistant specialise en administration systeme, Linux, Windows,
reseau, SSH, Docker et virtualisation.
L'utilisateur decrit ce qu'il veut accomplir en langage naturel. Propose UNE
commande (ou une courte suite de commandes) qui repond a la demande, dans un
bloc de code, avec une breve explication de ce qu'elle fait.
Ne suppose jamais le systeme d'exploitation ou l'environnement si l'utilisateur
ne l'a pas precise : demande-le, ou propose une variante par systeme si
pertinent. La commande proposee ne sera jamais executee automatiquement : elle
sera seulement affichee a l'utilisateur pour verification."""
class AIProvider(abc.ABC):
@abc.abstractmethod
def chat(self, messages: list[dict], *, temperature: float | None = None) -> str:
"""Renvoie le texte de la reponse. Leve AIError en cas d'echec."""
def explain_output(self, output: str) -> str:
return self.chat([
{"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT},
{"role": "user", "content": f"Explique cette sortie terminal :\n\n{output}"},
])
def diagnose(self, output: str) -> str:
return self.chat([
{"role": "system", "content": _SYSTEM_PROMPT_DIAGNOSE},
{"role": "user", "content": f"Diagnostique cette erreur :\n\n{output}"},
])
def propose_solution(self, output: str) -> str:
return self.chat([
{"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT},
{"role": "user", "content": (
"Propose une solution concrete a ce probleme, avec les commandes "
f"eventuellement necessaires :\n\n{output}"
)},
])
def generate_command(self, request: str) -> str:
return self.chat([
{"role": "system", "content": _SYSTEM_PROMPT_COMMAND},
{"role": "user", "content": request},
])
def ask(self, question: str, context: str = "") -> str:
messages = [{"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT}]
if context:
messages.append({"role": "user", "content": f"Contexte (sortie terminal selectionnee) :\n{context}"})
messages.append({"role": "user", "content": question})
return self.chat(messages)
class OpenAICompatibleProvider(AIProvider):
def __init__(
self, base_url: str, api_key: str, model: str,
timeout: float = 30.0, temperature: float = 0.3,
):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
self.timeout = timeout
self.temperature = temperature
def chat(self, messages: list[dict], *, temperature: float | None = None) -> str:
if not self.base_url:
raise AIError("Aucune URL de base configuree pour l'assistant IA.")
if not self.model:
raise AIError("Aucun modele configure pour l'assistant IA.")
url = f"{self.base_url}/chat/completions"
payload = {
"model": self.model,
"messages": messages,
"temperature": self.temperature if temperature is None else temperature,
}
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
request = urllib.request.Request(
url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read()
except urllib.error.HTTPError as exc:
raise AIError(self._describe_http_error(exc)) from exc
except urllib.error.URLError as exc:
if isinstance(exc.reason, TimeoutError):
raise AIError("Le fournisseur IA n'a pas repondu a temps (delai depasse).") from exc
raise AIError(f"Impossible de joindre le fournisseur IA : {exc.reason}") from exc
try:
parsed = json.loads(raw.decode("utf-8"))
return parsed["choices"][0]["message"]["content"]
except (json.JSONDecodeError, KeyError, IndexError, TypeError, UnicodeDecodeError) as exc:
raise AIError(f"Reponse du fournisseur IA invalide ou inattendue : {exc}") from exc
@staticmethod
def _describe_http_error(exc: urllib.error.HTTPError) -> str:
if exc.code == 401:
return "Authentification refusee (401) : verifiez la cle API."
if exc.code == 429:
return "Trop de requetes (429) : le fournisseur limite le debit, reessayez plus tard."
try:
body = exc.read().decode("utf-8", errors="replace")
except Exception: # noqa: BLE001
body = ""
return f"Erreur du fournisseur IA ({exc.code}) : {(body or exc.reason)[:300]}"

52
app/ai/redaction.py Normal file
View File

@@ -0,0 +1,52 @@
"""Detection/redaction basique de secrets avant envoi a un fournisseur IA
externe : cles privees SSH, jetons Bearer, cles AWS, en-tetes Authorization,
password=/token=/api_key=, et longues chaines ressemblant a un secret.
Heuristique volontairement simple (comme la detection de commandes
dangereuses) : mieux vaut avertir a tort sur un hash ou un identifiant anodin
que laisser passer un vrai secret."""
from __future__ import annotations
import re
from dataclasses import dataclass
_PATTERNS: list[tuple[str, re.Pattern]] = [
("Cle privee (PEM/OpenSSH)", re.compile(
r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----"
)),
("Jeton Bearer", re.compile(r"\bBearer\s+[A-Za-z0-9\-_.=]{10,}", re.IGNORECASE)),
("Cle d'acces AWS", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
("En-tete Authorization", re.compile(r"\bAuthorization\s*:\s*\S+", re.IGNORECASE)),
("Mot de passe (password=)", re.compile(r"\b(?:password|passwd|pwd)\s*[:=]\s*\S+", re.IGNORECASE)),
("Cle API (api_key=)", re.compile(r"\b(?:api[_-]?key|apikey)\s*[:=]\s*\S+", re.IGNORECASE)),
("Jeton (token=)", re.compile(r"\btoken\s*[:=]\s*\S+", re.IGNORECASE)),
("Chaine longue ressemblant a un secret", re.compile(r"\b[A-Za-z0-9_\-]{32,}\b")),
]
@dataclass
class DetectedSecret:
label: str
excerpt: str
def detect_secrets(text: str) -> list[DetectedSecret]:
"""Renvoie les categories detectees (au plus une occurrence par categorie,
pour un resume lisible), dans l'ordre des motifs (du plus specifique au
plus generique)."""
found: list[DetectedSecret] = []
for label, pattern in _PATTERNS:
match = pattern.search(text)
if match:
excerpt = match.group(0)
if len(excerpt) > 60:
excerpt = excerpt[:57] + "..."
found.append(DetectedSecret(label, excerpt))
return found
def redact(text: str) -> str:
redacted = text
for _label, pattern in _PATTERNS:
redacted = pattern.sub("[MASQUE]", redacted)
return redacted

54
app/ai/service.py Normal file
View File

@@ -0,0 +1,54 @@
"""Execution des requetes IA en arriere-plan : aucun appel reseau ne doit
jamais bloquer le thread Qt."""
from __future__ import annotations
from PySide6.QtCore import QThread, Signal
from app.ai.provider import AIProvider, AIError
KIND_EXPLAIN = "explain"
KIND_DIAGNOSE = "diagnose"
KIND_SOLUTION = "solution"
KIND_GENERATE = "generate"
KIND_ASK = "ask"
KIND_TEST = "test"
class AIRequestWorker(QThread):
"""Une requete = un worker jetable. L'annulation est cooperative : la
requete HTTP en cours (bornee par son timeout) va a son terme, mais le
signal de resultat peut etre ignore par l'appelant (voir AIPanel)."""
finished_ok = Signal(str)
finished_error = Signal(str)
def __init__(self, provider: AIProvider, kind: str, payload: str, context: str = "", parent=None):
super().__init__(parent)
self.provider = provider
self.kind = kind
self.payload = payload
self.context = context
def run(self) -> None:
try:
result = self._execute()
self.finished_ok.emit(result)
except AIError as exc:
self.finished_error.emit(str(exc))
except Exception as exc: # noqa: BLE001 - ne jamais laisser un thread planter silencieusement
self.finished_error.emit(f"Erreur inattendue : {exc}")
def _execute(self) -> str:
if self.kind == KIND_EXPLAIN:
return self.provider.explain_output(self.payload)
if self.kind == KIND_DIAGNOSE:
return self.provider.diagnose(self.payload)
if self.kind == KIND_SOLUTION:
return self.provider.propose_solution(self.payload)
if self.kind == KIND_GENERATE:
return self.provider.generate_command(self.payload)
if self.kind == KIND_ASK:
return self.provider.ask(self.payload, context=self.context)
if self.kind == KIND_TEST:
return self.provider.chat([{"role": "user", "content": "Reponds uniquement 'OK'."}])
raise AIError(f"Type de requete IA inconnu : {self.kind}")

85
app/ai/settings.py Normal file
View File

@@ -0,0 +1,85 @@
"""Configuration de l'assistant IA (optionnel, desactive par defaut).
La cle API n'est jamais stockee ici : seuls les parametres non sensibles
(activation, fournisseur, URL, modele, timeout, temperature) le sont, via
app.app_settings. La cle passe exclusivement par CredentialStore
(app.credentials), jamais dans settings.json/sessions.json/logs."""
from __future__ import annotations
from dataclasses import dataclass
from app.app_settings import get_setting, set_setting
from app.credentials import CredentialStore
from app.ai.provider import AIProvider, OpenAICompatibleProvider
AI_API_KEY_CREDENTIAL = "ai:api_key"
DEFAULT_BASE_URL = "https://api.openai.com/v1"
DEFAULT_PROVIDER_NAME = "OpenAI"
DEFAULT_MODEL = ""
DEFAULT_TIMEOUT = 30.0
DEFAULT_TEMPERATURE = 0.3
@dataclass
class AISettings:
enabled: bool = False
provider_name: str = DEFAULT_PROVIDER_NAME
base_url: str = DEFAULT_BASE_URL
model: str = DEFAULT_MODEL
timeout: float = DEFAULT_TIMEOUT
temperature: float = DEFAULT_TEMPERATURE
def load_ai_settings() -> AISettings:
try:
timeout = float(get_setting("ai_timeout", DEFAULT_TIMEOUT))
except (TypeError, ValueError):
timeout = DEFAULT_TIMEOUT
try:
temperature = float(get_setting("ai_temperature", DEFAULT_TEMPERATURE))
except (TypeError, ValueError):
temperature = DEFAULT_TEMPERATURE
return AISettings(
enabled=bool(get_setting("ai_enabled", False)),
provider_name=get_setting("ai_provider_name", DEFAULT_PROVIDER_NAME) or DEFAULT_PROVIDER_NAME,
base_url=get_setting("ai_base_url", DEFAULT_BASE_URL) or DEFAULT_BASE_URL,
model=get_setting("ai_model", DEFAULT_MODEL) or DEFAULT_MODEL,
timeout=timeout,
temperature=temperature,
)
def save_ai_settings(settings: AISettings) -> None:
set_setting("ai_enabled", settings.enabled)
set_setting("ai_provider_name", settings.provider_name)
set_setting("ai_base_url", settings.base_url)
set_setting("ai_model", settings.model)
set_setting("ai_timeout", settings.timeout)
set_setting("ai_temperature", settings.temperature)
def load_api_key(credential_store: CredentialStore) -> str:
return credential_store.get_secret(AI_API_KEY_CREDENTIAL) or ""
def save_api_key(credential_store: CredentialStore, api_key: str) -> bool:
if api_key:
return credential_store.set_secret(AI_API_KEY_CREDENTIAL, api_key)
credential_store.delete_secret(AI_API_KEY_CREDENTIAL)
return True
def build_provider_from_settings(credential_store: CredentialStore) -> AIProvider | None:
"""None si l'assistant est desactive : point d'appel unique pour tous les
endroits de l'UI qui ont besoin d'un fournisseur pret a l'emploi."""
settings = load_ai_settings()
if not settings.enabled:
return None
return OpenAICompatibleProvider(
base_url=settings.base_url,
api_key=load_api_key(credential_store),
model=settings.model,
timeout=settings.timeout,
temperature=settings.temperature,
)

View File

@@ -33,6 +33,7 @@ class ConnectionController(QObject):
broadcast: BroadcastManager,
snippet_store=None,
credential_store: CredentialStore | None = None,
ai_provider_factory=None,
parent_widget: QWidget | None = None,
):
super().__init__(parent_widget)
@@ -41,6 +42,7 @@ class ConnectionController(QObject):
self.broadcast = broadcast
self.snippet_store = snippet_store
self.credential_store = credential_store or NullCredentialStore()
self.ai_provider_factory = ai_provider_factory
self._parent_widget = parent_widget
self.tabs = QTabWidget(parent_widget)
@@ -108,6 +110,7 @@ class ConnectionController(QObject):
return SessionTabWidget(
session, secret, bastion_target=bastion_target, bastion_password=bastion_password,
snippet_store=self.snippet_store, broadcast=self.broadcast,
ai_provider_factory=self.ai_provider_factory, credential_store=self.credential_store,
)
def build_sftp_tab(self, session: Session) -> SFTPTabWidget | None:
@@ -149,7 +152,10 @@ class ConnectionController(QObject):
def new_local_terminal(self, shell: str = DEFAULT_SHELL) -> LocalTerminalTabWidget:
"""Cree un terminal local cable (multi-send, SFTP detecte), sans l'ajouter
au QTabWidget : reutilise pour un onglet normal comme pour un panneau."""
tab = LocalTerminalTabWidget(shell, snippet_store=self.snippet_store, broadcast=self.broadcast)
tab = LocalTerminalTabWidget(
shell, snippet_store=self.snippet_store, broadcast=self.broadcast,
ai_provider_factory=self.ai_provider_factory, credential_store=self.credential_store,
)
tab.sftp_requested.connect(self.quick_sftp)
self.broadcast.register(tab)
return tab

View File

@@ -0,0 +1,79 @@
"""Affiche une commande proposee par l'assistant IA : jamais executee
automatiquement, seulement copiable ou inseree dans le terminal (sans Entree)
apres verification par l'utilisateur."""
from __future__ import annotations
import re
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QLabel, QApplication,
)
_CODE_BLOCK_RE = re.compile(r"```(?:[a-zA-Z0-9_+-]*\n)?(.*?)```", re.DOTALL)
def extract_command(response: str) -> str:
"""Prend le contenu du premier bloc de code s'il y en a un (le prompt
systeme en demande un), sinon la reponse entiere : mieux vaut proposer un
texte un peu trop large que perdre silencieusement la commande."""
match = _CODE_BLOCK_RE.search(response)
text = match.group(1) if match else response
return text.strip()
class AICommandDialog(QDialog):
def __init__(self, response: str, dangerous_labels: list[str] | None = None, parent=None):
super().__init__(parent)
self.setWindowTitle("Commande generee par l'assistant IA")
self.setMinimumSize(520, 360)
self.command = extract_command(response)
self.insert_requested = False
layout = QVBoxLayout(self)
if dangerous_labels:
warning = QLabel(
"⚠ Commande potentiellement destructive : " + ", ".join(dangerous_labels)
)
warning.setWordWrap(True)
warning.setStyleSheet("color: #c0392b; font-weight: bold;")
layout.addWidget(warning)
layout.addWidget(QLabel("Reponse complete de l'assistant :"))
self.response_view = QTextEdit(response)
self.response_view.setReadOnly(True)
layout.addWidget(self.response_view, 1)
layout.addWidget(QLabel("Commande proposee (celle qui sera copiee/inseree) :"))
self.command_view = QTextEdit(self.command)
self.command_view.setFixedHeight(70)
layout.addWidget(self.command_view)
self.status_label = QLabel("Cette commande ne sera jamais executee automatiquement.")
self.status_label.setObjectName("statusLabel")
layout.addWidget(self.status_label)
btn_row = QHBoxLayout()
self.copy_btn = QPushButton("Copier")
self.insert_btn = QPushButton("Inserer dans le terminal")
self.cancel_btn = QPushButton("Annuler")
for b in (self.copy_btn, self.insert_btn, self.cancel_btn):
btn_row.addWidget(b)
layout.addLayout(btn_row)
self.copy_btn.clicked.connect(self._copy)
self.insert_btn.clicked.connect(self._insert)
self.cancel_btn.clicked.connect(self.reject)
def _current_command(self) -> str:
# l'utilisateur peut corriger la commande proposee avant de l'utiliser
return self.command_view.toPlainText().strip()
def _copy(self) -> None:
QApplication.clipboard().setText(self._current_command())
self.status_label.setText("Copie dans le presse-papiers.")
def _insert(self) -> None:
self.command = self._current_command()
self.insert_requested = True
self.accept()

70
app/dialogs/ai_prompts.py Normal file
View File

@@ -0,0 +1,70 @@
"""Confirmations de securite avant tout envoi de contenu au fournisseur IA :
un avertissement de principe affiche une seule fois, puis une verification
systematique des secrets evidents a chaque envoi d'une selection de terminal
ou d'une demande libre."""
from __future__ import annotations
from PySide6.QtWidgets import QMessageBox
from app.app_settings import get_setting, set_setting
from app.ai.redaction import detect_secrets
_NOTICE_ACK_KEY = "ai_notice_acknowledged"
MASK = "mask"
SEND = "send"
CANCEL = "cancel"
def ensure_ai_notice_acknowledged(parent) -> bool:
"""Affiche l'avertissement de principe une seule fois (jamais reaffiche
ensuite, l'accord etant persiste). Renvoie False si l'utilisateur refuse :
l'appelant ne doit alors rien envoyer."""
if get_setting(_NOTICE_ACK_KEY, False):
return True
box = QMessageBox(parent)
box.setWindowTitle("Assistant IA")
box.setIcon(QMessageBox.Information)
box.setText(
"Le texte selectionne dans le terminal (ou votre question) va etre envoye "
"au fournisseur IA que vous avez configure, en dehors de cet ordinateur.\n\n"
"PySSH tente de detecter les secrets evidents (mots de passe, cles, jetons) "
"avant chaque envoi et vous permet de les masquer, mais ne peut pas tout "
"detecter : verifiez ce que vous envoyez.\n\n"
"Ce message ne sera plus affiche."
)
box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
box.setDefaultButton(QMessageBox.Ok)
accepted = box.exec() == QMessageBox.Ok
if accepted:
set_setting(_NOTICE_ACK_KEY, True)
return accepted
def confirm_sensitive_content(parent, text: str) -> str:
"""Renvoie MASK, SEND ou CANCEL. Si aucun secret evident n'est detecte,
renvoie SEND directement sans rien afficher."""
secrets = detect_secrets(text)
if not secrets:
return SEND
labels = "\n".join(f"- {s.label}" for s in secrets)
box = QMessageBox(parent)
box.setWindowTitle("Donnees potentiellement sensibles")
box.setIcon(QMessageBox.Warning)
box.setText(
"Des donnees potentiellement sensibles ont ete detectees dans le texte "
f"qui serait envoye au fournisseur IA :\n\n{labels}\n\n"
"« Masquer automatiquement » les remplace par [MASQUE] avant l'envoi.\n"
"« Voir le contenu » l'envoie tel quel, sans modification."
)
mask_btn = box.addButton("Masquer automatiquement", QMessageBox.AcceptRole)
send_btn = box.addButton("Voir le contenu", QMessageBox.DestructiveRole)
box.addButton("Annuler", QMessageBox.RejectRole)
box.setDefaultButton(mask_btn)
box.exec()
clicked = box.clickedButton()
if clicked is mask_btn:
return MASK
if clicked is send_btn:
return SEND
return CANCEL

View File

@@ -0,0 +1,161 @@
"""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...)."""
from __future__ import annotations
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QLineEdit, QCheckBox, QDoubleSpinBox,
QPushButton, QDialogButtonBox, QMessageBox, QLabel,
)
from app.ai.settings import AISettings, load_ai_settings, save_ai_settings, load_api_key, save_api_key
from app.ai.provider import OpenAICompatibleProvider
from app.ai.service import KIND_TEST, AIRequestWorker
from app.credentials import CredentialStore, NullCredentialStore
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_name_edit = QLineEdit(settings.provider_name)
self.provider_name_edit.setPlaceholderText("ex : OpenAI, OpenRouter, LLM local...")
form.addRow("Fournisseur :", self.provider_name_edit)
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")
form.addRow("Modele :", self.model_edit)
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():
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)
# ---------- 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",
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)

View File

@@ -21,6 +21,8 @@ from app.dialogs.known_hosts_dialog import KnownHostsDialog
from app.dialogs.workspace_dialog import WorkspaceDialog
from app.dialogs.snippet_dialog import SnippetManagerDialog
from app.dialogs.quick_connect_dialog import QuickConnectDialog
from app.dialogs.ai_settings_dialog import AISettingsDialog
from app.ai.settings import build_provider_from_settings
from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme
from app.workspace import WorkspaceStore
from app.snippets import SnippetStore
@@ -52,7 +54,9 @@ class MainWindow(QMainWindow):
self.broadcast = BroadcastManager(self._iter_tabs, self)
self.connection_controller = ConnectionController(
self.session_store, self.key_manager, self.broadcast,
snippet_store=self.snippet_store, credential_store=self.credential_store, parent_widget=self,
snippet_store=self.snippet_store, credential_store=self.credential_store,
ai_provider_factory=lambda: build_provider_from_settings(self.credential_store),
parent_widget=self,
)
self.session_controller = SessionController(
self.session_store, self.key_manager, self.broadcast.button, parent_widget=self,
@@ -189,6 +193,11 @@ class MainWindow(QMainWindow):
manage_snippets_action.triggered.connect(self.open_snippet_manager)
snippets_menu.addAction(manage_snippets_action)
ai_menu = self.menuBar().addMenu("&Assistant IA")
ai_settings_action = QAction("Parametres...", self)
ai_settings_action.triggered.connect(self.open_ai_settings)
ai_menu.addAction(ai_settings_action)
help_menu = self.menuBar().addMenu("&Aide")
about_action = QAction("A propos", self)
about_action.triggered.connect(self.show_about)
@@ -246,6 +255,10 @@ class MainWindow(QMainWindow):
dialog = SnippetManagerDialog(self.snippet_store, parent=self)
dialog.exec()
def open_ai_settings(self) -> None:
dialog = AISettingsDialog(credential_store=self.credential_store, parent=self)
dialog.exec()
def open_quick_connect(self) -> None:
dialog = QuickConnectDialog(
self.session_store, self.connection_controller, self.workspace_manager,

171
app/ui/ai_panel.py Normal file
View File

@@ -0,0 +1,171 @@
"""Panneau lateral de l'assistant IA : conversation en memoire uniquement
(jamais persistee, jamais journalisee), une requete a la fois.
Point d'entree unique (`run_request`) pour toutes les actions IA d'un onglet :
c'est ici, et seulement ici, que se fait la verification de securite (avis
unique + detection de secrets) avant tout envoi au fournisseur configure.
"""
from __future__ import annotations
import html
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QPushButton, QLabel, QLineEdit, QApplication,
)
from app.ai.service import AIRequestWorker, KIND_ASK
from app.ai.redaction import redact
from app.dialogs.ai_prompts import ensure_ai_notice_acknowledged, confirm_sensitive_content, MASK, CANCEL
_KIND_LABELS = {
"explain": "Expliquer cette sortie",
"diagnose": "Diagnostiquer cette erreur",
"solution": "Proposer une solution",
"ask": "Question",
}
def _escape(text: str) -> str:
return html.escape(text).replace("\n", "<br>")
class AIPanel(QWidget):
"""`provider_factory` est un callable sans argument renvoyant un AIProvider
pret a l'emploi, ou None si l'assistant est desactive/non configure -
rappele a chaque requete pour refleter immediatement un changement de
parametres, sans avoir a rouvrir l'onglet."""
def __init__(self, provider_factory, parent=None):
super().__init__(parent)
self.provider_factory = provider_factory
self._worker: AIRequestWorker | None = None
self._generation = 0
self._last_response = ""
self._pending_context = ""
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
header = QHBoxLayout()
header.addWidget(QLabel("🤖 Assistant IA"), 1)
close_btn = QPushButton("✕")
close_btn.setFixedWidth(28)
close_btn.setToolTip("Masquer le panneau")
close_btn.clicked.connect(self.hide)
header.addWidget(close_btn)
layout.addLayout(header)
self.conversation = QTextEdit()
self.conversation.setReadOnly(True)
layout.addWidget(self.conversation, 1)
self.busy_label = QLabel("")
self.busy_label.setObjectName("statusLabel")
layout.addWidget(self.busy_label)
self.question_row = QWidget()
question_layout = QHBoxLayout(self.question_row)
question_layout.setContentsMargins(0, 0, 0, 0)
self.question_edit = QLineEdit()
self.question_edit.setPlaceholderText("Votre question...")
self.question_edit.returnPressed.connect(self._send_question)
send_btn = QPushButton("Envoyer")
send_btn.clicked.connect(self._send_question)
question_layout.addWidget(self.question_edit, 1)
question_layout.addWidget(send_btn)
self.question_row.hide()
layout.addWidget(self.question_row)
btn_row = QHBoxLayout()
self.copy_btn = QPushButton("Copier reponse")
self.new_question_btn = QPushButton("Nouvelle question")
self.clear_btn = QPushButton("Effacer la conversation")
self.copy_btn.clicked.connect(self._copy_last_response)
self.new_question_btn.clicked.connect(lambda: self.prompt_question(""))
self.clear_btn.clicked.connect(self.clear_conversation)
for b in (self.copy_btn, self.new_question_btn, self.clear_btn):
btn_row.addWidget(b)
layout.addLayout(btn_row)
# ---------- conversation ----------
def clear_conversation(self) -> None:
self._generation += 1 # toute reponse en vol devient perimee et sera ignoree
self.conversation.clear()
self._last_response = ""
self.busy_label.clear()
self.question_row.hide()
def _copy_last_response(self) -> None:
if self._last_response:
QApplication.clipboard().setText(self._last_response)
# ---------- question libre ----------
def prompt_question(self, context: str = "") -> None:
"""Affiche le panneau et la zone de saisie d'une question. `context`
est un texte optionnel (ex : selection du terminal) joint a la
question, propose a la meme verification de secrets avant l'envoi."""
self.show()
self._pending_context = context
self.question_row.show()
self.question_edit.setFocus()
def _send_question(self) -> None:
question = self.question_edit.text().strip()
if not question:
return
self.question_edit.clear()
self.question_row.hide()
context, self._pending_context = self._pending_context, ""
self.run_request(KIND_ASK, question, context=context, label=_KIND_LABELS["ask"])
# ---------- requetes ----------
def run_request(self, kind: str, payload: str, context: str = "", label: str = "") -> None:
if self._worker is not None and self._worker.isRunning():
self.busy_label.setText("Une requete est deja en cours, patientez...")
return
provider = self.provider_factory() if self.provider_factory else None
if provider is None:
self.show()
self.conversation.append("<i>Assistant IA non configure (menu Assistant IA > Parametres...).</i>")
return
if not ensure_ai_notice_acknowledged(self):
return
to_check = payload + ("\n" + context if context else "")
decision = confirm_sensitive_content(self, to_check)
if decision == CANCEL:
return
if decision == MASK:
payload = redact(payload)
context = redact(context) if context else context
self.show()
label = label or _KIND_LABELS.get(kind, kind)
self.conversation.append(f"<b>{html.escape(label)}</b><br>{_escape(payload)}")
self.busy_label.setText("Interrogation du fournisseur IA...")
generation = self._generation
self._worker = AIRequestWorker(provider, kind, payload, context=context)
self._worker.finished_ok.connect(lambda text: self._on_ok(generation, text))
self._worker.finished_error.connect(lambda message: self._on_error(generation, message))
self._worker.start()
def _on_ok(self, generation: int, text: str) -> None:
if generation != self._generation:
return
self.busy_label.clear()
self._last_response = text
self.conversation.append(f"<div>{_escape(text)}</div>")
def _on_error(self, generation: int, message: str) -> None:
if generation != self._generation:
return
self.busy_label.clear()
self.conversation.append(f"<span style='color:#c0392b;'>Erreur : {_escape(message)}</span>")
def shutdown(self) -> None:
"""A appeler avant la destruction du panneau (fermeture de l'onglet) :
une requete deja en vol devient perimee (voir `_generation`) et son
thread dispose d'un court delai pour se terminer proprement."""
self._generation += 1
if self._worker is not None and self._worker.isRunning():
self._worker.wait(2000)

View File

@@ -15,7 +15,7 @@ from PySide6.QtCore import Qt, Signal, QTimer
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter,
QFrame, QLineEdit, QFileDialog, QMenu,
QFrame, QLineEdit, QFileDialog, QMenu, QInputDialog,
)
from app.session_manager import Session, AUTH_KEY, AUTH_PASSWORD
@@ -30,6 +30,12 @@ from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_hos
from app.services.ssh_target import detect_ssh_target
from app.services.reconnect import compute_backoff_delay
from app.snippets import Snippet, SnippetStore
from app.ai.service import KIND_EXPLAIN, KIND_DIAGNOSE, KIND_SOLUTION, KIND_GENERATE, AIRequestWorker
from app.ai.danger import check_dangerous
from app.ai.redaction import redact
from app.ui.ai_panel import AIPanel
from app.dialogs.ai_command_dialog import AICommandDialog
from app.dialogs.ai_prompts import ensure_ai_notice_acknowledged, confirm_sensitive_content, MASK, CANCEL
# sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles
ANSI_ESCAPE_RE = re.compile(
@@ -48,11 +54,15 @@ class BaseTerminalTab(QWidget):
def __init__(
self, initial_status: str, snippet_store: SnippetStore | None = None,
broadcast=None, parent=None,
broadcast=None, ai_provider_factory=None, credential_store=None, parent=None,
):
super().__init__(parent)
self.snippet_store = snippet_store
self.broadcast = broadcast
self.ai_provider_factory = ai_provider_factory
self.credential_store = credential_store
self.ai_panel: AIPanel | None = None
self._ai_command_worker: AIRequestWorker | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
@@ -82,6 +92,13 @@ class BaseTerminalTab(QWidget):
self.snippets_btn.clicked.connect(self._show_snippets_menu)
self.bar.addWidget(self.snippets_btn)
self.ai_btn = QToolButton()
self.ai_btn.setText("🤖 Assistant IA")
self.ai_btn.setToolTip("Analyser la sortie selectionnee ou generer une commande (optionnel)")
self.ai_btn.setPopupMode(QToolButton.InstantPopup)
self.ai_btn.clicked.connect(self._show_ai_menu)
self.bar.addWidget(self.ai_btn)
self.log_btn = QToolButton()
self.log_btn.setText("⏺ Journal")
self.log_btn.setToolTip("Enregistrer la session dans un fichier")
@@ -269,6 +286,141 @@ class BaseTerminalTab(QWidget):
elif self.connection:
self.connection.send(data)
# ---------- assistant IA (optionnel) ----------
def _show_ai_menu(self) -> None:
menu = self._build_ai_menu()
menu.exec(self.ai_btn.mapToGlobal(self.ai_btn.rect().bottomLeft()))
def _build_ai_menu(self) -> QMenu:
menu = QMenu(self)
has_selection = self.terminal.has_selection()
explain_action = menu.addAction("Expliquer cette sortie")
explain_action.setEnabled(has_selection)
explain_action.triggered.connect(self._ai_explain)
diagnose_action = menu.addAction("Diagnostiquer cette erreur")
diagnose_action.setEnabled(has_selection)
diagnose_action.triggered.connect(self._ai_diagnose)
solution_action = menu.addAction("Proposer une solution")
solution_action.setEnabled(has_selection)
solution_action.triggered.connect(self._ai_solution)
ask_action = menu.addAction("Poser une question...")
ask_action.setEnabled(has_selection)
ask_action.triggered.connect(self._ai_ask)
if not has_selection:
for action in (explain_action, diagnose_action, solution_action, ask_action):
action.setToolTip("Selectionnez d'abord du texte dans le terminal.")
menu.addSeparator()
generate_action = menu.addAction("Generer une commande...")
generate_action.triggered.connect(self._ai_generate_command)
menu.addSeparator()
panel_visible = self.ai_panel is not None and self.ai_panel.isVisible()
toggle_action = menu.addAction("Masquer le panneau IA" if panel_visible else "Afficher le panneau IA")
toggle_action.triggered.connect(self._toggle_ai_panel)
menu.addSeparator()
settings_action = menu.addAction("Parametres de l'assistant IA...")
settings_action.triggered.connect(self._open_ai_settings)
return menu
def _ensure_ai_panel(self) -> AIPanel:
if self.ai_panel is None:
self.ai_panel = AIPanel(self.ai_provider_factory)
self.splitter.addWidget(self.ai_panel)
# cree masque : sans cela, Qt affiche automatiquement un widget
# fraichement ajoute a un splitter deja visible, ce qui romprait
# l'etat "ferme par defaut" attendu du panneau.
self.ai_panel.hide()
return self.ai_panel
def _toggle_ai_panel(self) -> None:
panel = self._ensure_ai_panel()
panel.setVisible(not panel.isVisible())
def _open_ai_settings(self) -> None:
from app.dialogs.ai_settings_dialog import AISettingsDialog
AISettingsDialog(credential_store=self.credential_store, parent=self).exec()
def _ai_explain(self) -> None:
self._run_ai_selection_action(KIND_EXPLAIN, "Expliquer cette sortie")
def _ai_diagnose(self) -> None:
self._run_ai_selection_action(KIND_DIAGNOSE, "Diagnostiquer cette erreur")
def _ai_solution(self) -> None:
self._run_ai_selection_action(KIND_SOLUTION, "Proposer une solution")
def _run_ai_selection_action(self, kind: str, label: str) -> None:
selection = self.terminal.selected_text()
if not selection:
return
panel = self._ensure_ai_panel()
panel.run_request(kind, selection, label=label)
def _ai_ask(self) -> None:
selection = self.terminal.selected_text()
if not selection:
return
panel = self._ensure_ai_panel()
panel.prompt_question(context=selection)
def _ai_generate_command(self) -> None:
if self._ai_command_worker is not None and self._ai_command_worker.isRunning():
return
provider = self.ai_provider_factory() if self.ai_provider_factory else None
if provider is None:
QMessageBox.information(
self, "Assistant IA",
"L'assistant IA n'est pas configure (menu Assistant IA > Parametres de l'assistant IA...).",
)
return
request, ok = QInputDialog.getMultiLineText(
self, "Generer une commande", "Decrivez ce que vous voulez accomplir :",
)
if not ok or not request.strip():
return
request = request.strip()
if not ensure_ai_notice_acknowledged(self):
return
decision = confirm_sensitive_content(self, request)
if decision == CANCEL:
return
if decision == MASK:
request = redact(request)
self.ai_btn.setEnabled(False)
self.ai_btn.setText("🤖 Generation...")
self._ai_command_worker = AIRequestWorker(provider, KIND_GENERATE, request)
self._ai_command_worker.finished_ok.connect(self._on_ai_command_ok)
self._ai_command_worker.finished_error.connect(self._on_ai_command_error)
self._ai_command_worker.start()
def _reset_ai_generate_button(self) -> None:
self.ai_btn.setEnabled(True)
self.ai_btn.setText("🤖 Assistant IA")
def _on_ai_command_ok(self, response: str) -> None:
self._reset_ai_generate_button()
dangerous = [m.label for m in check_dangerous(response)]
dialog = AICommandDialog(response, dangerous_labels=dangerous, parent=self)
if dialog.exec() and dialog.insert_requested and dialog.command:
self.insert_snippet_text(dialog.command)
def _on_ai_command_error(self, message: str) -> None:
self._reset_ai_generate_button()
QMessageBox.critical(self, "Assistant IA", message)
def insert_snippet_text(self, text: str) -> None:
"""N'envoie jamais Entree : le texte est ecrit, a l'utilisateur de valider."""
if self.connection:
self.connection.send(text.encode("utf-8"))
# ---------- recherche dans l'historique ----------
def open_search(self) -> None:
self.search_bar.show()
@@ -290,6 +442,10 @@ class BaseTerminalTab(QWidget):
def close_connection(self) -> None:
self._closing = True # ne jamais reconnecter automatiquement une fermeture voulue
self.stop_logging()
if self.ai_panel is not None:
self.ai_panel.shutdown()
if self._ai_command_worker is not None and self._ai_command_worker.isRunning():
self._ai_command_worker.wait(2000)
if self.connection:
self.connection.close_connection()
self.connection.wait(2000)
@@ -300,11 +456,12 @@ class SessionTabWidget(BaseTerminalTab):
def __init__(
self, session: Session, secret: str, bastion_target=None, bastion_password: str = "",
snippet_store=None, broadcast=None, parent=None,
snippet_store=None, broadcast=None, ai_provider_factory=None, credential_store=None, parent=None,
):
super().__init__(
f"○ Connexion a {session.username}@{session.host}...",
snippet_store=snippet_store, broadcast=broadcast, parent=parent,
snippet_store=snippet_store, broadcast=broadcast,
ai_provider_factory=ai_provider_factory, credential_store=credential_store, parent=parent,
)
self.session = session
self._bastion_target = bastion_target
@@ -594,10 +751,14 @@ class LocalTerminalTabWidget(BaseTerminalTab):
sftp_requested = Signal(object) # cible ssh detectee (ou None)
def __init__(self, shell: str = SHELL_POWERSHELL, snippet_store=None, broadcast=None, parent=None):
def __init__(
self, shell: str = SHELL_POWERSHELL, snippet_store=None, broadcast=None,
ai_provider_factory=None, credential_store=None, parent=None,
):
super().__init__(
f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...",
snippet_store=snippet_store, broadcast=broadcast, parent=parent,
snippet_store=snippet_store, broadcast=broadcast,
ai_provider_factory=ai_provider_factory, credential_store=credential_store, parent=parent,
)
self.shell = shell