From ad66a0d3cb921aff5ecce2951ed3dbca9bb56188 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Tue, 10 Feb 2026 11:00:52 -0300 Subject: [PATCH] Use base URL for chat completions --- README.md | 89 ++------ src/aiprocess.py | 45 ++-- src/config.py | 194 +++++++---------- src/settings_window.py | 485 ++++++++++++++++------------------------- 4 files changed, 320 insertions(+), 493 deletions(-) diff --git a/README.md b/README.md index 9629066..ae7d24f 100644 --- a/README.md +++ b/README.md @@ -17,19 +17,19 @@ Python X11 transcription daemon that records audio, runs Whisper, logs the trans Install Python deps: ```bash -pip install -r requirements.txt +uv sync ``` Run: ```bash -python3 src/leld.py --config ~/.config/lel/config.json +uv run python3 src/leld.py --config ~/.config/lel/config.json ``` Open settings: ```bash -python3 src/leld.py --settings --config ~/.config/lel/config.json +uv run python3 src/leld.py --settings --config ~/.config/lel/config.json ``` ## Config @@ -38,66 +38,28 @@ Create `~/.config/lel/config.json`: ```json { - "hotkey": "Cmd+m", - "edit_hotkey": "Cmd+n", - "ffmpeg_input": "pulse:default", - "ffmpeg_path": "", - "whisper_model": "base", - "whisper_lang": "en", - "whisper_device": "cpu", - "record_timeout_sec": 120, - "edit_record_timeout_sec": 120, - "injection_backend": "clipboard", - "edit_injection_backend": "clipboard", - "languages": { - "en": { "code": "en", "hotkey": "Cmd+m", "label": "English" }, - "ptBR": { "code": "pt-BR", "hotkey": "Cmd+b", "label": "Português (Brasil)" } - }, - "edit_language_detection": { "enabled": true, "provider": "langdetect", "fallback_code": "en" }, + "daemon": { "hotkey": "Cmd+m" }, + "recording": { "input": "pulse:default" }, + "transcribing": { "model": "base", "device": "cpu" }, + "injection": { "backend": "clipboard" }, - "context_capture": { - "provider": "i3ipc", - "on_focus_change": "abort" - }, - "context_rules": [ - { - "tag": "terminal", - "match": { "class": "Alacritty" }, - "ai_enabled": false - }, - { - "tag": "chat", - "match": { "title_contains": "Slack" }, - "ai_prompt_file": "/home/thales/.config/lel/prompts/slack.txt" - } - ], - - "ai_enabled": true, - "ai_model": "llama3.2:3b", - "ai_temperature": 0.0, - "ai_system_prompt_file": "", - "ai_base_url": "http://localhost:11434/v1/chat/completions", - "ai_api_key": "", - "ai_timeout_sec": 20, - "edit_ai_enabled": true, - "edit_ai_temperature": 0.0, - "edit_ai_system_prompt_file": "", - "edit_window": { "width": 800, "height": 400 } + "ai_cleanup": { + "enabled": true, + "model": "llama3.2:3b", + "temperature": 0.0, + "base_url": "http://localhost:11434", + "api_key": "" + } } ``` Env overrides: -- `WHISPER_MODEL`, `WHISPER_LANG`, `WHISPER_DEVICE` +- `WHISPER_MODEL`, `WHISPER_DEVICE` - `WHISPER_FFMPEG_IN` -- `LEL_RECORD_TIMEOUT_SEC`, `LEL_HOTKEY`, `LEL_INJECTION_BACKEND` -- `LEL_EDIT_RECORD_TIMEOUT_SEC`, `LEL_EDIT_HOTKEY`, `LEL_EDIT_INJECTION_BACKEND` -- `LEL_FFMPEG_PATH` -- `LEL_AI_ENABLED`, `LEL_AI_MODEL`, `LEL_AI_TEMPERATURE`, `LEL_AI_SYSTEM_PROMPT_FILE` -- `LEL_AI_BASE_URL`, `LEL_AI_API_KEY`, `LEL_AI_TIMEOUT_SEC` -- `LEL_EDIT_AI_ENABLED`, `LEL_EDIT_AI_TEMPERATURE`, `LEL_EDIT_AI_SYSTEM_PROMPT_FILE` -- `LEL_CONTEXT_PROVIDER`, `LEL_CONTEXT_ON_FOCUS_CHANGE` -- `LEL_LANGUAGES_JSON`, `LEL_EDIT_LANG_FALLBACK` +- `LEL_HOTKEY`, `LEL_INJECTION_BACKEND` +- `LEL_AI_CLEANUP_ENABLED`, `LEL_AI_CLEANUP_MODEL`, `LEL_AI_CLEANUP_TEMPERATURE` +- `LEL_AI_CLEANUP_BASE_URL`, `LEL_AI_CLEANUP_API_KEY` ## systemd user service @@ -114,14 +76,6 @@ systemctl --user enable --now lel - Press the hotkey once to start recording. - Press it again to stop and transcribe. - The transcript is logged to stderr. -- Press the edit hotkey to open the edit window; click Apply to edit using spoken instructions. - - Default language hotkeys: English `Cmd+m`, Portuguese (Brazil) `Cmd+b`. - -Edit workflow notes: - -- Uses the X11 primary selection (currently selected text). -- Opens a floating GTK window with the selected text. -- Records your spoken edit instruction until you click Apply. Injection backends: @@ -130,13 +84,12 @@ Injection backends: AI provider: -- Generic OpenAI-compatible chat API at `ai_base_url` +- Generic OpenAI-compatible chat API at `ai_base_url` (base URL only; the app uses `/v1/chat/completions`) -Context capture: +Context capture (i3 only): -- `context_capture` stores the focused window at hotkey time (via i3 IPC). +- The focused window at hotkey time is stored via i3 IPC. - If focus changes before injection, the workflow aborts (interpreted as a cancel). -- `context_rules` lets you match on app/title and override AI/injection behavior. Control: diff --git a/src/aiprocess.py b/src/aiprocess.py index 39fc6ac..f784fd5 100644 --- a/src/aiprocess.py +++ b/src/aiprocess.py @@ -49,7 +49,8 @@ class GenericAPIProcessor: "temperature": self.cfg.temperature, } data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request(self.cfg.base_url, data=data, method="POST") + url = _chat_completions_url(self.cfg.base_url) + req = urllib.request.Request(url, data=data, method="POST") req.add_header("Content-Type", "application/json") if self.cfg.api_key: req.add_header("Authorization", f"Bearer {self.cfg.api_key}") @@ -101,10 +102,28 @@ def list_models(base_url: str, api_key: str = "", timeout_sec: int = 10) -> list def _models_url(base_url: str) -> str: - if "/v1/" in base_url: - root = base_url.split("/v1/")[0] - return root.rstrip("/") + "/v1/models" - return base_url.rstrip("/") + "/v1/models" + root = _root_url(base_url) + return root.rstrip("/") + "/v1/models" + + +def _chat_completions_url(base_url: str) -> str: + if not base_url: + return "" + trimmed = base_url.rstrip("/") + if "/v1/" in trimmed: + return trimmed + if trimmed.endswith("/v1"): + return trimmed + "/chat/completions" + return trimmed + "/v1/chat/completions" + + +def _root_url(base_url: str) -> str: + trimmed = base_url.rstrip("/") + if "/v1/" in trimmed: + return trimmed.split("/v1/")[0] + if trimmed.endswith("/v1"): + return trimmed[: -len("/v1")] + return trimmed def _read_text(arg_text: str) -> str: @@ -130,20 +149,20 @@ def main() -> int: json.dumps(redacted_dict(cfg), indent=2), ) - if not cfg.ai_enabled: + if not cfg.ai_cleanup.get("enabled", False): logging.warning("ai_enabled is false; proceeding anyway") - prompt = load_system_prompt(cfg.ai_system_prompt_file) + prompt = load_system_prompt("") logging.info("system prompt:\n%s", prompt) processor = build_processor( AIConfig( - model=cfg.ai_model, - temperature=cfg.ai_temperature, - system_prompt_file=cfg.ai_system_prompt_file, - base_url=cfg.ai_base_url, - api_key=cfg.ai_api_key, - timeout_sec=cfg.ai_timeout_sec, + model=cfg.ai_cleanup.get("model", ""), + temperature=cfg.ai_cleanup.get("temperature", 0.0), + system_prompt_file="", + base_url=cfg.ai_cleanup.get("base_url", ""), + api_key=cfg.ai_cleanup.get("api_key", ""), + timeout_sec=25, ) ) diff --git a/src/config.py b/src/config.py index 47428c2..27ca226 100644 --- a/src/config.py +++ b/src/config.py @@ -10,43 +10,21 @@ def _parse_bool(val: str) -> bool: @dataclass class Config: - hotkey: str = "Cmd+m" - edit_hotkey: str = "Cmd+n" - ffmpeg_input: str = "pulse:default" - ffmpeg_path: str = "" - - whisper_model: str = "base" - whisper_lang: str = "en" - whisper_device: str = "cpu" - - record_timeout_sec: int = 120 - edit_record_timeout_sec: int = 120 - - injection_backend: str = "clipboard" - edit_injection_backend: str = "clipboard" - - ai_enabled: bool = False - ai_model: str = "llama3.2:3b" - ai_temperature: float = 0.0 - ai_system_prompt_file: str = "" - ai_base_url: str = "http://localhost:11434/v1/chat/completions" - ai_api_key: str = "" - ai_timeout_sec: int = 20 - edit_ai_enabled: bool = True - edit_ai_temperature: float = 0.0 - edit_ai_system_prompt_file: str = "" - edit_window: dict = field(default_factory=lambda: {"width": 800, "height": 400}) - - context_capture: dict = field(default_factory=lambda: {"provider": "i3ipc", "on_focus_change": "abort"}) - context_rules: list[dict] = field(default_factory=list) - - languages: dict = field( + daemon: dict = field(default_factory=lambda: {"hotkey": "Cmd+m"}) + recording: dict = field(default_factory=lambda: {"input": "pulse:default"}) + transcribing: dict = field(default_factory=lambda: {"model": "base", "device": "cpu"}) + injection: dict = field(default_factory=lambda: {"backend": "clipboard"}) + ai_cleanup: dict = field( default_factory=lambda: { - "en": {"code": "en", "hotkey": "Cmd+m", "label": "English"}, - "ptBR": {"code": "pt-BR", "hotkey": "Cmd+b", "label": "Português (Brasil)"}, + "enabled": False, + "model": "llama3.2:3b", + "temperature": 0.0, + "base_url": "http://localhost:11434", + "api_key": "", } ) - edit_language_detection: dict = field(default_factory=lambda: {"enabled": True, "provider": "langdetect", "fallback_code": "en"}) + + def default_path() -> Path: @@ -58,112 +36,92 @@ def load(path: str | None) -> Config: p = Path(path) if path else default_path() if p.exists(): data = json.loads(p.read_text(encoding="utf-8")) - for k, v in data.items(): - if hasattr(cfg, k): - setattr(cfg, k, v) + if any(k in data for k in ("daemon", "recording", "transcribing", "injection", "ai_cleanup", "ai")): + for k, v in data.items(): + if hasattr(cfg, k): + setattr(cfg, k, v) + else: + cfg.daemon["hotkey"] = data.get("hotkey", cfg.daemon["hotkey"]) + cfg.recording["input"] = data.get("ffmpeg_input", cfg.recording["input"]) + cfg.transcribing["model"] = data.get("whisper_model", cfg.transcribing["model"]) + cfg.transcribing["device"] = data.get("whisper_device", cfg.transcribing["device"]) + cfg.injection["backend"] = data.get("injection_backend", cfg.injection["backend"]) + cfg.ai_cleanup["enabled"] = data.get("ai_enabled", cfg.ai_cleanup["enabled"]) + cfg.ai_cleanup["model"] = data.get("ai_model", cfg.ai_cleanup["model"]) + cfg.ai_cleanup["temperature"] = data.get("ai_temperature", cfg.ai_cleanup["temperature"]) + cfg.ai_cleanup["base_url"] = data.get("ai_base_url", cfg.ai_cleanup["base_url"]) + cfg.ai_cleanup["api_key"] = data.get("ai_api_key", cfg.ai_cleanup["api_key"]) - if not isinstance(cfg.context_capture, dict): - cfg.context_capture = {"provider": "i3ipc", "on_focus_change": "abort"} - if not isinstance(cfg.context_rules, list): - cfg.context_rules = [] + if not isinstance(cfg.daemon, dict): + cfg.daemon = {"hotkey": "Cmd+m"} + if not isinstance(cfg.recording, dict): + cfg.recording = {"input": "pulse:default"} + if not isinstance(cfg.transcribing, dict): + cfg.transcribing = {"model": "base", "device": "cpu"} + if not isinstance(cfg.injection, dict): + cfg.injection = {"backend": "clipboard"} + if not isinstance(cfg.ai_cleanup, dict): + cfg.ai_cleanup = { + "enabled": False, + "model": "llama3.2:3b", + "temperature": 0.0, + "base_url": "http://localhost:11434", + "api_key": "", + } + if isinstance(getattr(cfg, "ai", None), dict) and not cfg.ai_cleanup: + cfg.ai_cleanup = cfg.ai + if hasattr(cfg, "ai"): + try: + delattr(cfg, "ai") + except Exception: + pass # env overrides if os.getenv("WHISPER_MODEL"): - cfg.whisper_model = os.environ["WHISPER_MODEL"] - if os.getenv("WHISPER_LANG"): - cfg.whisper_lang = os.environ["WHISPER_LANG"] + cfg.transcribing["model"] = os.environ["WHISPER_MODEL"] if os.getenv("WHISPER_DEVICE"): - cfg.whisper_device = os.environ["WHISPER_DEVICE"] + cfg.transcribing["device"] = os.environ["WHISPER_DEVICE"] if os.getenv("WHISPER_FFMPEG_IN"): - cfg.ffmpeg_input = os.environ["WHISPER_FFMPEG_IN"] + cfg.recording["input"] = os.environ["WHISPER_FFMPEG_IN"] - if os.getenv("LEL_FFMPEG_PATH"): - cfg.ffmpeg_path = os.environ["LEL_FFMPEG_PATH"] - if os.getenv("LEL_RECORD_TIMEOUT_SEC"): - cfg.record_timeout_sec = int(os.environ["LEL_RECORD_TIMEOUT_SEC"]) - if os.getenv("LEL_EDIT_RECORD_TIMEOUT_SEC"): - cfg.edit_record_timeout_sec = int(os.environ["LEL_EDIT_RECORD_TIMEOUT_SEC"]) if os.getenv("LEL_HOTKEY"): - cfg.hotkey = os.environ["LEL_HOTKEY"] - if os.getenv("LEL_EDIT_HOTKEY"): - cfg.edit_hotkey = os.environ["LEL_EDIT_HOTKEY"] + cfg.daemon["hotkey"] = os.environ["LEL_HOTKEY"] if os.getenv("LEL_INJECTION_BACKEND"): - cfg.injection_backend = os.environ["LEL_INJECTION_BACKEND"] - if os.getenv("LEL_EDIT_INJECTION_BACKEND"): - cfg.edit_injection_backend = os.environ["LEL_EDIT_INJECTION_BACKEND"] + cfg.injection["backend"] = os.environ["LEL_INJECTION_BACKEND"] + + if os.getenv("LEL_AI_CLEANUP_ENABLED"): + cfg.ai_cleanup["enabled"] = _parse_bool(os.environ["LEL_AI_CLEANUP_ENABLED"]) + if os.getenv("LEL_AI_CLEANUP_MODEL"): + cfg.ai_cleanup["model"] = os.environ["LEL_AI_CLEANUP_MODEL"] + if os.getenv("LEL_AI_CLEANUP_TEMPERATURE"): + cfg.ai_cleanup["temperature"] = float(os.environ["LEL_AI_CLEANUP_TEMPERATURE"]) + if os.getenv("LEL_AI_CLEANUP_BASE_URL"): + cfg.ai_cleanup["base_url"] = os.environ["LEL_AI_CLEANUP_BASE_URL"] + if os.getenv("LEL_AI_CLEANUP_API_KEY"): + cfg.ai_cleanup["api_key"] = os.environ["LEL_AI_CLEANUP_API_KEY"] if os.getenv("LEL_AI_ENABLED"): - cfg.ai_enabled = _parse_bool(os.environ["LEL_AI_ENABLED"]) + cfg.ai_cleanup["enabled"] = _parse_bool(os.environ["LEL_AI_ENABLED"]) if os.getenv("LEL_AI_MODEL"): - cfg.ai_model = os.environ["LEL_AI_MODEL"] + cfg.ai_cleanup["model"] = os.environ["LEL_AI_MODEL"] if os.getenv("LEL_AI_TEMPERATURE"): - cfg.ai_temperature = float(os.environ["LEL_AI_TEMPERATURE"]) - if os.getenv("LEL_AI_SYSTEM_PROMPT_FILE"): - cfg.ai_system_prompt_file = os.environ["LEL_AI_SYSTEM_PROMPT_FILE"] + cfg.ai_cleanup["temperature"] = float(os.environ["LEL_AI_TEMPERATURE"]) if os.getenv("LEL_AI_BASE_URL"): - cfg.ai_base_url = os.environ["LEL_AI_BASE_URL"] + cfg.ai_cleanup["base_url"] = os.environ["LEL_AI_BASE_URL"] if os.getenv("LEL_AI_API_KEY"): - cfg.ai_api_key = os.environ["LEL_AI_API_KEY"] - if os.getenv("LEL_AI_TIMEOUT_SEC"): - cfg.ai_timeout_sec = int(os.environ["LEL_AI_TIMEOUT_SEC"]) - if os.getenv("LEL_EDIT_AI_ENABLED"): - cfg.edit_ai_enabled = _parse_bool(os.environ["LEL_EDIT_AI_ENABLED"]) - if os.getenv("LEL_EDIT_AI_TEMPERATURE"): - cfg.edit_ai_temperature = float(os.environ["LEL_EDIT_AI_TEMPERATURE"]) - if os.getenv("LEL_EDIT_AI_SYSTEM_PROMPT_FILE"): - cfg.edit_ai_system_prompt_file = os.environ["LEL_EDIT_AI_SYSTEM_PROMPT_FILE"] - - if os.getenv("LEL_LANGUAGES_JSON"): - cfg.languages = json.loads(os.environ["LEL_LANGUAGES_JSON"]) - if os.getenv("LEL_EDIT_LANG_FALLBACK"): - cfg.edit_language_detection["fallback_code"] = os.environ["LEL_EDIT_LANG_FALLBACK"] - - if os.getenv("LEL_CONTEXT_PROVIDER"): - cfg.context_capture["provider"] = os.environ["LEL_CONTEXT_PROVIDER"] - if os.getenv("LEL_CONTEXT_ON_FOCUS_CHANGE"): - cfg.context_capture["on_focus_change"] = os.environ["LEL_CONTEXT_ON_FOCUS_CHANGE"] - + cfg.ai_cleanup["api_key"] = os.environ["LEL_AI_API_KEY"] validate(cfg) return cfg def redacted_dict(cfg: Config) -> dict: d = cfg.__dict__.copy() - d["ai_api_key"] = "" + if isinstance(d.get("ai_cleanup"), dict): + d["ai_cleanup"] = d["ai_cleanup"].copy() + d["ai_cleanup"]["api_key"] = "" return d def validate(cfg: Config) -> None: - if not cfg.hotkey: - raise ValueError("hotkey cannot be empty") - if not cfg.edit_hotkey: - raise ValueError("edit_hotkey cannot be empty") - if cfg.record_timeout_sec <= 0: - raise ValueError("record_timeout_sec must be > 0") - if cfg.edit_record_timeout_sec <= 0: - raise ValueError("edit_record_timeout_sec must be > 0") - if cfg.context_capture.get("provider") not in {"i3ipc"}: - raise ValueError("context_capture.provider must be i3ipc") - if cfg.context_capture.get("on_focus_change") not in {"abort"}: - raise ValueError("context_capture.on_focus_change must be abort") - if not isinstance(cfg.context_rules, list): - cfg.context_rules = [] - if not isinstance(cfg.edit_window, dict): - cfg.edit_window = {"width": 800, "height": 400} - if not isinstance(cfg.languages, dict) or not cfg.languages: - raise ValueError("languages must be a non-empty map") - seen_hotkeys = set() - for name, info in cfg.languages.items(): - if not isinstance(info, dict): - raise ValueError(f"languages[{name}] must be an object") - code = info.get("code") - hotkey = info.get("hotkey") - if not code or not hotkey: - raise ValueError(f"languages[{name}] must include code and hotkey") - if hotkey in seen_hotkeys: - raise ValueError(f"duplicate hotkey in languages: {hotkey}") - seen_hotkeys.add(hotkey) - if not isinstance(cfg.edit_language_detection, dict): - cfg.edit_language_detection = {"enabled": True, "provider": "langdetect", "fallback_code": "en"} - if cfg.edit_language_detection.get("provider") not in {"langdetect"}: - raise ValueError("edit_language_detection.provider must be langdetect") + if not cfg.daemon.get("hotkey"): + raise ValueError("daemon.hotkey cannot be empty") diff --git a/src/settings_window.py b/src/settings_window.py index c37d6bb..ebe4a9f 100644 --- a/src/settings_window.py +++ b/src/settings_window.py @@ -15,7 +15,6 @@ from gi.repository import Gdk, Gtk from config import Config, validate from history import HistoryStore -from recorder import _resolve_ffmpeg_path from aiprocess import list_models @@ -125,7 +124,7 @@ class SettingsWindow: if not text: self.widgets["quick_status"].set_text("No input text") return - language = self.widgets["quick_language"].get_text().strip() + language = "en" output = text steps = self._collect_quick_steps() if not steps: @@ -142,10 +141,10 @@ class SettingsWindow: AIConfig( model=step["model"], temperature=step["temperature"], - system_prompt_file=self.cfg.ai_system_prompt_file, + system_prompt_file="", base_url=step["base_url"], api_key=step["api_key"], - timeout_sec=step["timeout"], + timeout_sec=25, language_hint=language, ) ) @@ -155,10 +154,10 @@ class SettingsWindow: AIConfig( model=step["model"], temperature=step["temperature"], - system_prompt_file=self.cfg.ai_system_prompt_file, + system_prompt_file="", base_url=step["base_url"], api_key=step["api_key"], - timeout_sec=step["timeout"], + timeout_sec=25, language_hint=language, ) ) @@ -196,12 +195,12 @@ class SettingsWindow: prompt_text = prompt_buf.get_text(start, end, True).strip() steps.append( { - "model": model or self.cfg.ai_model, + "model": model or self.cfg.ai_cleanup.get("model", ""), "temperature": float(e["temperature"].get_value()), "prompt_text": prompt_text, - "base_url": e["base_url"].get_text().strip() or self.cfg.ai_base_url, - "api_key": e["api_key"].get_text().strip() or self.cfg.ai_api_key, - "timeout": int(e["timeout"].get_value()), + "base_url": e["base_url"].get_text().strip() or self.cfg.ai_cleanup.get("base_url", ""), + "api_key": e["api_key"].get_text().strip() or self.cfg.ai_cleanup.get("api_key", ""), + "timeout": 25, } ) return steps @@ -231,12 +230,12 @@ class SettingsWindow: processor = build_processor( AIConfig( - model=self.cfg.ai_model, - temperature=self.cfg.ai_temperature, - system_prompt_file=self.cfg.ai_system_prompt_file, - base_url=self.cfg.ai_base_url, - api_key=self.cfg.ai_api_key, - timeout_sec=self.cfg.ai_timeout_sec, + model=self.cfg.ai_cleanup.get("model", ""), + temperature=self.cfg.ai_cleanup.get("temperature", 0.0), + system_prompt_file="", + base_url=self.cfg.ai_cleanup.get("base_url", ""), + api_key=self.cfg.ai_cleanup.get("api_key", ""), + timeout_sec=25, ) ) output = processor.process(text) @@ -247,16 +246,34 @@ class SettingsWindow: dialog.destroy() def _build_tabs(self): - self._add_tab("Hotkeys", self._build_hotkeys_tab()) - self._add_tab("Recording", self._build_recording_tab()) - self._add_tab("STT", self._build_stt_tab()) - self._add_tab("Injection", self._build_injection_tab()) - self._add_tab("AI", self._build_ai_tab()) - self._add_tab("Edit", self._build_edit_tab()) - self._add_tab("Context", self._build_context_tab()) + self._add_tab("Settings", self._build_settings_tab()) self._add_tab("History", self._build_history_tab()) self._add_tab("Quick Run", self._build_quick_run_tab()) + def _build_settings_tab(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_border_width(8) + + box.pack_start(self._section_label("Daemon"), False, False, 0) + box.pack_start(self._build_hotkeys_tab(), False, False, 0) + + box.pack_start(self._section_label("Recording"), False, False, 0) + box.pack_start(self._build_recording_tab(), False, False, 0) + + box.pack_start(self._section_label("Transcribing"), False, False, 0) + box.pack_start(self._build_stt_tab(), False, False, 0) + + box.pack_start(self._section_label("Injection"), False, False, 0) + box.pack_start(self._build_injection_tab(), False, False, 0) + + box.pack_start(self._section_label("AI Cleanup"), False, False, 0) + box.pack_start(self._build_ai_tab(), False, False, 0) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.add(box) + return scroll + def _add_tab(self, title: str, widget: Gtk.Widget): label = Gtk.Label(label=title) self.notebook.append_page(widget, label) @@ -302,67 +319,30 @@ class SettingsWindow: def _build_hotkeys_tab(self) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) grid = self._grid() - self.widgets["hotkey"] = self._entry(self.cfg.hotkey) - self.widgets["edit_hotkey"] = self._entry(self.cfg.edit_hotkey) - self._row(grid, 0, "Hotkey", self.widgets["hotkey"]) - self._row(grid, 1, "Edit Hotkey", self.widgets["edit_hotkey"]) + current_hotkey = self.cfg.daemon.get("hotkey", "") + self.widgets["hotkey_value"] = Gtk.Label(label=current_hotkey or "(not set)") + self.widgets["hotkey_value"].set_xalign(0.0) + set_btn = Gtk.Button(label="Set Hotkey") + set_btn.connect("clicked", lambda *_: self._capture_hotkey()) + hotkey_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + hotkey_row.pack_start(self.widgets["hotkey_value"], True, True, 0) + hotkey_row.pack_start(set_btn, False, False, 0) + self._row(grid, 0, "Hotkey", hotkey_row) box.pack_start(grid, False, False, 0) - - lang_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - lang_label = Gtk.Label(label="Languages") - lang_label.set_xalign(0.0) - lang_box.pack_start(lang_label, False, False, 0) - - self.lang_list = Gtk.ListBox() - for key, info in self.cfg.languages.items(): - self._add_language_row(key, info) - lang_box.pack_start(self.lang_list, False, False, 0) - - btn_add = Gtk.Button(label="Add Language") - btn_add.connect("clicked", lambda *_: self._add_language_row("", {"code": "", "hotkey": "", "label": ""})) - lang_box.pack_start(btn_add, False, False, 0) - - box.pack_start(lang_box, False, False, 0) return box - def _add_language_row(self, key: str, info: dict): - row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - key_entry = self._entry(key) - code_entry = self._entry(info.get("code", "")) - hotkey_entry = self._entry(info.get("hotkey", "")) - label_entry = self._entry(info.get("label", "")) - row.pack_start(Gtk.Label(label="Key"), False, False, 0) - row.pack_start(key_entry, True, True, 0) - row.pack_start(Gtk.Label(label="Code"), False, False, 0) - row.pack_start(code_entry, True, True, 0) - row.pack_start(Gtk.Label(label="Hotkey"), False, False, 0) - row.pack_start(hotkey_entry, True, True, 0) - row.pack_start(Gtk.Label(label="Label"), False, False, 0) - row.pack_start(label_entry, True, True, 0) - btn_remove = Gtk.Button(label="Remove") - btn_remove.connect("clicked", lambda *_: self.lang_list.remove(row)) - row.pack_start(btn_remove, False, False, 0) - row._lel_lang_entries = (key_entry, code_entry, hotkey_entry, label_entry) - self.lang_list.add(row) - self.lang_list.show_all() - def _build_recording_tab(self) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) grid = self._grid() self.widgets["ffmpeg_input"] = Gtk.ComboBoxText() self._populate_mic_sources() - self.widgets["ffmpeg_path"] = self._entry(self.cfg.ffmpeg_path) - self.widgets["record_timeout_sec"] = self._spin(self.cfg.record_timeout_sec, 1, 3600) - self.widgets["edit_record_timeout_sec"] = self._spin(self.cfg.edit_record_timeout_sec, 1, 3600) refresh_btn = Gtk.Button(label="Refresh") refresh_btn.connect("clicked", lambda *_: self._populate_mic_sources()) mic_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) mic_row.pack_start(self.widgets["ffmpeg_input"], True, True, 0) mic_row.pack_start(refresh_btn, False, False, 0) self._row(grid, 0, "Microphone", mic_row) - self._row(grid, 1, "FFmpeg Path", self.widgets["ffmpeg_path"]) - self._row(grid, 2, "Record Timeout (sec)", self.widgets["record_timeout_sec"]) - self._row(grid, 3, "Edit Record Timeout (sec)", self.widgets["edit_record_timeout_sec"]) + # Record timeout is fixed (300s); no UI control. box.pack_start(grid, False, False, 0) return box @@ -371,14 +351,14 @@ class SettingsWindow: text = combo.get_active_text() or "" if text.startswith("pulse:"): return text.split(" ", 1)[0] - return self.cfg.ffmpeg_input + return self.cfg.recording.get("input", "pulse:default") def _populate_mic_sources(self): combo: Gtk.ComboBoxText = self.widgets["ffmpeg_input"] combo.remove_all() sources, default_name = self._list_pulse_sources() self._mic_sources = sources - selected = self.cfg.ffmpeg_input or "pulse:default" + selected = self.cfg.recording.get("input") or "pulse:default" selected_index = 0 for idx, (name, desc) in enumerate(sources): text = f"pulse:{name} ({desc})" @@ -428,92 +408,39 @@ class SettingsWindow: def _build_stt_tab(self) -> Gtk.Widget: grid = self._grid() - self.widgets["whisper_model"] = self._entry(self.cfg.whisper_model) - self.widgets["whisper_lang"] = self._entry(self.cfg.whisper_lang) - self.widgets["whisper_device"] = self._entry(self.cfg.whisper_device) + models = ["tiny", "base", "small", "medium", "large", "large-v2", "large-v3"] + model_value = self.cfg.transcribing.get("model", "base") + self.widgets["whisper_model"] = self._combo(models, model_value if model_value in models else "base") + device_value = (self.cfg.transcribing.get("device", "cpu") or "cpu").lower() + self.widgets["whisper_device"] = self._combo(["cpu", "cuda"], device_value if device_value in {"cpu", "cuda"} else "cpu") self._row(grid, 0, "Model", self.widgets["whisper_model"]) - self._row(grid, 1, "Language", self.widgets["whisper_lang"]) - self._row(grid, 2, "Device", self.widgets["whisper_device"]) + self._row(grid, 1, "Device", self.widgets["whisper_device"]) return grid def _build_injection_tab(self) -> Gtk.Widget: grid = self._grid() - self.widgets["injection_backend"] = self._entry(self.cfg.injection_backend) - self.widgets["edit_injection_backend"] = self._entry(self.cfg.edit_injection_backend) + backend_value = self.cfg.injection.get("backend", "clipboard") + self.widgets["injection_backend"] = self._combo(["clipboard", "injection"], backend_value) self._row(grid, 0, "Injection Backend", self.widgets["injection_backend"]) - self._row(grid, 1, "Edit Injection Backend", self.widgets["edit_injection_backend"]) return grid def _build_ai_tab(self) -> Gtk.Widget: grid = self._grid() self.widgets["ai_enabled"] = Gtk.CheckButton() - self.widgets["ai_enabled"].set_active(self.cfg.ai_enabled) - self.widgets["ai_model"] = self._entry(self.cfg.ai_model) - self.widgets["ai_temperature"] = self._float_spin(self.cfg.ai_temperature, 0.0, 2.0, 0.05) - self.widgets["ai_system_prompt_file"] = self._entry(self.cfg.ai_system_prompt_file) - self.widgets["ai_base_url"] = self._entry(self.cfg.ai_base_url) - self.widgets["ai_api_key"] = self._entry(self.cfg.ai_api_key) + self.widgets["ai_enabled"].set_active(self.cfg.ai_cleanup.get("enabled", False)) + self.widgets["ai_model"] = self._entry(self.cfg.ai_cleanup.get("model", "")) + self.widgets["ai_temperature"] = self._float_spin(float(self.cfg.ai_cleanup.get("temperature", 0.0)), 0.0, 2.0, 0.05) + self.widgets["ai_base_url"] = self._entry(self.cfg.ai_cleanup.get("base_url", "")) + self.widgets["ai_api_key"] = self._entry(self.cfg.ai_cleanup.get("api_key", "")) self.widgets["ai_api_key"].set_visibility(False) - self.widgets["ai_timeout_sec"] = self._spin(self.cfg.ai_timeout_sec, 1, 600) self._row(grid, 0, "AI Enabled", self.widgets["ai_enabled"]) - self._row(grid, 1, "AI Model", self.widgets["ai_model"]) - self._row(grid, 2, "AI Temperature", self.widgets["ai_temperature"]) - self._row(grid, 3, "AI Prompt File", self.widgets["ai_system_prompt_file"]) - self._row(grid, 4, "AI Base URL", self.widgets["ai_base_url"]) - self._row(grid, 5, "AI API Key", self.widgets["ai_api_key"]) - self._row(grid, 6, "AI Timeout (sec)", self.widgets["ai_timeout_sec"]) + self._row(grid, 1, "AI Cleanup Model", self.widgets["ai_model"]) + self._row(grid, 2, "AI Cleanup Temperature", self.widgets["ai_temperature"]) + self._row(grid, 3, "AI Cleanup Base URL", self.widgets["ai_base_url"]) + self._row(grid, 4, "AI Cleanup API Key", self.widgets["ai_api_key"]) + # AI timeout is fixed (25s); no UI control. return grid - def _build_edit_tab(self) -> Gtk.Widget: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - grid = self._grid() - self.widgets["edit_ai_enabled"] = Gtk.CheckButton() - self.widgets["edit_ai_enabled"].set_active(self.cfg.edit_ai_enabled) - self.widgets["edit_ai_temperature"] = self._float_spin(self.cfg.edit_ai_temperature, 0.0, 2.0, 0.05) - self.widgets["edit_ai_system_prompt_file"] = self._entry(self.cfg.edit_ai_system_prompt_file) - self.widgets["edit_window_width"] = self._spin(self.cfg.edit_window.get("width", 800), 200, 2400) - self.widgets["edit_window_height"] = self._spin(self.cfg.edit_window.get("height", 400), 200, 1600) - self._row(grid, 0, "Edit AI Enabled", self.widgets["edit_ai_enabled"]) - self._row(grid, 1, "Edit AI Temperature", self.widgets["edit_ai_temperature"]) - self._row(grid, 2, "Edit Prompt File", self.widgets["edit_ai_system_prompt_file"]) - self._row(grid, 3, "Edit Window Width", self.widgets["edit_window_width"]) - self._row(grid, 4, "Edit Window Height", self.widgets["edit_window_height"]) - box.pack_start(grid, False, False, 0) - - detect_grid = self._grid() - self.widgets["edit_lang_enabled"] = Gtk.CheckButton() - self.widgets["edit_lang_enabled"].set_active(self.cfg.edit_language_detection.get("enabled", True)) - self.widgets["edit_lang_provider"] = self._entry(self.cfg.edit_language_detection.get("provider", "langdetect")) - self.widgets["edit_lang_fallback"] = self._entry(self.cfg.edit_language_detection.get("fallback_code", "en")) - self._row(detect_grid, 0, "Edit Lang Detect Enabled", self.widgets["edit_lang_enabled"]) - self._row(detect_grid, 1, "Edit Lang Provider", self.widgets["edit_lang_provider"]) - self._row(detect_grid, 2, "Edit Lang Fallback", self.widgets["edit_lang_fallback"]) - box.pack_start(detect_grid, False, False, 0) - return box - - def _build_context_tab(self) -> Gtk.Widget: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - grid = self._grid() - self.widgets["context_provider"] = self._entry(self.cfg.context_capture.get("provider", "i3ipc")) - self.widgets["context_on_focus_change"] = self._entry(self.cfg.context_capture.get("on_focus_change", "abort")) - self._row(grid, 0, "Context Provider", self.widgets["context_provider"]) - self._row(grid, 1, "On Focus Change", self.widgets["context_on_focus_change"]) - box.pack_start(grid, False, False, 0) - - rules_label = Gtk.Label(label="Context Rules") - rules_label.set_xalign(0.0) - box.pack_start(rules_label, False, False, 0) - - self.rules_list = Gtk.ListBox() - for rule in self.cfg.context_rules: - self._add_rule_row(rule) - box.pack_start(self.rules_list, False, False, 0) - - btn_add = Gtk.Button(label="Add Rule") - btn_add.connect("clicked", lambda *_: self._add_rule_row({})) - box.pack_start(btn_add, False, False, 0) - return box - def _build_history_tab(self) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) header = Gtk.Label(label="History") @@ -569,7 +496,8 @@ class SettingsWindow: box.pack_start(scroll, True, True, 0) opts = self._grid() - self.widgets["quick_language"] = self._entry(self.cfg.whisper_lang) + self.widgets["quick_language"] = self._entry("en") + self.widgets["quick_language"].set_editable(False) self._row(opts, 0, "Language Hint", self.widgets["quick_language"]) box.pack_start(opts, False, False, 0) @@ -582,12 +510,11 @@ class SettingsWindow: self.quick_steps.set_can_focus(False) self._add_quick_step_row( { - "model": self.cfg.ai_model, - "temperature": self.cfg.ai_temperature, - "prompt_file": self.cfg.ai_system_prompt_file, - "base_url": self.cfg.ai_base_url, - "api_key": self.cfg.ai_api_key, - "timeout": self.cfg.ai_timeout_sec, + "model": self.cfg.ai_cleanup.get("model", ""), + "temperature": self.cfg.ai_cleanup.get("temperature", 0.0), + "base_url": self.cfg.ai_cleanup.get("base_url", ""), + "api_key": self.cfg.ai_cleanup.get("api_key", ""), + "timeout": 25, } ) box.pack_start(self.quick_steps, False, False, 0) @@ -618,10 +545,10 @@ class SettingsWindow: grid = self._grid() model_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) model_combo = Gtk.ComboBoxText() - model_entry = self._entry(step.get("model", self.cfg.ai_model)) + model_entry = self._entry(step.get("model", self.cfg.ai_cleanup.get("model", ""))) model_box.pack_start(model_combo, True, True, 0) model_box.pack_start(model_entry, True, True, 0) - temperature = self._float_spin(step.get("temperature", self.cfg.ai_temperature), 0.0, 2.0, 0.05) + temperature = self._float_spin(step.get("temperature", self.cfg.ai_cleanup.get("temperature", 0.0)), 0.0, 2.0, 0.05) prompt_text = Gtk.TextView() prompt_text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) prompt_buf = prompt_text.get_buffer() @@ -629,16 +556,15 @@ class SettingsWindow: prompt_scroll = Gtk.ScrolledWindow() prompt_scroll.set_size_request(400, 120) prompt_scroll.add(prompt_text) - base_url = self._entry(step.get("base_url", self.cfg.ai_base_url)) - api_key = self._entry(step.get("api_key", self.cfg.ai_api_key)) + base_url = self._entry(step.get("base_url", self.cfg.ai_cleanup.get("base_url", ""))) + api_key = self._entry(step.get("api_key", self.cfg.ai_cleanup.get("api_key", ""))) api_key.set_visibility(False) - timeout = self._spin(step.get("timeout", self.cfg.ai_timeout_sec), 1, 600) self._row(grid, 0, "AI Model", model_box) self._row(grid, 1, "AI Temperature", temperature) self._row(grid, 2, "AI Prompt", prompt_scroll) self._row(grid, 3, "AI Base URL", base_url) self._row(grid, 4, "AI API Key", api_key) - self._row(grid, 5, "AI Timeout (sec)", timeout) + # AI timeout is fixed (25s); no UI control. base_url.connect("changed", lambda *_: self._refresh_models_for_row(row)) controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) @@ -662,7 +588,7 @@ class SettingsWindow: "prompt_text": prompt_text, "base_url": base_url, "api_key": api_key, - "timeout": timeout, + "timeout": 25, } self._refresh_models_for_row(row) self.quick_steps.add(row) @@ -683,8 +609,7 @@ class SettingsWindow: e = row._lel_step_entries base_url = e["base_url"].get_text().strip() api_key = e["api_key"].get_text().strip() - timeout = int(e["timeout"].get_value()) - models = self._get_models(base_url, api_key, timeout) + models = self._get_models(base_url, api_key, 25) combo = e["model_combo"] entry = e["model_entry"] combo.remove_all() @@ -706,64 +631,6 @@ class SettingsWindow: self._model_cache[key] = models return models - def _add_rule_row(self, rule: dict): - row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - top = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - tag_entry = self._entry(rule.get("tag", "")) - ai_prompt_entry = self._entry(rule.get("ai_prompt_file", "")) - inj_entry = self._entry(rule.get("injection_backend", "")) - ai_enabled = self._combo(["default", "true", "false"], "default") - if rule.get("ai_enabled") is True: - ai_enabled.set_active(1) - elif rule.get("ai_enabled") is False: - ai_enabled.set_active(2) - top.pack_start(Gtk.Label(label="Tag"), False, False, 0) - top.pack_start(tag_entry, True, True, 0) - top.pack_start(Gtk.Label(label="AI Prompt"), False, False, 0) - top.pack_start(ai_prompt_entry, True, True, 0) - top.pack_start(Gtk.Label(label="AI Enabled"), False, False, 0) - top.pack_start(ai_enabled, False, False, 0) - top.pack_start(Gtk.Label(label="Injection"), False, False, 0) - top.pack_start(inj_entry, True, True, 0) - - match = rule.get("match") or {} - match_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - app_id = self._entry(match.get("app_id", "")) - klass = self._entry(match.get("class", "")) - instance = self._entry(match.get("instance", "")) - title_contains = self._entry(match.get("title_contains", "")) - title_regex = self._entry(match.get("title_regex", "")) - match_row.pack_start(Gtk.Label(label="App ID"), False, False, 0) - match_row.pack_start(app_id, True, True, 0) - match_row.pack_start(Gtk.Label(label="Class"), False, False, 0) - match_row.pack_start(klass, True, True, 0) - match_row.pack_start(Gtk.Label(label="Instance"), False, False, 0) - match_row.pack_start(instance, True, True, 0) - match_row.pack_start(Gtk.Label(label="Title Contains"), False, False, 0) - match_row.pack_start(title_contains, True, True, 0) - match_row.pack_start(Gtk.Label(label="Title Regex"), False, False, 0) - match_row.pack_start(title_regex, True, True, 0) - - btn_remove = Gtk.Button(label="Remove") - btn_remove.connect("clicked", lambda *_: self.rules_list.remove(row)) - - row.pack_start(top, False, False, 0) - row.pack_start(match_row, False, False, 0) - row.pack_start(btn_remove, False, False, 0) - row._lel_rule_entries = { - "tag": tag_entry, - "ai_prompt_file": ai_prompt_entry, - "ai_enabled": ai_enabled, - "injection_backend": inj_entry, - "app_id": app_id, - "class": klass, - "instance": instance, - "title_contains": title_contains, - "title_regex": title_regex, - } - self.rules_list.add(row) - self.rules_list.show_all() - def _on_save(self, *_args): try: cfg = self._collect_config() @@ -778,86 +645,116 @@ class SettingsWindow: def _collect_config(self) -> Config: cfg = Config() - cfg.hotkey = self.widgets["hotkey"].get_text().strip() - cfg.edit_hotkey = self.widgets["edit_hotkey"].get_text().strip() - cfg.ffmpeg_input = self._selected_mic_source() - cfg.ffmpeg_path = self.widgets["ffmpeg_path"].get_text().strip() - cfg.record_timeout_sec = int(self.widgets["record_timeout_sec"].get_value()) - cfg.edit_record_timeout_sec = int(self.widgets["edit_record_timeout_sec"].get_value()) - cfg.whisper_model = self.widgets["whisper_model"].get_text().strip() - cfg.whisper_lang = self.widgets["whisper_lang"].get_text().strip() - cfg.whisper_device = self.widgets["whisper_device"].get_text().strip() - cfg.injection_backend = self.widgets["injection_backend"].get_text().strip() - cfg.edit_injection_backend = self.widgets["edit_injection_backend"].get_text().strip() - cfg.ai_enabled = self.widgets["ai_enabled"].get_active() - cfg.ai_model = self.widgets["ai_model"].get_text().strip() - cfg.ai_temperature = float(self.widgets["ai_temperature"].get_value()) - cfg.ai_system_prompt_file = self.widgets["ai_system_prompt_file"].get_text().strip() - cfg.ai_base_url = self.widgets["ai_base_url"].get_text().strip() - cfg.ai_api_key = self.widgets["ai_api_key"].get_text().strip() - cfg.ai_timeout_sec = int(self.widgets["ai_timeout_sec"].get_value()) - cfg.edit_ai_enabled = self.widgets["edit_ai_enabled"].get_active() - cfg.edit_ai_temperature = float(self.widgets["edit_ai_temperature"].get_value()) - cfg.edit_ai_system_prompt_file = self.widgets["edit_ai_system_prompt_file"].get_text().strip() - cfg.edit_window = { - "width": int(self.widgets["edit_window_width"].get_value()), - "height": int(self.widgets["edit_window_height"].get_value()), - } - cfg.edit_language_detection = { - "enabled": self.widgets["edit_lang_enabled"].get_active(), - "provider": self.widgets["edit_lang_provider"].get_text().strip() or "langdetect", - "fallback_code": self.widgets["edit_lang_fallback"].get_text().strip() or "en", - } - cfg.context_capture = { - "provider": self.widgets["context_provider"].get_text().strip() or "i3ipc", - "on_focus_change": self.widgets["context_on_focus_change"].get_text().strip() or "abort", - } - cfg.context_rules = self._collect_rules() - cfg.languages = self._collect_languages() + hotkey_text = self.widgets["hotkey_value"].get_text().strip() + cfg.daemon["hotkey"] = "" if hotkey_text == "(not set)" else hotkey_text + cfg.recording["input"] = self._selected_mic_source() + model = self.widgets["whisper_model"].get_active_text() + cfg.transcribing["model"] = (model or "base").strip() + device = self.widgets["whisper_device"].get_active_text() + cfg.transcribing["device"] = (device or "cpu").strip() + backend = self.widgets["injection_backend"].get_active_text() + cfg.injection["backend"] = (backend or "").strip() or "clipboard" + cfg.ai_cleanup["enabled"] = self.widgets["ai_enabled"].get_active() + cfg.ai_cleanup["model"] = self.widgets["ai_model"].get_text().strip() + cfg.ai_cleanup["temperature"] = float(self.widgets["ai_temperature"].get_value()) + cfg.ai_cleanup["base_url"] = self.widgets["ai_base_url"].get_text().strip() + cfg.ai_cleanup["api_key"] = self.widgets["ai_api_key"].get_text().strip() return cfg - def _collect_languages(self) -> dict: - out: dict[str, dict] = {} - for row in self.lang_list.get_children(): - key_entry, code_entry, hotkey_entry, label_entry = row._lel_lang_entries - key = key_entry.get_text().strip() - if not key: - continue - out[key] = { - "code": code_entry.get_text().strip(), - "hotkey": hotkey_entry.get_text().strip(), - "label": label_entry.get_text().strip(), - } - return out + def _section_label(self, text: str) -> Gtk.Label: + label = Gtk.Label(label=text) + label.set_xalign(0.0) + ctx = label.get_style_context() + ctx.add_class("section") + return label - def _collect_rules(self) -> list[dict]: - rules: list[dict] = [] - for row in self.rules_list.get_children(): - e = row._lel_rule_entries - ai_enabled_val = e["ai_enabled"].get_active_text() - ai_enabled = None - if ai_enabled_val == "true": - ai_enabled = True - elif ai_enabled_val == "false": - ai_enabled = False - match = { - "app_id": e["app_id"].get_text().strip(), - "class": e["class"].get_text().strip(), - "instance": e["instance"].get_text().strip(), - "title_contains": e["title_contains"].get_text().strip(), - "title_regex": e["title_regex"].get_text().strip(), - } - match = {k: v for k, v in match.items() if v} - rule = { - "tag": e["tag"].get_text().strip(), - "ai_prompt_file": e["ai_prompt_file"].get_text().strip(), - "ai_enabled": ai_enabled, - "injection_backend": e["injection_backend"].get_text().strip(), - "match": match, - } - rule = {k: v for k, v in rule.items() if v is not None and v != ""} - rules.append(rule) - return rules + def _capture_hotkey(self): + dialog = Gtk.Dialog(title="Set Hotkey", transient_for=self.window, flags=0) + dialog.set_modal(True) + dialog.set_default_size(360, 120) + dialog.set_decorated(True) + dialog.set_resizable(False) + + box = dialog.get_content_area() + info = Gtk.Label(label="Press desired hotkey…\nEsc cancels, Backspace/Delete clears") + info.set_xalign(0.0) + status = Gtk.Label(label="") + status.set_xalign(0.0) + box.pack_start(info, False, False, 8) + box.pack_start(status, False, False, 8) + + def on_key(_widget, event): + keyval = event.keyval + if keyval == Gdk.KEY_Escape: + dialog.destroy() + return True + if keyval in (Gdk.KEY_BackSpace, Gdk.KEY_Delete): + self.widgets["hotkey_value"].set_text("(not set)") + dialog.destroy() + return True + + hotkey = self._format_hotkey(event) + if not hotkey: + status.set_text("Please include a modifier key.") + return True + self.widgets["hotkey_value"].set_text(hotkey) + dialog.destroy() + return True + + dialog.connect("key-press-event", on_key) + dialog.show_all() + + def _format_hotkey(self, event) -> str: + state = event.state + mods = [] + if state & Gdk.ModifierType.SUPER_MASK: + mods.append("Cmd") + if state & Gdk.ModifierType.CONTROL_MASK: + mods.append("Ctrl") + if state & Gdk.ModifierType.MOD1_MASK: + mods.append("Alt") + if state & Gdk.ModifierType.SHIFT_MASK: + mods.append("Shift") + if not mods: + return "" + + key_name = Gdk.keyval_name(event.keyval) or "" + if not key_name: + return "" + if len(key_name) == 1: + key_name = key_name.upper() + key_map = { + "Return": "Enter", + "space": "Space", + "Tab": "Tab", + "Escape": "Esc", + } + key_name = key_map.get(key_name, key_name) + return "+".join(mods + [key_name]) + + def _build_settings_tab(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_border_width(8) + + box.pack_start(self._section_label("Daemon"), False, False, 0) + box.pack_start(self._build_hotkeys_tab(), False, False, 0) + + box.pack_start(self._section_label("Recording"), False, False, 0) + box.pack_start(self._build_recording_tab(), False, False, 0) + + box.pack_start(self._section_label("Transcribing"), False, False, 0) + box.pack_start(self._build_stt_tab(), False, False, 0) + + box.pack_start(self._section_label("Injection"), False, False, 0) + box.pack_start(self._build_injection_tab(), False, False, 0) + + box.pack_start(self._section_label("AI Cleanup"), False, False, 0) + box.pack_start(self._build_ai_tab(), False, False, 0) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.add(box) + return scroll def _write_config(self, cfg: Config): self.config_path.parent.mkdir(parents=True, exist_ok=True)