add imrpvoed

This commit is contained in:
okendoken 2026-02-16 23:00:15 +01:00
parent 0eb918c893
commit beb64e2ed0
5 changed files with 377 additions and 35 deletions

View File

@ -27,6 +27,7 @@ Notes:
- The server listens on `http://127.0.0.1:8765/screenshot`. - The server listens on `http://127.0.0.1:8765/screenshot`.
- If you omit `--ai`, it will only save files (no OpenAI call). - If you omit `--ai`, it will only save files (no OpenAI call).
- If you set `--ai`, it will generate reply suggestions and return them back to the extension (and also save `*.ai.json`). - If you set `--ai`, it will generate reply suggestions and return them back to the extension (and also save `*.ai.json`).
- If you're debugging, add `--log-level debug` (or set `AIEA_LOG_LEVEL=debug`) for more detail.
- Optional: you can still use `--run ...` as a post-save hook. - Optional: you can still use `--run ...` as a post-save hook.
## 2) Load the extension (unpacked) ## 2) Load the extension (unpacked)

View File

@ -65,6 +65,14 @@ Without OpenAI (save files only):
python3 tools/local_screenshot_bridge.py --port 8765 --out-dir screenshots python3 tools/local_screenshot_bridge.py --port 8765 --out-dir screenshots
``` ```
More logging (includes AI request/response ids + parse details):
```bash
python3 tools/local_screenshot_bridge.py --port 8765 --out-dir screenshots --ai --log-level debug
python3 tools/local_screenshot_bridge.py --port 8765 --out-dir screenshots --ai --log-level debug --ai-max-output-tokens 2500
```
Health check: Health check:
```bash ```bash

View File

@ -46,7 +46,14 @@ function renderResult(entryOrResp) {
if (resp.ai_result) { if (resp.ai_result) {
if (resp.ai_result.ok && resp.ai_result.ai && Array.isArray(resp.ai_result.ai.posts)) { if (resp.ai_result.ok && resp.ai_result.ai && Array.isArray(resp.ai_result.ai.posts)) {
lines.push(""); lines.push("");
lines.push(`AI (${resp.ai_result.took_ms || "?"}ms):`); {
const ms = resp.ai_result.took_ms || "?";
const model = resp.ai_result.model ? ` model=${resp.ai_result.model}` : "";
const rid = resp.ai_result.response_id ? ` id=${resp.ai_result.response_id}` : "";
const status = resp.ai_result.status && resp.ai_result.status !== "completed" ? ` status=${resp.ai_result.status}` : "";
const inc = resp.ai_result.incomplete_reason ? ` incomplete=${resp.ai_result.incomplete_reason}` : "";
lines.push(`AI (${ms}ms${model}${rid}${status}${inc}):`);
}
for (const p of resp.ai_result.ai.posts) { for (const p of resp.ai_result.ai.posts) {
const idx = typeof p.index === "number" ? p.index : "?"; const idx = typeof p.index === "number" ? p.index : "?";
const postText = (p.post_text || "").replace(/\s+/g, " ").trim(); const postText = (p.post_text || "").replace(/\s+/g, " ").trim();
@ -84,10 +91,40 @@ function renderResult(entryOrResp) {
} }
} }
if (resp.ai_result.ai_path) lines.push(`\nAI file: ${resp.ai_result.ai_path}`); if (resp.ai_result.ai_path) lines.push(`\nAI file: ${resp.ai_result.ai_path}`);
if (resp.ai_result.usage && typeof resp.ai_result.usage === "object") {
const u = resp.ai_result.usage;
const ins = typeof u.input_tokens === "number" ? u.input_tokens : null;
const outs = typeof u.output_tokens === "number" ? u.output_tokens : null;
const tots = typeof u.total_tokens === "number" ? u.total_tokens : null;
const parts = [];
if (ins != null) parts.push(`in=${ins}`);
if (outs != null) parts.push(`out=${outs}`);
if (tots != null) parts.push(`total=${tots}`);
if (parts.length) lines.push(`AI usage: ${parts.join(" ")}`);
}
} else { } else {
lines.push(""); lines.push("");
lines.push(`AI error: ${resp.ai_result.error || "unknown"}`); const ms = resp.ai_result.took_ms || "?";
if (resp.ai_result.detail) lines.push(`Detail: ${resp.ai_result.detail}`); const model = resp.ai_result.model ? ` model=${resp.ai_result.model}` : "";
const rid = resp.ai_result.response_id ? ` id=${resp.ai_result.response_id}` : "";
const status = resp.ai_result.status ? ` status=${resp.ai_result.status}` : "";
const inc = resp.ai_result.incomplete_reason ? ` incomplete=${resp.ai_result.incomplete_reason}` : "";
const err = resp.ai_result.error || (resp.ai_result.ai && resp.ai_result.ai.error) || "unknown";
lines.push(`AI error (${ms}ms${model}${rid}${status}${inc}): ${err}`);
if (resp.ai_result.detail) lines.push(`Detail: ${clampString(resp.ai_result.detail, 600)}`);
if (resp.ai_result.raw_preview) lines.push(`Raw: ${clampString(resp.ai_result.raw_preview, 600)}`);
if (resp.ai_result.usage && typeof resp.ai_result.usage === "object") {
const u = resp.ai_result.usage;
const ins = typeof u.input_tokens === "number" ? u.input_tokens : null;
const outs = typeof u.output_tokens === "number" ? u.output_tokens : null;
const tots = typeof u.total_tokens === "number" ? u.total_tokens : null;
const parts = [];
if (ins != null) parts.push(`in=${ins}`);
if (outs != null) parts.push(`out=${outs}`);
if (tots != null) parts.push(`total=${tots}`);
if (parts.length) lines.push(`AI usage: ${parts.join(" ")}`);
}
if (resp.ai_result.ai_path) lines.push(`AI file: ${resp.ai_result.ai_path}`);
} }
} }

