Author SHA1 Message Date
Hermes Agent 1d13e7c22b UI: fenêtre unifiée à onglets + réaffichage Tk depuis l'icône
Remplace l'architecture 'une fenêtre = un processus' par une fenêtre
unique (racine Tk sur le thread principal) avec trois onglets:

- Profils    : liste + éditeur intégré (ex-ProfileDialog)
- Authentification : gestion des clés API (ex-AuthDialog)
- Logs       : journaux temps réel (ex-LogsWindow, désormais dans le
  même processus que le moteur, donc réellement alimenté)

Fermer la fenêtre (WM_DELETE_WINDOW -> withdraw) la masque en zone de
notification sans quitter. L'icône pystray (thread dédié) réaffiche la
fenêtre dans le bon onglet via master.after(0, ...); la commutation de
profil reste directe depuis le sous-menu radio (sans afficher la fenêtre).

Supprime _tk_spawn/_tk_window_process et les trois anciens dialogues.
2026-09-18 14:56:37 +02:00
Hermes Agent 4adb88fd35 refactor: subprocess.Popen au lieu de multiprocessing.Process pour les fenêtres Tk
- Nouveau module _tk_window_process.py : point d'entrée pour le subprocess
  (python -m ai_typewriter.ui._tk_window_process). Reçoit les args en
  pickle sur stdin, écrit window.result en pickle sur stdout.
- _tk_spawn.py réécrit avec subprocess.Popen. Payload picklé envoyé sur
  stdin, résultat lu via communicate() dans un thread poller.
- Plus besoin de multiprocessing.Queue ni de start_method.
- PYTHONPATH injecté automatiquement pour que le subprocess trouve le
  package ai_typewriter.
2026-09-18 14:27:35 +02:00
Hermes Agent 7fa401b287 refactor: multiprocessing.Process au lieu de threading.Thread pour les fenêtres Tk
- _tk_spawn.py : chaque fenêtre tourne dans un Process séparé (spawn,
  pas fork) avec sa propre instance tk.Tk. Résultats renvoyés au parent
  via multiprocessing.Queue + thread poller.
- API changée : spawn_tk_window(Class, *args, on_result=..., **kwargs)
  au lieu d'une lambda factory — nécessaire pour la picklabilité.
- SecureStore n'est plus passé aux processus fils (non picklable) ;
  chaque dialogue crée le sien (déjà le comportement par défaut).
- TrayApp allégé : plus de self.secure, plus d'import SecureStore.
2026-09-18 13:56:53 +02:00
Hermes Agent e07f401b5c refactor: un thread par fenêtre Tk, main thread dédié au hotkey
- Nouveau helper ui/_tk_spawn.py : chaque fenêtre Tk dans son propre thread
  avec sa propre racine tk.Tk (plus de racine partagée)
- app.py : thread principal pour le hotkey + attente stop_event,
  thread séparé pour l'icône pystray
- tray.py : suppression de get_root/_defer_tk/_present_window,
  spawn_tk_window pour chaque dialogue, callbacks on_result au lieu
  de wait_window bloquant
2026-09-18 13:25:24 +02:00
Hermes Agent 15eb856bf2 fix(tray): fenêtres invisibles et coche de profil non mise à jour
- Ajoute _present_window() (deiconify/lift/topmost/focus_force) pour que
  les Toplevel enfants d'une racine withdraw() apparaissent bien au
  premier plan sous Windows (logs, profil, ajout, authentification).
- Rend le sous-menu des profils dynamique via un générateur avec items
  radio + checked=, et appelle icon.update_menu() après changement de
  profil pour que la coche suive l'état courant.
