blitztext-app-linux/linux/blitztext/llm.py
mARTin-B78 ae01472b77 overlay: fix AT-SPI caret freeze; fuse routing match + live LLM into HUD; Release 1.6.0
Fix a desktop-session freeze (forced logout/reboot) caused by the overlay's
AT-SPI caret tracker: it subscribed to the high-frequency object:text-caret-moved
signal and made synchronous, blocking AT-SPI reads from inside the event handler,
re-entering the a11y dispatcher and getting stormed by the app's own xdotool
typing until GNOME stopped responding. Now track focus changes only and read the
caret rectangle lazily, once, when the overlay shows — never on the hot path.

Fuse voice-routing feedback into the overlay instead of a desktop notification:
show the matched preset's emoji, name, and spoken keyword on a banner, narrate
the phase (Transcribing -> Rewriting), and stream the LLM rewrite into the bubble
token-by-token. Redundant per-dictation notifications are suppressed when the
overlay is present (errors still notify); headless/overlay-off is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:58:47 +02:00

127 lines
4.2 KiB
Python

"""LLM engine abstraction for the rewrite step.
Engines are user-managed presets pointing at any OpenAI-compatible chat endpoint
(OpenAI, vLLM, llama-swap, Ollama /v1, LM Studio, Groq, OpenRouter, …). Provides
reachability status (online/offline) and a chat-completion call. Uses only the
standard library so the daemon needs no extra deps.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Callable
from .stt import reachable
@dataclass
class LLMEngine:
name: str
url: str = "https://api.openai.com/v1" # base URL (incl. /v1)
model: str = "gpt-4o-mini"
api_key_env: str = "OPENAI_API_KEY"
temperature: float = 0.3
type: str = "cloud" # "local" | "cloud" (label/category)
@property
def api_key(self) -> str | None:
return os.environ.get(self.api_key_env) if self.api_key_env else None
class LLMError(RuntimeError):
pass
def status(engine: LLMEngine, timeout: float = 2.0) -> bool:
"""Reachable if the endpoint host:port accepts a TCP connection."""
return reachable(engine.url, timeout)
def chat(
engine: LLMEngine,
system_prompt: str,
user_text: str,
*,
model: str | None = None,
temperature: float | None = None,
timeout: int = 45,
on_token: Callable[[str], None] | None = None,
) -> str:
"""Run a chat completion and return the full text.
When ``on_token`` is given, the request is streamed and each content delta is
handed to the callback as it arrives (so a UI can show the model writing in
real time). The callback is best-effort — it never affects the return value,
which is always the complete, stripped response.
"""
stream = on_token is not None
api_key = engine.api_key
body_obj = {
"model": model or engine.model,
"temperature": engine.temperature if temperature is None else temperature,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text},
],
}
if stream:
body_obj["stream"] = True
payload = json.dumps(body_obj).encode("utf-8")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
engine.url.rstrip("/") + "/chat/completions", data=payload, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
if stream:
content = _read_stream(resp, on_token)
else:
body = json.loads(resp.read().decode("utf-8"))
content = body["choices"][0]["message"]["content"]
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
raise LLMError(f"HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise LLMError(f"Connection failed: {exc.reason}") from exc
except (KeyError, IndexError, TypeError) as exc:
raise LLMError(f"Unexpected response: {exc}") from exc
content = (content or "").strip()
if not content:
raise LLMError("Empty response from model.")
return content
def _read_stream(resp, on_token: Callable[[str], None]) -> str:
"""Parse an OpenAI-style SSE stream, returning the accumulated content and
feeding each delta to ``on_token``. Tolerant of keep-alive blanks and the
trailing ``[DONE]`` sentinel."""
parts: list[str] = []
for raw in resp:
line = raw.decode("utf-8", "replace").strip()
if not line or not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if data == "[DONE]":
break
try:
obj = json.loads(data)
delta = obj["choices"][0]["delta"].get("content")
except (ValueError, KeyError, IndexError, TypeError):
continue
if delta:
parts.append(delta)
try:
on_token(delta)
except Exception: # noqa: BLE001 - UI hiccups must not break delivery
pass
return "".join(parts)