From 946d344090e2be55d73cad06eaf610da4079954d Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 9 Jun 2026 14:39:59 +0200 Subject: [PATCH] presets: per-preset LLM engine selector + fix streaming freeze (v1.9.7) - Add llm_engine field to Workflow (config.py load/save) - Daemon picks the named engine per-preset, falls back to active engine - Settings Presets tab: replace free-text model field with engine dropdown populated from configured engines; "(active engine)" = default - Fix LLM token streaming crash: coalesce GLib.idle_add calls so only one flush is ever queued (prevents GTK main loop flooding) Co-Authored-By: Claude Sonnet 4.6 --- linux/blitztext/config.py | 6 ++++++ linux/blitztext/daemon.py | 8 +++++++- linux/blitztext/gtksettings.py | 17 +++++++++++++---- linux/blitztext/overlay.py | 18 +++++++++++++++++- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py index 4b7bafc..b61fa1d 100644 --- a/linux/blitztext/config.py +++ b/linux/blitztext/config.py @@ -25,6 +25,9 @@ class Workflow: # Optional per-workflow overrides of the [rewrite] defaults. model: str | None = None temperature: float | None = None + # Which LLM engine to use for this preset's rewrite step. + # "" (empty) = use whichever engine is currently active in the Engines tab. + llm_engine: str = "" # Cosmetic, used by the GUI. description: str = "" icon: str = "⚡" @@ -245,6 +248,7 @@ def load(path: Path = CONFIG_PATH) -> Config: keywords=list(entry.get("keywords", [])), model=entry.get("model"), temperature=entry.get("temperature"), + llm_engine=entry.get("llm_engine", ""), description=entry.get("description", ""), icon=entry.get("icon", "⚡"), ) @@ -396,6 +400,8 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None: entry["model"] = wf.model if wf.temperature is not None: entry["temperature"] = wf.temperature + if wf.llm_engine: + entry["llm_engine"] = wf.llm_engine if wf.description: entry["description"] = wf.description if wf.icon and wf.icon != "⚡": diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index a99b724..f1fb976 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -520,9 +520,15 @@ class Daemon: _acc.append(delta) self.text_cb("".join(_acc)) + # Use the preset's pinned engine when set, else the active one. + engine_name = getattr(target, "llm_engine", "") or "" + llm_engine = ( + next((e for e in self.cfg.llm_engines if e.name == engine_name), None) + or self.cfg.active_llm + ) try: text = llm.chat( - self.cfg.active_llm, + llm_engine, target.prompt, text, model=target.model or None, diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index b7e0407..ad16f84 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -846,8 +846,13 @@ notebook.bt-nb tab:checked label { beh_card = _card_section(page, "Behaviour") self.wf_mode = _labeled(beh_card, "Mode", _combo(["transcribe", "rewrite", "stream"]), tooltip="’transcribe’ types your words as-is. ‘rewrite’ sends them to the language model first. ‘stream’ shows live text from a realtime engine.") - self.wf_model = _labeled(beh_card, "LLM model (opt.)", _entry(placeholder="blank = active LLM engine model"), - tooltip="Override the language model just for this preset. Leave blank to use the active LLM engine.") + # Engine dropdown — "(active engine)" + one entry per configured LLM engine. + llm_names = [e.name for e in self.cfg.llm_engines] + self.wf_llm_engine = _labeled( + beh_card, "LLM engine", + _combo(["(active engine)"] + llm_names), + tooltip="Which LLM engine to use for the rewrite step. ‘(active engine)’ follows whatever is selected in the Engines tab.", + ) self.wf_temp = _labeled(beh_card, "Temperature (opt.)", _entry(placeholder="blank = engine default (e.g. 0.3)"), tooltip="Creativity of the rewrite, 0–1. Lower is more predictable. Blank uses the engine default.") @@ -872,7 +877,10 @@ notebook.bt-nb tab:checked label { self.wf_keywords.set_text(", ".join(wf.keywords)) self.wf_hotkey.set_text(wf.hotkey) self.wf_mode.set_active(["transcribe", "rewrite", "stream"].index(wf.mode) if wf.mode in ("transcribe", "rewrite", "stream") else 0) - self.wf_model.set_text(wf.model or "") + llm_names = [e.name for e in self.cfg.llm_engines] + engine = getattr(wf, "llm_engine", "") or "" + idx_e = (llm_names.index(engine) + 1) if engine in llm_names else 0 + self.wf_llm_engine.set_active(idx_e) self.wf_temp.set_text("" if wf.temperature is None else str(wf.temperature)) self.wf_prompt.get_buffer().set_text(wf.prompt) self._wf_idx = idx @@ -888,7 +896,8 @@ notebook.bt-nb tab:checked label { wf.keywords = [k.strip() for k in self.wf_keywords.get_text().split(",") if k.strip()] wf.hotkey = self.wf_hotkey.get_text().strip() wf.mode = self.wf_mode.get_active_text() or "transcribe" - wf.model = self.wf_model.get_text().strip() or None + engine_text = self.wf_llm_engine.get_active_text() or "" + wf.llm_engine = "" if engine_text == "(active engine)" else engine_text t = self.wf_temp.get_text().strip() wf.temperature = float(t) if _isfloat(t) else None b = self.wf_prompt.get_buffer() diff --git a/linux/blitztext/overlay.py b/linux/blitztext/overlay.py index d8e8d68..a6b5b5f 100644 --- a/linux/blitztext/overlay.py +++ b/linux/blitztext/overlay.py @@ -86,6 +86,11 @@ class Overlay: self._tick_id: int | None = None self._hide_id: int | None = None self._t0 = time.time() + # Coalescing text updates: background LLM streaming can fire dozens of + # set_text() calls per second. We buffer the latest text and only ever + # have ONE idle_add pending — so the GTK main loop is never flooded. + self._pending_text: str = "" + self._text_flush_queued: bool = False self._win = Gtk.Window(type=Gtk.WindowType.POPUP) self._win.set_app_paintable(True) @@ -123,7 +128,18 @@ class Overlay: GLib.idle_add(self._set_level, float(level)) def set_text(self, text: str) -> None: - GLib.idle_add(self._set_text, text or "") + self._pending_text = text or "" + # Only schedule a flush if none is already queued; this collapses a burst + # of token callbacks (e.g. 50/s from LLM streaming) into a single GTK + # redraw, preventing main-loop flooding and session freezes. + if not self._text_flush_queued: + self._text_flush_queued = True + GLib.idle_add(self._flush_text) + + def _flush_text(self) -> bool: + self._text_flush_queued = False + self._set_text(self._pending_text) + return False def set_preset(self, icon: str, name: str, keyword: str | None) -> None: """Show the matched voice-routing preset on the overlay (emoji + name +