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.
This commit is contained in:
Hermes Agent
2026-09-18 14:56:37 +02:00
parent 4adb88fd35
commit 1d13e7c22b
11 changed files with 558 additions and 567 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
+34 -22
View File
@@ -1,20 +1,23 @@
"""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. Deux modes console restent
disponibles pour le développement / le test :
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)
------------------------
* **Processus principal** : capture du raccourci global (Ctrl+Alt+A) et
traitement des requêtes IA. Aucune boucle Tk ne tourne ici.
* **Thread UI (tray)** : icône pystray dans la zone de notification.
* **Fenêtres** : chaque dialogue (logs, profil, authentification, …) est
lancé dans son **propre processus** avec sa **propre instance Tk** racine.
Aucune racine Tk partagée — une fenêtre = un processus = un ``tk.Tk``.
* **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
@@ -75,14 +78,15 @@ def main(argv: list[str] | None = None) -> int:
def run_background(store: ConfigStore, engine: AITypewriterEngine) -> int:
"""Lance l'application en arrière-plan.
"""Lance la fenêtre unifiée + l'icône de notification.
- Le thread principal enregistre le raccourci global et attend l'arrêt.
- Le thread principal crée ``MainWindow`` (racine Tk) puis entre dans
``mainloop``.
- L'icône de notification (pystray) tourne dans un thread dédié.
- Chaque fenêtre Tk est créée dans son propre processus (voir
``ui._tk_spawn.spawn_tk_window``).
- Le raccourci global est enregistré via ``keyboard`` (thread d'écoute
interne).
"""
# -- raccourci global (sur le thread principal, via le hook keyboard) -----
# -- raccourci global (thread d'écoute interne à keyboard) ----------------
hotkey = store.hotkey
try:
bind_hotkey(engine, hotkey)
@@ -90,26 +94,34 @@ def run_background(store: ConfigStore, engine: AITypewriterEngine) -> int:
except Exception as exc:
LOG.exception("Impossible d'enregistrer le raccourci : %s", exc)
# -- icône de notification (thread dédié) --------------------------------
# -- 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
stop_event = threading.Event()
tray = TrayApp(store, stop_event=stop_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()
# -- le thread principal reste en vie jusqu'au signal d'arrêt ------------
# SIGINT / SIGTERM → arrêt propre
# -- 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:
stop_event.wait()
root.mainloop()
except KeyboardInterrupt:
pass
@@ -121,4 +133,4 @@ def run_background(store: ConfigStore, engine: AITypewriterEngine) -> int:
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
raise SystemExit(main(sys.argv[1:]))
+49 -88
View File
@@ -1,22 +1,18 @@
"""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.
Architecture
------------
* **Thread UI** : exécute ``pystray.Icon.run()`` (boucle GTK / appindicator).
* **Fenêtres** : chaque action du menu crée une fenêtre Tk dans un
**processus** dédié avec sa propre racine ``tk.Tk`` (via
``ui._tk_spawn.spawn_tk_window``). Il n'y a **aucune** racine Tk
partagée.
* **Résultats** : les dialogues qui retournent une valeur (ProfileDialog,
ModelPicker) passent leur résultat via un callback exécuté à la fermeture.
* **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
@@ -26,10 +22,7 @@ import threading
from typing import Callable
from .config import ConfigStore
from .ui._tk_spawn import spawn_tk_window
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")
@@ -40,11 +33,13 @@ class TrayApp:
def __init__(
self,
store: ConfigStore,
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.master = master
self._icon = None
self._icon_factory = icon_factory
self._menu_factory = menu_factory
@@ -60,7 +55,7 @@ class TrayApp:
icon.run()
def stop(self, icon=None, item=None) -> None:
"""Arrête l'icône et signale l'arrêt au thread principal."""
"""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:
@@ -69,59 +64,45 @@ class TrayApp:
pass
if self._stop_event is not None:
self._stop_event.set()
if self.master is not None:
try:
self.master.after(0, self.master.destroy)
except Exception:
pass
# -- actions du menu -------------------------------------------------------
# -- actions du menu --------------------------------------------------------
def _show_logs(self) -> None:
spawn_tk_window(LogsWindow)
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()
def on_done(result: object) -> None:
if result is None:
return
try:
store.upsert(result)
store.set_active(result.name)
self._refresh_menu()
except Exception as exc:
LOG.exception("Impossible d'enregistrer le profil : %s", exc)
spawn_tk_window(
ProfileDialog,
existing=target,
on_result=on_done,
)
self._show_tab(TAB_LOGS)
def _add_profile(self) -> None:
store = self.store
self._marshal(self.master.open_new_profile)
self._show_tab(TAB_PROFILES)
def on_done(result: object) -> None:
if result is None:
return
try:
store.upsert(result)
LOG.info("Profil « %s » ajouté.", result.name)
self._refresh_menu()
except Exception as exc:
LOG.exception("Impossible d'ajouter le profil : %s", exc)
def _manage_auth(self) -> None:
self._show_tab(TAB_AUTH)
spawn_tk_window(
ProfileDialog,
on_result=on_done,
)
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 _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)
def _refresh_menu(self) -> None:
"""Demande à pystray de reconstruire le menu (met à jour les coches)."""
if self._icon is not None:
@@ -130,10 +111,7 @@ class TrayApp:
except Exception:
LOG.debug("update_menu() a échoué", exc_info=True)
def _manage_auth(self) -> None:
spawn_tk_window(AuthDialog)
# -- construction de l'icône ----------------------------------------------
# -- construction de l'icône ------------------------------------------------
def build_icon(self):
import pystray
@@ -147,24 +125,13 @@ class TrayApp:
menu_items = []
menu_items.append(
self._menu_item(
"Ouvrir les logs",
self._guard("Ouvrir les logs", self._show_logs),
)
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),
)
self._menu_item("Ajouter un profil", self._guard("Ajouter un profil", self._add_profile))
)
# Sous-menu des profils — construit dynamiquement à chaque affichage
# (grâce au callable), de sorte que la coche « ✓ » et la liste des
# profils reflètent toujours l'état courant après update_menu().
profiles_sub = pystray.Menu(self._profile_menu_items)
menu_items.append(
self._menu_item("Modifier le profil", None, submenu=profiles_sub)
)
menu_items.append(self._menu_item("Modifier le profil", None, submenu=profiles_sub))
menu_items.append(pystray.Menu.SEPARATOR)
menu_items.append(
@@ -174,9 +141,7 @@ class TrayApp:
)
)
menu_items.append(pystray.Menu.SEPARATOR)
menu_items.append(
self._menu_item("Quitter", self._guard("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)
@@ -222,13 +187,9 @@ class TrayApp:
def _guard(self, label: str, action: Callable) -> Callable:
"""Enveloppe une action de menu avec journalisation et capture d'erreurs.
pystray invoque les actions avec ``(icon, item)`` et utilise la
signature pour adapter les arguments. Un handler à 2 params est
appelé tel quel, sans reshufflage.
Contrairement à l'ancienne architecture, on n'a **plus** besoin de
déférer l'exécution vers un thread Tk principal via ``after()`` :
chaque fenêtre tourne dans son propre processus Tk.
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.
"""
def handler(icon, item) -> None:
@@ -245,4 +206,4 @@ class TrayApp:
LOG.debug("Sélection du profil « %s » demandée", profile.name)
self._set_active(profile.name)
return action
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."""
-120
View File
@@ -1,120 +0,0 @@
"""Helper to spawn Tk windows in dedicated **subprocesses** with their own Tk root.
Each call to ``spawn_tk_window()`` launches a fresh Python interpreter via
``subprocess.Popen`` that runs ``ai_typewriter.ui._tk_window_process``.
The child creates its own ``tk.Tk`` (withdrawn), instantiates the window
as a ``tk.Toplevel`` of that root, and runs the Tk event loop. When the
toplevel is closed the root quits, the child exits, and the optional
*on_result* callback is invoked **in the parent process** with
``window.result``.
Communication
-------------
* **Parent → child** : pickled payload written to the child's **stdin**,
containing the fully-qualified class path, positional args, and keyword
args. Stdin is closed immediately after writing.
* **Child → parent** : pickled ``window.result`` written to the child's
**stdout** just before exit. The parent reads it via ``communicate()``
in a background poller thread so the caller is never blocked.
Why subprocess instead of multiprocessing
-----------------------------------------
* No ``multiprocessing`` import overhead or start-method constraints.
* Clean, debuggable separation: the child is a standalone ``python -m``
invocation.
* No pickling restrictions on function targets — the child imports the
window class by its module path.
* ``SecureStore`` instances are **not** passed across the boundary; each
child process creates its own (the dialog classes already default to
``SecureStore()`` when ``secure=None``).
"""
from __future__ import annotations
import os
import pickle
import subprocess
import sys
import threading
from typing import Any, Callable
def spawn_tk_window(
window_class: type,
*win_args: Any,
on_result: Callable[[object], None] | None = None,
**win_kwargs: Any,
) -> subprocess.Popen:
"""Spawn *window_class* in a dedicated **subprocess** with its own Tk root.
Parameters
----------
window_class:
A ``tk.Toplevel`` subclass (defined at module level). Its
``__init__`` must accept a ``parent`` (``tk.Tk``) as the first
positional argument.
*win_args:
Extra positional arguments forwarded to the window constructor
(after the parent).
on_result:
If provided, called **in the parent process** (on a short-lived
poller thread) with the value of ``window.result`` once the
window closes. ``None`` is passed when the attribute is absent
or the child exits abnormally.
**win_kwargs:
Keyword arguments forwarded to the window constructor.
Returns
-------
The ``subprocess.Popen`` instance for the child process.
"""
# -- build the payload ---------------------------------------------------
payload = {
"class": f"{window_class.__module__}.{window_class.__qualname__}",
"args": win_args,
"kwargs": win_kwargs,
}
payload_bytes = pickle.dumps(payload)
# -- ensure the child can find the ai_typewriter package -----------------
env = os.environ.copy()
src_dir = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
existing = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = f"{src_dir}{os.pathsep}{existing}" if existing else src_dir
# -- launch the child ----------------------------------------------------
proc = subprocess.Popen(
[sys.executable, "-m", "ai_typewriter.ui._tk_window_process"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
# Send the payload and close stdin so the child knows it's complete.
try:
proc.stdin.write(payload_bytes) # type: ignore[union-attr]
proc.stdin.close() # type: ignore[union-attr]
except BrokenPipeError:
# Child exited before reading — nothing to do.
pass
# -- background result collection ----------------------------------------
if on_result is not None:
def _poll() -> None:
try:
stdout_data, stderr_data = proc.communicate()
if stdout_data:
result = pickle.loads(stdout_data)
else:
result = None
on_result(result)
except Exception:
on_result(None)
threading.Thread(target=_poll, daemon=True, name="tk-result-poller").start()
return proc
@@ -1,86 +0,0 @@
"""Entry point executed by ``subprocess.Popen`` to run a single Tk window.
Reads a pickled payload from **stdin** that describes the window class to
instantiate, creates a fresh ``tk.Tk`` + ``tk.Toplevel``, runs the Tk
event loop, and writes the pickled ``window.result`` back to **stdout**
before exiting.
This module is designed to be invoked as::
python -m ai_typewriter.ui._tk_window_process
It is **not** imported by the parent process.
"""
from __future__ import annotations
import importlib
import pickle
import sys
import tkinter as tk
def main() -> None:
"""Read args from stdin, run the Tk window, write result to stdout."""
# -- decode the payload from stdin ---------------------------------------
payload = pickle.loads(sys.stdin.buffer.read())
mod_path, cls_name = payload["class"].rsplit(".", 1)
win_args: tuple = payload.get("args", ())
win_kwargs: dict = payload.get("kwargs", {})
# -- import the window class dynamically ---------------------------------
mod = importlib.import_module(mod_path)
window_class = getattr(mod, cls_name)
# -- create the Tk root + window ----------------------------------------
root = tk.Tk()
root.withdraw()
try:
win = window_class(root, *win_args, **win_kwargs)
except Exception:
root.quit()
raise
_closed = False
_result: object = None
def _on_destroy(event: tk.Event) -> None:
nonlocal _closed, _result
if event.widget is not win:
return
if _closed:
return
_closed = True
try:
if hasattr(win, "result"):
_result = win.result
except Exception:
pass
try:
root.quit()
except tk.TclError:
pass
win.bind("<Destroy>", _on_destroy)
win.protocol("WM_DELETE_WINDOW", lambda: win.destroy())
# -- present the window --------------------------------------------------
try:
win.deiconify()
win.lift()
win.attributes("-topmost", True)
win.after(200, lambda: win.attributes("-topmost", False))
win.focus_force()
except tk.TclError:
pass
root.mainloop()
# -- write the result back to the parent via stdout ----------------------
sys.stdout.buffer.write(pickle.dumps(_result))
sys.stdout.buffer.flush()
if __name__ == "__main__":
main()
@@ -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.credential for p in self._known_profiles() if p.credential}
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)