Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d13e7c22b | ||
|
|
4adb88fd35 | ||
|
|
7fa401b287 | ||
|
|
e07f401b5c | ||
|
|
15eb856bf2 | ||
|
|
2f5c428545 | ||
|
|
e7d13d9ad1 | ||
|
|
05e6515011 | ||
|
|
fa7b037e7f |
@@ -1,6 +1,8 @@
|
||||
# AI-Typewriter
|
||||
|
||||
Application Python qui tourne **en arrière-plan** (icône dans la zone de notification de Windows, aucune fenêtre au démarrage), lit le contenu du presse-papier sur un raccourci global, l'envoie à un modèle IA, puis réécrit la réponse **caractère par caractère** à chaque pression de touche physique.
|
||||
Application Python qui **lit le contenu du presse-papier** sur un raccourci global, l'envoie à un modèle IA, puis réécrit la réponse **caractère par caractère** à chaque pression de touche physique.
|
||||
|
||||
Une **fenêtre unifiée à trois onglets** (Profils / Authentification / Logs) permet de tout configurer sans fenêtres éparpillées. Fermer la fenêtre (✕) la masque dans la zone de notification — l'application continue de tourner. Le menu de l'icône de notification réaffiche la fenêtre dans le bon onglet, et la commutation de profil reste directement accessible depuis l'icône (sans afficher la fenêtre).
|
||||
|
||||
Un profil spécialisé « Mathématiques (LaTeX) » marque les équations avec `[EQ]...[/EQ]` : l'application les intercepte et déclenche `Alt+=` pour ouvrir une équation (Word/OneNote) et `→` pour en sortir.
|
||||
|
||||
@@ -54,15 +56,23 @@ Les racines sont [EQ]z_1 = x + iy[/EQ] et [EQ]z_2 = x - iy[/EQ].
|
||||
|
||||
À chaque `[EQ]` l'application envoie `Alt+=` (ouvre une équation inline), tape le LaTeX littéralement, puis envoie `→` à chaque `[/EQ]`. Ce comportement est désactivé par défaut sur les autres profils (le texte est tapé tel quel).
|
||||
|
||||
## Fenêtre unifiée (onglets)
|
||||
|
||||
L'application démarre avec **une seule fenêtre** comportant trois onglets :
|
||||
|
||||
- **Profils** — liste de tous les profils (éditeur intégré) pour créer, modifier, supprimer ou activer un profil directement.
|
||||
- **Authentification** — enregistrement/vérification/suppression des clés d'API par référence.
|
||||
- **Logs** — journaux en temps réel (également écrits dans `~/.config/ai-typewriter/logs/app.log`).
|
||||
|
||||
## Zone de notification (icône)
|
||||
|
||||
L'application se lance sans fenêtre visible. Le menu de l'icône propose :
|
||||
Fermer la fenêtre (✕) ne quitte **pas** l'application : elle est masquée et continue de tourner, avec l'icône toujours visible dans la zone de notification. Le menu de l'icône propose :
|
||||
|
||||
- **Ouvrir les logs** — fenêtre des journaux en temps réel (également écrits dans `%APPDATA%\ai-typewriter\logs\app.log`).
|
||||
- **Ajouter un profil** — formulaire (nom, fournisseur, modèle, serveur, prompt, équations).
|
||||
- **Modifier le profil** — sous-menu listant tous les profils pour choisir le profil actif.
|
||||
- **Gérer l'authentification** — enregistrer les clés d'API des fournisseurs.
|
||||
- **Quitter** — arrête le processus.
|
||||
- **Ouvrir les logs** — réaffiche la fenêtre dans l'onglet *Logs*.
|
||||
- **Ajouter un profil** — réaffiche la fenêtre dans l'onglet *Profils* (formulaire vierge pour créer).
|
||||
- **Modifier le profil** — sous-menu listant tous les profils pour choisir le profil actif **directement** (sans afficher la fenêtre).
|
||||
- **Gérer l'authentification** — réaffiche la fenêtre dans l'onglet *Authentification*.
|
||||
- **Quitter** — arrête complètement le processus.
|
||||
|
||||
### Sélection et téléchargement des modèles
|
||||
|
||||
|
||||
+70
-21
@@ -1,22 +1,36 @@
|
||||
"""Point d'entrée de l'application.
|
||||
|
||||
Par défaut, l'application s'exécute en arrière-plan, sans fenêtre visible,
|
||||
avec une icône dans la zone de notification (Windows). Deux modes console
|
||||
restent disponibles pour le développement / le test :
|
||||
- `python main.py --ask "texte"` : envoie le texte au profil actif et
|
||||
L'application démarre avec une **fenêtre unifiée à onglets** (Profils,
|
||||
Authentification, Logs) et une icône dans la zone de notification. Fermer la
|
||||
fenêtre la met en zone de notification (l'application continue de tourner) ;
|
||||
le menu de l'icône réaffiche la fenêtre dans le bon onglet.
|
||||
|
||||
Deux modes restent disponibles pour le développement / le test :
|
||||
- ``python main.py --ask "texte"`` : envoie le texte au profil actif et
|
||||
imprime la réponse sans intercepter le clavier.
|
||||
- `python main.py` : lance l'icône de la zone de notification.
|
||||
- ``python main.py`` : lance la fenêtre + l'icône.
|
||||
|
||||
Architecture (processus)
|
||||
------------------------
|
||||
* **Thread principal** : la racine Tk (``MainWindow``) et sa boucle
|
||||
``mainloop``. Le raccourci global (``keyboard``) tourne dans son propre
|
||||
thread d'écoute.
|
||||
* **Thread UI (tray)** : icône pystray dans la zone de notification. Elle
|
||||
n'ordonne des actions à la fenêtre que via ``master.after(0, …)``.
|
||||
* **Une seule racine Tk** partagée : plus de fenêtre par processus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from .config import ConfigStore, load_config
|
||||
from .credentials import SecureStore
|
||||
from .engine import AITypewriterEngine
|
||||
from .engine import AITypewriterEngine, bind_hotkey
|
||||
from .logging_utils import setup_logging
|
||||
|
||||
LOG = logging.getLogger("ai_typewriter")
|
||||
@@ -40,7 +54,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
setup_logging(level=logging.DEBUG if args.debug else logging.INFO)
|
||||
setup_logging(level=logging.DEBUG)
|
||||
LOG.debug("Démarrage : args=%s", args)
|
||||
|
||||
try:
|
||||
store = load_config(args.config)
|
||||
@@ -59,27 +74,61 @@ def main(argv: list[str] | None = None) -> int:
|
||||
LOG.error("%s", exc)
|
||||
return 1
|
||||
|
||||
return run_tray(store)
|
||||
return run_background(store, engine)
|
||||
|
||||
|
||||
def run_tray(store: ConfigStore) -> int:
|
||||
"""Lance l'application en arrière-plan avec l'icône de notification."""
|
||||
# Import différé pour garantir que --ask / les tests fonctionnent même si
|
||||
# pystray ou PIL ne sont pas installés / sans affichage graphique.
|
||||
def run_background(store: ConfigStore, engine: AITypewriterEngine) -> int:
|
||||
"""Lance la fenêtre unifiée + l'icône de notification.
|
||||
|
||||
- Le thread principal crée ``MainWindow`` (racine Tk) puis entre dans
|
||||
``mainloop``.
|
||||
- L'icône de notification (pystray) tourne dans un thread dédié.
|
||||
- Le raccourci global est enregistré via ``keyboard`` (thread d'écoute
|
||||
interne).
|
||||
"""
|
||||
# -- raccourci global (thread d'écoute interne à keyboard) ----------------
|
||||
hotkey = store.hotkey
|
||||
try:
|
||||
bind_hotkey(engine, hotkey)
|
||||
LOG.info("Raccourci global actif : %s", hotkey)
|
||||
except Exception as exc:
|
||||
LOG.exception("Impossible d'enregistrer le raccourci : %s", exc)
|
||||
|
||||
# -- fenêtre unifiée (onglets) — thread principal -------------------------
|
||||
from .ui.main_window import MainWindow
|
||||
|
||||
root = MainWindow(store)
|
||||
|
||||
# -- icône de notification (thread dédié) ---------------------------------
|
||||
from .tray import TrayApp
|
||||
|
||||
app = TrayApp(store)
|
||||
app.start()
|
||||
stop_event = threading.Event()
|
||||
tray = TrayApp(store, master=root, stop_event=stop_event)
|
||||
ui_thread = threading.Thread(target=tray.run, daemon=True, name="ui-tray")
|
||||
ui_thread.start()
|
||||
|
||||
# Maintient le processus en vie ; pystray run() tourne déjà dans un thread.
|
||||
# -- signaux d'arrêt propre ----------------------------------------------
|
||||
def _handle_signal(signum, frame):
|
||||
LOG.info("Signal %s reçu, arrêt…", signum)
|
||||
stop_event.set()
|
||||
try:
|
||||
root.after(0, root.destroy)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
|
||||
LOG.info("Fenêtre principale affichée (onglets Profils / Authentification / Logs).")
|
||||
try:
|
||||
# Boucle événementielle tant que l'application n'est pas arrêtée.
|
||||
while True:
|
||||
import time
|
||||
|
||||
time.sleep(3600)
|
||||
root.mainloop()
|
||||
except KeyboardInterrupt:
|
||||
app.stop()
|
||||
pass
|
||||
|
||||
LOG.info("Arrêt demandé.")
|
||||
tray.stop()
|
||||
ui_thread.join(timeout=3)
|
||||
LOG.info("Application terminée.")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -73,12 +73,16 @@ def setup_file_logging(level: int = logging.INFO) -> Path:
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
path, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
||||
)
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s [tid:%(thread)d:%(threadName)s] %(message)s"
|
||||
)
|
||||
)
|
||||
logging.getLogger().addHandler(handler)
|
||||
return path
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO) -> Path:
|
||||
def setup_logging(level: int = logging.DEBUG) -> Path:
|
||||
broadcaster().install(level)
|
||||
return setup_file_logging(level)
|
||||
|
||||
|
||||
+133
-100
@@ -1,102 +1,117 @@
|
||||
"""Application dans la zone de notification (icône dans la barre des tâches).
|
||||
"""Icône de zone de notification — réaffiche la fenêtre unifiée dans le bon onglet.
|
||||
|
||||
L'application tourne en arrière-plan : aucune fenêtre visible au démarrage,
|
||||
seule une icône dans la zone de notification. Le menu de l'icône permet :
|
||||
- Ouvrir les logs (fenêtre des journaux en temps réel)
|
||||
- Modifier le profil (choisir parmi la liste des profils disponibles)
|
||||
- Ajouter un profil (formulaire)
|
||||
- Gérer l'authentification (clés d'API sécurisées)
|
||||
- Quitter
|
||||
La fenêtre principale unique (onglets Profils / Authentification / Logs) vit
|
||||
dans le **processus principal**, racine ``tk.Tk`` sur le thread principal.
|
||||
L'icône pystray tourne dans un **thread dédié** et ne fait qu'ordonner des
|
||||
actions à la fenêtre via ``master.after(0, …)`` — la seule façon sûre de
|
||||
toucher à Tk depuis un autre thread.
|
||||
|
||||
La fenêtre racine Tk est créée de manière invisible et sert uniquement de
|
||||
référence pour les dialogues ; l'icône est pilotée par pystray.
|
||||
* **Fermer la fenêtre (✕)** → ``withdraw()`` : l'app continue de tourner et
|
||||
l'icône reste visible.
|
||||
* **Ouvrir les logs / Ajouter un profil / Gérer l'authentification** →
|
||||
réaffiche la fenêtre et sélectionne l'onglet correspondant.
|
||||
* **Modifier le profil (sous-menu radio)** → bascule le profil actif
|
||||
directement, **sans** afficher la fenêtre.
|
||||
* **Quitter** → arrête l'icône et détruit la fenêtre.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from typing import Callable
|
||||
|
||||
from .config import ConfigStore
|
||||
from .credentials import SecureStore
|
||||
from .engine import AITypewriterEngine, bind_hotkey
|
||||
from .ui.auth_dialog import AuthDialog
|
||||
from .ui.logs_window import LogsWindow
|
||||
from .ui.profile_dialog import ProfileDialog
|
||||
from .ui.main_window import MainWindow, TAB_AUTH, TAB_LOGS, TAB_PROFILES
|
||||
|
||||
LOG = logging.getLogger("ai_typewriter.tray")
|
||||
|
||||
|
||||
class TrayApp:
|
||||
"""Encapsule l'icône de zone de notification + l'UI Tk."""
|
||||
"""Encapsule l'icône de zone de notification (pystray)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: ConfigStore,
|
||||
secure: SecureStore | None = None,
|
||||
master: MainWindow | None = None,
|
||||
icon_factory: Callable | None = None,
|
||||
menu_factory: Callable | None = None,
|
||||
stop_event: threading.Event | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.secure = secure or SecureStore()
|
||||
self.engine = AITypewriterEngine(store, self.secure)
|
||||
self._root: tk.Tk | None = None
|
||||
self._root_lock = threading.Lock()
|
||||
self.master = master
|
||||
self._icon = None
|
||||
self._icon_thread: threading.Thread | None = None
|
||||
self._icon_factory = icon_factory
|
||||
self._menu_factory = menu_factory
|
||||
self._stop_event = stop_event
|
||||
|
||||
# -- fenêtre racine cachée (pour les dialogues) --------------------------
|
||||
# -- cycle de vie -----------------------------------------------------------
|
||||
|
||||
def get_root(self) -> tk.Tk:
|
||||
with self._root_lock:
|
||||
if self._root is None:
|
||||
self._root = tk.Tk()
|
||||
self._root.withdraw()
|
||||
return self._root
|
||||
def run(self) -> None:
|
||||
"""Lance la boucle pystray (bloquant, appelé depuis le thread UI)."""
|
||||
icon = self._icon if self._icon is not None else self.build_icon()
|
||||
self._icon = icon
|
||||
LOG.info("Icône de notification lancée (pystray).")
|
||||
icon.run()
|
||||
|
||||
# -- actions du menu -------------------------------------------------------
|
||||
|
||||
def show_logs(self) -> None:
|
||||
LogsWindow(self.get_root())
|
||||
|
||||
def edit_profile(self, name: str | None = None) -> None:
|
||||
"""Ouvre l'éditeur du profil `name`, ou le profil actif si `None`."""
|
||||
store = self.store
|
||||
target = store.get(name) if name else store.active()
|
||||
dlg = ProfileDialog(self.get_root(), existing=target, secure=self.secure)
|
||||
self.get_root().wait_window(dlg)
|
||||
if dlg.result:
|
||||
def stop(self, icon=None, item=None) -> None:
|
||||
"""Arrête l'icône et demande la fermeture de la fenêtre principale."""
|
||||
LOG.info("Arrêt de l'icône de notification.")
|
||||
if self._icon is not None:
|
||||
try:
|
||||
store.upsert(dlg.result)
|
||||
store.set_active(dlg.result.name)
|
||||
except Exception as exc:
|
||||
LOG.exception("Impossible d'enregistrer le profil : %s", exc)
|
||||
|
||||
def add_profile(self) -> None:
|
||||
dlg = ProfileDialog(self.get_root(), existing=None, secure=self.secure)
|
||||
self.get_root().wait_window(dlg)
|
||||
if dlg.result:
|
||||
self._icon.stop()
|
||||
except Exception:
|
||||
pass
|
||||
if self._stop_event is not None:
|
||||
self._stop_event.set()
|
||||
if self.master is not None:
|
||||
try:
|
||||
self.store.upsert(dlg.result)
|
||||
LOG.info("Profil « %s » ajouté.", dlg.result.name)
|
||||
except Exception as exc:
|
||||
LOG.exception("Impossible d'ajouter le profil : %s", exc)
|
||||
self.master.after(0, self.master.destroy)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_active(self, name: str) -> None:
|
||||
# -- actions du menu --------------------------------------------------------
|
||||
|
||||
def _show_logs(self) -> None:
|
||||
self._show_tab(TAB_LOGS)
|
||||
|
||||
def _add_profile(self) -> None:
|
||||
self._marshal(self.master.open_new_profile)
|
||||
self._show_tab(TAB_PROFILES)
|
||||
|
||||
def _manage_auth(self) -> None:
|
||||
self._show_tab(TAB_AUTH)
|
||||
|
||||
def _show_tab(self, name: str) -> None:
|
||||
self._marshal(lambda: self.master.show_tab(name))
|
||||
|
||||
def _set_active(self, name: str) -> None:
|
||||
try:
|
||||
self.store.set_active(name)
|
||||
LOG.info("Profil actif : %s", name)
|
||||
self._refresh_menu()
|
||||
self._marshal(self.master.refresh_all)
|
||||
except Exception as exc:
|
||||
LOG.exception("Impossible de sélectionner le profil : %s", exc)
|
||||
|
||||
def manage_auth(self) -> None:
|
||||
AuthDialog(self.get_root(), self.secure)
|
||||
def _marshal(self, fn: Callable[[], None]) -> None:
|
||||
"""Exécute ``fn`` sur le thread Tk via ``master.after(0, …)``."""
|
||||
if self.master is None:
|
||||
return
|
||||
try:
|
||||
self.master.after(0, fn)
|
||||
except Exception:
|
||||
LOG.debug("Ordre Tk impossible (fenêtre détruite ?)", exc_info=True)
|
||||
|
||||
# -- construction de l'icône ----------------------------------------------
|
||||
def _refresh_menu(self) -> None:
|
||||
"""Demande à pystray de reconstruire le menu (met à jour les coches)."""
|
||||
if self._icon is not None:
|
||||
try:
|
||||
self._icon.update_menu()
|
||||
except Exception:
|
||||
LOG.debug("update_menu() a échoué", exc_info=True)
|
||||
|
||||
# -- construction de l'icône ------------------------------------------------
|
||||
|
||||
def build_icon(self):
|
||||
import pystray
|
||||
@@ -109,24 +124,24 @@ class TrayApp:
|
||||
return img
|
||||
|
||||
menu_items = []
|
||||
menu_items.append(self._menu_item("Ouvrir les logs", self.show_logs))
|
||||
menu_items.append(self._menu_item("Ajouter un profil", self.add_profile))
|
||||
# Sous-menu des profils
|
||||
profiles_sub = pystray.Menu(
|
||||
*[
|
||||
self._menu_item(
|
||||
p.name + (" ✓" if p.name == self.store.active_name else ""),
|
||||
lambda n=p.name: self.set_active(n),
|
||||
)
|
||||
for p in self.store.get_all()
|
||||
]
|
||||
menu_items.append(
|
||||
self._menu_item("Ouvrir les logs", self._guard("Ouvrir les logs", self._show_logs))
|
||||
)
|
||||
menu_items.append(
|
||||
self._menu_item("Ajouter un profil", self._guard("Ajouter un profil", self._add_profile))
|
||||
)
|
||||
profiles_sub = pystray.Menu(self._profile_menu_items)
|
||||
menu_items.append(self._menu_item("Modifier le profil", None, submenu=profiles_sub))
|
||||
|
||||
menu_items.append(pystray.Menu.SEPARATOR)
|
||||
menu_items.append(self._menu_item("Gérer l'authentification", self.manage_auth))
|
||||
menu_items.append(
|
||||
self._menu_item(
|
||||
"Gérer l'authentification",
|
||||
self._guard("Gérer l'authentification", self._manage_auth),
|
||||
)
|
||||
)
|
||||
menu_items.append(pystray.Menu.SEPARATOR)
|
||||
menu_items.append(self._menu_item("Quitter", self.stop))
|
||||
menu_items.append(self._menu_item("Quitter", self._guard("Quitter", self.stop)))
|
||||
|
||||
if self._menu_factory:
|
||||
return self._menu_factory(_image, menu_items)
|
||||
@@ -137,40 +152,58 @@ class TrayApp:
|
||||
pystray.Menu(*menu_items),
|
||||
)
|
||||
|
||||
def _profile_menu_items(self):
|
||||
"""Génère dynamiquement les items du sous-menu des profils.
|
||||
|
||||
Appelé par pystray à chaque (re)construction du menu : la coche
|
||||
(via ``checked=``, un item radio) suit donc toujours le profil actif.
|
||||
"""
|
||||
import pystray
|
||||
|
||||
for p in self.store.get_all():
|
||||
yield pystray.MenuItem(
|
||||
p.name,
|
||||
self._guard(
|
||||
f"sélection du profil « {p.name} »",
|
||||
self._select_profile_action(p),
|
||||
),
|
||||
checked=self._make_checked(p.name),
|
||||
radio=True,
|
||||
)
|
||||
|
||||
def _make_checked(self, profile_name: str) -> Callable:
|
||||
"""Retourne un prédicat évalué à l'affichage du menu."""
|
||||
return lambda item: self.store.active_name == profile_name
|
||||
|
||||
def _menu_item(self, text: str, action, submenu=None):
|
||||
import pystray
|
||||
|
||||
if submenu is not None:
|
||||
return pystray.MenuItem(text, None, submenu=submenu)
|
||||
return pystray.MenuItem(text, action or (lambda icon, item: None))
|
||||
return pystray.MenuItem(text, submenu)
|
||||
if action is None:
|
||||
action = lambda icon, item: None
|
||||
return pystray.MenuItem(text, action)
|
||||
|
||||
# -- cycle de vie -----------------------------------------------------------
|
||||
def _guard(self, label: str, action: Callable) -> Callable:
|
||||
"""Enveloppe une action de menu avec journalisation et capture d'erreurs.
|
||||
|
||||
def start(self) -> None:
|
||||
"""Attache le raccourci global et lance l'icône dans un thread."""
|
||||
hotkey = self.store.hotkey
|
||||
try:
|
||||
bind_hotkey(self.engine, hotkey)
|
||||
LOG.info("Raccourci global actif : %s", hotkey)
|
||||
except Exception as exc:
|
||||
LOG.exception("Impossible d'enregistrer le raccourci : %s", exc)
|
||||
pystray invoque les actions avec ``(icon, item)``. Un handler à 2
|
||||
params est appelé tel quel, sans reshufflage. ``self.stop`` accepte
|
||||
ces deux paramètres ; les autres actions sont des closures sans args.
|
||||
"""
|
||||
|
||||
icon = self._icon if self._icon is not None else self.build_icon()
|
||||
self._icon = icon
|
||||
self._icon_thread = threading.Thread(target=icon.run, daemon=True)
|
||||
self._icon_thread.start()
|
||||
LOG.info("Application lancée en arrière-plan (icône zone de notification).")
|
||||
|
||||
def stop(self, icon=None, item=None) -> None:
|
||||
LOG.info("Arrêt de l'application.")
|
||||
if self._root is not None:
|
||||
def handler(icon, item) -> None:
|
||||
LOG.debug("Clic menu → %s", label)
|
||||
try:
|
||||
self._root.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
if self._icon is not None:
|
||||
try:
|
||||
self._icon.stop()
|
||||
action()
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit(0)
|
||||
LOG.exception("Erreur lors de l'action : %s", label)
|
||||
|
||||
return handler
|
||||
|
||||
def _select_profile_action(self, profile) -> Callable:
|
||||
def action() -> None:
|
||||
LOG.debug("Sélection du profil « %s » demandée", profile.name)
|
||||
self._set_active(profile.name)
|
||||
|
||||
return action
|
||||
@@ -1 +1 @@
|
||||
"""Interface graphique (Tkinter) de l'application."""
|
||||
"""Interface graphique (Tkinter) de l'application — fenêtre unifiée à onglets."""
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Fenêtre de gestion des authentifications des fournisseurs.
|
||||
"""Onglet « Authentification » : gestion des clés d'API des fournisseurs.
|
||||
|
||||
Permet d'enregistrer les clés d'API par nom de « référence » (celui que portent
|
||||
les profils). Les secrets sont conservés de façon sécurisée dans le gestionnaire
|
||||
d'identifiants de Windows via credentials.SecureStore ; ils ne sont jamais
|
||||
affichés ni écrits en clair dans un fichier.
|
||||
les profils). Les secrets sont conservés de façon sécurisée via
|
||||
``credentials.SecureStore`` ; ils ne sont jamais affichés ni écrits en clair.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,17 +14,20 @@ from tkinter import messagebox, ttk
|
||||
from ..credentials import CredentialError, SecureStore
|
||||
from ..model_catalog import list_providers
|
||||
|
||||
LOG = logging.getLogger("ai_typewriter.ui.auth_dialog")
|
||||
LOG = logging.getLogger("ai_typewriter.ui.auth_tab")
|
||||
|
||||
|
||||
class AuthDialog(tk.Toplevel):
|
||||
def __init__(self, parent: tk.Widget, secure: SecureStore | None = None) -> None:
|
||||
class AuthTab(ttk.Frame):
|
||||
def __init__(
|
||||
self,
|
||||
parent: ttk.Widget,
|
||||
secure: SecureStore | None = None,
|
||||
store=None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.title("Gérer l'authentification des fournisseurs")
|
||||
self.secure = secure or SecureStore()
|
||||
self.geometry("460x360")
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
self.store = store
|
||||
self._known = ["openai", "openrouter", "gemini", "custom", "ollama"]
|
||||
|
||||
body = ttk.Frame(self, padding=10)
|
||||
body.pack(fill="both", expand=True)
|
||||
@@ -33,15 +35,15 @@ class AuthDialog(tk.Toplevel):
|
||||
ttk.Label(
|
||||
body,
|
||||
text=(
|
||||
"Les clés sont enregistrées dans le gestionnaire d'identifiants "
|
||||
"de Windows, pas dans un fichier de configuration.\n"
|
||||
"Les clés sont enregistrées dans le gestionnaire d'identifiants, "
|
||||
"pas dans un fichier de configuration.\n"
|
||||
"Chaque profil référence une clé par son « nom de référence »."
|
||||
),
|
||||
foreground="#555",
|
||||
justify="left",
|
||||
).pack(fill="x", pady=(0, 8))
|
||||
|
||||
# -- formulaire ----------------------------------------------------------
|
||||
# -- formulaire ---------------------------------------------------------
|
||||
form = ttk.LabelFrame(body, text="Nouvelle / mise à jour d'une référence", padding=8)
|
||||
form.pack(fill="x")
|
||||
ttk.Label(form, text="Nom de la référence (fournisseur)").grid(row=0, column=0, sticky="w")
|
||||
@@ -63,7 +65,7 @@ class AuthDialog(tk.Toplevel):
|
||||
row=2, column=1, sticky="e", pady=(4, 0)
|
||||
)
|
||||
|
||||
# -- liste des références existantes -------------------------------------
|
||||
# -- liste des références existantes ------------------------------------
|
||||
frm_list = ttk.LabelFrame(body, text="Références existantes", padding=8)
|
||||
frm_list.pack(fill="both", expand=True, pady=(10, 0))
|
||||
self.listbox = tk.Listbox(frm_list, height=5)
|
||||
@@ -73,18 +75,14 @@ class AuthDialog(tk.Toplevel):
|
||||
ttk.Button(row, text="Vérifier", command=self._has).pack(side="left")
|
||||
ttk.Button(row, text="Supprimer", command=self._delete).pack(side="right")
|
||||
|
||||
self._known = ["openai", "openrouter", "gemini", "custom", "ollama"]
|
||||
self._refresh_list()
|
||||
|
||||
# -- helpers ------------------------------------------------------------------
|
||||
# -- helpers ---------------------------------------------------------------
|
||||
|
||||
def _refresh_list(self) -> None:
|
||||
self.listbox.delete(0, tk.END)
|
||||
# En l'absence d'énumération dans keyring, on propose les références
|
||||
# typiques et on laisse l'utilisateur vérifier leur existence.
|
||||
known = sorted(
|
||||
set(self._known)
|
||||
| {p.get("credential", "") for p in self._known_profiles()}
|
||||
set(self._known) | {p.credential for p in self._known_profiles() if p.credential}
|
||||
)
|
||||
known = [k for k in known if k]
|
||||
for k in known:
|
||||
@@ -92,6 +90,11 @@ class AuthDialog(tk.Toplevel):
|
||||
self.listbox.insert(tk.END, f"{status} {k}")
|
||||
|
||||
def _known_profiles(self) -> list:
|
||||
if self.store is not None:
|
||||
try:
|
||||
return self.store.get_all()
|
||||
except Exception:
|
||||
return []
|
||||
from ..config import ConfigStore
|
||||
|
||||
store = ConfigStore()
|
||||
@@ -122,7 +125,7 @@ class AuthDialog(tk.Toplevel):
|
||||
sel = self.listbox.curselection()
|
||||
if not sel:
|
||||
return
|
||||
name = self.listbox.get(sel[0]).split(" ", 1)[-1]
|
||||
name = self._selected_name()
|
||||
if self.secure.has(name):
|
||||
messagebox.showinfo("Présente", f"Une clé est enregistrée pour « {name} ».", parent=self)
|
||||
else:
|
||||
@@ -134,11 +137,15 @@ class AuthDialog(tk.Toplevel):
|
||||
sel = self.listbox.curselection()
|
||||
if not sel:
|
||||
return
|
||||
name = self.listbox.get(sel[0]).split(" ", 1)[-1]
|
||||
name = self._selected_name()
|
||||
try:
|
||||
self.secure.delete(name)
|
||||
except CredentialError as exc:
|
||||
messagebox.showerror("Suppression impossible", str(exc), parent=self)
|
||||
return
|
||||
self._refresh_list()
|
||||
LOG.info("Référence « %s » supprimée.", name)
|
||||
LOG.info("Référence « %s » supprimée.", name)
|
||||
|
||||
def _selected_name(self) -> str:
|
||||
sel = self.listbox.curselection()
|
||||
return self.listbox.get(sel[0]).split(" ", 1)[-1]
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Fenêtre des journaux en temps réel.
|
||||
"""Onglet « Logs » : journaux en temps réel.
|
||||
|
||||
S'abonne au `LogBroadcaster` du module logging_utils et affiche les messages
|
||||
au fur et à mesure qu'ils sont émis par l'application.
|
||||
au fur et à mesure qu'ils sont émis par l'application. Tourne dans le même
|
||||
processus que le moteur, donc reçoit réellement les enregistrements (contraire
|
||||
à l'ancienne fenêtre lancée dans un processus séparé).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,12 +15,9 @@ from tkinter import ttk
|
||||
from .. import logging_utils
|
||||
|
||||
|
||||
class LogsWindow(tk.Toplevel):
|
||||
def __init__(self, parent: tk.Widget) -> None:
|
||||
class LogsTab(ttk.Frame):
|
||||
def __init__(self, parent: ttk.Widget) -> None:
|
||||
super().__init__(parent)
|
||||
self.title("AI-Typewriter — Journaux")
|
||||
self.geometry("640x420")
|
||||
self.transient(parent)
|
||||
|
||||
txt = tk.Text(self, state="disabled", wrap="word")
|
||||
scroll = ttk.Scrollbar(self, command=txt.yview)
|
||||
@@ -30,7 +29,6 @@ class LogsWindow(tk.Toplevel):
|
||||
bar = ttk.Frame(self)
|
||||
bar.pack(fill="x", padx=6, pady=4)
|
||||
ttk.Button(bar, text="Vider l'affichage", command=self._clear).pack(side="left")
|
||||
ttk.Button(bar, text="Fermer", command=self.destroy).pack(side="right")
|
||||
|
||||
self._history: list[str] = []
|
||||
self._append_pending(logging_utils.broadcaster().drain())
|
||||
@@ -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