Plugin visione: webcam, schermo e finestra con OCR e immagine per i motori

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5.1 committed 2026-09-24 15:29:03 +02:00
1 parent 64f3474d92
commit 8687c895a1
8 files changed
+193 -2

No files matched your search

+17 -1
View File
@@ -23,6 +23,22 @@ class Aborted(Exception):
pass
def _tool_content(res: str):
"""Se il risultato cita un'immagine (riga «IMMAGINE: percorso»), la allega come blocco image."""
import base64
import re as _re
from pathlib import Path as _P
m = _re.search(r"^IMMAGINE: (.+)$", res, flags=_re.M)
if not m or not _P(m.group(1).strip()).exists():
return res
try:
from avatar.attachments import image_payload
media, data = image_payload(_P(m.group(1).strip()))
return [{"type": "text", "text": res}, {"type": "image", "source": {"type": "base64", "media_type": media, "data": base64.b64encode(data).decode()}}]
except Exception:
return res
def friendly_error(err: Exception) -> str:
if isinstance(err, anthropic.AuthenticationError):
return "La chiave API Anthropic non è valida. Controllala nelle impostazioni."
@@ -147,7 +163,7 @@ class AnthropicEngine:
res, ev = run_tool(b.name, parse_args(b.input))
if ev:
emit(ev)
results.append({"type": "tool_result", "tool_use_id": b.id, "content": res})
results.append({"type": "tool_result", "tool_use_id": b.id, "content": _tool_content(res)})
self.history.messages.append({"role": "user", "content": results})
emit({"type": "status", "status": "thinking"})
+7
View File
@@ -93,11 +93,18 @@ class ClaudeCodeEngine:
f"Adesso è {today_label()}.",
"Strumenti di memoria (obbligatori): per ricordare un fatto sull'utente chiama `mcp__avatar__salva_memoria`; per cancellarne uno `mcp__avatar__dimentica_memoria`; per cercare `mcp__avatar__cerca_memoria`. Non dire mai di aver salvato qualcosa senza averlo chiamato davvero. Gli altri strumenti `mcp__avatar__*` (per esempio il calendario) agiscono sul Mac dell'utente: usali quando servono, e per le azioni irreversibili chiedi conferma a voce prima di richiamarli con confermato=true.",
access,
"Quando uno strumento risponde con una riga «IMMAGINE: percorso», leggi quel file con Read per vedere l'immagine prima di rispondere.",
"Stai parlando dentro un'app vocale: rispondi in modo conversazionale, senza intestazioni Markdown né tabelle, e non citare nomi di file o strumenti interni a meno che non serva.",
])
def _args(self, session_id: str, resume: bool, attach_path: str = "") -> list[str]:
flags = dict(ACCESS[self.access])
# Le catture di webcam/schermo del plugin visione sono sempre leggibili.
from avatar.settings import DATA_DIR
captures = str(DATA_DIR / "captures")
if "Read" not in flags["tools"] and flags["tools"] != "default":
flags["tools"] = flags["tools"] + ",Read"
flags["allowed"] = [*flags["allowed"], f"Read(//{captures.lstrip('/')}/**)"]
if attach_path:
# Permesso di lettura limitato al file allegato (immagini incluse: Claude Code le vede).
if "Read" not in flags["tools"] and flags["tools"] != "default":
+97
View File
@@ -0,0 +1,97 @@
"""Cattura di webcam, schermo e finestra in primo piano, con OCR e anteprima per i motori."""
from __future__ import annotations
import subprocess
import time
from pathlib import Path
from avatar.settings import DATA_DIR
CAPTURES = DATA_DIR / "captures"
KEEP = 30
def _prune() -> None:
files = sorted(CAPTURES.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True)
for p in files[KEEP:]:
p.unlink(missing_ok=True)
def _shrink(src: Path, max_side: int = 1600, quality: int = 85) -> Path:
"""Versione JPEG ridotta, adatta ai modelli con visione."""
from PIL import Image
im = Image.open(src).convert("RGB")
im.thumbnail((max_side, max_side))
out = src.with_suffix(".jpg") if src.suffix.lower() != ".jpg" else src
im.save(out, format="JPEG", quality=quality)
if out != src:
src.unlink(missing_ok=True)
return out
def capture_webcam(warmup_frames: int = 8) -> Path:
import cv2
CAPTURES.mkdir(parents=True, exist_ok=True)
cap = cv2.VideoCapture(0)
if not cap.isOpened():
raise RuntimeError("Webcam non disponibile: controlla il permesso Fotocamera in Impostazioni di Sistema > Privacy e sicurezza.")
try:
frame = None
for _ in range(warmup_frames): # qualche fotogramma per l'esposizione automatica
ok, frame = cap.read()
if not ok:
break
time.sleep(0.05)
if frame is None:
raise RuntimeError("La webcam non ha restituito immagini.")
out = CAPTURES / f"webcam-{time.strftime('%Y%m%d-%H%M%S')}.jpg"
cv2.imwrite(str(out), frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
finally:
cap.release()
_prune()
return _shrink(out)
def capture_screen(all_displays: bool = False) -> Path:
CAPTURES.mkdir(parents=True, exist_ok=True)
stamp = time.strftime("%Y%m%d-%H%M%S")
out = CAPTURES / f"schermo-{stamp}.png"
cmd = ["screencapture", "-x", str(out)] if not all_displays else ["screencapture", "-x", str(out), str(CAPTURES / f"schermo2-{stamp}.png")]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
if res.returncode != 0 or not out.exists():
raise RuntimeError("Cattura schermo non riuscita: concedi «Registrazione schermo» all'app in Impostazioni di Sistema > Privacy e sicurezza.")
_prune()
return _shrink(out, max_side=2000)
def _front_window_id() -> tuple[int | None, str]:
import Quartz
from AppKit import NSWorkspace
app = NSWorkspace.sharedWorkspace().frontmostApplication()
name = app.localizedName() if app else ""
pid = app.processIdentifier() if app else None
for w in Quartz.CGWindowListCopyWindowInfo(Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID):
if w.get("kCGWindowOwnerPID") == pid and w.get("kCGWindowLayer", 0) == 0 and (w.get("kCGWindowBounds") or {}).get("Height", 0) > 50:
return w["kCGWindowNumber"], name
return None, name
def capture_front_window() -> tuple[Path, str]:
CAPTURES.mkdir(parents=True, exist_ok=True)
wid, name = _front_window_id()
if wid is None:
return capture_screen(), name or "schermo"
out = CAPTURES / f"finestra-{time.strftime('%Y%m%d-%H%M%S')}.png"
res = subprocess.run(["screencapture", "-x", "-l", str(wid), str(out)], capture_output=True, text=True, timeout=20)
if res.returncode != 0 or not out.exists():
raise RuntimeError("Cattura finestra non riuscita: serve il permesso «Registrazione schermo».")
_prune()
return _shrink(out, max_side=2000), name
def ocr_text(path: Path) -> str:
from avatar.attachments import ocr
try:
return ocr(path).strip()
except Exception:
return ""