feat: first-run setup wizard for new users (v2.03.31)

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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-10 12:14:06 +02:00
parent 5aafb572ed
commit ca1f408876
6 changed files with 877 additions and 1 deletions

View File

@ -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

View File

@ -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"

View File

@ -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,

View File

@ -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)

View File

@ -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 ---------------------------------------------------------------

View File

@ -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"<b><small>{GLib.markup_escape_text(text.upper())}</small></b>")
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(
'<span size="small" alpha="75%">'
"Tip: use modifier keys (Ctrl, Alt, Win/Cmd) rather than letters "
"so shortcuts don't interfere with typing."
"</span>")
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(
'<span size="small" alpha="75%">'
"Don't have a wakeword server yet? Run one with Docker:\n"
"<tt>docker run -it -p 10400:10400 homeassistant/wyoming-openwakeword</tt>"
"</span>")
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(
'<span size="small" alpha="65%">'
"The local model is downloaded the first time you use it (~500 MB for Small). "
"Subsequent starts are instant."
"</span>")
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(
'<span size="small" alpha="65%">'
"Works with OpenAI, or a local model via Ollama / LM Studio / vLLM. "
"You can fine-tune prompts per workflow in Settings → Workflows."
"</span>")
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<span size="small" alpha="70%">'
"You can always open <b>Settings</b> from the system tray to add workflows, "
"tune STT quality, or configure more shortcuts."
"</span>")
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(
'<span foreground="#2a7d2a">✓ Connected</span>')
else:
self._wiz_ww_test_lbl.set_markup(
'<span foreground="#cc3333">✗ Could not connect — is the server running?</span>')
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()