2026-09-18 11:00:41 +02:00
Hermes Agent 2f5c428545 fix(tray): architecture hybride pystray (thread) + Tk.mainloop() (thread principal) — les callbacks dispatch via root.after() pour que Tk ait sa boucle d'événements ; fenêtres peintes et interactives 2026-09-18 10:54:03 +02:00
Hermes Agent e7d13d9ad1 fix(tray): handlers de menu à 2 args (icon,item) — le lambda 1-arg capturait l'icône au lieu du nom du profil; Icon.run() sur le thread principal (exigence pystray); logs DEBUG par défaut + formateur enrichi 2026-09-18 10:45:25 +02:00
Hermes Agent 05e6515011 fix(auth): lire p.credential sur les objets Profile; sortie propre via Event (plus de SystemExit dans le callback pystray) 2026-09-18 10:24:23 +02:00
Hermes Agent fa7b037e7f fix(tray): créer le sous-menu via l'action MenuItem (pystray n'accepte pas submenu=) 2026-09-18 10:17:17 +02:00
10 changed files with 684 additions and 374 deletions
+17 -7
View File
@@ -1,6 +1,8 @@
# AI-Typewriter
Application Python qui tourne **en arrière-plan** (icône dans la zone de notification de Windows, aucune fenêtre au démarrage), lit le contenu du presse-papier sur un raccourci global, l'envoie à un modèle IA, puis réécrit la réponse **caractère par caractère** à chaque pression de touche physique.
Application Python qui **lit le contenu du presse-papier** sur un raccourci global, l'envoie à un modèle IA, puis réécrit la réponse **caractère par caractère** à chaque pression de touche physique.
Une **fenêtre unifiée à trois onglets** (Profils / Authentification / Logs) permet de tout configurer sans fenêtres éparpillées. Fermer la fenêtre (✕) la masque dans la zone de notification — l'application continue de tourner. Le menu de l'icône de notification réaffiche la fenêtre dans le bon onglet, et la commutation de profil reste directement accessible depuis l'icône (sans afficher la fenêtre).
Un profil spécialisé « Mathématiques (LaTeX) » marque les équations avec `[EQ]...[/EQ]` : l'application les intercepte et déclenche `Alt+=` pour ouvrir une équation (Word/OneNote) et `→` pour en sortir.
@@ -54,15 +56,23 @@ Les racines sont [EQ]z_1 = x + iy[/EQ] et [EQ]z_2 = x - iy[/EQ].
À chaque `[EQ]` l'application envoie `Alt+=` (ouvre une équation inline), tape le LaTeX littéralement, puis envoie `→` à chaque `[/EQ]`. Ce comportement est désactivé par défaut sur les autres profils (le texte est tapé tel quel).
## Fenêtre unifiée (onglets)
L'application démarre avec **une seule fenêtre** comportant trois onglets :
- **Profils** — liste de tous les profils (éditeur intégré) pour créer, modifier, supprimer ou activer un profil directement.
- **Authentification** — enregistrement/vérification/suppression des clés d'API par référence.
- **Logs** — journaux en temps réel (également écrits dans `~/.config/ai-typewriter/logs/app.log`).
## Zone de notification (icône)
L'application se lance sans fenêtre visible. Le menu de l'icône propose :
Fermer la fenêtre (✕) ne quitte **pas** l'application : elle est masquée et continue de tourner, avec l'icône toujours visible dans la zone de notification. Le menu de l'icône propose :
- **Ouvrir les logs** — fenêtre des journaux en temps réel (également écrits dans `%APPDATA%\ai-typewriter\logs\app.log`).
- **Ajouter un profil** — formulaire (nom, fournisseur, modèle, serveur, prompt, équations).
- **Modifier le profil** — sous-menu listant tous les profils pour choisir le profil actif.
- **Gérer l'authentification** — enregistrer les clés d'API des fournisseurs.
- **Quitter** — arrête le processus.
- **Ouvrir les logs** — réaffiche la fenêtre dans l'onglet *Logs*.
- **Ajouter un profil** — réaffiche la fenêtre dans l'onglet *Profils* (formulaire vierge pour créer).
- **Modifier le profil** — sous-menu listant tous les profils pour choisir le profil actif **directement** (sans afficher la fenêtre).
- **Gérer l'authentification** — réaffiche la fenêtre dans l'onglet *Authentification*.
- **Quitter** — arrête complètement le processus.
### Sélection et téléchargement des modèles
+70 -21
View File
@@ -1,22 +1,36 @@
"""Point d'entrée de l'application.
Par défaut, l'application s'exécute en arrière-plan, sans fenêtre visible,
avec une icône dans la zone de notification (Windows). Deux modes console
restent disponibles pour le développement / le test :
- `python main.py --ask "texte"` : envoie le texte au profil actif et
L'application démarre avec une **fenêtre unifiée à onglets** (Profils,
Authentification, Logs) et une icône dans la zone de notification. Fermer la
fenêtre la met en zone de notification (l'application continue de tourner) ;
le menu de l'icône réaffiche la fenêtre dans le bon onglet.
Deux modes restent disponibles pour le développement / le test :
- ``python main.py --ask "texte"`` : envoie le texte au profil actif et
imprime la réponse sans intercepter le clavier.
- `python main.py` : lance l'icône de la zone de notification.
- ``python main.py`` : lance la fenêtre + l'icône.
Architecture (processus)
------------------------
* **Thread principal** : la racine Tk (``MainWindow``) et sa boucle
``mainloop``. Le raccourci global (``keyboard``) tourne dans son propre
thread d'écoute.
* **Thread UI (tray)** : icône pystray dans la zone de notification. Elle
n'ordonne des actions à la fenêtre que via ``master.after(0, …)``.
* **Une seule racine Tk** partagée : plus de fenêtre par processus.
"""
from __future__ import annotations
import argparse
import logging
import signal
import sys
import threading
from .config import ConfigStore, load_config
from .credentials import SecureStore
from .engine import AITypewriterEngine
from .engine import AITypewriterEngine, bind_hotkey
from .logging_utils import setup_logging
LOG = logging.getLogger("ai_typewriter")
@@ -40,7 +54,8 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
setup_logging(level=logging.DEBUG if args.debug else logging.INFO)
setup_logging(level=logging.DEBUG)
LOG.debug("Démarrage : args=%s", args)
try:
store = load_config(args.config)
@@ -59,27 +74,61 @@ def main(argv: list[str] | None = None) -> int:
LOG.error("%s", exc)
return 1
return run_tray(store)
return run_background(store, engine)
def run_tray(store: ConfigStore) -> int:
"""Lance l'application en arrière-plan avec l'icône de notification."""
# Import différé pour garantir que --ask / les tests fonctionnent même si
# pystray ou PIL ne sont pas installés / sans affichage graphique.
def run_background(store: ConfigStore, engine: AITypewriterEngine) -> int:
"""Lance la fenêtre unifiée + l'icône de notification.
- Le thread principal crée ``MainWindow`` (racine Tk) puis entre dans
``mainloop``.
- L'icône de notification (pystray) tourne dans un thread dédié.
- Le raccourci global est enregistré via ``keyboard`` (thread d'écoute
interne).
"""
# -- raccourci global (thread d'écoute interne à keyboard) ----------------
hotkey = store.hotkey
try:
bind_hotkey(engine, hotkey)
LOG.info("Raccourci global actif : %s", hotkey)
except Exception as exc:
LOG.exception("Impossible d'enregistrer le raccourci : %s", exc)
# -- fenêtre unifiée (onglets) — thread principal -------------------------
from .ui.main_window import MainWindow
root = MainWindow(store)
# -- icône de notification (thread dédié) ---------------------------------
from .tray import TrayApp
app = TrayApp(store)
app.start()
stop_event = threading.Event()
tray = TrayApp(store, master=root, stop_event=stop_event)
ui_thread = threading.Thread(target=tray.run, daemon=True, name="ui-tray")
ui_thread.start()
# Maintient le processus en vie ; pystray run() tourne déjà dans un thread.
# -- signaux d'arrêt propre ----------------------------------------------
def _handle_signal(signum, frame):
LOG.info("Signal %s reçu, arrêt…", signum)
stop_event.set()
try:
root.after(0, root.destroy)
except Exception:
pass
signal.signal(signal.SIGINT, _handle_signal)
signal.signal(signal.SIGTERM, _handle_signal)
LOG.info("Fenêtre principale affichée (onglets Profils / Authentification / Logs).")
try:
# Boucle événementielle tant que l'application n'est pas arrêtée.
while True:
import time
time.sleep(3600)
root.mainloop()
except KeyboardInterrupt:
app.stop()
pass
LOG.info("Arrêt demandé.")
tray.stop()
ui_thread.join(timeout=3)
LOG.info("Application terminée.")
return 0
+6 -2
View File
@@ -73,12 +73,16 @@ def setup_file_logging(level: int = logging.INFO) -> Path:
handler = logging.handlers.RotatingFileHandler(
path, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8"
)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
handler.setFormatter(
logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s [tid:%(thread)d:%(threadName)s] %(message)s"
)
)
logging.getLogger().addHandler(handler)
return path
def setup_logging(level: int = logging.INFO) -> Path:
def setup_logging(level: int = logging.DEBUG) -> Path:
broadcaster().install(level)
return setup_file_logging(level)
+133 -100
View File
@@ -1,102 +1,117 @@
"""Application dans la zone de notification (icône dans la barre des tâches).
"""Icône de zone de notification — réaffiche la fenêtre unifiée dans le bon onglet.
L'application tourne en arrière-plan : aucune fenêtre visible au démarrage,
seule une icône dans la zone de notification. Le menu de l'icône permet :
- Ouvrir les logs (fenêtre des journaux en temps réel)
- Modifier le profil (choisir parmi la liste des profils disponibles)
- Ajouter un profil (formulaire)
- Gérer l'authentification (clés d'API sécurisées)
- Quitter
La fenêtre principale unique (onglets Profils / Authentification / Logs) vit
dans le **processus principal**, racine ``tk.Tk`` sur le thread principal.
L'icône pystray tourne dans un **thread dédié** et ne fait qu'ordonner des
actions à la fenêtre via ``master.after(0, …)`` — la seule façon sûre de
toucher à Tk depuis un autre thread.
La fenêtre racine Tk est créée de manière invisible et sert uniquement de
référence pour les dialogues ; l'icône est pilotée par pystray.
* **Fermer la fenêtre (✕)** → ``withdraw()`` : l'app continue de tourner et
l'icône reste visible.
* **Ouvrir les logs / Ajouter un profil / Gérer l'authentification** →
réaffiche la fenêtre et sélectionne l'onglet correspondant.
* **Modifier le profil (sous-menu radio)** → bascule le profil actif
directement, **sans** afficher la fenêtre.
* **Quitter** → arrête l'icône et détruit la fenêtre.
"""
from __future__ import annotations
import logging
import threading
import tkinter as tk
from typing import Callable
from .config import ConfigStore
from .credentials import SecureStore
from .engine import AITypewriterEngine, bind_hotkey
from .ui.auth_dialog import AuthDialog
from .ui.logs_window import LogsWindow
from .ui.profile_dialog import ProfileDialog
from .ui.main_window import MainWindow, TAB_AUTH, TAB_LOGS, TAB_PROFILES
LOG = logging.getLogger("ai_typewriter.tray")
class TrayApp:
"""Encapsule l'icône de zone de notification + l'UI Tk."""
"""Encapsule l'icône de zone de notification (pystray)."""
def __init__(
self,
store: ConfigStore,
secure: SecureStore | None = None,
master: MainWindow | None = None,
icon_factory: Callable | None = None,
menu_factory: Callable | None = None,
stop_event: threading.Event | None = None,
) -> None:
self.store = store
self.secure = secure or SecureStore()
self.engine = AITypewriterEngine(store, self.secure)
self._root: tk.Tk | None = None
self._root_lock = threading.Lock()
self.master = master
self._icon = None
self._icon_thread: threading.Thread | None = None
self._icon_factory = icon_factory
self._menu_factory = menu_factory
self._stop_event = stop_event
# -- fenêtre racine cachée (pour les dialogues) --------------------------
# -- cycle de vie -----------------------------------------------------------
def get_root(self) -> tk.Tk:
with self._root_lock:
if self._root is None:
self._root = tk.Tk()
self._root.withdraw()
return self._root
def run(self) -> None:
"""Lance la boucle pystray (bloquant, appelé depuis le thread UI)."""
icon = self._icon if self._icon is not None else self.build_icon()
self._icon = icon
LOG.info("Icône de notification lancée (pystray).")
icon.run()
# -- actions du menu -------------------------------------------------------
def show_logs(self) -> None:
LogsWindow(self.get_root())
def edit_profile(self, name: str | None = None) -> None:
"""Ouvre l'éditeur du profil `name`, ou le profil actif si `None`."""
store = self.store
target = store.get(name) if name else store.active()
dlg = ProfileDialog(self.get_root(), existing=target, secure=self.secure)
self.get_root().wait_window(dlg)
if dlg.result:
def stop(self, icon=None, item=None) -> None:
"""Arrête l'icône et demande la fermeture de la fenêtre principale."""
LOG.info("Arrêt de l'icône de notification.")
if self._icon is not None:
try:
store.upsert(dlg.result)
store.set_active(dlg.result.name)
except Exception as exc:
LOG.exception("Impossible d'enregistrer le profil : %s", exc)
def add_profile(self) -> None:
dlg = ProfileDialog(self.get_root(), existing=None, secure=self.secure)
self.get_root().wait_window(dlg)
if dlg.result:
self._icon.stop()
except Exception:
pass
if self._stop_event is not None:
self._stop_event.set()
if self.master is not None:
try:
self.store.upsert(dlg.result)
LOG.info("Profil « %s » ajouté.", dlg.result.name)
except Exception as exc:
LOG.exception("Impossible d'ajouter le profil : %s", exc)
self.master.after(0, self.master.destroy)
except Exception:
pass
def set_active(self, name: str) -> None:
# -- actions du menu --------------------------------------------------------
def _show_logs(self) -> None:
self._show_tab(TAB_LOGS)
def _add_profile(self) -> None:
self._marshal(self.master.open_new_profile)
self._show_tab(TAB_PROFILES)
def _manage_auth(self) -> None:
self._show_tab(TAB_AUTH)
def _show_tab(self, name: str) -> None:
self._marshal(lambda: self.master.show_tab(name))
def _set_active(self, name: str) -> None:
try:
self.store.set_active(name)
LOG.info("Profil actif : %s", name)
self._refresh_menu()
self._marshal(self.master.refresh_all)
except Exception as exc:
LOG.exception("Impossible de sélectionner le profil : %s", exc)
def manage_auth(self) -> None:
AuthDialog(self.get_root(), self.secure)
def _marshal(self, fn: Callable[[], None]) -> None:
"""Exécute ``fn`` sur le thread Tk via ``master.after(0, …)``."""
if self.master is None:
return
try:
self.master.after(0, fn)
except Exception:
LOG.debug("Ordre Tk impossible (fenêtre détruite ?)", exc_info=True)
# -- construction de l'icône ----------------------------------------------
def _refresh_menu(self) -> None:
"""Demande à pystray de reconstruire le menu (met à jour les coches)."""
if self._icon is not None:
try:
self._icon.update_menu()
except Exception:
LOG.debug("update_menu() a échoué", exc_info=True)
# -- construction de l'icône ------------------------------------------------
def build_icon(self):
import pystray
@@ -109,24 +124,24 @@ class TrayApp:
return img
menu_items = []
menu_items.append(self._menu_item("Ouvrir les logs", self.show_logs))
menu_items.append(self._menu_item("Ajouter un profil", self.add_profile))
# Sous-menu des profils
profiles_sub = pystray.Menu(
*[
self._menu_item(
p.name + (" ✓" if p.name == self.store.active_name else ""),
lambda n=p.name: self.set_active(n),
)
for p in self.store.get_all()
]
menu_items.append(
self._menu_item("Ouvrir les logs", self._guard("Ouvrir les logs", self._show_logs))
)
menu_items.append(
self._menu_item("Ajouter un profil", self._guard("Ajouter un profil", self._add_profile))
)
profiles_sub = pystray.Menu(self._profile_menu_items)
menu_items.append(self._menu_item("Modifier le profil", None, submenu=profiles_sub))
menu_items.append(pystray.Menu.SEPARATOR)
menu_items.append(self._menu_item("Gérer l'authentification", self.manage_auth))
menu_items.append(
self._menu_item(
"Gérer l'authentification",
self._guard("Gérer l'authentification", self._manage_auth),
)
)
menu_items.append(pystray.Menu.SEPARATOR)
menu_items.append(self._menu_item("Quitter", self.stop))
menu_items.append(self._menu_item("Quitter", self._guard("Quitter", self.stop)))
if self._menu_factory:
return self._menu_factory(_image, menu_items)
@@ -137,40 +152,58 @@ class TrayApp:
pystray.Menu(*menu_items),
)
def _profile_menu_items(self):
"""Génère dynamiquement les items du sous-menu des profils.
Appelé par pystray à chaque (re)construction du menu : la coche
(via ``checked=``, un item radio) suit donc toujours le profil actif.
"""
import pystray
for p in self.store.get_all():
yield pystray.MenuItem(
p.name,
self._guard(
f"sélection du profil « {p.name} »",
self._select_profile_action(p),
),
checked=self._make_checked(p.name),
radio=True,
)
def _make_checked(self, profile_name: str) -> Callable:
"""Retourne un prédicat évalué à l'affichage du menu."""
return lambda item: self.store.active_name == profile_name
def _menu_item(self, text: str, action, submenu=None):
import pystray
if submenu is not None:
return pystray.MenuItem(text, None, submenu=submenu)
return pystray.MenuItem(text, action or (lambda icon, item: None))
return pystray.MenuItem(text, submenu)
if action is None:
action = lambda icon, item: None
return pystray.MenuItem(text, action)
# -- cycle de vie -----------------------------------------------------------
def _guard(self, label: str, action: Callable) -> Callable:
"""Enveloppe une action de menu avec journalisation et capture d'erreurs.
def start(self) -> None:
"""Attache le raccourci global et lance l'icône dans un thread."""
hotkey = self.store.hotkey
try:
bind_hotkey(self.engine, hotkey)
LOG.info("Raccourci global actif : %s", hotkey)
except Exception as exc:
LOG.exception("Impossible d'enregistrer le raccourci : %s", exc)
pystray invoque les actions avec ``(icon, item)``. Un handler à 2
params est appelé tel quel, sans reshufflage. ``self.stop`` accepte
ces deux paramètres ; les autres actions sont des closures sans args.
"""
icon = self._icon if self._icon is not None else self.build_icon()
self._icon = icon
self._icon_thread = threading.Thread(target=icon.run, daemon=True)
self._icon_thread.start()
LOG.info("Application lancée en arrière-plan (icône zone de notification).")
def stop(self, icon=None, item=None) -> None:
LOG.info("Arrêt de l'application.")
if self._root is not None:
def handler(icon, item) -> None:
LOG.debug("Clic menu → %s", label)
try:
self._root.destroy()
except tk.TclError:
pass
if self._icon is not None:
try:
self._icon.stop()
action()
except Exception:
pass
raise SystemExit(0)
LOG.exception("Erreur lors de l'action : %s", label)
return handler
def _select_profile_action(self, profile) -> Callable:
def action() -> None:
LOG.debug("Sélection du profil « %s » demandée", profile.name)
self._set_active(profile.name)
return action
+1 -1
View File
@@ -1 +1 @@
"""Interface graphique (Tkinter) de l'application."""
"""Interface graphique (Tkinter) de l'application — fenêtre unifiée à onglets."""
@@ -1,9 +1,8 @@
"""Fenêtre de gestion des authentifications des fournisseurs.
"""Onglet « Authentification » : gestion des clés d'API des fournisseurs.
Permet d'enregistrer les clés d'API par nom de « référence » (celui que portent
les profils). Les secrets sont conservés de façon sécurisée dans le gestionnaire
d'identifiants de Windows via credentials.SecureStore ; ils ne sont jamais
affichés ni écrits en clair dans un fichier.
les profils). Les secrets sont conservés de façon sécurisée via
``credentials.SecureStore`` ; ils ne sont jamais affichés ni écrits en clair.
"""
from __future__ import annotations
@@ -15,17 +14,20 @@ from tkinter import messagebox, ttk
from ..credentials import CredentialError, SecureStore
from ..model_catalog import list_providers
LOG = logging.getLogger("ai_typewriter.ui.auth_dialog")
LOG = logging.getLogger("ai_typewriter.ui.auth_tab")
class AuthDialog(tk.Toplevel):
def __init__(self, parent: tk.Widget, secure: SecureStore | None = None) -> None:
class AuthTab(ttk.Frame):
def __init__(
self,
parent: ttk.Widget,
secure: SecureStore | None = None,
store=None,
) -> None:
super().__init__(parent)
self.title("Gérer l'authentification des fournisseurs")
self.secure = secure or SecureStore()
self.geometry("460x360")
self.transient(parent)
self.grab_set()
self.store = store
self._known = ["openai", "openrouter", "gemini", "custom", "ollama"]
body = ttk.Frame(self, padding=10)
body.pack(fill="both", expand=True)
@@ -33,15 +35,15 @@ class AuthDialog(tk.Toplevel):
ttk.Label(
body,
text=(
"Les clés sont enregistrées dans le gestionnaire d'identifiants "
"de Windows, pas dans un fichier de configuration.\n"
"Les clés sont enregistrées dans le gestionnaire d'identifiants, "
"pas dans un fichier de configuration.\n"
"Chaque profil référence une clé par son « nom de référence »."
),
foreground="#555",
justify="left",
).pack(fill="x", pady=(0, 8))
# -- formulaire ----------------------------------------------------------
# -- formulaire ---------------------------------------------------------
form = ttk.LabelFrame(body, text="Nouvelle / mise à jour d'une référence", padding=8)
form.pack(fill="x")
ttk.Label(form, text="Nom de la référence (fournisseur)").grid(row=0, column=0, sticky="w")
@@ -63,7 +65,7 @@ class AuthDialog(tk.Toplevel):
row=2, column=1, sticky="e", pady=(4, 0)
)
# -- liste des références existantes -------------------------------------
# -- liste des références existantes ------------------------------------
frm_list = ttk.LabelFrame(body, text="Références existantes", padding=8)
frm_list.pack(fill="both", expand=True, pady=(10, 0))
self.listbox = tk.Listbox(frm_list, height=5)
@@ -73,18 +75,14 @@ class AuthDialog(tk.Toplevel):
ttk.Button(row, text="Vérifier", command=self._has).pack(side="left")
ttk.Button(row, text="Supprimer", command=self._delete).pack(side="right")
self._known = ["openai", "openrouter", "gemini", "custom", "ollama"]
self._refresh_list()
# -- helpers ------------------------------------------------------------------
# -- helpers ---------------------------------------------------------------
def _refresh_list(self) -> None:
self.listbox.delete(0, tk.END)
# En l'absence d'énumération dans keyring, on propose les références
# typiques et on laisse l'utilisateur vérifier leur existence.
known = sorted(
set(self._known)
| {p.get("credential", "") for p in self._known_profiles()}
set(self._known) | {p.credential for p in self._known_profiles() if p.credential}
)
known = [k for k in known if k]
for k in known:
@@ -92,6 +90,11 @@ class AuthDialog(tk.Toplevel):
self.listbox.insert(tk.END, f"{status} {k}")
def _known_profiles(self) -> list:
if self.store is not None:
try:
return self.store.get_all()
except Exception:
return []
from ..config import ConfigStore
store = ConfigStore()
@@ -122,7 +125,7 @@ class AuthDialog(tk.Toplevel):
sel = self.listbox.curselection()
if not sel:
return
name = self.listbox.get(sel[0]).split(" ", 1)[-1]
name = self._selected_name()
if self.secure.has(name):
messagebox.showinfo("Présente", f"Une clé est enregistrée pour « {name} ».", parent=self)
else:
@@ -134,11 +137,15 @@ class AuthDialog(tk.Toplevel):
sel = self.listbox.curselection()
if not sel:
return
name = self.listbox.get(sel[0]).split(" ", 1)[-1]
name = self._selected_name()
try:
self.secure.delete(name)
except CredentialError as exc:
messagebox.showerror("Suppression impossible", str(exc), parent=self)
return
self._refresh_list()
LOG.info("Référence « %s » supprimée.", name)
LOG.info("Référence « %s » supprimée.", name)
def _selected_name(self) -> str:
sel = self.listbox.curselection()
return self.listbox.get(sel[0]).split(" ", 1)[-1]
@@ -1,7 +1,9 @@
"""Fenêtre des journaux en temps réel.
"""Onglet « Logs » : journaux en temps réel.
S'abonne au `LogBroadcaster` du module logging_utils et affiche les messages
au fur et à mesure qu'ils sont émis par l'application.
au fur et à mesure qu'ils sont émis par l'application. Tourne dans le même
processus que le moteur, donc reçoit réellement les enregistrements (contraire
à l'ancienne fenêtre lancée dans un processus séparé).
"""
from __future__ import annotations
@@ -13,12 +15,9 @@ from tkinter import ttk
from .. import logging_utils
class LogsWindow(tk.Toplevel):
def __init__(self, parent: tk.Widget) -> None:
class LogsTab(ttk.Frame):
def __init__(self, parent: ttk.Widget) -> None:
super().__init__(parent)
self.title("AI-Typewriter — Journaux")
self.geometry("640x420")
self.transient(parent)
txt = tk.Text(self, state="disabled", wrap="word")
scroll = ttk.Scrollbar(self, command=txt.yview)
@@ -30,7 +29,6 @@ class LogsWindow(tk.Toplevel):
bar = ttk.Frame(self)
bar.pack(fill="x", padx=6, pady=4)
ttk.Button(bar, text="Vider l'affichage", command=self._clear).pack(side="left")
ttk.Button(bar, text="Fermer", command=self.destroy).pack(side="right")
self._history: list[str] = []
self._append_pending(logging_utils.broadcaster().drain())
+94
View File
@@ -0,0 +1,94 @@
"""Fenêtre principale unifiée à onglets.
Une seule racine ``tk.Tk`` regroupe trois onglets :
* **Profils** — liste + éditeur de profils ;
* **Authentification** — gestion des clés d'API ;
* **Logs** — journaux en temps réel.
Fermer la fenêtre (bouton ✕) ne quitte **pas** l'application : elle est
masquée (``withdraw``) et continue de tourner ; l'icône de la zone de
notification permet de la réafficher dans le bon onglet.
"""
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
from ..config import ConfigStore
from ..credentials import SecureStore
from .auth_tab import AuthTab
from .logs_tab import LogsTab
from .profiles_tab import ProfilesTab
TAB_PROFILES = "profiles"
TAB_AUTH = "auth"
TAB_LOGS = "logs"
_TAB_INDEX = {TAB_PROFILES: 0, TAB_AUTH: 1, TAB_LOGS: 2}
class MainWindow(tk.Tk):
def __init__(self, store: ConfigStore, secure: SecureStore | None = None) -> None:
super().__init__()
self.store = store
self.secure = secure or SecureStore()
self.geometry("860x660")
self.minsize(720, 520)
self.protocol("WM_DELETE_WINDOW", self.hide_to_tray)
self.notebook = ttk.Notebook(self)
self.notebook.pack(fill="both", expand=True)
self.profiles_tab = ProfilesTab(
self.notebook, store, self.secure, on_change=self._refresh_title
)
self.auth_tab = AuthTab(self.notebook, secure=self.secure, store=store)
self.logs_tab = LogsTab(self.notebook)
self.notebook.add(self.profiles_tab, text="Profils")
self.notebook.add(self.auth_tab, text="Authentification")
self.notebook.add(self.logs_tab, text="Logs")
self._refresh_title()
# -- cycle de vie -----------------------------------------------------------
def hide_to_tray(self) -> None:
"""Masque la fenêtre (l'application continue de tourner en fond)."""
self.withdraw()
def show_tab(self, name: str) -> None:
"""Réaffiche la fenêtre et sélectionne l'onglet ``name``."""
self.deiconify()
idx = _TAB_INDEX[name]
self.notebook.select(idx)
self._present()
def open_new_profile(self) -> None:
"""Prépare l'onglet Profils pour créer un nouveau profil."""
self.profiles_tab.new_profile()
def _present(self) -> None:
self.lift()
try:
self.attributes("-topmost", True)
self.after(200, lambda: self.attributes("-topmost", False))
except tk.TclError:
pass
try:
self.focus_force()
except tk.TclError:
pass
# -- synchronisation --------------------------------------------------------
def refresh_all(self) -> None:
"""Met à jour la liste des profils et le titre après un changement externe."""
self.profiles_tab.refresh_list()
self._refresh_title()
def _refresh_title(self) -> None:
self.title(f"AI-Typewriter — {self.store.active_name}")
-211
View File
@@ -1,211 +0,0 @@
"""Boîte de dialogue de création / édition d'un profil.
Dans le menu « Ajouter un profil », on demande les différents éléments d'un
profil : nom, fournisseur, modèle (avec sélecteur), URL serveur, clé/identifiant
de référence, prompt système et réglages d'équations LaTeX.
"""
from __future__ import annotations
import logging
import tkinter as tk
from tkinter import messagebox, ttk
from ..config import Profile, math_latex_profile
from ..credentials import CredentialError, SecureStore
from ..model_catalog import list_providers
from .model_picker import ModelPicker
LOG = logging.getLogger("ai_typewriter.ui.profile_dialog")
class ProfileDialog(tk.Toplevel):
"""Fenêtre modale d'ajout/édition de profil.
Attribut `result` : Profile créé/modifié, ou None si annulé.
"""
def __init__(
self,
parent: tk.Widget,
existing: Profile | None = None,
secure: SecureStore | None = None,
) -> None:
super().__init__(parent)
self.title("Ajouter un profil" if existing is None else "Modifier le profil")
self.secure = secure or SecureStore()
self.result: Profile | None = None
self.geometry("540x640")
self.transient(parent)
self.grab_set()
body = ttk.Frame(self, padding=12)
body.pack(fill="both", expand=True)
# -- identité ---------------------------------------------------------
ttk.Label(body, text="Nom du profil *").grid(row=0, column=0, sticky="w")
self.name_var = tk.StringVar(value=existing.name if existing else "")
ttk.Entry(body, textvariable=self.name_var, width=38).grid(row=0, column=1, sticky="we", pady=4)
# -- fournisseur ------------------------------------------------------
ttk.Label(body, text="Fournisseur *").grid(row=1, column=0, sticky="w")
self.provider_var = tk.StringVar(value=existing.provider if existing else "ollama")
self.provider_combo = ttk.Combobox(
body,
textvariable=self.provider_var,
values=[p["label"] for p in list_providers()],
state="readonly",
width=36,
)
self.provider_combo.grid(row=1, column=1, sticky="we", pady=4)
self.provider_combo.bind("<<ComboboxSelected>>", lambda e: self._provider_changed())
# -- modèle ------------------------------------------------------------
ttk.Label(body, text="Modèle *").grid(row=2, column=0, sticky="w")
self.model_var = tk.StringVar(value=existing.model if existing else "llama3.1")
ttk.Entry(body, textvariable=self.model_var, width=30).grid(row=2, column=1, sticky="we", pady=4)
ttk.Button(body, text="Choisir / télécharger…", command=self._open_picker).grid(
row=2, column=2, sticky="e", padx=(4, 0)
)
# -- URL serveur ------------------------------------------------------
ttk.Label(body, text="URL du serveur").grid(row=3, column=0, sticky="w")
self.server_url_var = tk.StringVar(
value=existing.server_url
if existing and existing.server_url
else "http://localhost:11434"
)
ttk.Entry(body, textvariable=self.server_url_var, width=38).grid(row=3, column=1, sticky="we", pady=4)
# -- référence de clé -------------------------------------------------
ttk.Label(body, text="Nom de la référence (clé API)").grid(row=4, column=0, sticky="w")
self.credential_var = tk.StringVar(value=existing.credential if existing else "")
ttk.Entry(body, textvariable=self.credential_var, width=38).grid(row=4, column=1, sticky="we", pady=4)
ttk.Label(
body,
text="Référence enregistrée dans le gestionnaire\nd'identifiants de Windows (via « Gérer l'authentification »).",
foreground="#666",
).grid(row=4, column=2, sticky="w", padx=6)
# -- prompt système ----------------------------------------------------
ttk.Label(body, text="Prompt système").grid(row=5, column=0, sticky="nw")
self.prompt_text = tk.Text(body, width=48, height=7, wrap="word")
self.prompt_text.grid(row=5, column=1, columnspan=2, sticky="we", pady=4)
# -- équations ---------------------------------------------------------
fr_eq = ttk.LabelFrame(body, text="Équations LaTeX", padding=6)
fr_eq.grid(row=6, column=0, columnspan=3, sticky="we", pady=6)
self.equation_var = tk.BooleanVar(value=existing.equation_enabled if existing else False)
ttk.Checkbutton(
fr_eq,
text="Intercepter les marqueurs et déclencher les touches (ex. Alt+= / →)",
variable=self.equation_var,
command=self._eq_toggle,
).grid(row=0, column=0, columnspan=3, sticky="w")
self.eq_start_var = tk.StringVar(
value=(existing.eq_start_marker if existing else "[EQ]")
)
self.eq_end_var = tk.StringVar(
value=(existing.eq_end_marker if existing else "[/EQ]")
)
ttk.Label(fr_eq, text="Début:").grid(row=1, column=0, sticky="e")
ttk.Entry(fr_eq, textvariable=self.eq_start_var, width=14).grid(row=1, column=1, sticky="w")
ttk.Label(fr_eq, text="Fin:").grid(row=1, column=2, sticky="e", padx=(8, 0))
ttk.Entry(fr_eq, textvariable=self.eq_end_var, width=14).grid(row=1, column=3, sticky="w")
# Case « Profil math prédéfini »
ttk.Button(fr_eq, text="Préremplir (profil math)", command=self._prefill_math).grid(
row=2, column=0, columnspan=4, sticky="w", pady=(4, 0)
)
# -- boutons -----------------------------------------------------------
btns = ttk.Frame(body)
btns.grid(row=7, column=0, columnspan=3, sticky="e", pady=(8, 0))
ttk.Button(btns, text="Annuler", command=self.destroy).pack(side="right")
ttk.Button(btns, text="Enregistrer", command=self._save).pack(side="right", padx=4)
self._set_prompt(existing.system_prompt if existing else "")
self._provider_changed()
self._eq_toggle()
# -- helpers --------------------------------------------------------------
def _set_prompt(self, value: str) -> None:
self.prompt_text.delete("1.0", tk.END)
self.prompt_text.insert("1.0", value)
def _provider_changed(self) -> None:
label = self.provider_var.get()
for p in list_providers():
if p["label"] == label:
base = p.get("base_url") or ""
if base and not self.server_url_var.get():
self.server_url_var.set(base)
if not p["needs_key"]:
pass
# ON MET À JOUR le libellé du bouton selon le fournisseur
self._update_model_hint()
def _update_model_hint(self) -> None:
for p in list_providers():
if p["label"] == self.provider_var.get():
if p["id"] == "ollama":
self.server_url_var.set(self.server_url_var.get() or "http://localhost:11434")
def _provider_id(self) -> str:
for p in list_providers():
if p["label"] == self.provider_var.get():
return p["id"]
return "ollama"
def _open_picker(self) -> None:
picker = ModelPicker(self, server_url=self.server_url_var.get(), initial=self.model_var.get())
self.wait_window(picker)
if picker.result:
self.model_var.set(picker.result)
def _eq_toggle(self) -> None:
# La case contrôle l'activation de l'interception ; les champs de
# marqueurs restent renseignés pour être réutilisés si l'on bascule
# plus tard. Rien d'autre à faire ici (l'activation se lit depuis
# self.equation_var lors de l'enregistrement).
pass
def _prefill_math(self) -> None:
m = math_latex_profile(name=self.name_var.get() or "Mathématiques (LaTeX)")
self.equation_var.set(True)
self.eq_start_var.set(m.eq_start_marker)
self.eq_end_var.set(m.eq_end_marker)
self._set_prompt(m.system_prompt)
if not self.provider_var.get():
self.provider_var.set("Ollama (local)")
self.model_var.set(m.model)
self._eq_toggle()
def _save(self) -> None:
name = self.name_var.get().strip()
if not name:
messagebox.showerror("Nom requis", "Le profil doit avoir un nom.", parent=self)
return
model = self.model_var.get().strip() or "llama3.1"
profile = Profile(
name=name,
provider=self._provider_id(),
model=model,
server_url=self.server_url_var.get().strip(),
request_timeout_seconds=300.0,
type_delay_seconds=0.0,
system_prompt=self.prompt_text.get("1.0", tk.END).strip(),
equation_enabled=self.equation_var.get(),
eq_start_marker=self.eq_start_var.get().strip(),
eq_end_marker=self.eq_end_var.get().strip(),
credential=self.credential_var.get().strip() or "",
)
# Vérifie que la référence de clé est bien présente si le fournisseur
# en exige une ET qu'une clé est requise.
needs_key = self._provider_id() != "ollama"
if needs_key and profile.credential and not self.secure.has(profile.credential):
LOG.info("Profil %s : référence %s non encore enregistrée.", name, profile.credential)
self.result = profile
self.destroy()
+326
View File
@@ -0,0 +1,326 @@
"""Onglet « Profils » : liste des profils + éditeur de profil intégré.
Remplace l'ancienne fenêtre modale ``ProfileDialog``. La liste (à gauche)
affiche le profil actif avec un marqueur « ● » ; l'éditeur (à droite) permet
de créer ou modifier un profil. Les actions principales (Activer, Nouveau,
Supprimer, Enregistrer) sont directement accessibles dans l'onglet.
"""
from __future__ import annotations
import logging
import tkinter as tk
from tkinter import messagebox, ttk
from ..config import ConfigStore, Profile, math_latex_profile
from ..credentials import SecureStore
from ..model_catalog import list_providers
from .model_picker import ModelPicker
LOG = logging.getLogger("ai_typewriter.ui.profiles_tab")
class ProfilesTab(ttk.Frame):
"""Liste des profils + formulaire d'édition, le tout dans un onglet."""
def __init__(
self,
parent: ttk.Widget,
store: ConfigStore,
secure: SecureStore | None = None,
on_change=None,
) -> None:
super().__init__(parent)
self.store = store
self.secure = secure or SecureStore()
self.on_change = on_change
self._names: list[str] = []
self._building = False
self._build()
self.refresh_list()
# -- construction ----------------------------------------------------------
def _build(self) -> None:
left = ttk.Frame(self, padding=(10, 10, 4, 10))
left.pack(side="left", fill="y")
ttk.Label(left, text="Profils", font=("TkDefaultFont", 10, "bold")).pack(
anchor="w"
)
self.listbox = tk.Listbox(left, width=28, height=16, exportselection=False)
self.listbox.pack(fill="both", expand=True, pady=(6, 0))
self.listbox.bind("<<ListboxSelect>>", self._on_select)
rows = ttk.Frame(left)
rows.pack(fill="x", pady=(6, 0))
ttk.Button(rows, text="Activer", command=self._activate).pack(
side="left", fill="x", expand=True
)
ttk.Button(rows, text="Nouveau", command=self.new_profile).pack(
side="left", fill="x", expand=True, padx=2
)
ttk.Button(rows, text="Supprimer", command=self._delete).pack(
side="left", fill="x", expand=True
)
f = ttk.Frame(self, padding=(4, 10, 10, 10))
f.pack(side="left", fill="both", expand=True)
ttk.Label(f, text="Nom *").grid(row=0, column=0, sticky="w", padx=(0, 6), pady=4)
self.name_var = tk.StringVar()
ttk.Entry(f, textvariable=self.name_var, width=38).grid(
row=0, column=1, columnspan=2, sticky="we", pady=4
)
ttk.Label(f, text="Fournisseur *").grid(row=1, column=0, sticky="w", padx=(0, 6), pady=4)
self.provider_var = tk.StringVar(value=self._label_for_id("ollama"))
self.provider_combo = ttk.Combobox(
f,
textvariable=self.provider_var,
values=[p["label"] for p in list_providers()],
state="readonly",
width=34,
)
self.provider_combo.grid(row=1, column=1, columnspan=2, sticky="we", pady=4)
self.provider_combo.bind("<<ComboboxSelected>>", lambda e: self._provider_changed())
ttk.Label(f, text="Modèle *").grid(row=2, column=0, sticky="w", padx=(0, 6), pady=4)
self.model_var = tk.StringVar(value="llama3.1")
ttk.Entry(f, textvariable=self.model_var, width=28).grid(
row=2, column=1, sticky="we", pady=4
)
ttk.Button(f, text="Choisir / télécharger…", command=self._open_picker).grid(
row=2, column=2, sticky="e", padx=(4, 0)
)
ttk.Label(f, text="URL du serveur").grid(row=3, column=0, sticky="w", padx=(0, 6), pady=4)
self.server_url_var = tk.StringVar(value="http://localhost:11434")
ttk.Entry(f, textvariable=self.server_url_var, width=38).grid(
row=3, column=1, columnspan=2, sticky="we", pady=4
)
ttk.Label(f, text="Référence (clé API)").grid(row=4, column=0, sticky="w", padx=(0, 6), pady=4)
self.credential_var = tk.StringVar()
ttk.Entry(f, textvariable=self.credential_var, width=38).grid(
row=4, column=1, columnspan=2, sticky="we", pady=4
)
ttk.Label(f, text="Prompt système").grid(row=5, column=0, sticky="nw", padx=(0, 6), pady=4)
self.prompt_text = tk.Text(f, width=48, height=7, wrap="word")
self.prompt_text.grid(row=5, column=1, columnspan=2, sticky="we", pady=4)
fr_eq = ttk.LabelFrame(f, text="Équations LaTeX", padding=6)
fr_eq.grid(row=6, column=0, columnspan=3, sticky="we", pady=6)
self.equation_var = tk.BooleanVar(value=False)
ttk.Checkbutton(
fr_eq,
text="Intercepter les marqueurs et déclencher les touches (ex. Alt+= / →)",
variable=self.equation_var,
).grid(row=0, column=0, columnspan=4, sticky="w")
self.eq_start_var = tk.StringVar(value="[EQ]")
self.eq_end_var = tk.StringVar(value="[/EQ]")
ttk.Label(fr_eq, text="Début:").grid(row=1, column=0, sticky="e")
ttk.Entry(fr_eq, textvariable=self.eq_start_var, width=14).grid(
row=1, column=1, sticky="w"
)
ttk.Label(fr_eq, text="Fin:").grid(row=1, column=2, sticky="e", padx=(8, 0))
ttk.Entry(fr_eq, textvariable=self.eq_end_var, width=14).grid(
row=1, column=3, sticky="w"
)
ttk.Button(fr_eq, text="Préremplir (profil math)", command=self._prefill_math).grid(
row=2, column=0, columnspan=4, sticky="w", pady=(4, 0)
)
btns = ttk.Frame(f)
btns.grid(row=7, column=0, columnspan=3, sticky="e", pady=(8, 0))
ttk.Button(btns, text="Effacer", command=self.new_profile).pack(side="right")
ttk.Button(btns, text="Enregistrer", command=self._save).pack(
side="right", padx=4
)
# -- liste des profils ------------------------------------------------------
def refresh_list(self, select: str | None = None) -> None:
"""Reconstruit la liste et charge le profil ``select`` (ou l'actif)."""
active = self.store.active_name
self._names = [p.name for p in self.store.get_all()]
self.listbox.delete(0, tk.END)
target = select if select in self._names else active
idx = self._names.index(target) if target in self._names else 0
for i, name in enumerate(self._names):
marker = "● " if name == active else " "
self.listbox.insert(tk.END, f"{marker}{name}")
self._building = True
try:
self.listbox.selection_clear(0, tk.END)
self.listbox.selection_set(idx)
self.listbox.see(idx)
finally:
self._building = False
if target in self._names:
self._load(target)
def _selected_name(self) -> str | None:
sel = self.listbox.curselection()
if not sel:
return None
return self._names[sel[0]]
def _on_select(self, event=None) -> None:
if self._building:
return
name = self._selected_name()
if name:
self._load(name)
def _load(self, name: str) -> None:
try:
p = self.store.get(name)
except KeyError:
return
self._building = True
try:
self.name_var.set(p.name)
self.provider_var.set(self._label_for_id(p.provider))
self.model_var.set(p.model)
self.server_url_var.set(p.server_url or "http://localhost:11434")
self.credential_var.set(p.credential or "")
self.equation_var.set(p.equation_enabled)
self.eq_start_var.set(p.eq_start_marker)
self.eq_end_var.set(p.eq_end_marker)
self._set_prompt(p.system_prompt)
finally:
self._building = False
def new_profile(self) -> None:
"""Vide le formulaire pour créer un nouveau profil."""
self._building = True
try:
self.listbox.selection_clear(0, tk.END)
finally:
self._building = False
self.name_var.set("")
self.provider_var.set(self._label_for_id("ollama"))
self.model_var.set("llama3.1")
self.server_url_var.set("http://localhost:11434")
self.credential_var.set("")
self.equation_var.set(False)
self.eq_start_var.set("[EQ]")
self.eq_end_var.set("[/EQ]")
self._set_prompt("")
# -- actions ---------------------------------------------------------------
def _activate(self) -> None:
name = self._selected_name()
if not name:
return
try:
self.store.set_active(name)
LOG.info("Profil actif : %s", name)
self.refresh_list(select=name)
self._notify_change()
except Exception as exc:
LOG.exception("Impossible d'activer le profil : %s", exc)
messagebox.showerror("Activation impossible", str(exc), parent=self)
def _delete(self) -> None:
name = self._selected_name()
if not name:
return
if not messagebox.askyesno(
"Supprimer", f"Supprimer le profil « {name} » ?", parent=self
):
return
try:
self.store.remove(name)
LOG.info("Profil « %s » supprimé.", name)
self.refresh_list()
self._notify_change()
except Exception as exc:
LOG.exception("Suppression impossible : %s", exc)
messagebox.showerror("Suppression impossible", str(exc), parent=self)
def _save(self) -> None:
name = self.name_var.get().strip()
if not name:
messagebox.showerror("Nom requis", "Le profil doit avoir un nom.", parent=self)
return
model = self.model_var.get().strip() or "llama3.1"
profile = Profile(
name=name,
provider=self._provider_id(),
model=model,
server_url=self.server_url_var.get().strip(),
request_timeout_seconds=300.0,
type_delay_seconds=0.0,
system_prompt=self.prompt_text.get("1.0", tk.END).strip(),
equation_enabled=self.equation_var.get(),
eq_start_marker=self.eq_start_var.get().strip(),
eq_end_marker=self.eq_end_var.get().strip(),
credential=self.credential_var.get().strip() or "",
)
needs_key = self._provider_id() != "ollama"
if needs_key and profile.credential and not self.secure.has(profile.credential):
LOG.info(
"Profil %s : référence %s non encore enregistrée.",
name,
profile.credential,
)
try:
self.store.upsert(profile)
LOG.info("Profil « %s » enregistré.", name)
self.refresh_list(select=name)
self._notify_change()
except Exception as exc:
LOG.exception("Enregistrement impossible : %s", exc)
messagebox.showerror("Enregistrement impossible", str(exc), parent=self)
# -- helpers ---------------------------------------------------------------
def _notify_change(self) -> None:
if self.on_change:
self.on_change()
def _set_prompt(self, value: str) -> None:
self.prompt_text.delete("1.0", tk.END)
self.prompt_text.insert("1.0", value)
def _provider_id(self) -> str:
for p in list_providers():
if p["label"] == self.provider_var.get():
return p["id"]
return "ollama"
def _label_for_id(self, provider_id: str) -> str:
for p in list_providers():
if p["id"] == provider_id:
return p["label"]
return provider_id
def _provider_changed(self) -> None:
for p in list_providers():
if p["label"] == self.provider_var.get():
base = p.get("base_url") or ""
if base and not self.server_url_var.get().strip():
self.server_url_var.set(base)
break
def _open_picker(self) -> None:
picker = ModelPicker(
self.winfo_toplevel(),
server_url=self.server_url_var.get(),
initial=self.model_var.get(),
)
self.wait_window(picker)
if picker.result:
self.model_var.set(picker.result)
def _prefill_math(self) -> None:
m = math_latex_profile(name=self.name_var.get().strip() or "Mathématiques (LaTeX)")
self.equation_var.set(True)
self.eq_start_var.set(m.eq_start_marker)
self.eq_end_var.set(m.eq_end_marker)
self._set_prompt(m.system_prompt)
self.provider_var.set(self._label_for_id("ollama"))
self.model_var.set(m.model)