From ca1f4088767b369a084f87303c962556d5570c13 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 10 Jun 2026 12:14:06 +0200 Subject: [PATCH] feat: first-run setup wizard for new users (v2.03.31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paged GTK dialog guides through trigger method, keyboard shortcuts, wakeword server, STT engine, and optional LLM setup. Shows automatically on first launch, re-openable via Settings → "Setup Wizard…". Sets setup_complete in config so it doesn't reappear. Co-Authored-By: Claude Sonnet 4.6 --- linux/CHANGELOG.md | 14 + linux/blitztext/__init__.py | 2 +- linux/blitztext/config.py | 3 + linux/blitztext/gtksettings.py | 9 + linux/blitztext/gtkui.py | 6 + linux/blitztext/setup_wizard.py | 844 ++++++++++++++++++++++++++++++++ 6 files changed, 877 insertions(+), 1 deletion(-) create mode 100644 linux/blitztext/setup_wizard.py diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index ad845ac..ef957f7 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,20 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +## [2.03.31] - 2026-06-10 + +### Added +- **First-run setup wizard.** A paged GTK dialog (`setup_wizard.py`) guides + new users through five steps: trigger method (keyboard / wakeword / both), + keyboard shortcut assignment (with live key capture), wakeword server + configuration (with connection test), speech-to-text engine selection + (local Whisper model size or remote API), and optional AI rewriting (LLM + endpoint + model). Navigation has Back, Next, and Skip buttons. The wizard + shows automatically on first launch (before the daemon starts) and can be + reopened via the "Setup Wizard…" button in the Settings header bar. + Completing the wizard sets `setup_complete = true` in the config so it + does not reappear. + ## [2.03.30] - 2026-06-10 ### Added diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index 481b8a3..b0af300 100644 --- a/linux/blitztext/__init__.py +++ b/linux/blitztext/__init__.py @@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host (not in a container) so it can type into any application via xdotool. """ -__version__ = "2.03.30" +__version__ = "2.03.31" diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py index 3f0451a..2e0dfc3 100644 --- a/linux/blitztext/config.py +++ b/linux/blitztext/config.py @@ -112,6 +112,7 @@ class Config: wakeword_silence_seconds: float = 2.0 # auto-stop after this much trailing silence wakeword_cancel_model: str = "" # wakeword model that cancels an in-progress recording wakeword_send_model: str = "" # wakeword model that finishes + sends (Enter) a recording + setup_complete: bool = False # True once the first-run wizard has been completed # Text-to-speech for the wakeword benchmark — its own OpenAI-compatible # endpoint (Kokoro, XTTS, OpenAI, …): base URL incl. /v1, an optional bearer # key env var, a model id, and the voices to cycle through. @@ -202,6 +203,7 @@ def load(path: Path = CONFIG_PATH) -> Config: output=g.get("output", "type"), type_delay_ms=int(g.get("type_delay_ms", 4)), notify=bool(g.get("notify", True)), + setup_complete=bool(g.get("setup_complete", False)), notify_routing=bool(g.get("notify_routing", True)), language=g.get("language", "de"), overlay_enabled=bool(g.get("overlay_enabled", True)), @@ -335,6 +337,7 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None: "output": cfg.output, "type_delay_ms": cfg.type_delay_ms, "notify": cfg.notify, + "setup_complete": cfg.setup_complete, "notify_routing": cfg.notify_routing, "language": cfg.language, "overlay_enabled": cfg.overlay_enabled, diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index 614078a..5d39ef0 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -767,6 +767,10 @@ class SettingsDialog: "Save and restart the daemon — required when changing STT engine, hotkeys, " "microphone, or wakeword server.") _header.pack_end(self._save_restart_btn) + _wizard_btn = Gtk.Button(label="Setup Wizard…") + _wizard_btn.set_tooltip_text("Re-run the first-run setup wizard.") + _wizard_btn.connect("clicked", self._open_wizard) + _header.pack_start(_wizard_btn) _save = Gtk.Button(label="Save") _save.connect("clicked", lambda _b: self.dlg.response(RESP_SAVE)) _save.set_tooltip_text("Save settings and apply what can be applied without restarting.") @@ -3058,6 +3062,11 @@ notebook.bt-nb tab:checked label { cr.arc(cx, cy, radius, 0, 2 * math.pi) cr.fill() + def _open_wizard(self, _btn=None) -> None: + from .setup_wizard import SetupWizard + wiz = SetupWizard(self.cfg, parent=self.dlg) + wiz.run() + def _cleanup(self) -> None: self._stop_meter() t = getattr(self, "_log_timer", 0) diff --git a/linux/blitztext/gtkui.py b/linux/blitztext/gtkui.py index 4568f98..f56ae53 100644 --- a/linux/blitztext/gtkui.py +++ b/linux/blitztext/gtkui.py @@ -120,6 +120,12 @@ class App: _install_css() self._build_window() + # Show the first-run wizard for fresh installs before the daemon starts. + if not getattr(cfg, "setup_complete", False): + from .setup_wizard import SetupWizard + wiz = SetupWizard(cfg, parent=None) + wiz.run() + threading.Thread(target=self._startup, daemon=True).start() # -- window --------------------------------------------------------------- diff --git a/linux/blitztext/setup_wizard.py b/linux/blitztext/setup_wizard.py new file mode 100644 index 0000000..bd13653 --- /dev/null +++ b/linux/blitztext/setup_wizard.py @@ -0,0 +1,844 @@ +"""First-run setup wizard — guides new users through the essential settings. + +Shows automatically when no config file exists yet (fresh install). +Can also be reopened from Settings → "Setup Wizard…". + +Flow: + Welcome → Trigger method → [Keyboard] → [Wakeword] → STT → LLM → Done + +Pages in brackets are shown conditionally depending on the trigger choice. +""" + +from __future__ import annotations + +import threading +from pathlib import Path + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 + + +# --------------------------------------------------------------------------- +# Tiny style helpers +# --------------------------------------------------------------------------- + +def _h1(text: str) -> Gtk.Label: + lbl = Gtk.Label(label=text, xalign=0.0) + lbl.set_line_wrap(True) + attrs = Pango.AttrList() + attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD)) + attrs.insert(Pango.attr_scale_new(1.45)) + lbl.set_attributes(attrs) + return lbl + + +def _sub(text: str) -> Gtk.Label: + lbl = Gtk.Label(label=text, xalign=0.0) + lbl.set_line_wrap(True) + lbl.set_max_width_chars(64) + lbl.get_style_context().add_class("dim-label") + return lbl + + +def _section(text: str) -> Gtk.Label: + lbl = Gtk.Label(xalign=0.0) + lbl.set_markup(f"{GLib.markup_escape_text(text.upper())}") + lbl.set_margin_top(12) + lbl.get_style_context().add_class("dim-label") + return lbl + + +def _page_box() -> Gtk.Box: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.set_margin_top(28); box.set_margin_bottom(8) + box.set_margin_start(32); box.set_margin_end(32) + return box + + +def _option_card(icon: str, title: str, desc: str) -> tuple[Gtk.RadioButton, Gtk.Box]: + """A large selectable card with an icon, bold title, and grey description.""" + card = Gtk.Box(spacing=14) + card.set_margin_top(4); card.set_margin_bottom(4) + card.get_style_context().add_class("card") + + icon_lbl = Gtk.Label(label=icon) + attrs = Pango.AttrList() + attrs.insert(Pango.attr_scale_new(2.0)) + icon_lbl.set_attributes(attrs) + icon_lbl.set_margin_start(14) + card.pack_start(icon_lbl, False, False, 0) + + text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + text_box.set_margin_top(12); text_box.set_margin_bottom(12) + title_lbl = Gtk.Label(label=title, xalign=0.0) + attrs2 = Pango.AttrList() + attrs2.insert(Pango.attr_weight_new(Pango.Weight.BOLD)) + title_lbl.set_attributes(attrs2) + text_box.pack_start(title_lbl, False, False, 0) + desc_lbl = Gtk.Label(label=desc, xalign=0.0) + desc_lbl.get_style_context().add_class("dim-label") + desc_lbl.set_line_wrap(True) + desc_lbl.set_max_width_chars(55) + text_box.pack_start(desc_lbl, False, False, 0) + card.pack_start(text_box, True, True, 0) + + # Invisible RadioButton — the whole card is the clickable area. + rb = Gtk.RadioButton() + rb.set_margin_end(14) + card.pack_start(rb, False, False, 0) + return rb, card + + +def _keyval_token(keyval: int) -> str | None: + from gi.repository import Gdk as _Gdk + name = _Gdk.keyval_name(keyval) or "" + low = name.lower() + for mod in ("control", "ctrl"): + if low.startswith(mod): + return "ctrl" + for mod in ("alt", "meta"): + if low.startswith(mod): + return "alt" + if low.startswith("super") or low.startswith("hyper") or low.startswith("win"): + return "cmd" + if low.startswith("shift"): + return "shift" + if low in ("escape", "esc"): + return "esc" + if low in ("return", "enter"): + return "enter" + if low == "space": + return "space" + if len(name) == 1 and name.isalpha(): + return name.lower() + return None + + +def _format_combo(tokens: list[str]) -> str: + if not tokens: + return "" + order = ["ctrl", "alt", "shift", "cmd"] + mods = [t for t in order if t in tokens] + rest = [t for t in tokens if t not in order] + parts = mods + rest + return "+".join(f"<{p}>" for p in parts) + + +# --------------------------------------------------------------------------- +# Wizard +# --------------------------------------------------------------------------- + +class SetupWizard: + """Paged first-run setup dialog.""" + + # Page names in fixed order; "keyboard" and "wakeword" are conditional. + _ALL_PAGES = ("welcome", "trigger", "keyboard", "wakeword", "stt", "llm", "done") + + def __init__(self, cfg, parent: Gtk.Window | None = None): + self.cfg = cfg + self._parent = parent + + # Wizard state collected as user moves through pages. + self._trigger = "keyboard" # "keyboard" | "wakeword" | "both" + self._kb_start = cfg.key_start + self._kb_stop = cfg.key_stop + self._kb_send = cfg.key_send + self._kb_cancel = cfg.key_cancel + self._ww_uri = cfg.wakeword_uri + self._ww_model = cfg.wakeword_model + self._stt_local = True # True = faster-whisper, False = remote API + self._stt_size = "small" + self._stt_url = "" + self._stt_key = "" + self._llm_enabled = bool(cfg.base_url and cfg.base_url != "https://api.openai.com/v1") + self._llm_url = cfg.base_url + self._llm_model = cfg.rewrite_model + self._llm_key = cfg.api_key_env + + # Key-binding capture state + self._bind_entry: Gtk.Entry | None = None + self._bind_pressed: list[str] = [] + + self._build() + + # ------------------------------------------------------------------ + # Dialog shell + # ------------------------------------------------------------------ + + def _build(self) -> None: + self.dlg = Gtk.Dialog(title="Blitztext Setup") + if self._parent: + self.dlg.set_transient_for(self._parent) + self.dlg.set_modal(True) + self.dlg.set_default_size(580, 520) + self.dlg.set_resizable(False) + self.dlg.get_action_area().hide() + + # Key capture + self.dlg.add_events(Gdk.EventMask.KEY_PRESS_MASK | Gdk.EventMask.KEY_RELEASE_MASK) + self.dlg.connect("key-press-event", self._on_key_press) + self.dlg.connect("key-release-event", self._on_key_release) + + content = self.dlg.get_content_area() + content.set_spacing(0) + + # Stack + self.stack = Gtk.Stack() + self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) + self.stack.set_transition_duration(180) + content.pack_start(self.stack, True, True, 0) + + self._pg: dict[str, Gtk.Widget] = {} + builders = { + "welcome": self._build_welcome, + "trigger": self._build_trigger, + "keyboard": self._build_keyboard, + "wakeword": self._build_wakeword, + "stt": self._build_stt, + "llm": self._build_llm, + "done": self._build_done, + } + for name in self._ALL_PAGES: + w = builders[name]() + self.stack.add_named(w, name) + self._pg[name] = w + + # Nav bar + sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + content.pack_start(sep, False, False, 0) + + nav = Gtk.Box(spacing=8) + nav.set_margin_top(10); nav.set_margin_bottom(10) + nav.set_margin_start(20); nav.set_margin_end(20) + + self._btn_back = Gtk.Button(label="← Back") + self._btn_back.connect("clicked", lambda _b: self._go(-1)) + nav.pack_start(self._btn_back, False, False, 0) + + self._step_lbl = Gtk.Label() + self._step_lbl.get_style_context().add_class("dim-label") + nav.pack_start(self._step_lbl, True, True, 0) + + self._btn_skip = Gtk.Button(label="Skip") + self._btn_skip.get_style_context().add_class("flat") + self._btn_skip.connect("clicked", lambda _b: self._go(+1, skip=True)) + nav.pack_start(self._btn_skip, False, False, 0) + + self._btn_next = Gtk.Button(label="Next →") + self._btn_next.get_style_context().add_class("suggested-action") + self._btn_next.connect("clicked", lambda _b: self._go(+1)) + nav.pack_start(self._btn_next, False, False, 0) + + content.pack_start(nav, False, False, 0) + + self._page_order: list[str] = [] + self._idx = 0 + self._refresh_page_order() + self._show(0) + + # ------------------------------------------------------------------ + # Navigation + # ------------------------------------------------------------------ + + def _refresh_page_order(self) -> None: + pages = ["welcome", "trigger"] + if self._trigger in ("keyboard", "both"): + pages.append("keyboard") + if self._trigger in ("wakeword", "both"): + pages.append("wakeword") + pages += ["stt", "llm", "done"] + self._page_order = pages + + def _show(self, idx: int) -> None: + self._idx = max(0, min(idx, len(self._page_order) - 1)) + name = self._page_order[self._idx] + self.stack.set_visible_child_name(name) + + is_first = self._idx == 0 + is_last = self._idx == len(self._page_order) - 1 + is_welcome = name == "welcome" + is_done = name == "done" + + self._btn_back.set_sensitive(not is_first) + self._btn_skip.set_visible(not is_welcome and not is_done) + self._btn_next.set_label("Start dictating →" if is_last else "Next →") + + # Step counter (exclude welcome and done from the count) + core_pages = [p for p in self._page_order if p not in ("welcome", "done")] + if name in core_pages: + step = core_pages.index(name) + 1 + self._step_lbl.set_text(f"Step {step} of {len(core_pages)}") + else: + self._step_lbl.set_text("") + + # When leaving "trigger", re-evaluate page order. + if name == "trigger": + self._read_trigger() + self._refresh_page_order() + + def _go(self, direction: int, skip: bool = False) -> None: + name = self._page_order[self._idx] + if not skip: + self._commit_page(name) + if direction > 0 and self._idx >= len(self._page_order) - 1: + self._finish() + return + self._show(self._idx + direction) + + def _finish(self) -> None: + self._apply_to_cfg() + self.dlg.response(Gtk.ResponseType.OK) + + # ------------------------------------------------------------------ + # Page builders + # ------------------------------------------------------------------ + + def _build_welcome(self) -> Gtk.Widget: + box = _page_box() + box.set_valign(Gtk.Align.CENTER) + + logo = Gtk.Label(label="🎙") + attrs = Pango.AttrList() + attrs.insert(Pango.attr_scale_new(4.0)) + logo.set_attributes(attrs) + logo.set_margin_bottom(12) + box.pack_start(logo, False, False, 0) + + box.pack_start(_h1("Welcome to Blitztext"), False, False, 0) + box.pack_start(_sub( + "Blitztext lets you speak and have your words typed anywhere on screen — " + "with optional AI polishing.\n\n" + "This short wizard sets up the basics. " + "You can change everything later in Settings." + ), False, False, 0) + return box + + def _build_trigger(self) -> Gtk.Widget: + box = _page_box() + box.pack_start(_h1("How do you want to trigger recording?"), False, False, 0) + box.pack_start(_sub("You can change this later in Settings → Input."), False, False, 0) + + self._rb_kb, card_kb = _option_card("⌨", "Keyboard shortcut", + "Press a key combination to start and stop recording. " + "Great for desktop use.") + self._rb_ww, card_ww = _option_card("🎙", "Voice wakeword", + "Say a wake phrase like \"okay computer\" to start hands-free. " + "Needs a wakeword server.") + self._rb_both, card_both = _option_card("✨", "Both", + "Use a keyboard shortcut AND a voice wakeword — whichever is handy.") + + # Group the radio buttons + self._rb_ww.join_group(self._rb_kb) + self._rb_both.join_group(self._rb_kb) + self._rb_kb.set_active(True) + + for rb, card in ((self._rb_kb, card_kb), (self._rb_ww, card_ww), + (self._rb_both, card_both)): + # Make the card clickable by forwarding clicks to the radio button + ebox = Gtk.EventBox() + ebox.add(card) + ebox.connect("button-press-event", + lambda _e, _ev, r=rb: r.set_active(True)) + box.pack_start(ebox, False, False, 0) + + return box + + def _build_keyboard(self) -> Gtk.Widget: + box = _page_box() + box.pack_start(_h1("Set up your keyboard shortcuts"), False, False, 0) + box.pack_start(_sub( + 'Click "Set" and press a key combination to capture it. ' + "The defaults shown here work well for most users." + ), False, False, 0) + + grid = Gtk.Grid(column_spacing=8, row_spacing=8) + grid.set_margin_top(16) + + rows = [ + ("Start recording", self._kb_start, "_kb_start"), + ("Stop and paste", self._kb_stop, "_kb_stop"), + ("Stop, paste + Enter", self._kb_send, "_kb_send"), + ("Cancel", self._kb_cancel, "_kb_cancel"), + ] + self._kb_entries: dict[str, Gtk.Entry] = {} + + for i, (label, value, attr) in enumerate(rows): + lbl = Gtk.Label(label=label, xalign=0.0) + lbl.set_size_request(180, -1) + grid.attach(lbl, 0, i, 1, 1) + + entry = Gtk.Entry() + entry.set_text(value) + entry.set_size_request(140, -1) + entry.set_editable(False) + self._kb_entries[attr] = entry + grid.attach(entry, 1, i, 1, 1) + + btn = Gtk.Button(label="Set") + btn.connect("clicked", lambda _b, e=entry: self._bind_key(e)) + grid.attach(btn, 2, i, 1, 1) + + box.pack_start(grid, False, False, 0) + + note = Gtk.Label(xalign=0.0) + note.set_markup( + '' + "Tip: use modifier keys (Ctrl, Alt, Win/Cmd) rather than letters " + "so shortcuts don't interfere with typing." + "") + note.set_line_wrap(True) + note.set_margin_top(8) + box.pack_start(note, False, False, 0) + return box + + def _build_wakeword(self) -> Gtk.Widget: + box = _page_box() + box.pack_start(_h1("Set up voice activation"), False, False, 0) + box.pack_start(_sub( + "Blitztext listens for your wake phrase through a local wakeword server. " + "The server runs on your machine — nothing is sent to the cloud." + ), False, False, 0) + + grid = Gtk.Grid(column_spacing=8, row_spacing=10) + grid.set_margin_top(16) + + # Server URL + lbl_uri = Gtk.Label(label="Server URL", xalign=0.0) + lbl_uri.set_size_request(120, -1) + self._wiz_ww_uri = Gtk.Entry() + self._wiz_ww_uri.set_text(self._ww_uri) + self._wiz_ww_uri.set_hexpand(True) + self._wiz_ww_uri.set_placeholder_text("tcp://127.0.0.1:10400") + grid.attach(lbl_uri, 0, 0, 1, 1) + grid.attach(self._wiz_ww_uri, 1, 0, 1, 1) + + # Model + lbl_model = Gtk.Label(label="Wake phrase", xalign=0.0) + self._wiz_ww_model = Gtk.Entry() + self._wiz_ww_model.set_text(self._ww_model) + self._wiz_ww_model.set_placeholder_text("okay_computer") + grid.attach(lbl_model, 0, 1, 1, 1) + grid.attach(self._wiz_ww_model, 1, 1, 1, 1) + + box.pack_start(grid, False, False, 0) + + # Test row + test_row = Gtk.Box(spacing=10) + test_row.set_margin_top(8) + self._wiz_ww_test_btn = Gtk.Button(label="Test connection") + self._wiz_ww_test_btn.connect("clicked", self._ww_test) + self._wiz_ww_test_lbl = Gtk.Label(label="", xalign=0.0) + test_row.pack_start(self._wiz_ww_test_btn, False, False, 0) + test_row.pack_start(self._wiz_ww_test_lbl, False, False, 0) + box.pack_start(test_row, False, False, 0) + + # Setup hint (collapsible-ish — just always visible for simplicity) + hint = Gtk.Label(xalign=0.0) + hint.set_markup( + '' + "Don't have a wakeword server yet? Run one with Docker:\n" + "docker run -it -p 10400:10400 homeassistant/wyoming-openwakeword" + "") + hint.set_line_wrap(True) + hint.set_selectable(True) + hint.set_margin_top(12) + box.pack_start(hint, False, False, 0) + return box + + def _build_stt(self) -> Gtk.Widget: + box = _page_box() + box.pack_start(_h1("Choose your speech-to-text engine"), False, False, 0) + box.pack_start(_sub( + "Blitztext transcribes your speech locally using Whisper, " + "or you can connect to a remote OpenAI-compatible API." + ), False, False, 0) + + # Local vs remote toggle + self._rb_local, card_local = _option_card( + "💻", "Local (faster-whisper)", + "Runs on your machine — private, no API key needed. " + "Choose the model size below.") + self._rb_remote, card_remote = _option_card( + "☁", "Remote API", + "Send audio to an OpenAI-compatible endpoint " + "(OpenAI, local Whisper server, etc.).") + self._rb_remote.join_group(self._rb_local) + self._rb_local.set_active(True) + + for rb, card in ((self._rb_local, card_local), (self._rb_remote, card_remote)): + ebox = Gtk.EventBox() + ebox.add(card) + ebox.connect("button-press-event", + lambda _e, _ev, r=rb: (r.set_active(True), + self._stt_toggle())) + box.pack_start(ebox, False, False, 0) + rb.connect("toggled", lambda _r: self._stt_toggle()) + + # Local options + self._stt_local_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self._stt_local_box.set_margin_top(8) + size_lbl = Gtk.Label(label="Model size", xalign=0.0) + size_lbl.get_style_context().add_class("dim-label") + + self._stt_size_rb: dict[str, Gtk.RadioButton] = {} + sizes = [ + ("tiny", "Tiny — fastest, basic accuracy"), + ("base", "Base — fast, decent accuracy"), + ("small", "Small — balanced ✓ recommended"), + ("medium", "Medium — better accuracy, slower"), + ("large-v3", "Large — best accuracy, most memory"), + ] + size_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) + prev = None + for key, desc in sizes: + rb = Gtk.RadioButton(label=desc) + if prev: + rb.join_group(prev) + rb.set_active(key == self._stt_size) + rb.connect("toggled", lambda r, k=key: r.get_active() and + setattr(self, "_stt_size", k)) + self._stt_size_rb[key] = rb + size_box.pack_start(rb, False, False, 0) + prev = rb + self._stt_local_box.pack_start(size_lbl, False, False, 0) + self._stt_local_box.pack_start(size_box, False, False, 0) + box.pack_start(self._stt_local_box, False, False, 0) + + # Remote options + self._stt_remote_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self._stt_remote_box.set_margin_top(8) + rgrid = Gtk.Grid(column_spacing=8, row_spacing=6) + rlbl_url = Gtk.Label(label="API URL", xalign=0.0); rlbl_url.set_size_request(100, -1) + self._stt_url_entry = Gtk.Entry(); self._stt_url_entry.set_hexpand(True) + self._stt_url_entry.set_text(self._stt_url or "http://localhost:8010/v1") + self._stt_url_entry.set_placeholder_text("http://localhost:8010/v1") + rlbl_key = Gtk.Label(label="API key env", xalign=0.0) + self._stt_key_entry = Gtk.Entry(); self._stt_key_entry.set_hexpand(True) + self._stt_key_entry.set_text(self._stt_key) + self._stt_key_entry.set_placeholder_text("OPENAI_API_KEY (leave empty if not needed)") + rgrid.attach(rlbl_url, 0, 0, 1, 1); rgrid.attach(self._stt_url_entry, 1, 0, 1, 1) + rgrid.attach(rlbl_key, 0, 1, 1, 1); rgrid.attach(self._stt_key_entry, 1, 1, 1, 1) + self._stt_remote_box.pack_start(rgrid, False, False, 0) + self._stt_remote_box.set_no_show_all(True) + box.pack_start(self._stt_remote_box, False, False, 0) + + first_run_note = Gtk.Label(xalign=0.0) + first_run_note.set_markup( + '' + "The local model is downloaded the first time you use it (~500 MB for Small). " + "Subsequent starts are instant." + "") + first_run_note.set_line_wrap(True) + first_run_note.set_margin_top(6) + box.pack_start(first_run_note, False, False, 0) + return box + + def _build_llm(self) -> Gtk.Widget: + box = _page_box() + box.pack_start(_h1("AI text processing (optional)"), False, False, 0) + box.pack_start(_sub( + "Blitztext can send your transcript to an AI model that rewrites it — " + "cleaning up speech artifacts, adjusting tone, or reformatting it." + ), False, False, 0) + + enable_row = Gtk.Box(spacing=10) + enable_row.set_margin_top(12) + self._llm_switch = Gtk.Switch() + self._llm_switch.set_active(self._llm_enabled) + self._llm_switch.set_valign(Gtk.Align.CENTER) + enable_lbl = Gtk.Label(label="Enable AI rewriting", xalign=0.0) + enable_row.pack_start(self._llm_switch, False, False, 0) + enable_row.pack_start(enable_lbl, False, False, 0) + box.pack_start(enable_row, False, False, 0) + + self._llm_detail = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self._llm_detail.set_margin_top(10) + + lgrid = Gtk.Grid(column_spacing=8, row_spacing=8) + llbl_url = Gtk.Label(label="API URL", xalign=0.0); llbl_url.set_size_request(120, -1) + self._llm_url_entry = Gtk.Entry(); self._llm_url_entry.set_hexpand(True) + self._llm_url_entry.set_text(self._llm_url) + self._llm_url_entry.set_placeholder_text("https://api.openai.com/v1") + + llbl_model = Gtk.Label(label="Model", xalign=0.0) + self._llm_model_entry = Gtk.Entry(); self._llm_model_entry.set_hexpand(True) + self._llm_model_entry.set_text(self._llm_model) + self._llm_model_entry.set_placeholder_text("gpt-4o-mini") + + llbl_key = Gtk.Label(label="API key env", xalign=0.0) + self._llm_key_entry = Gtk.Entry(); self._llm_key_entry.set_hexpand(True) + self._llm_key_entry.set_text(self._llm_key) + self._llm_key_entry.set_placeholder_text("OPENAI_API_KEY") + + lgrid.attach(llbl_url, 0, 0, 1, 1); lgrid.attach(self._llm_url_entry, 1, 0, 1, 1) + lgrid.attach(llbl_model, 0, 1, 1, 1); lgrid.attach(self._llm_model_entry, 1, 1, 1, 1) + lgrid.attach(llbl_key, 0, 2, 1, 1); lgrid.attach(self._llm_key_entry, 1, 2, 1, 1) + self._llm_detail.pack_start(lgrid, False, False, 0) + + llm_note = Gtk.Label(xalign=0.0) + llm_note.set_markup( + '' + "Works with OpenAI, or a local model via Ollama / LM Studio / vLLM. " + "You can fine-tune prompts per workflow in Settings → Workflows." + "") + llm_note.set_line_wrap(True) + self._llm_detail.pack_start(llm_note, False, False, 0) + + box.pack_start(self._llm_detail, False, False, 0) + + self._llm_switch.connect("notify::active", lambda s, _p: self._llm_toggle()) + self._llm_toggle() + return box + + def _build_done(self) -> Gtk.Widget: + box = _page_box() + box.set_valign(Gtk.Align.CENTER) + + lbl_done = Gtk.Label(label="✅") + attrs = Pango.AttrList() + attrs.insert(Pango.attr_scale_new(3.5)) + lbl_done.set_attributes(attrs) + lbl_done.set_margin_bottom(12) + box.pack_start(lbl_done, False, False, 0) + + box.pack_start(_h1("You're all set!"), False, False, 0) + self._done_summary = Gtk.Label(xalign=0.0) + self._done_summary.set_line_wrap(True) + self._done_summary.set_max_width_chars(60) + self._done_summary.get_style_context().add_class("dim-label") + box.pack_start(self._done_summary, False, False, 0) + + tip = Gtk.Label(xalign=0.0) + tip.set_markup( + '\n' + "You can always open Settings from the system tray to add workflows, " + "tune STT quality, or configure more shortcuts." + "") + tip.set_line_wrap(True) + box.pack_start(tip, False, False, 0) + return box + + # ------------------------------------------------------------------ + # Page commit helpers + # ------------------------------------------------------------------ + + def _read_trigger(self) -> None: + if self._rb_ww.get_active(): + self._trigger = "wakeword" + elif self._rb_both.get_active(): + self._trigger = "both" + else: + self._trigger = "keyboard" + + def _commit_page(self, name: str) -> None: + if name == "trigger": + self._read_trigger() + self._refresh_page_order() + elif name == "keyboard": + for attr, entry in self._kb_entries.items(): + setattr(self, attr, entry.get_text().strip()) + elif name == "wakeword": + self._ww_uri = self._wiz_ww_uri.get_text().strip() + self._ww_model = self._wiz_ww_model.get_text().strip() + elif name == "stt": + self._stt_local = self._rb_local.get_active() + if not self._stt_local: + self._stt_url = self._stt_url_entry.get_text().strip() + self._stt_key = self._stt_key_entry.get_text().strip() + elif name == "llm": + self._llm_enabled = self._llm_switch.get_active() + self._llm_url = self._llm_url_entry.get_text().strip() + self._llm_model = self._llm_model_entry.get_text().strip() + self._llm_key = self._llm_key_entry.get_text().strip() + self._update_done_summary() + + def _update_done_summary(self) -> None: + lines: list[str] = [] + if self._trigger in ("keyboard", "both"): + lines.append(f"⌨ Keyboard — start: {self._kb_start}, cancel: {self._kb_cancel}") + if self._trigger in ("wakeword", "both"): + lines.append(f"🎙 Wakeword — {self._ww_model!r} @ {self._ww_uri}") + if self._stt_local: + lines.append(f"🤖 Local Whisper ({self._stt_size})") + else: + lines.append(f"☁ Remote STT — {self._stt_url}") + if self._llm_enabled: + lines.append(f"✨ AI rewriting — {self._llm_model} @ {self._llm_url}") + else: + lines.append("✨ AI rewriting — disabled") + if hasattr(self, "_done_summary"): + self._done_summary.set_text("\n".join(lines)) + + # ------------------------------------------------------------------ + # Apply to config + # ------------------------------------------------------------------ + + def _apply_to_cfg(self) -> None: + from .config import save + from .stt import STTEngine + cfg = self.cfg + + # Trigger / keyboard mode + if self._trigger in ("keyboard", "both"): + cfg.input_mode = "modifiers" + cfg.key_start = self._kb_start + cfg.key_stop = self._kb_stop + cfg.key_send = self._kb_send + cfg.key_cancel = self._kb_cancel + + # Wakeword + cfg.wakeword_enabled = self._trigger in ("wakeword", "both") + if cfg.wakeword_enabled: + cfg.wakeword_uri = self._ww_uri + cfg.wakeword_model = self._ww_model + from .config import WakewordEngine + # Update or create the first engine preset. + if cfg.wakeword_engines: + cfg.wakeword_engines[0].uri = self._ww_uri + cfg.wakeword_engines[0].model = self._ww_model + else: + cfg.wakeword_engines = [WakewordEngine( + name="Default", uri=self._ww_uri, model=self._ww_model)] + cfg.wakeword_active = cfg.wakeword_engines[0].name + + # STT + if self._stt_local: + local_eng = STTEngine(name="Local Whisper", type="local", + model=self._stt_size) + # Replace or add. + locals_ = [e for e in cfg.stt_engines if e.is_local] + if locals_: + idx = cfg.stt_engines.index(locals_[0]) + cfg.stt_engines[idx] = local_eng + else: + cfg.stt_engines.insert(0, local_eng) + cfg.stt_active = local_eng.name + cfg.model = self._stt_size + else: + remote_eng = STTEngine(name="Remote STT", type="openai", + url=self._stt_url, api_key_env=self._stt_key) + remotes = [e for e in cfg.stt_engines if not e.is_local] + if remotes: + idx = cfg.stt_engines.index(remotes[0]) + cfg.stt_engines[idx] = remote_eng + else: + cfg.stt_engines.append(remote_eng) + cfg.stt_active = remote_eng.name + + # LLM + if self._llm_enabled: + cfg.base_url = self._llm_url + cfg.rewrite_model = self._llm_model + cfg.api_key_env = self._llm_key + from .llm import LLMEngine + llm_eng = LLMEngine("Default", self._llm_url, + self._llm_model, self._llm_key) + if cfg.llm_engines: + cfg.llm_engines[0] = llm_eng + else: + cfg.llm_engines = [llm_eng] + cfg.llm_active = llm_eng.name + + # Mark setup as complete so the wizard doesn't auto-show again. + cfg.setup_complete = True + save(cfg) + + # ------------------------------------------------------------------ + # UI toggle helpers + # ------------------------------------------------------------------ + + def _stt_toggle(self) -> None: + local = self._rb_local.get_active() + self._stt_local_box.set_visible(local) + self._stt_remote_box.set_visible(not local) + + def _llm_toggle(self) -> None: + on = self._llm_switch.get_active() + self._llm_detail.set_sensitive(on) + + # ------------------------------------------------------------------ + # Wakeword connection test + # ------------------------------------------------------------------ + + def _ww_test(self, _btn) -> None: + uri = self._wiz_ww_uri.get_text().strip() + self._wiz_ww_test_lbl.set_text("Testing…") + self._wiz_ww_test_btn.set_sensitive(False) + + def _probe(): + import socket as _socket + from urllib.parse import urlparse + p = urlparse(uri) + host = p.hostname or "127.0.0.1" + port = p.port or 10400 + try: + with _socket.create_connection((host, port), timeout=3.0): + ok = True + except OSError: + ok = False + + def _update(): + self._wiz_ww_test_btn.set_sensitive(True) + if ok: + self._wiz_ww_test_lbl.set_markup( + '✓ Connected') + else: + self._wiz_ww_test_lbl.set_markup( + '✗ Could not connect — is the server running?') + GLib.idle_add(_update) + + threading.Thread(target=_probe, daemon=True).start() + + # ------------------------------------------------------------------ + # Key-binding capture + # ------------------------------------------------------------------ + + def _bind_key(self, entry: Gtk.Entry) -> None: + self._bind_entry = entry + self._bind_pressed = [] + entry.set_text("") + entry.set_placeholder_text("press the key combination…") + + def _on_key_press(self, _w, event) -> bool: + if self._bind_entry is None: + return False + tok = _keyval_token(event.keyval) + if tok and tok not in self._bind_pressed: + self._bind_pressed.append(tok) + return True + + def _on_key_release(self, _w, event) -> bool: + if self._bind_entry is None: + return False + combo = _format_combo(self._bind_pressed) + if combo: + self._bind_entry.set_text(combo) + self._bind_entry.set_placeholder_text("") + self._bind_entry = None + self._bind_pressed = [] + return True + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def run(self) -> None: + self.dlg.show_all() + # Hide remote STT box initially (local is default) + self._stt_remote_box.hide() + self.dlg.run() + self.dlg.destroy() + + +# --------------------------------------------------------------------------- +# Convenience: show if this is a fresh install +# --------------------------------------------------------------------------- + +def maybe_show(cfg, parent: Gtk.Window | None = None) -> None: + """Show the wizard if setup has never been completed.""" + if not getattr(cfg, "setup_complete", False): + wiz = SetupWizard(cfg, parent=parent) + wiz.run()