Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d13e7c22b |
@@ -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
@@ -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
@@ -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 @@
|
||||
"""Interface graphique (Tkinter) de l'application."""
|
||||
"""Interface graphique (Tkinter) de l'application — fenêtre unifiée à onglets."""
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user