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:
1 parent
64f3474d92
commit
8687c895a1
8 files changed
+193
-2
No files matched your search
@@ -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"})
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 ""
|
||||
Reference in new issue
Block a user