View File

@ -222,7 +222,7 @@ def main(argv: list[str]) -> int:
"definition_of_post": "A single feed item / post / story / comment root visible on-screen right now. If it's a single-article page, treat the main article as one post.", "definition_of_post": "A single feed item / post / story / comment root visible on-screen right now. If it's a single-article page, treat the main article as one post.",
"output_requirements": { "output_requirements": {
#"ironic": "Lightly ironic, laughing at us humans (not cruel).", #"ironic": "Lightly ironic, laughing at us humans (not cruel).",
"improved": "Proofreading-style improvements, preserving the original words as much as possible. Improving in medium version.", "improved": "Proofread whatever is given in extra_instructions [EXTRA_INSTRUCTIONS]!!! Proofreading-style improvements, preserving the original words as much as possible. Improving in medium version.",
"critical": "Bold/critical: politely questions the premise or assumptions.", "critical": "Bold/critical: politely questions the premise or assumptions.",
"suggested": "Best style you think fits (helpful/witty/clarifying/etc).", "suggested": "Best style you think fits (helpful/witty/clarifying/etc).",
"short": "1-2 sentences, direct, useful, no fluff.", "short": "1-2 sentences, direct, useful, no fluff.",

View File

@ -7,9 +7,33 @@ import re
import subprocess import subprocess
import sys import sys
import time import time
import traceback
from datetime import datetime, timezone from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path from pathlib import Path
from typing import Optional
_LOG_LEVELS = {"debug": 10, "info": 20, "error": 40, "quiet": 100}
def _log(server, level: str, msg: str) -> None:
"""
Minimal structured-ish logging to stderr.
server.log_level: debug|info|error|quiet (default: info)
"""
try:
configured = getattr(server, "log_level", "info") if server is not None else "info"
configured_rank = _LOG_LEVELS.get(str(configured).lower(), _LOG_LEVELS["info"])
level_rank = _LOG_LEVELS.get(str(level).lower(), _LOG_LEVELS["info"])
if level_rank < configured_rank:
return
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
print(f"[{ts}] {level.upper()}: {msg}", file=sys.stderr)
except Exception:
# Never let logging break the handler.
return
def _slug(s: str, max_len: int = 80) -> str: def _slug(s: str, max_len: int = 80) -> str:
@ -58,6 +82,91 @@ def _safe_json_dump(obj: object, max_chars: int) -> str:
s = json.dumps(obj, ensure_ascii=True, separators=(",", ":"), sort_keys=False) s = json.dumps(obj, ensure_ascii=True, separators=(",", ":"), sort_keys=False)
return _truncate(s, max_chars) return _truncate(s, max_chars)
def _maybe_dump_model(obj: object) -> Optional[dict]:
"""
Best-effort conversion of SDK model objects (pydantic-ish) to plain dicts.
"""
if obj is None:
return None
for attr in ("model_dump", "dict", "to_dict"):
fn = getattr(obj, attr, None)
if callable(fn):
try:
out = fn()
return out if isinstance(out, dict) else None
except Exception:
continue
return None
def _extract_json_text(raw: str) -> Optional[str]:
"""
Best-effort extraction of a JSON object from a text response.
Some providers occasionally wrap JSON in code fences or add preamble text.
We keep this conservative; if it can't be extracted, we return None.
"""
if not raw:
return None
# ```json ... ``` or any fenced block: ```...```
# We capture the whole fenced body (not just {...}) so nested braces don't break extraction.
m = re.search(r"```(?:[a-z0-9_+-]+)?\s*(.*?)\s*```", raw, flags=re.DOTALL | re.IGNORECASE)
if m:
return m.group(1).strip()
# First {...last} span
start = raw.find("{")
end = raw.rfind("}")
if start != -1 and end != -1 and end > start:
return raw[start : end + 1].strip()
return None
def _ai_error_payload(
*,
error: str,
detail: str = "",
raw: str = "",
model: str = "",
response_id: str = "",
status: str = "",
incomplete_reason: str = "",
usage: Optional[dict] = None,
took_ms: Optional[int] = None,
ai_path: Optional[Path] = None,
) -> dict:
payload: dict = {"ok": False, "error": error}
if detail:
payload["detail"] = detail
if raw:
payload["raw_preview"] = _truncate(raw, 2000)
if model:
payload["model"] = model
if response_id:
payload["response_id"] = response_id
if status:
payload["status"] = status
if incomplete_reason:
payload["incomplete_reason"] = incomplete_reason
if usage:
payload["usage"] = usage
if isinstance(took_ms, int):
payload["took_ms"] = took_ms
if ai_path is not None:
payload["ai_path"] = str(ai_path)
return payload
def _is_valid_ai_json(parsed: object) -> bool:
if not isinstance(parsed, dict):
return False
posts = parsed.get("posts", None)
return isinstance(posts, list)
def _ea_sanitize_text(text: object) -> str: def _ea_sanitize_text(text: object) -> str:
""" """
Port of fl_geo_sanitize_text(), plus lowercase output (no capitals). Port of fl_geo_sanitize_text(), plus lowercase output (no capitals).
@ -209,11 +318,13 @@ def _maybe_generate_ai(server, png_path: Path, meta: dict, content: object) -> d
_load_dotenv_if_present(project_root) _load_dotenv_if_present(project_root)
if not os.getenv("OPENAI_API_KEY"): if not os.getenv("OPENAI_API_KEY"):
_log(server, "error", "AI disabled: missing OPENAI_API_KEY")
return {"ok": False, "error": "missing_openai_api_key"} return {"ok": False, "error": "missing_openai_api_key"}
try: try:
from openai import OpenAI # type: ignore from openai import OpenAI # type: ignore
except Exception as e: except Exception as e:
_log(server, "error", f"AI disabled: missing openai sdk ({type(e).__name__}: {e})")
return {"ok": False, "error": "missing_openai_sdk", "detail": str(e)} return {"ok": False, "error": "missing_openai_sdk", "detail": str(e)}
instructions_text = getattr(server, "ai_instructions", "") instructions_text = getattr(server, "ai_instructions", "")
@ -227,6 +338,12 @@ def _maybe_generate_ai(server, png_path: Path, meta: dict, content: object) -> d
page_title = str(meta.get("title") or "") page_title = str(meta.get("title") or "")
extra_instructions = str(meta.get("extra_instructions") or "").strip() extra_instructions = str(meta.get("extra_instructions") or "").strip()
_log(
server,
"debug",
f"AI request: model={model} max_posts={max_posts} image_detail={image_detail} max_output_tokens={max_output_tokens} url={_truncate(page_url, 140)}",
)
user_payload = { user_payload = {
"page_url": page_url, "page_url": page_url,
"page_title": page_title, "page_title": page_title,
@ -237,7 +354,7 @@ def _maybe_generate_ai(server, png_path: Path, meta: dict, content: object) -> d
"definition_of_post": "A single feed item / post / story / comment root visible on-screen right now. If it's a single-article page, treat the main article as one post.", "definition_of_post": "A single feed item / post / story / comment root visible on-screen right now. If it's a single-article page, treat the main article as one post.",
"output_requirements": { "output_requirements": {
# "ironic": "Lightly ironic, laughing at us humans (not cruel).", # "ironic": "Lightly ironic, laughing at us humans (not cruel).",
"improved": "Proofreading-style improvements, preserving the original words as much as possible. Improving in medium version.", "improved": "Proofread whatever is given in extra_instructions [EXTRA_INSTRUCTIONS]. Proofreading-style improvements, preserving the original words as much as possible. Improving in medium version.",
"critical": "Bold/critical: politely questions the premise or assumptions.", "critical": "Bold/critical: politely questions the premise or assumptions.",
"suggested": "Best style you think fits (helpful/witty/clarifying/etc).", "suggested": "Best style you think fits (helpful/witty/clarifying/etc).",
"short": "1-2 sentences, direct, useful, no fluff.", "short": "1-2 sentences, direct, useful, no fluff.",
@ -266,49 +383,196 @@ def _maybe_generate_ai(server, png_path: Path, meta: dict, content: object) -> d
t0 = time.monotonic() t0 = time.monotonic()
client = OpenAI() client = OpenAI()
resp = client.responses.create( resp = None
model=model, took_ms = None
instructions=instructions_text, try:
input=[ resp = client.responses.create(
{ model=model,
"role": "user", instructions=instructions_text,
"content": [ input=[
{"type": "input_text", "text": prompt_text}, {
{"type": "input_image", "image_url": image_data_url, "detail": image_detail}, "role": "user",
], "content": [
} {"type": "input_text", "text": prompt_text},
], {"type": "input_image", "image_url": image_data_url, "detail": image_detail},
text={ ],
"format": { }
"type": "json_schema", ],
"name": "ea_post_responses", text={
"description": "Draft short and medium replies for each visible post on the page.", "format": {
"schema": _response_schema(max_posts), "type": "json_schema",
"strict": True, "name": "ea_post_responses",
"description": "Draft short and medium replies for each visible post on the page.",
"schema": _response_schema(max_posts),
"strict": True,
},
"verbosity": "low",
}, },
"verbosity": "low", max_output_tokens=max_output_tokens,
}, )
max_output_tokens=max_output_tokens, took_ms = int((time.monotonic() - t0) * 1000)
) except Exception as e:
took_ms = int((time.monotonic() - t0) * 1000) took_ms = int((time.monotonic() - t0) * 1000)
tb = traceback.format_exc(limit=8)
detail = f"{type(e).__name__}: {e}"
# Some OpenAI exceptions have request id / status in attrs; include if present.
rid = getattr(e, "request_id", "") or getattr(getattr(e, "response", None), "headers", {}).get("x-request-id", "")
sc = getattr(e, "status_code", None)
if rid:
detail = f"{detail} (request_id={rid})"
if isinstance(sc, int):
detail = f"{detail} (status={sc})"
_log(server, "error", f"AI exception after {took_ms}ms: {detail}")
_log(server, "debug", f"AI traceback:\n{tb}")
# Still write a debug file for the screenshot for later inspection.
ai_path = png_path.with_suffix(".ai.json")
debug_obj = {
"error": "ai_exception",
"detail": detail,
"took_ms": took_ms,
"model": str(model),
"traceback": tb if getattr(server, "log_level", "info") == "debug" else "",
}
try:
ai_path.write_text(json.dumps(debug_obj, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
except Exception:
pass
return _ai_error_payload(
error="ai_exception",
detail=detail,
model=str(model),
response_id="",
status="",
incomplete_reason="",
usage=None,
took_ms=took_ms,
ai_path=ai_path,
)
raw = resp.output_text or "" response_id = str(getattr(resp, "id", "") or "")
status = str(getattr(resp, "status", "") or "")
incomplete_reason = ""
try:
incomplete_details = getattr(resp, "incomplete_details", None)
if incomplete_details is not None:
incomplete_reason = str(getattr(incomplete_details, "reason", "") or "")
except Exception:
incomplete_reason = ""
usage = _maybe_dump_model(getattr(resp, "usage", None))
_log(
server,
"debug",
f"AI response meta: status={status or '?'} incomplete_reason={incomplete_reason or '(none)'} usage={_safe_json_dump(usage or {}, 800)} response_id={response_id}",
)
raw = ""
try:
raw = resp.output_text or ""
except Exception:
raw = ""
_log(server, "debug", f"AI output_text chars={len(raw)} response_id={response_id}")
parsed: object
parse_error = ""
parse_detail = ""
try: try:
parsed = json.loads(raw) parsed = json.loads(raw)
except Exception: except Exception as e:
parsed = {"error": "non_json_output", "raw": raw} # Try a couple conservative extraction heuristics.
candidate = _extract_json_text(raw)
if candidate:
try:
parsed = json.loads(candidate)
except Exception as e2:
parse_error = "non_json_output"
parse_detail = f"{type(e2).__name__}: {e2}"
parsed = {"error": "non_json_output", "raw": raw}
else:
parse_error = "non_json_output"
parse_detail = f"{type(e).__name__}: {e}"
parsed = {"error": "non_json_output", "raw": raw}
if parse_error:
_log(server, "error", f"AI output parse failed: {parse_error} ({parse_detail}) response_id={response_id}")
if isinstance(parsed, dict) and "posts" in parsed: if isinstance(parsed, dict) and "posts" in parsed:
parsed = _sanitize_ai_payload(parsed, page_url=page_url, page_title=page_title) parsed = _sanitize_ai_payload(parsed, page_url=page_url, page_title=page_title)
ai_path = png_path.with_suffix(".ai.json") ai_path = png_path.with_suffix(".ai.json")
ai_path.write_text(json.dumps(parsed, indent=2, ensure_ascii=True) + "\n", encoding="utf-8") try:
return {"ok": True, "ai": parsed, "ai_path": str(ai_path), "took_ms": took_ms} # When generation fails, persist helpful metadata along with the raw text.
if parse_error and isinstance(parsed, dict) and "raw" in parsed:
parsed = {
**parsed,
"response_id": response_id,
"model": str(model),
"status": status,
"incomplete_reason": incomplete_reason,
"usage": usage or {},
}
ai_path.write_text(json.dumps(parsed, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
except Exception as e:
_log(server, "error", f"Failed writing AI file {ai_path}: {type(e).__name__}: {e}")
# If we didn't get schema-conformant output, treat it as an error so the popup doesn't show "unknown".
if not _is_valid_ai_json(parsed):
err = parse_error or (parsed.get("error", "") if isinstance(parsed, dict) else "") or "invalid_ai_output"
detail = parse_detail or "AI returned JSON that does not match the expected schema (missing posts[])."
if incomplete_reason:
err = incomplete_reason if incomplete_reason in ("max_output_tokens", "content_filter") else err
if incomplete_reason == "max_output_tokens":
detail = (
f"AI response was truncated (incomplete_reason=max_output_tokens). "
f"Try increasing --ai-max-output-tokens (currently {max_output_tokens}) or decreasing --ai-max-posts (currently {max_posts}). "
f"Original parse error: {parse_detail or '(none)'}"
)
else:
detail = f"AI response incomplete (incomplete_reason={incomplete_reason}). Parse error: {parse_detail or '(none)'}"
_log(server, "error", f"AI output invalid: error={err} response_id={response_id} ai_path={ai_path}")
return _ai_error_payload(
error=str(err),
detail=str(detail),
raw=raw,
model=str(model),
response_id=response_id,
status=status,
incomplete_reason=incomplete_reason,
usage=usage,
took_ms=took_ms,
ai_path=ai_path,
)
posts_count = len(parsed.get("posts", [])) if isinstance(parsed, dict) else 0
_log(server, "info", f"AI ok: posts={posts_count} took_ms={took_ms} response_id={response_id} ai_path={ai_path}")
return {
"ok": True,
"ai": parsed,
"ai_path": str(ai_path),
"took_ms": took_ms,
"response_id": response_id,
"model": str(model),
"status": status,
"incomplete_reason": incomplete_reason,
"usage": usage,
}
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
server_version = "LocalScreenshotBridge/0.1" server_version = "LocalScreenshotBridge/0.1"
def log_message(self, fmt: str, *args): # noqa: N802
# Route default HTTP request logs through our logger (and respect --log-level).
try:
msg = fmt % args
except Exception:
msg = fmt
try:
ip = self.client_address[0] if getattr(self, "client_address", None) else "?"
except Exception:
ip = "?"
_log(self.server, "debug", f"HTTP {ip} {msg}") # type: ignore[arg-type]
def _send_json(self, status: int, payload: dict): def _send_json(self, status: int, payload: dict):
body = json.dumps(payload, ensure_ascii=True).encode("utf-8") body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
self.send_response(status) self.send_response(status)
@ -368,6 +632,18 @@ class Handler(BaseHTTPRequestHandler):
content = req.get("content", None) content = req.get("content", None)
extra_instructions = req.get("extra_instructions") or "" extra_instructions = req.get("extra_instructions") or ""
extra_s = str(extra_instructions).strip()
if extra_s:
extra_s = _truncate(extra_s.replace("\r", " ").replace("\n", " "), 140)
extra_part = f" extra={extra_s}"
else:
extra_part = ""
_log(
self.server, # type: ignore[arg-type]
"info",
f"Capture received: title={_truncate(str(title), 80)} url={_truncate(str(page_url), 140)} content={'yes' if content is not None else 'no'}{extra_part}",
)
m = re.match(r"^data:image/png;base64,(.*)$", data_url) m = re.match(r"^data:image/png;base64,(.*)$", data_url)
if not m: if not m:
self._send_json(400, {"ok": False, "error": "expected_png_data_url"}) self._send_json(400, {"ok": False, "error": "expected_png_data_url"})
@ -407,6 +683,7 @@ class Handler(BaseHTTPRequestHandler):
content_path.write_text(raw_content, encoding="utf-8") content_path.write_text(raw_content, encoding="utf-8")
wrote_content = True wrote_content = True
except Exception: except Exception:
_log(self.server, "error", f"Failed writing content file {content_path}") # type: ignore[arg-type]
# Don't fail the whole request if content writing fails. # Don't fail the whole request if content writing fails.
wrote_content = False wrote_content = False
@ -442,6 +719,11 @@ class Handler(BaseHTTPRequestHandler):
"content_path": final_content_path, "content_path": final_content_path,
"extra_instructions": extra_instructions, "extra_instructions": extra_instructions,
} }
_log(
self.server, # type: ignore[arg-type]
"info",
f"Saved: png={png_path} meta={meta_path} content={final_content_path or '(none)'}",
)
run = getattr(self.server, "run_cmd", None) # type: ignore[attr-defined] run = getattr(self.server, "run_cmd", None) # type: ignore[attr-defined]
ran = None ran = None
@ -464,15 +746,21 @@ class Handler(BaseHTTPRequestHandler):
"stdout": proc.stdout[-4000:], "stdout": proc.stdout[-4000:],
"stderr": proc.stderr[-4000:], "stderr": proc.stderr[-4000:],
} }
if proc.returncode != 0:
_log(self.server, "error", f"Hook failed: exit={proc.returncode} cmd={' '.join(run)}") # type: ignore[arg-type]
except Exception as e: except Exception as e:
ran = {"cmd": run, "error": str(e)} ran = {"cmd": run, "error": str(e)}
_log(self.server, "error", f"Hook exception: {type(e).__name__}: {e}") # type: ignore[arg-type]
ai_result = None ai_result = None
if getattr(self.server, "ai_enabled", False): # type: ignore[attr-defined] if getattr(self.server, "ai_enabled", False): # type: ignore[attr-defined]
try: try:
ai_result = _maybe_generate_ai(self.server, png_path, meta_obj, content) ai_result = _maybe_generate_ai(self.server, png_path, meta_obj, content)
except Exception as e: except Exception as e:
ai_result = {"ok": False, "error": "ai_exception", "detail": str(e)} detail = f"{type(e).__name__}: {e}"
_log(self.server, "error", f"AI exception (outer): {detail}") # type: ignore[arg-type]
_log(self.server, "debug", f"AI outer traceback:\n{traceback.format_exc(limit=8)}") # type: ignore[arg-type]
ai_result = {"ok": False, "error": "ai_exception", "detail": detail}
self._send_json( self._send_json(
200, 200,
@ -493,6 +781,12 @@ def main(argv: list[str]) -> int:
p.add_argument("--bind", default="127.0.0.1", help="Bind address (default: 127.0.0.1)") p.add_argument("--bind", default="127.0.0.1", help="Bind address (default: 127.0.0.1)")
p.add_argument("--out-dir", default="screenshots", help="Output directory relative to project root") p.add_argument("--out-dir", default="screenshots", help="Output directory relative to project root")
p.add_argument("--ai", action="store_true", help="Run OpenAI to generate reply suggestions and return them to the extension") p.add_argument("--ai", action="store_true", help="Run OpenAI to generate reply suggestions and return them to the extension")
p.add_argument(
"--log-level",
default=os.getenv("AIEA_LOG_LEVEL", "info"),
choices=sorted(_LOG_LEVELS.keys()),
help="Logging verbosity: debug|info|error|quiet (env: AIEA_LOG_LEVEL)",
)
p.add_argument("--ai-model", default=os.getenv("AI_EA_MODEL", "gpt-5.2")) p.add_argument("--ai-model", default=os.getenv("AI_EA_MODEL", "gpt-5.2"))
p.add_argument("--ai-max-posts", type=int, default=int(os.getenv("AI_EA_MAX_POSTS", "12"))) p.add_argument("--ai-max-posts", type=int, default=int(os.getenv("AI_EA_MAX_POSTS", "12")))
p.add_argument("--ai-content-max-chars", type=int, default=int(os.getenv("AI_EA_CONTENT_MAX_CHARS", "120000"))) p.add_argument("--ai-content-max-chars", type=int, default=int(os.getenv("AI_EA_CONTENT_MAX_CHARS", "120000")))
@ -518,6 +812,7 @@ def main(argv: list[str]) -> int:
httpd.out_dir = out_dir # type: ignore[attr-defined] httpd.out_dir = out_dir # type: ignore[attr-defined]
httpd.run_cmd = args.run # type: ignore[attr-defined] httpd.run_cmd = args.run # type: ignore[attr-defined]
httpd.ai_enabled = bool(args.ai) # type: ignore[attr-defined] httpd.ai_enabled = bool(args.ai) # type: ignore[attr-defined]
httpd.log_level = args.log_level # type: ignore[attr-defined]
httpd.ai_model = args.ai_model # type: ignore[attr-defined] httpd.ai_model = args.ai_model # type: ignore[attr-defined]
httpd.ai_max_posts = args.ai_max_posts # type: ignore[attr-defined] httpd.ai_max_posts = args.ai_max_posts # type: ignore[attr-defined]
httpd.ai_content_max_chars = args.ai_content_max_chars # type: ignore[attr-defined] httpd.ai_content_max_chars = args.ai_content_max_chars # type: ignore[attr-defined]
@ -527,6 +822,7 @@ def main(argv: list[str]) -> int:
print(f"Listening on http://{args.bind}:{args.port}/screenshot", file=sys.stderr) print(f"Listening on http://{args.bind}:{args.port}/screenshot", file=sys.stderr)
print(f"Saving screenshots to {out_dir}", file=sys.stderr) print(f"Saving screenshots to {out_dir}", file=sys.stderr)
print(f"Log level: {args.log_level}", file=sys.stderr)
if args.ai: if args.ai:
print(f"OpenAI enabled: model={args.ai_model} max_posts={args.ai_max_posts}", file=sys.stderr) print(f"OpenAI enabled: model={args.ai_model} max_posts={args.ai_max_posts}", file=sys.stderr)
if args.run: if args.run: