Use base URL for chat completions

This commit is contained in:
Thales Maciel 2026-02-10 11:00:52 -03:00
parent a0c3b02ab1
commit ad66a0d3cb
4 changed files with 320 additions and 493 deletions

View file

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

View file

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

View file

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

View file

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