Add settings history and quick AI chain
This commit is contained in:
parent
328dcec458
commit
a0c3b02ab1
13 changed files with 1627 additions and 23 deletions
32
README.md
32
README.md
|
|
@ -7,6 +7,7 @@ Python X11 transcription daemon that records audio, runs Whisper, logs the trans
|
||||||
- X11 (not Wayland)
|
- X11 (not Wayland)
|
||||||
- `ffmpeg`
|
- `ffmpeg`
|
||||||
- `faster-whisper`
|
- `faster-whisper`
|
||||||
|
- `pactl` (PulseAudio utilities for mic selection)
|
||||||
- Tray icon deps: `gtk3`
|
- Tray icon deps: `gtk3`
|
||||||
- i3 window manager (focus metadata via i3 IPC)
|
- i3 window manager (focus metadata via i3 IPC)
|
||||||
- Python deps: `pillow`, `python-xlib`, `faster-whisper`, `PyGObject`, `i3ipc`
|
- Python deps: `pillow`, `python-xlib`, `faster-whisper`, `PyGObject`, `i3ipc`
|
||||||
|
|
@ -25,6 +26,12 @@ Run:
|
||||||
python3 src/leld.py --config ~/.config/lel/config.json
|
python3 src/leld.py --config ~/.config/lel/config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Open settings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 src/leld.py --settings --config ~/.config/lel/config.json
|
||||||
|
```
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Create `~/.config/lel/config.json`:
|
Create `~/.config/lel/config.json`:
|
||||||
|
|
@ -32,13 +39,21 @@ Create `~/.config/lel/config.json`:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"hotkey": "Cmd+m",
|
"hotkey": "Cmd+m",
|
||||||
|
"edit_hotkey": "Cmd+n",
|
||||||
"ffmpeg_input": "pulse:default",
|
"ffmpeg_input": "pulse:default",
|
||||||
"ffmpeg_path": "",
|
"ffmpeg_path": "",
|
||||||
"whisper_model": "base",
|
"whisper_model": "base",
|
||||||
"whisper_lang": "en",
|
"whisper_lang": "en",
|
||||||
"whisper_device": "cpu",
|
"whisper_device": "cpu",
|
||||||
"record_timeout_sec": 120,
|
"record_timeout_sec": 120,
|
||||||
|
"edit_record_timeout_sec": 120,
|
||||||
"injection_backend": "clipboard",
|
"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" },
|
||||||
|
|
||||||
"context_capture": {
|
"context_capture": {
|
||||||
"provider": "i3ipc",
|
"provider": "i3ipc",
|
||||||
|
|
@ -63,7 +78,11 @@ Create `~/.config/lel/config.json`:
|
||||||
"ai_system_prompt_file": "",
|
"ai_system_prompt_file": "",
|
||||||
"ai_base_url": "http://localhost:11434/v1/chat/completions",
|
"ai_base_url": "http://localhost:11434/v1/chat/completions",
|
||||||
"ai_api_key": "",
|
"ai_api_key": "",
|
||||||
"ai_timeout_sec": 20
|
"ai_timeout_sec": 20,
|
||||||
|
"edit_ai_enabled": true,
|
||||||
|
"edit_ai_temperature": 0.0,
|
||||||
|
"edit_ai_system_prompt_file": "",
|
||||||
|
"edit_window": { "width": 800, "height": 400 }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -72,10 +91,13 @@ Env overrides:
|
||||||
- `WHISPER_MODEL`, `WHISPER_LANG`, `WHISPER_DEVICE`
|
- `WHISPER_MODEL`, `WHISPER_LANG`, `WHISPER_DEVICE`
|
||||||
- `WHISPER_FFMPEG_IN`
|
- `WHISPER_FFMPEG_IN`
|
||||||
- `LEL_RECORD_TIMEOUT_SEC`, `LEL_HOTKEY`, `LEL_INJECTION_BACKEND`
|
- `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_FFMPEG_PATH`
|
||||||
- `LEL_AI_ENABLED`, `LEL_AI_MODEL`, `LEL_AI_TEMPERATURE`, `LEL_AI_SYSTEM_PROMPT_FILE`
|
- `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_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_CONTEXT_PROVIDER`, `LEL_CONTEXT_ON_FOCUS_CHANGE`
|
||||||
|
- `LEL_LANGUAGES_JSON`, `LEL_EDIT_LANG_FALLBACK`
|
||||||
|
|
||||||
## systemd user service
|
## systemd user service
|
||||||
|
|
||||||
|
|
@ -92,6 +114,14 @@ systemctl --user enable --now lel
|
||||||
- Press the hotkey once to start recording.
|
- Press the hotkey once to start recording.
|
||||||
- Press it again to stop and transcribe.
|
- Press it again to stop and transcribe.
|
||||||
- The transcript is logged to stderr.
|
- 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:
|
Injection backends:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,4 @@ pillow
|
||||||
python-xlib
|
python-xlib
|
||||||
PyGObject
|
PyGObject
|
||||||
i3ipc
|
i3ipc
|
||||||
|
langdetect
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ class AIConfig:
|
||||||
base_url: str
|
base_url: str
|
||||||
api_key: str
|
api_key: str
|
||||||
timeout_sec: int
|
timeout_sec: int
|
||||||
|
language_hint: str | None = None
|
||||||
|
wrap_transcript: bool = True
|
||||||
|
|
||||||
|
|
||||||
class GenericAPIProcessor:
|
class GenericAPIProcessor:
|
||||||
|
|
@ -31,11 +33,18 @@ class GenericAPIProcessor:
|
||||||
self.system = load_system_prompt(cfg.system_prompt_file)
|
self.system = load_system_prompt(cfg.system_prompt_file)
|
||||||
|
|
||||||
def process(self, text: str) -> str:
|
def process(self, text: str) -> str:
|
||||||
|
language = self.cfg.language_hint or ""
|
||||||
|
if self.cfg.wrap_transcript:
|
||||||
|
user_content = f"<transcript>{text}</transcript>"
|
||||||
|
else:
|
||||||
|
user_content = text
|
||||||
|
if language:
|
||||||
|
user_content = f"<language>{language}</language>\n{user_content}"
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.cfg.model,
|
"model": self.cfg.model,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": self.system},
|
{"role": "system", "content": self.system},
|
||||||
{"role": "user", "content": f"<transcript>{text}</transcript>"},
|
{"role": "user", "content": user_content},
|
||||||
],
|
],
|
||||||
"temperature": self.cfg.temperature,
|
"temperature": self.cfg.temperature,
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +79,34 @@ def build_processor(cfg: AIConfig) -> GenericAPIProcessor:
|
||||||
return GenericAPIProcessor(cfg)
|
return GenericAPIProcessor(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def list_models(base_url: str, api_key: str = "", timeout_sec: int = 10) -> list[str]:
|
||||||
|
if not base_url:
|
||||||
|
return []
|
||||||
|
url = _models_url(base_url)
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
if api_key:
|
||||||
|
req.add_header("Authorization", f"Bearer {api_key}")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
|
||||||
|
body = resp.read()
|
||||||
|
data = json.loads(body.decode("utf-8"))
|
||||||
|
models = []
|
||||||
|
for item in data.get("data", []):
|
||||||
|
model_id = item.get("id")
|
||||||
|
if model_id:
|
||||||
|
models.append(model_id)
|
||||||
|
return models
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
def _read_text(arg_text: str) -> str:
|
def _read_text(arg_text: str) -> str:
|
||||||
if arg_text:
|
if arg_text:
|
||||||
return arg_text
|
return arg_text
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ def _parse_bool(val: str) -> bool:
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
hotkey: str = "Cmd+m"
|
hotkey: str = "Cmd+m"
|
||||||
|
edit_hotkey: str = "Cmd+n"
|
||||||
ffmpeg_input: str = "pulse:default"
|
ffmpeg_input: str = "pulse:default"
|
||||||
ffmpeg_path: str = ""
|
ffmpeg_path: str = ""
|
||||||
|
|
||||||
|
|
@ -19,8 +20,10 @@ class Config:
|
||||||
whisper_device: str = "cpu"
|
whisper_device: str = "cpu"
|
||||||
|
|
||||||
record_timeout_sec: int = 120
|
record_timeout_sec: int = 120
|
||||||
|
edit_record_timeout_sec: int = 120
|
||||||
|
|
||||||
injection_backend: str = "clipboard"
|
injection_backend: str = "clipboard"
|
||||||
|
edit_injection_backend: str = "clipboard"
|
||||||
|
|
||||||
ai_enabled: bool = False
|
ai_enabled: bool = False
|
||||||
ai_model: str = "llama3.2:3b"
|
ai_model: str = "llama3.2:3b"
|
||||||
|
|
@ -29,10 +32,22 @@ class Config:
|
||||||
ai_base_url: str = "http://localhost:11434/v1/chat/completions"
|
ai_base_url: str = "http://localhost:11434/v1/chat/completions"
|
||||||
ai_api_key: str = ""
|
ai_api_key: str = ""
|
||||||
ai_timeout_sec: int = 20
|
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_capture: dict = field(default_factory=lambda: {"provider": "i3ipc", "on_focus_change": "abort"})
|
||||||
context_rules: list[dict] = field(default_factory=list)
|
context_rules: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
languages: dict = field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"en": {"code": "en", "hotkey": "Cmd+m", "label": "English"},
|
||||||
|
"ptBR": {"code": "pt-BR", "hotkey": "Cmd+b", "label": "Português (Brasil)"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
edit_language_detection: dict = field(default_factory=lambda: {"enabled": True, "provider": "langdetect", "fallback_code": "en"})
|
||||||
|
|
||||||
|
|
||||||
def default_path() -> Path:
|
def default_path() -> Path:
|
||||||
return Path.home() / ".config" / "lel" / "config.json"
|
return Path.home() / ".config" / "lel" / "config.json"
|
||||||
|
|
@ -66,10 +81,16 @@ def load(path: str | None) -> Config:
|
||||||
cfg.ffmpeg_path = os.environ["LEL_FFMPEG_PATH"]
|
cfg.ffmpeg_path = os.environ["LEL_FFMPEG_PATH"]
|
||||||
if os.getenv("LEL_RECORD_TIMEOUT_SEC"):
|
if os.getenv("LEL_RECORD_TIMEOUT_SEC"):
|
||||||
cfg.record_timeout_sec = int(os.environ["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"):
|
if os.getenv("LEL_HOTKEY"):
|
||||||
cfg.hotkey = os.environ["LEL_HOTKEY"]
|
cfg.hotkey = os.environ["LEL_HOTKEY"]
|
||||||
|
if os.getenv("LEL_EDIT_HOTKEY"):
|
||||||
|
cfg.edit_hotkey = os.environ["LEL_EDIT_HOTKEY"]
|
||||||
if os.getenv("LEL_INJECTION_BACKEND"):
|
if os.getenv("LEL_INJECTION_BACKEND"):
|
||||||
cfg.injection_backend = os.environ["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"]
|
||||||
|
|
||||||
if os.getenv("LEL_AI_ENABLED"):
|
if os.getenv("LEL_AI_ENABLED"):
|
||||||
cfg.ai_enabled = _parse_bool(os.environ["LEL_AI_ENABLED"])
|
cfg.ai_enabled = _parse_bool(os.environ["LEL_AI_ENABLED"])
|
||||||
|
|
@ -85,22 +106,24 @@ def load(path: str | None) -> Config:
|
||||||
cfg.ai_api_key = os.environ["LEL_AI_API_KEY"]
|
cfg.ai_api_key = os.environ["LEL_AI_API_KEY"]
|
||||||
if os.getenv("LEL_AI_TIMEOUT_SEC"):
|
if os.getenv("LEL_AI_TIMEOUT_SEC"):
|
||||||
cfg.ai_timeout_sec = int(os.environ["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"):
|
if os.getenv("LEL_CONTEXT_PROVIDER"):
|
||||||
cfg.context_capture["provider"] = os.environ["LEL_CONTEXT_PROVIDER"]
|
cfg.context_capture["provider"] = os.environ["LEL_CONTEXT_PROVIDER"]
|
||||||
if os.getenv("LEL_CONTEXT_ON_FOCUS_CHANGE"):
|
if os.getenv("LEL_CONTEXT_ON_FOCUS_CHANGE"):
|
||||||
cfg.context_capture["on_focus_change"] = os.environ["LEL_CONTEXT_ON_FOCUS_CHANGE"]
|
cfg.context_capture["on_focus_change"] = os.environ["LEL_CONTEXT_ON_FOCUS_CHANGE"]
|
||||||
|
|
||||||
if not cfg.hotkey:
|
validate(cfg)
|
||||||
raise ValueError("hotkey cannot be empty")
|
|
||||||
if cfg.record_timeout_sec <= 0:
|
|
||||||
raise ValueError("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 = []
|
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,3 +131,39 @@ def redacted_dict(cfg: Config) -> dict:
|
||||||
d = cfg.__dict__.copy()
|
d = cfg.__dict__.copy()
|
||||||
d["ai_api_key"] = ""
|
d["ai_api_key"] = ""
|
||||||
return d
|
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")
|
||||||
|
|
|
||||||
101
src/edit_window.py
Normal file
101
src/edit_window.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "3.0")
|
||||||
|
gi.require_version("Gdk", "3.0")
|
||||||
|
|
||||||
|
from gi.repository import Gdk, GLib, Gtk
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EditWindowConfig:
|
||||||
|
width: int = 800
|
||||||
|
height: int = 400
|
||||||
|
|
||||||
|
|
||||||
|
class EditWindow:
|
||||||
|
def __init__(self, text: str, cfg: EditWindowConfig, on_apply, on_copy_close):
|
||||||
|
self.on_apply = on_apply
|
||||||
|
self.on_copy_close = on_copy_close
|
||||||
|
|
||||||
|
self.window = Gtk.Window(title="lel edit")
|
||||||
|
self.window.set_default_size(cfg.width, cfg.height)
|
||||||
|
self.window.set_keep_above(True)
|
||||||
|
self.window.set_position(Gtk.WindowPosition.CENTER)
|
||||||
|
self.window.set_type_hint(Gdk.WindowTypeHint.DIALOG)
|
||||||
|
self.window.connect("delete-event", self._on_close)
|
||||||
|
|
||||||
|
self.status = Gtk.Label(label="Listening...")
|
||||||
|
self.status.set_xalign(0.0)
|
||||||
|
|
||||||
|
scrolled = Gtk.ScrolledWindow()
|
||||||
|
scrolled.set_hexpand(True)
|
||||||
|
scrolled.set_vexpand(True)
|
||||||
|
|
||||||
|
self.textview = Gtk.TextView()
|
||||||
|
self.textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
||||||
|
buffer = self.textview.get_buffer()
|
||||||
|
buffer.set_text(text)
|
||||||
|
scrolled.add(self.textview)
|
||||||
|
|
||||||
|
apply_btn = Gtk.Button(label="Apply")
|
||||||
|
apply_btn.connect("clicked", self._on_apply)
|
||||||
|
|
||||||
|
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
button_box.pack_end(apply_btn, False, False, 0)
|
||||||
|
|
||||||
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
vbox.set_border_width(12)
|
||||||
|
vbox.pack_start(self.status, False, False, 0)
|
||||||
|
vbox.pack_start(scrolled, True, True, 0)
|
||||||
|
vbox.pack_start(button_box, False, False, 0)
|
||||||
|
|
||||||
|
self.window.add(vbox)
|
||||||
|
self.window.show_all()
|
||||||
|
self.textview.grab_focus()
|
||||||
|
|
||||||
|
accel = Gtk.AccelGroup()
|
||||||
|
self.window.add_accel_group(accel)
|
||||||
|
key, mod = Gtk.accelerator_parse("<Ctrl>c")
|
||||||
|
accel.connect(key, mod, Gtk.AccelFlags.VISIBLE, self._on_copy)
|
||||||
|
|
||||||
|
def _on_apply(self, *_args):
|
||||||
|
self.on_apply(self.get_text())
|
||||||
|
|
||||||
|
def _on_copy(self, *_args):
|
||||||
|
self.on_copy_close(self.get_text())
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _on_close(self, *_args):
|
||||||
|
self.on_copy_close("")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_text(self) -> str:
|
||||||
|
buf = self.textview.get_buffer()
|
||||||
|
start, end = buf.get_bounds()
|
||||||
|
return buf.get_text(start, end, True)
|
||||||
|
|
||||||
|
def set_status(self, text: str) -> None:
|
||||||
|
self.status.set_text(text)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.window.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def open_edit_window(text: str, cfg: EditWindowConfig, on_apply, on_copy_close) -> EditWindow:
|
||||||
|
holder: dict[str, EditWindow] = {}
|
||||||
|
ready = threading.Event()
|
||||||
|
|
||||||
|
def _create():
|
||||||
|
holder["win"] = EditWindow(text, cfg, on_apply, on_copy_close)
|
||||||
|
ready.set()
|
||||||
|
return False
|
||||||
|
|
||||||
|
GLib.idle_add(_create)
|
||||||
|
if not ready.wait(timeout=2.0):
|
||||||
|
raise RuntimeError("GTK main loop not running; cannot open edit window")
|
||||||
|
return holder["win"]
|
||||||
141
src/history.py
Normal file
141
src/history.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import redacted_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _default_db_path() -> Path:
|
||||||
|
return Path.home() / ".local" / "share" / "lel" / "history.db"
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryStore:
|
||||||
|
def __init__(self, path: Path | None = None):
|
||||||
|
self.path = path or _default_db_path()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.conn = sqlite3.connect(str(self.path), check_same_thread=False)
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _init_db(self):
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
config_json TEXT,
|
||||||
|
context_json TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS artifacts (
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
data_json TEXT,
|
||||||
|
file_path TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
FOREIGN KEY(run_id) REFERENCES runs(id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def add_run(self, phase: str, status: str, config, context: dict | None = None) -> int:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO runs (created_at, phase, status, config_json, context_json) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
time.time(),
|
||||||
|
phase,
|
||||||
|
status,
|
||||||
|
json.dumps(redacted_dict(config)) if config else None,
|
||||||
|
json.dumps(context) if context else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
def add_artifact(self, run_id: int, kind: str, data: dict | None = None, file_path: str | None = None):
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO artifacts (run_id, kind, data_json, file_path, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
run_id,
|
||||||
|
kind,
|
||||||
|
json.dumps(data) if data is not None else None,
|
||||||
|
file_path,
|
||||||
|
time.time(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def list_runs(self, phase: str | None = None, limit: int = 200) -> list[dict]:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
if phase:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, created_at, phase, status, config_json, context_json FROM runs WHERE phase = ? ORDER BY id DESC LIMIT ?",
|
||||||
|
(phase, limit),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, created_at, phase, status, config_json, context_json FROM runs ORDER BY id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"id": row[0],
|
||||||
|
"created_at": row[1],
|
||||||
|
"phase": row[2],
|
||||||
|
"status": row[3],
|
||||||
|
"config": json.loads(row[4]) if row[4] else None,
|
||||||
|
"context": json.loads(row[5]) if row[5] else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def list_artifacts(self, run_id: int) -> list[dict]:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT kind, data_json, file_path, created_at FROM artifacts WHERE run_id = ? ORDER BY created_at ASC",
|
||||||
|
(run_id,),
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"kind": row[0],
|
||||||
|
"data": json.loads(row[1]) if row[1] else None,
|
||||||
|
"file_path": row[2],
|
||||||
|
"created_at": row[3],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def prune(self, limit_per_phase: int = 1000):
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute("SELECT DISTINCT phase FROM runs")
|
||||||
|
phases = [r[0] for r in cur.fetchall()]
|
||||||
|
for phase in phases:
|
||||||
|
cur.execute("SELECT id FROM runs WHERE phase = ? ORDER BY id DESC LIMIT ?", (phase, limit_per_phase))
|
||||||
|
keep_ids = [r[0] for r in cur.fetchall()]
|
||||||
|
if not keep_ids:
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM runs WHERE phase = ? AND id NOT IN (%s)" % ",".join("?" * len(keep_ids)),
|
||||||
|
(phase, *keep_ids),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM artifacts WHERE run_id NOT IN (%s)" % ",".join("?" * len(keep_ids)),
|
||||||
|
(*keep_ids,),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
24
src/language.py
Normal file
24
src/language.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langdetect import DetectorFactory, detect
|
||||||
|
|
||||||
|
DetectorFactory.seed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def detect_language(text: str, fallback: str = "en") -> str:
|
||||||
|
cleaned = (text or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
code = detect(cleaned)
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
return _normalize(code) or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(code: str) -> str:
|
||||||
|
if not code:
|
||||||
|
return ""
|
||||||
|
if code.lower() == "pt":
|
||||||
|
return "pt-BR"
|
||||||
|
return code
|
||||||
297
src/leld.py
297
src/leld.py
|
|
@ -14,9 +14,14 @@ from recorder import start_recording, stop_recording
|
||||||
from stt import FasterWhisperSTT, STTConfig
|
from stt import FasterWhisperSTT, STTConfig
|
||||||
from aiprocess import AIConfig, build_processor
|
from aiprocess import AIConfig, build_processor
|
||||||
from context import ContextRule, I3Provider, match_rule
|
from context import ContextRule, I3Provider, match_rule
|
||||||
from inject import inject
|
from edit_window import EditWindowConfig, open_edit_window
|
||||||
|
from inject import inject, write_clipboard
|
||||||
|
from history import HistoryStore
|
||||||
|
from language import detect_language
|
||||||
|
from selection import read_primary_selection
|
||||||
from x11_hotkey import listen
|
from x11_hotkey import listen
|
||||||
from tray import run_tray
|
from tray import run_tray
|
||||||
|
from settings_window import open_settings_window
|
||||||
|
|
||||||
|
|
||||||
class State:
|
class State:
|
||||||
|
|
@ -24,18 +29,28 @@ class State:
|
||||||
RECORDING = "recording"
|
RECORDING = "recording"
|
||||||
TRANSCRIBING = "transcribing"
|
TRANSCRIBING = "transcribing"
|
||||||
PROCESSING = "processing"
|
PROCESSING = "processing"
|
||||||
|
EDITING = "editing"
|
||||||
|
EDIT_PROCESSING = "edit_processing"
|
||||||
OUTPUTTING = "outputting"
|
OUTPUTTING = "outputting"
|
||||||
|
|
||||||
|
|
||||||
class Daemon:
|
class Daemon:
|
||||||
def __init__(self, cfg: Config):
|
def __init__(self, cfg: Config):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
|
self.history = HistoryStore()
|
||||||
|
self.history.prune(1000)
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.state = State.IDLE
|
self.state = State.IDLE
|
||||||
self.proc = None
|
self.proc = None
|
||||||
self.record = None
|
self.record = None
|
||||||
self.timer = None
|
self.timer = None
|
||||||
|
self.active_language = cfg.whisper_lang
|
||||||
self.context = None
|
self.context = None
|
||||||
|
self.edit_proc = None
|
||||||
|
self.edit_record = None
|
||||||
|
self.edit_timer = None
|
||||||
|
self.edit_context = None
|
||||||
|
self.edit_window = None
|
||||||
self.context_provider = None
|
self.context_provider = None
|
||||||
if cfg.context_capture.get("provider") == "i3ipc":
|
if cfg.context_capture.get("provider") == "i3ipc":
|
||||||
self.context_provider = I3Provider()
|
self.context_provider = I3Provider()
|
||||||
|
|
@ -45,7 +60,7 @@ class Daemon:
|
||||||
self.stt = FasterWhisperSTT(
|
self.stt = FasterWhisperSTT(
|
||||||
STTConfig(
|
STTConfig(
|
||||||
model=cfg.whisper_model,
|
model=cfg.whisper_model,
|
||||||
language=cfg.whisper_lang,
|
language=None,
|
||||||
device=cfg.whisper_device,
|
device=cfg.whisper_device,
|
||||||
vad_filter=True,
|
vad_filter=True,
|
||||||
)
|
)
|
||||||
|
|
@ -54,15 +69,19 @@ class Daemon:
|
||||||
|
|
||||||
def set_state(self, state: str):
|
def set_state(self, state: str):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
prev = self.state
|
||||||
self.state = state
|
self.state = state
|
||||||
|
if prev != state:
|
||||||
|
logging.info("state: %s -> %s", prev, state)
|
||||||
|
|
||||||
def get_state(self):
|
def get_state(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return self.state
|
return self.state
|
||||||
|
|
||||||
def toggle(self):
|
def toggle(self, language_code: str | None = None):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.state == State.IDLE:
|
if self.state == State.IDLE:
|
||||||
|
self.active_language = language_code or self.cfg.whisper_lang
|
||||||
self._start_recording_locked()
|
self._start_recording_locked()
|
||||||
return
|
return
|
||||||
if self.state == State.RECORDING:
|
if self.state == State.RECORDING:
|
||||||
|
|
@ -71,6 +90,14 @@ class Daemon:
|
||||||
return
|
return
|
||||||
logging.info("busy (%s), trigger ignored", self.state)
|
logging.info("busy (%s), trigger ignored", self.state)
|
||||||
|
|
||||||
|
def edit_trigger(self):
|
||||||
|
with self.lock:
|
||||||
|
if self.state != State.IDLE:
|
||||||
|
logging.info("busy (%s), edit trigger ignored", self.state)
|
||||||
|
return
|
||||||
|
self.state = State.EDITING
|
||||||
|
threading.Thread(target=self._start_edit_flow, daemon=True).start()
|
||||||
|
|
||||||
def _start_recording_locked(self):
|
def _start_recording_locked(self):
|
||||||
try:
|
try:
|
||||||
proc, record = start_recording(self.cfg.ffmpeg_input, self.cfg.ffmpeg_path)
|
proc, record = start_recording(self.cfg.ffmpeg_input, self.cfg.ffmpeg_path)
|
||||||
|
|
@ -83,10 +110,23 @@ class Daemon:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error("context capture failed: %s", exc)
|
logging.error("context capture failed: %s", exc)
|
||||||
self.context = None
|
self.context = None
|
||||||
|
if self.context:
|
||||||
|
logging.info(
|
||||||
|
"context: id=%s app_id=%s class=%s instance=%s title=%s",
|
||||||
|
self.context.window_id,
|
||||||
|
self.context.app_id,
|
||||||
|
self.context.klass,
|
||||||
|
self.context.instance,
|
||||||
|
self.context.title,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.info("context: none")
|
||||||
self.proc = proc
|
self.proc = proc
|
||||||
self.record = record
|
self.record = record
|
||||||
self.state = State.RECORDING
|
self.state = State.RECORDING
|
||||||
logging.info("recording started (%s)", record.wav_path)
|
logging.info("recording started (%s)", record.wav_path)
|
||||||
|
run_id = self.history.add_run("record", "started", self.cfg, self._context_json(self.context))
|
||||||
|
self.history.add_artifact(run_id, "audio", {"path": record.wav_path}, record.wav_path)
|
||||||
if self.timer:
|
if self.timer:
|
||||||
self.timer.cancel()
|
self.timer.cancel()
|
||||||
self.timer = threading.Timer(self.cfg.record_timeout_sec, self._timeout_stop)
|
self.timer = threading.Timer(self.cfg.record_timeout_sec, self._timeout_stop)
|
||||||
|
|
@ -128,13 +168,17 @@ class Daemon:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.set_state(State.TRANSCRIBING)
|
self.set_state(State.TRANSCRIBING)
|
||||||
text = self.stt.transcribe(record.wav_path)
|
logging.info("transcribing started")
|
||||||
|
text = self.stt.transcribe(record.wav_path, language=self.active_language)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error("stt failed: %s", exc)
|
logging.error("stt failed: %s", exc)
|
||||||
self.set_state(State.IDLE)
|
self.set_state(State.IDLE)
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.info("stt: %s", text)
|
logging.info("stt: %s", text)
|
||||||
|
run_id = self.history.add_run("stt", "ok", self.cfg, self._context_json(self.context))
|
||||||
|
self.history.add_artifact(run_id, "input", {"wav_path": record.wav_path, "language": self.active_language})
|
||||||
|
self.history.add_artifact(run_id, "output", {"text": text})
|
||||||
|
|
||||||
rule = match_rule(self.context, self.context_rules) if self.context else None
|
rule = match_rule(self.context, self.context_rules) if self.context else None
|
||||||
if rule:
|
if rule:
|
||||||
|
|
@ -149,6 +193,7 @@ class Daemon:
|
||||||
|
|
||||||
if ai_enabled:
|
if ai_enabled:
|
||||||
self.set_state(State.PROCESSING)
|
self.set_state(State.PROCESSING)
|
||||||
|
logging.info("ai processing started")
|
||||||
try:
|
try:
|
||||||
processor = build_processor(
|
processor = build_processor(
|
||||||
AIConfig(
|
AIConfig(
|
||||||
|
|
@ -158,9 +203,18 @@ class Daemon:
|
||||||
base_url=self.cfg.ai_base_url,
|
base_url=self.cfg.ai_base_url,
|
||||||
api_key=self.cfg.ai_api_key,
|
api_key=self.cfg.ai_api_key,
|
||||||
timeout_sec=self.cfg.ai_timeout_sec,
|
timeout_sec=self.cfg.ai_timeout_sec,
|
||||||
|
language_hint=self.active_language,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
text = processor.process(text) or text
|
ai_input = text
|
||||||
|
text = processor.process(ai_input) or text
|
||||||
|
run_id = self.history.add_run("ai", "ok", self.cfg, self._context_json(self.context))
|
||||||
|
self.history.add_artifact(
|
||||||
|
run_id,
|
||||||
|
"input",
|
||||||
|
{"text": ai_input, "model": self.cfg.ai_model, "temperature": self.cfg.ai_temperature},
|
||||||
|
)
|
||||||
|
self.history.add_artifact(run_id, "output", {"text": text})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error("ai process failed: %s", exc)
|
logging.error("ai process failed: %s", exc)
|
||||||
|
|
||||||
|
|
@ -168,6 +222,7 @@ class Daemon:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.set_state(State.OUTPUTTING)
|
self.set_state(State.OUTPUTTING)
|
||||||
|
logging.info("outputting started")
|
||||||
if self.context_provider and self.context:
|
if self.context_provider and self.context:
|
||||||
if not self.context_provider.is_same_focus(self.context):
|
if not self.context_provider.is_same_focus(self.context):
|
||||||
logging.info("focus changed, aborting injection")
|
logging.info("focus changed, aborting injection")
|
||||||
|
|
@ -177,11 +232,216 @@ class Daemon:
|
||||||
if rule and rule.injection_backend:
|
if rule and rule.injection_backend:
|
||||||
backend = rule.injection_backend
|
backend = rule.injection_backend
|
||||||
inject(text, backend)
|
inject(text, backend)
|
||||||
|
run_id = self.history.add_run("inject", "ok", self.cfg, self._context_json(self.context))
|
||||||
|
self.history.add_artifact(run_id, "input", {"text": text, "backend": backend})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.error("output failed: %s", exc)
|
logging.error("output failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
self.set_state(State.IDLE)
|
self.set_state(State.IDLE)
|
||||||
|
|
||||||
|
def _start_edit_flow(self):
|
||||||
|
try:
|
||||||
|
text = read_primary_selection()
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("selection capture failed: %s", exc)
|
||||||
|
self.set_state(State.IDLE)
|
||||||
|
return
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
logging.info("selection empty, aborting edit")
|
||||||
|
self.set_state(State.IDLE)
|
||||||
|
return
|
||||||
|
edit_language = self.cfg.edit_language_detection.get("fallback_code", self.cfg.whisper_lang)
|
||||||
|
if self.cfg.edit_language_detection.get("enabled"):
|
||||||
|
edit_language = detect_language(text, fallback=edit_language)
|
||||||
|
self.active_language = edit_language
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.context_provider:
|
||||||
|
self.edit_context = self.context_provider.capture()
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("context capture failed: %s", exc)
|
||||||
|
self.edit_context = None
|
||||||
|
if self.edit_context:
|
||||||
|
logging.info(
|
||||||
|
"edit context: id=%s app_id=%s class=%s instance=%s title=%s",
|
||||||
|
self.edit_context.window_id,
|
||||||
|
self.edit_context.app_id,
|
||||||
|
self.edit_context.klass,
|
||||||
|
self.edit_context.instance,
|
||||||
|
self.edit_context.title,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.info("edit context: none")
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc, record = start_recording(self.cfg.ffmpeg_input, self.cfg.ffmpeg_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("record start failed: %s", exc)
|
||||||
|
self.set_state(State.IDLE)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.edit_proc = proc
|
||||||
|
self.edit_record = record
|
||||||
|
logging.info("edit recording started (%s)", record.wav_path)
|
||||||
|
run_id = self.history.add_run("record", "started", self.cfg, self._context_json(self.edit_context))
|
||||||
|
self.history.add_artifact(run_id, "audio", {"path": record.wav_path}, record.wav_path)
|
||||||
|
|
||||||
|
if self.edit_timer:
|
||||||
|
self.edit_timer.cancel()
|
||||||
|
self.edit_timer = threading.Timer(self.cfg.edit_record_timeout_sec, self._edit_timeout_stop)
|
||||||
|
self.edit_timer.daemon = True
|
||||||
|
self.edit_timer.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.edit_window = open_edit_window(
|
||||||
|
text,
|
||||||
|
EditWindowConfig(**self.cfg.edit_window),
|
||||||
|
self._on_edit_apply,
|
||||||
|
self._on_edit_copy_close,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("edit window failed: %s", exc)
|
||||||
|
self._abort_edit()
|
||||||
|
return
|
||||||
|
|
||||||
|
def _edit_timeout_stop(self):
|
||||||
|
logging.info("edit recording timeout")
|
||||||
|
self._on_edit_apply(self._edit_get_text())
|
||||||
|
|
||||||
|
def _edit_get_text(self) -> str:
|
||||||
|
if not self.edit_window:
|
||||||
|
return ""
|
||||||
|
return self.edit_window.get_text()
|
||||||
|
|
||||||
|
def _on_edit_copy_close(self, text: str):
|
||||||
|
if text:
|
||||||
|
try:
|
||||||
|
write_clipboard(text)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("copy failed: %s", exc)
|
||||||
|
self._abort_edit()
|
||||||
|
|
||||||
|
def _on_edit_apply(self, text: str):
|
||||||
|
if self.state != State.EDITING:
|
||||||
|
return
|
||||||
|
self.set_state(State.EDIT_PROCESSING)
|
||||||
|
threading.Thread(target=self._stop_and_process_edit, args=(text,), daemon=True).start()
|
||||||
|
|
||||||
|
def _stop_and_process_edit(self, base_text: str):
|
||||||
|
proc = self.edit_proc
|
||||||
|
record = self.edit_record
|
||||||
|
self.edit_proc = None
|
||||||
|
self.edit_record = None
|
||||||
|
if self.edit_timer:
|
||||||
|
self.edit_timer.cancel()
|
||||||
|
self.edit_timer = None
|
||||||
|
|
||||||
|
if not proc or not record:
|
||||||
|
self._abort_edit()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
stop_recording(proc)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("record stop failed: %s", exc)
|
||||||
|
self._abort_edit()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not Path(record.wav_path).exists():
|
||||||
|
logging.error("no audio captured")
|
||||||
|
self._abort_edit()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
logging.info("edit transcribing started")
|
||||||
|
instruction = self.stt.transcribe(record.wav_path, language=self.active_language)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("stt failed: %s", exc)
|
||||||
|
self._abort_edit()
|
||||||
|
return
|
||||||
|
|
||||||
|
logging.info("edit instruction: %s", instruction)
|
||||||
|
run_id = self.history.add_run("stt", "ok", self.cfg, self._context_json(self.edit_context))
|
||||||
|
self.history.add_artifact(run_id, "input", {"wav_path": record.wav_path, "language": self.active_language})
|
||||||
|
self.history.add_artifact(run_id, "output", {"text": instruction})
|
||||||
|
|
||||||
|
result = base_text
|
||||||
|
if self.cfg.edit_ai_enabled:
|
||||||
|
try:
|
||||||
|
prompt_file = self.cfg.edit_ai_system_prompt_file
|
||||||
|
if not prompt_file:
|
||||||
|
prompt_file = str(Path(__file__).parent / "system_prompt_edit.txt")
|
||||||
|
processor = build_processor(
|
||||||
|
AIConfig(
|
||||||
|
model=self.cfg.ai_model,
|
||||||
|
temperature=self.cfg.edit_ai_temperature,
|
||||||
|
system_prompt_file=prompt_file,
|
||||||
|
base_url=self.cfg.ai_base_url,
|
||||||
|
api_key=self.cfg.ai_api_key,
|
||||||
|
timeout_sec=self.cfg.ai_timeout_sec,
|
||||||
|
language_hint=None,
|
||||||
|
wrap_transcript=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
payload = f"<text>{base_text}</text>\n<instruction>{instruction}</instruction>"
|
||||||
|
result = processor.process(payload) or base_text
|
||||||
|
run_id = self.history.add_run("ai", "ok", self.cfg, self._context_json(self.edit_context))
|
||||||
|
self.history.add_artifact(
|
||||||
|
run_id,
|
||||||
|
"input",
|
||||||
|
{"text": payload, "model": self.cfg.ai_model, "temperature": self.cfg.edit_ai_temperature},
|
||||||
|
)
|
||||||
|
self.history.add_artifact(run_id, "output", {"text": result})
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("ai process failed: %s", exc)
|
||||||
|
|
||||||
|
logging.info("edit result: %s", result)
|
||||||
|
|
||||||
|
if self.edit_window:
|
||||||
|
self.edit_window.set_status("Applying...")
|
||||||
|
|
||||||
|
if self.context_provider and self.edit_context:
|
||||||
|
if not self.context_provider.focus_window(self.edit_context.window_id):
|
||||||
|
logging.info("original window missing, aborting edit injection")
|
||||||
|
self._abort_edit()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
inject(result, self.cfg.edit_injection_backend)
|
||||||
|
run_id = self.history.add_run("inject", "ok", self.cfg, self._context_json(self.edit_context))
|
||||||
|
self.history.add_artifact(run_id, "input", {"text": result, "backend": self.cfg.edit_injection_backend})
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error("output failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
self._abort_edit()
|
||||||
|
|
||||||
|
def _context_json(self, ctx):
|
||||||
|
if not ctx:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"window_id": ctx.window_id,
|
||||||
|
"app_id": ctx.app_id,
|
||||||
|
"class": ctx.klass,
|
||||||
|
"instance": ctx.instance,
|
||||||
|
"title": ctx.title,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _abort_edit(self):
|
||||||
|
if self.edit_window:
|
||||||
|
try:
|
||||||
|
self.edit_window.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.edit_window = None
|
||||||
|
self.edit_proc = None
|
||||||
|
self.edit_record = None
|
||||||
|
self.edit_context = None
|
||||||
|
if self.edit_timer:
|
||||||
|
self.edit_timer.cancel()
|
||||||
|
self.edit_timer = None
|
||||||
|
self.set_state(State.IDLE)
|
||||||
|
|
||||||
def stop_recording(self):
|
def stop_recording(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.state != State.RECORDING:
|
if self.state != State.RECORDING:
|
||||||
|
|
@ -209,15 +469,26 @@ def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--config", default="", help="path to config.json")
|
parser.add_argument("--config", default="", help="path to config.json")
|
||||||
parser.add_argument("--no-tray", action="store_true", help="disable tray icon")
|
parser.add_argument("--no-tray", action="store_true", help="disable tray icon")
|
||||||
|
parser.add_argument("--settings", action="store_true", help="open settings window and exit")
|
||||||
parser.add_argument("--dry-run", action="store_true", help="log hotkey only")
|
parser.add_argument("--dry-run", action="store_true", help="log hotkey only")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="leld: %(asctime)s %(message)s")
|
logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="leld: %(asctime)s %(message)s")
|
||||||
cfg = load(args.config)
|
cfg = load(args.config)
|
||||||
|
config_path = Path(args.config) if args.config else Path.home() / ".config" / "lel" / "config.json"
|
||||||
|
|
||||||
|
if args.settings:
|
||||||
|
open_settings_window(cfg, config_path)
|
||||||
|
import gi
|
||||||
|
gi.require_version("Gtk", "3.0")
|
||||||
|
from gi.repository import Gtk
|
||||||
|
Gtk.main()
|
||||||
|
return
|
||||||
|
|
||||||
_lock_single_instance()
|
_lock_single_instance()
|
||||||
|
|
||||||
logging.info("ready (hotkey: %s)", cfg.hotkey)
|
hotkeys = ", ".join(f"{name}={info.get('hotkey')}" for name, info in cfg.languages.items())
|
||||||
|
logging.info("ready (hotkeys: %s; edit: %s)", hotkeys, cfg.edit_hotkey)
|
||||||
logging.info("config (%s):\n%s", args.config or str(Path.home() / ".config" / "lel" / "config.json"), json.dumps(redacted_dict(cfg), indent=2))
|
logging.info("config (%s):\n%s", args.config or str(Path.home() / ".config" / "lel" / "config.json"), json.dumps(redacted_dict(cfg), indent=2))
|
||||||
|
|
||||||
daemon = Daemon(cfg)
|
daemon = Daemon(cfg)
|
||||||
|
|
@ -240,8 +511,18 @@ def main():
|
||||||
listen(cfg.hotkey, lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle())
|
listen(cfg.hotkey, lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle())
|
||||||
return
|
return
|
||||||
|
|
||||||
threading.Thread(target=lambda: listen(cfg.hotkey, lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle()), daemon=True).start()
|
for name, info in cfg.languages.items():
|
||||||
run_tray(daemon.get_state, on_quit)
|
hotkey = info.get("hotkey")
|
||||||
|
code = info.get("code")
|
||||||
|
threading.Thread(
|
||||||
|
target=lambda h=hotkey, c=code: listen(
|
||||||
|
h,
|
||||||
|
lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(c),
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
threading.Thread(target=lambda: listen(cfg.edit_hotkey, lambda: logging.info("edit hotkey pressed (dry-run)") if args.dry_run else daemon.edit_trigger()), daemon=True).start()
|
||||||
|
run_tray(daemon.get_state, on_quit, lambda: open_settings_window(load(args.config), config_path))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
33
src/selection.py
Normal file
33
src/selection.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from Xlib import X, Xatom, display
|
||||||
|
|
||||||
|
|
||||||
|
def read_primary_selection(timeout_sec: float = 2.0) -> str:
|
||||||
|
disp = display.Display()
|
||||||
|
root = disp.screen().root
|
||||||
|
win = root.create_window(0, 0, 1, 1, 0, X.CopyFromParent)
|
||||||
|
utf8 = disp.intern_atom("UTF8_STRING")
|
||||||
|
prop = disp.intern_atom("LEL_SELECTION")
|
||||||
|
|
||||||
|
win.convert_selection(Xatom.PRIMARY, utf8, prop, X.CurrentTime)
|
||||||
|
disp.flush()
|
||||||
|
|
||||||
|
end = time.time() + timeout_sec
|
||||||
|
while time.time() < end:
|
||||||
|
if disp.pending_events():
|
||||||
|
ev = disp.next_event()
|
||||||
|
if ev.type == X.SelectionNotify:
|
||||||
|
if ev.property == X.NONE:
|
||||||
|
return ""
|
||||||
|
data = win.get_property(prop, X.AnyPropertyType, 0, 2**31 - 1)
|
||||||
|
if not data or data.value is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return data.value.decode("utf-8", errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
time.sleep(0.01)
|
||||||
|
return ""
|
||||||
869
src/settings_window.py
Normal file
869
src/settings_window.py
Normal file
|
|
@ -0,0 +1,869 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "3.0")
|
||||||
|
gi.require_version("Gdk", "3.0")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsWindow:
|
||||||
|
def __init__(self, cfg: Config, config_path: Path):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.config_path = config_path
|
||||||
|
self.history = HistoryStore()
|
||||||
|
self._model_cache: dict[str, list[str]] = {}
|
||||||
|
self.window = Gtk.Window(title="lel settings")
|
||||||
|
self.window.set_default_size(920, 700)
|
||||||
|
self.window.set_position(Gtk.WindowPosition.CENTER)
|
||||||
|
self.window.set_type_hint(Gdk.WindowTypeHint.DIALOG)
|
||||||
|
|
||||||
|
self.error_label = Gtk.Label()
|
||||||
|
self.error_label.set_xalign(0.0)
|
||||||
|
self.error_label.get_style_context().add_class("error")
|
||||||
|
|
||||||
|
self.notebook = Gtk.Notebook()
|
||||||
|
self.widgets: dict[str, Gtk.Widget] = {}
|
||||||
|
|
||||||
|
self._build_tabs()
|
||||||
|
|
||||||
|
btn_save = Gtk.Button(label="Save")
|
||||||
|
btn_save.connect("clicked", self._on_save)
|
||||||
|
btn_cancel = Gtk.Button(label="Cancel")
|
||||||
|
btn_cancel.connect("clicked", lambda *_: self.window.destroy())
|
||||||
|
|
||||||
|
btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
btn_row.pack_end(btn_save, False, False, 0)
|
||||||
|
btn_row.pack_end(btn_cancel, False, False, 0)
|
||||||
|
|
||||||
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
vbox.set_border_width(12)
|
||||||
|
vbox.pack_start(self.error_label, False, False, 0)
|
||||||
|
vbox.pack_start(self.notebook, True, True, 0)
|
||||||
|
vbox.pack_start(btn_row, False, False, 0)
|
||||||
|
|
||||||
|
self.window.add(vbox)
|
||||||
|
self.window.show_all()
|
||||||
|
|
||||||
|
def _refresh_history(self, *_args):
|
||||||
|
if not hasattr(self, "history_list"):
|
||||||
|
return
|
||||||
|
for row in self.history_list.get_children():
|
||||||
|
self.history_list.remove(row)
|
||||||
|
phase = self.widgets["history_phase"].get_active_text()
|
||||||
|
if phase == "all":
|
||||||
|
phase = None
|
||||||
|
runs = self.history.list_runs(phase=phase, limit=200)
|
||||||
|
for run in runs:
|
||||||
|
row = Gtk.ListBoxRow()
|
||||||
|
label = Gtk.Label(
|
||||||
|
label=f"#{run['id']} {run['phase']} {run['status']} {time.strftime('%H:%M:%S', time.localtime(run['created_at']))}"
|
||||||
|
)
|
||||||
|
label.set_xalign(0.0)
|
||||||
|
row.add(label)
|
||||||
|
row._run = run
|
||||||
|
self.history_list.add(row)
|
||||||
|
self.history_list.show_all()
|
||||||
|
|
||||||
|
def _on_history_select(self, _listbox, row):
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
run = row._run
|
||||||
|
artifacts = self.history.list_artifacts(run["id"])
|
||||||
|
buf = self.history_detail.get_buffer()
|
||||||
|
buf.set_text(self._format_run(run, artifacts))
|
||||||
|
|
||||||
|
def _format_run(self, run: dict, artifacts: list[dict]) -> str:
|
||||||
|
lines = [f"Run #{run['id']} ({run['phase']})", f"Status: {run['status']}"]
|
||||||
|
if run.get("context"):
|
||||||
|
lines.append(f"Context: {run['context']}")
|
||||||
|
for art in artifacts:
|
||||||
|
lines.append(f"- {art['kind']}: {art.get('data') or art.get('file_path')}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _on_history_copy(self, *_args):
|
||||||
|
row = self.history_list.get_selected_row()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
run = row._run
|
||||||
|
artifacts = self.history.list_artifacts(run["id"])
|
||||||
|
text = ""
|
||||||
|
for art in artifacts:
|
||||||
|
if art["kind"] == "output" and art.get("data") and art["data"].get("text"):
|
||||||
|
text = art["data"]["text"]
|
||||||
|
if text:
|
||||||
|
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||||
|
clipboard.set_text(text, -1)
|
||||||
|
clipboard.store()
|
||||||
|
|
||||||
|
def _on_history_rerun(self, *_args):
|
||||||
|
row = self.history_list.get_selected_row()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
run = row._run
|
||||||
|
artifacts = self.history.list_artifacts(run["id"])
|
||||||
|
phase = run["phase"]
|
||||||
|
if phase == "ai":
|
||||||
|
self._open_ai_rerun(run, artifacts)
|
||||||
|
|
||||||
|
def _on_quick_run(self, *_args):
|
||||||
|
buf = self.quick_text.get_buffer()
|
||||||
|
start, end = buf.get_bounds()
|
||||||
|
text = buf.get_text(start, end, True).strip()
|
||||||
|
if not text:
|
||||||
|
self.widgets["quick_status"].set_text("No input text")
|
||||||
|
return
|
||||||
|
language = self.widgets["quick_language"].get_text().strip()
|
||||||
|
output = text
|
||||||
|
steps = self._collect_quick_steps()
|
||||||
|
if not steps:
|
||||||
|
self.widgets["quick_status"].set_text("No AI steps")
|
||||||
|
return
|
||||||
|
from aiprocess import AIConfig, build_processor
|
||||||
|
|
||||||
|
for idx, step in enumerate(steps, 1):
|
||||||
|
prompt_text = step.get("prompt_text") or ""
|
||||||
|
if prompt_text:
|
||||||
|
from aiprocess import GenericAPIProcessor
|
||||||
|
|
||||||
|
processor = GenericAPIProcessor(
|
||||||
|
AIConfig(
|
||||||
|
model=step["model"],
|
||||||
|
temperature=step["temperature"],
|
||||||
|
system_prompt_file=self.cfg.ai_system_prompt_file,
|
||||||
|
base_url=step["base_url"],
|
||||||
|
api_key=step["api_key"],
|
||||||
|
timeout_sec=step["timeout"],
|
||||||
|
language_hint=language,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
processor.system = prompt_text
|
||||||
|
else:
|
||||||
|
processor = build_processor(
|
||||||
|
AIConfig(
|
||||||
|
model=step["model"],
|
||||||
|
temperature=step["temperature"],
|
||||||
|
system_prompt_file=self.cfg.ai_system_prompt_file,
|
||||||
|
base_url=step["base_url"],
|
||||||
|
api_key=step["api_key"],
|
||||||
|
timeout_sec=step["timeout"],
|
||||||
|
language_hint=language,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output = processor.process(output)
|
||||||
|
run_id = self.history.add_run("ai", "ok", self.cfg, None)
|
||||||
|
self.history.add_artifact(
|
||||||
|
run_id,
|
||||||
|
"input",
|
||||||
|
{
|
||||||
|
"step": idx,
|
||||||
|
"text": output,
|
||||||
|
"language": language,
|
||||||
|
"model": step["model"],
|
||||||
|
"temperature": step["temperature"],
|
||||||
|
"prompt_text": step.get("prompt_text") or "",
|
||||||
|
"base_url": step["base_url"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.history.add_artifact(run_id, "output", {"text": output})
|
||||||
|
self.widgets["quick_status"].set_text("Done")
|
||||||
|
self._refresh_history()
|
||||||
|
|
||||||
|
def _collect_quick_steps(self) -> list[dict]:
|
||||||
|
steps: list[dict] = []
|
||||||
|
for row in self.quick_steps.get_children():
|
||||||
|
e = row._lel_step_entries
|
||||||
|
model = e["model_entry"].get_text().strip()
|
||||||
|
combo = e["model_combo"]
|
||||||
|
if combo.get_visible():
|
||||||
|
combo_text = combo.get_active_text()
|
||||||
|
if combo_text:
|
||||||
|
model = combo_text
|
||||||
|
prompt_buf = e["prompt_text"].get_buffer()
|
||||||
|
start, end = prompt_buf.get_bounds()
|
||||||
|
prompt_text = prompt_buf.get_text(start, end, True).strip()
|
||||||
|
steps.append(
|
||||||
|
{
|
||||||
|
"model": model or self.cfg.ai_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()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return steps
|
||||||
|
|
||||||
|
def _open_ai_rerun(self, _run: dict, artifacts: list[dict]):
|
||||||
|
input_text = ""
|
||||||
|
for art in artifacts:
|
||||||
|
if art["kind"] == "input" and art.get("data"):
|
||||||
|
input_text = art["data"].get("text", "")
|
||||||
|
dialog = Gtk.Dialog(title="Re-run AI", transient_for=self.window, flags=0)
|
||||||
|
dialog.add_button("Run", Gtk.ResponseType.OK)
|
||||||
|
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
||||||
|
box = dialog.get_content_area()
|
||||||
|
textview = Gtk.TextView()
|
||||||
|
textview.get_buffer().set_text(input_text)
|
||||||
|
scroll = Gtk.ScrolledWindow()
|
||||||
|
scroll.add(textview)
|
||||||
|
scroll.set_size_request(600, 300)
|
||||||
|
box.add(scroll)
|
||||||
|
dialog.show_all()
|
||||||
|
resp = dialog.run()
|
||||||
|
if resp == Gtk.ResponseType.OK:
|
||||||
|
buf = textview.get_buffer()
|
||||||
|
start, end = buf.get_bounds()
|
||||||
|
text = buf.get_text(start, end, True)
|
||||||
|
from aiprocess import AIConfig, build_processor
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output = processor.process(text)
|
||||||
|
run_id = self.history.add_run("ai", "ok", self.cfg, None)
|
||||||
|
self.history.add_artifact(run_id, "input", {"text": text})
|
||||||
|
self.history.add_artifact(run_id, "output", {"text": output})
|
||||||
|
self._refresh_history()
|
||||||
|
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("History", self._build_history_tab())
|
||||||
|
self._add_tab("Quick Run", self._build_quick_run_tab())
|
||||||
|
|
||||||
|
def _add_tab(self, title: str, widget: Gtk.Widget):
|
||||||
|
label = Gtk.Label(label=title)
|
||||||
|
self.notebook.append_page(widget, label)
|
||||||
|
|
||||||
|
def _grid(self) -> Gtk.Grid:
|
||||||
|
grid = Gtk.Grid()
|
||||||
|
grid.set_row_spacing(8)
|
||||||
|
grid.set_column_spacing(12)
|
||||||
|
grid.set_margin_top(8)
|
||||||
|
grid.set_margin_bottom(8)
|
||||||
|
grid.set_margin_start(8)
|
||||||
|
grid.set_margin_end(8)
|
||||||
|
return grid
|
||||||
|
|
||||||
|
def _entry(self, value: str) -> Gtk.Entry:
|
||||||
|
entry = Gtk.Entry()
|
||||||
|
entry.set_text(value or "")
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _spin(self, value: int, min_val: int, max_val: int) -> Gtk.SpinButton:
|
||||||
|
adj = Gtk.Adjustment(value=value, lower=min_val, upper=max_val, step_increment=1, page_increment=10)
|
||||||
|
spin = Gtk.SpinButton(adjustment=adj, climb_rate=1, digits=0)
|
||||||
|
return spin
|
||||||
|
|
||||||
|
def _float_spin(self, value: float, min_val: float, max_val: float, step: float) -> Gtk.SpinButton:
|
||||||
|
adj = Gtk.Adjustment(value=value, lower=min_val, upper=max_val, step_increment=step, page_increment=0.1)
|
||||||
|
spin = Gtk.SpinButton(adjustment=adj, climb_rate=0.1, digits=2)
|
||||||
|
return spin
|
||||||
|
|
||||||
|
def _combo(self, options: list[str], value: str) -> Gtk.ComboBoxText:
|
||||||
|
combo = Gtk.ComboBoxText()
|
||||||
|
for opt in options:
|
||||||
|
combo.append_text(opt)
|
||||||
|
combo.set_active(options.index(value) if value in options else 0)
|
||||||
|
return combo
|
||||||
|
|
||||||
|
def _row(self, grid: Gtk.Grid, row: int, label: str, widget: Gtk.Widget):
|
||||||
|
lbl = Gtk.Label(label=label)
|
||||||
|
lbl.set_xalign(0.0)
|
||||||
|
grid.attach(lbl, 0, row, 1, 1)
|
||||||
|
grid.attach(widget, 1, row, 1, 1)
|
||||||
|
|
||||||
|
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"])
|
||||||
|
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"])
|
||||||
|
box.pack_start(grid, False, False, 0)
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _selected_mic_source(self) -> str:
|
||||||
|
combo = self.widgets["ffmpeg_input"]
|
||||||
|
text = combo.get_active_text() or ""
|
||||||
|
if text.startswith("pulse:"):
|
||||||
|
return text.split(" ", 1)[0]
|
||||||
|
return self.cfg.ffmpeg_input
|
||||||
|
|
||||||
|
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_index = 0
|
||||||
|
for idx, (name, desc) in enumerate(sources):
|
||||||
|
text = f"pulse:{name} ({desc})"
|
||||||
|
combo.append_text(text)
|
||||||
|
if selected.startswith(f"pulse:{name}"):
|
||||||
|
selected_index = idx
|
||||||
|
if selected == "pulse:default" and default_name:
|
||||||
|
for idx, (name, _desc) in enumerate(sources):
|
||||||
|
if name == default_name:
|
||||||
|
selected_index = idx
|
||||||
|
break
|
||||||
|
if sources:
|
||||||
|
combo.set_active(selected_index)
|
||||||
|
else:
|
||||||
|
combo.append_text("pulse:default (default)")
|
||||||
|
combo.set_active(0)
|
||||||
|
|
||||||
|
def _list_pulse_sources(self) -> tuple[list[tuple[str, str]], str | None]:
|
||||||
|
default_name = None
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(["pactl", "list", "sources", "short"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return ([], None)
|
||||||
|
out = []
|
||||||
|
for line in proc.stdout.splitlines():
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
name = parts[1]
|
||||||
|
desc = parts[-1] if parts[-1] else name
|
||||||
|
out.append((name, desc))
|
||||||
|
default_name = self._get_pulse_default_source()
|
||||||
|
return (out, default_name)
|
||||||
|
except Exception:
|
||||||
|
return ([], None)
|
||||||
|
|
||||||
|
def _get_pulse_default_source(self) -> str | None:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(["pactl", "info"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return None
|
||||||
|
for line in proc.stdout.splitlines():
|
||||||
|
if line.lower().startswith("default source:"):
|
||||||
|
return line.split(":", 1)[1].strip()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
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)
|
||||||
|
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"])
|
||||||
|
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)
|
||||||
|
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_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"])
|
||||||
|
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")
|
||||||
|
header.set_xalign(0.0)
|
||||||
|
box.pack_start(header, False, False, 0)
|
||||||
|
|
||||||
|
filter_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
self.widgets["history_phase"] = self._combo(["all", "record", "stt", "ai", "inject"], "all")
|
||||||
|
refresh_btn = Gtk.Button(label="Refresh")
|
||||||
|
refresh_btn.connect("clicked", self._refresh_history)
|
||||||
|
filter_row.pack_start(Gtk.Label(label="Phase"), False, False, 0)
|
||||||
|
filter_row.pack_start(self.widgets["history_phase"], False, False, 0)
|
||||||
|
filter_row.pack_start(refresh_btn, False, False, 0)
|
||||||
|
|
||||||
|
box.pack_start(filter_row, False, False, 0)
|
||||||
|
|
||||||
|
self.history_list = Gtk.ListBox()
|
||||||
|
self.history_list.set_selection_mode(Gtk.SelectionMode.SINGLE)
|
||||||
|
self.history_list.connect("row-selected", self._on_history_select)
|
||||||
|
box.pack_start(self.history_list, True, True, 0)
|
||||||
|
|
||||||
|
self.history_detail = Gtk.TextView()
|
||||||
|
self.history_detail.set_editable(False)
|
||||||
|
self.history_detail.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
||||||
|
detail_scroll = Gtk.ScrolledWindow()
|
||||||
|
detail_scroll.add(self.history_detail)
|
||||||
|
detail_scroll.set_vexpand(True)
|
||||||
|
box.pack_start(detail_scroll, True, True, 0)
|
||||||
|
|
||||||
|
action_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
self.widgets["history_rerun"] = Gtk.Button(label="Re-run")
|
||||||
|
self.widgets["history_rerun"].connect("clicked", self._on_history_rerun)
|
||||||
|
self.widgets["history_copy"] = Gtk.Button(label="Copy Output")
|
||||||
|
self.widgets["history_copy"].connect("clicked", self._on_history_copy)
|
||||||
|
action_row.pack_start(self.widgets["history_rerun"], False, False, 0)
|
||||||
|
action_row.pack_start(self.widgets["history_copy"], False, False, 0)
|
||||||
|
box.pack_start(action_row, False, False, 0)
|
||||||
|
|
||||||
|
self._refresh_history()
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _build_quick_run_tab(self) -> Gtk.Widget:
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
header = Gtk.Label(label="Bypass recording and run from text")
|
||||||
|
header.set_xalign(0.0)
|
||||||
|
box.pack_start(header, False, False, 0)
|
||||||
|
|
||||||
|
self.quick_text = Gtk.TextView()
|
||||||
|
self.quick_text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
||||||
|
scroll = Gtk.ScrolledWindow()
|
||||||
|
scroll.add(self.quick_text)
|
||||||
|
scroll.set_size_request(600, 140)
|
||||||
|
box.pack_start(scroll, True, True, 0)
|
||||||
|
|
||||||
|
opts = self._grid()
|
||||||
|
self.widgets["quick_language"] = self._entry(self.cfg.whisper_lang)
|
||||||
|
self._row(opts, 0, "Language Hint", self.widgets["quick_language"])
|
||||||
|
box.pack_start(opts, False, False, 0)
|
||||||
|
|
||||||
|
steps_label = Gtk.Label(label="AI Steps (run in order)")
|
||||||
|
steps_label.set_xalign(0.0)
|
||||||
|
box.pack_start(steps_label, False, False, 0)
|
||||||
|
|
||||||
|
self.quick_steps = Gtk.ListBox()
|
||||||
|
self.quick_steps.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
box.pack_start(self.quick_steps, False, False, 0)
|
||||||
|
|
||||||
|
step_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
add_btn = Gtk.Button(label="Add Step")
|
||||||
|
add_btn.connect("clicked", lambda *_: self._add_quick_step_row({}))
|
||||||
|
step_actions.pack_start(add_btn, False, False, 0)
|
||||||
|
box.pack_start(step_actions, False, False, 0)
|
||||||
|
|
||||||
|
action = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
run_btn = Gtk.Button(label="Run")
|
||||||
|
run_btn.connect("clicked", self._on_quick_run)
|
||||||
|
action.pack_start(run_btn, False, False, 0)
|
||||||
|
self.widgets["quick_status"] = Gtk.Label(label="")
|
||||||
|
self.widgets["quick_status"].set_xalign(0.0)
|
||||||
|
action.pack_start(self.widgets["quick_status"], True, True, 0)
|
||||||
|
box.pack_start(action, False, False, 0)
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _add_quick_step_row(self, step: dict):
|
||||||
|
row = Gtk.ListBoxRow()
|
||||||
|
row.set_activatable(False)
|
||||||
|
row.set_selectable(False)
|
||||||
|
row.set_can_focus(False)
|
||||||
|
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
content.set_can_focus(False)
|
||||||
|
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_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)
|
||||||
|
prompt_text = Gtk.TextView()
|
||||||
|
prompt_text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
||||||
|
prompt_buf = prompt_text.get_buffer()
|
||||||
|
prompt_buf.set_text(step.get("prompt_text", ""))
|
||||||
|
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))
|
||||||
|
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)
|
||||||
|
base_url.connect("changed", lambda *_: self._refresh_models_for_row(row))
|
||||||
|
|
||||||
|
controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
btn_up = Gtk.Button(label="Up")
|
||||||
|
btn_down = Gtk.Button(label="Down")
|
||||||
|
btn_remove = Gtk.Button(label="Remove")
|
||||||
|
btn_up.connect("clicked", lambda *_: self._move_step(row, -1))
|
||||||
|
btn_down.connect("clicked", lambda *_: self._move_step(row, 1))
|
||||||
|
btn_remove.connect("clicked", lambda *_: self.quick_steps.remove(row))
|
||||||
|
controls.pack_start(btn_up, False, False, 0)
|
||||||
|
controls.pack_start(btn_down, False, False, 0)
|
||||||
|
controls.pack_start(btn_remove, False, False, 0)
|
||||||
|
|
||||||
|
content.pack_start(grid, False, False, 0)
|
||||||
|
content.pack_start(controls, False, False, 0)
|
||||||
|
row.add(content)
|
||||||
|
row._lel_step_entries = {
|
||||||
|
"model_combo": model_combo,
|
||||||
|
"model_entry": model_entry,
|
||||||
|
"temperature": temperature,
|
||||||
|
"prompt_text": prompt_text,
|
||||||
|
"base_url": base_url,
|
||||||
|
"api_key": api_key,
|
||||||
|
"timeout": timeout,
|
||||||
|
}
|
||||||
|
self._refresh_models_for_row(row)
|
||||||
|
self.quick_steps.add(row)
|
||||||
|
self.quick_steps.show_all()
|
||||||
|
|
||||||
|
def _move_step(self, row: Gtk.Widget, direction: int):
|
||||||
|
children = self.quick_steps.get_children()
|
||||||
|
idx = children.index(row)
|
||||||
|
new_idx = idx + direction
|
||||||
|
if new_idx < 0 or new_idx >= len(children):
|
||||||
|
return
|
||||||
|
self.quick_steps.remove(row)
|
||||||
|
self.quick_steps.insert(row, new_idx)
|
||||||
|
self.quick_steps.show_all()
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_models_for_row(self, row: Gtk.Widget):
|
||||||
|
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)
|
||||||
|
combo = e["model_combo"]
|
||||||
|
entry = e["model_entry"]
|
||||||
|
combo.remove_all()
|
||||||
|
if models:
|
||||||
|
for m in models:
|
||||||
|
combo.append_text(m)
|
||||||
|
combo.set_active(0)
|
||||||
|
combo.show()
|
||||||
|
entry.hide()
|
||||||
|
else:
|
||||||
|
combo.hide()
|
||||||
|
entry.show()
|
||||||
|
|
||||||
|
def _get_models(self, base_url: str, api_key: str, timeout: int) -> list[str]:
|
||||||
|
key = f"{base_url}|{api_key}|{timeout}"
|
||||||
|
if key in self._model_cache:
|
||||||
|
return self._model_cache[key]
|
||||||
|
models = list_models(base_url, api_key, timeout)
|
||||||
|
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()
|
||||||
|
validate(cfg)
|
||||||
|
self._write_config(cfg)
|
||||||
|
self.window.destroy()
|
||||||
|
except Exception as exc:
|
||||||
|
self._set_error(str(exc))
|
||||||
|
|
||||||
|
def _set_error(self, text: str):
|
||||||
|
self.error_label.set_text(text)
|
||||||
|
|
||||||
|
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()
|
||||||
|
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 _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 _write_config(self, cfg: Config):
|
||||||
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = asdict(cfg)
|
||||||
|
self.config_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def open_settings_window(cfg: Config, config_path: Path):
|
||||||
|
return SettingsWindow(cfg, config_path)
|
||||||
|
|
@ -33,11 +33,11 @@ class FasterWhisperSTT:
|
||||||
compute_type=_compute_type(self.cfg.device),
|
compute_type=_compute_type(self.cfg.device),
|
||||||
)
|
)
|
||||||
|
|
||||||
def transcribe(self, wav_path: str) -> str:
|
def transcribe(self, wav_path: str, language: str | None = None) -> str:
|
||||||
self._load()
|
self._load()
|
||||||
segments, _info = self._model.transcribe(
|
segments, _info = self._model.transcribe(
|
||||||
wav_path,
|
wav_path,
|
||||||
language=self.cfg.language,
|
language=language or self.cfg.language,
|
||||||
vad_filter=self.cfg.vad_filter,
|
vad_filter=self.cfg.vad_filter,
|
||||||
)
|
)
|
||||||
parts = []
|
parts = []
|
||||||
|
|
|
||||||
15
src/system_prompt_edit.txt
Normal file
15
src/system_prompt_edit.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
You are a deterministic text editing engine.
|
||||||
|
You edit the provided text according to the user's spoken instruction.
|
||||||
|
|
||||||
|
Follow these rules strictly:
|
||||||
|
1. Do NOT add content not implied by the instruction.
|
||||||
|
2. Preserve tone and intent unless instructed otherwise.
|
||||||
|
3. Prefer minimal edits.
|
||||||
|
4. Keep formatting unless the instruction says to change it.
|
||||||
|
5. Do NOT explain; output ONLY the edited text.
|
||||||
|
|
||||||
|
Input format:
|
||||||
|
<text>...</text>
|
||||||
|
<instruction>...</instruction>
|
||||||
|
|
||||||
|
You should only output the raw text content, without any XML tags.
|
||||||
15
src/tray.py
15
src/tray.py
|
|
@ -28,8 +28,12 @@ class Tray:
|
||||||
def _icon_path(self, state: str) -> str:
|
def _icon_path(self, state: str) -> str:
|
||||||
if state == "recording":
|
if state == "recording":
|
||||||
return str(self.base / "recording.png")
|
return str(self.base / "recording.png")
|
||||||
|
if state == "editing":
|
||||||
|
return str(self.base / "recording.png")
|
||||||
if state == "transcribing":
|
if state == "transcribing":
|
||||||
return str(self.base / "transcribing.png")
|
return str(self.base / "transcribing.png")
|
||||||
|
if state == "edit_processing":
|
||||||
|
return str(self.base / "processing.png")
|
||||||
if state == "processing":
|
if state == "processing":
|
||||||
return str(self.base / "processing.png")
|
return str(self.base / "processing.png")
|
||||||
return str(self.base / "idle.png")
|
return str(self.base / "idle.png")
|
||||||
|
|
@ -37,8 +41,12 @@ class Tray:
|
||||||
def _title(self, state: str) -> str:
|
def _title(self, state: str) -> str:
|
||||||
if state == "recording":
|
if state == "recording":
|
||||||
return "Recording"
|
return "Recording"
|
||||||
|
if state == "editing":
|
||||||
|
return "Editing"
|
||||||
if state == "transcribing":
|
if state == "transcribing":
|
||||||
return "Transcribing"
|
return "Transcribing"
|
||||||
|
if state == "edit_processing":
|
||||||
|
return "Edit Processing"
|
||||||
if state == "processing":
|
if state == "processing":
|
||||||
return "AI Processing"
|
return "AI Processing"
|
||||||
return "Idle"
|
return "Idle"
|
||||||
|
|
@ -50,8 +58,13 @@ class Tray:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def run_tray(state_getter, on_quit):
|
def run_tray(state_getter, on_quit, on_settings):
|
||||||
tray = Tray(state_getter, on_quit)
|
tray = Tray(state_getter, on_quit)
|
||||||
tray.update()
|
tray.update()
|
||||||
GLib.timeout_add(250, tray.update)
|
GLib.timeout_add(250, tray.update)
|
||||||
|
if on_settings:
|
||||||
|
settings_item = Gtk.MenuItem(label="Settings")
|
||||||
|
settings_item.connect("activate", lambda *_: on_settings())
|
||||||
|
tray.menu.prepend(settings_item)
|
||||||
|
tray.menu.show_all()
|
||||||
Gtk.main()
|
Gtk.main()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue