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