App web per il telefono (HTTPS, PIN, WebSocket/SSE/interrogazione, voce, immagini, PDF, impostazioni), stati dettagliati, maestre di violino e canto, PDF, sessioni Claude Code, plugin manager rifatto

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5.1 committed 2026-09-28 00:20:46 +02:00
1 parent a078dff608
commit 706c80cc20
23 files changed
+1943 -49

No files matched your search

+29 -6
View File
@@ -22,7 +22,7 @@ from .tts import SentenceSplitter, clean_for_speech, make_voice
END = object()
STATUS_LABELS = {
"thinking": "THINKING", "searching": "PROCESSING", "memory": "PROCESSING",
"working": "PROCESSING", "responding": "THINKING",
"working": "PROCESSING", "responding": "THINKING", "loading": "PROCESSING",
}
@@ -140,7 +140,7 @@ class Assistant:
print(f"[scheduler] {err}")
def _warmup(self) -> None:
self.ui.set_state("PROCESSING")
self.ui.set_state("PROCESSING · avvio")
self.ui.write_log(f"SYS: {self.name} si sta avviando: carico voce e riconoscimento vocale…")
try:
self.voice.load()
@@ -290,7 +290,7 @@ class Assistant:
def _on_utterance(self, audio: np.ndarray) -> None:
self._last_speech = time.monotonic()
self.ui.set_state("PROCESSING")
self.ui.set_state("PROCESSING · trascrivo")
try:
text = self.stt.transcribe(audio)
except Exception as err:
@@ -305,13 +305,14 @@ class Assistant:
self.handle_text(text)
# ── Turno di conversazione ───────────────────────────────────────────
def handle_text(self, text: str) -> None:
"""Chiamabile da qualunque thread (UI, microfono, quiz…)."""
def handle_text(self, text: str, origin: str = "local") -> None:
"""Chiamabile da qualunque thread (UI, microfono, telefono…). origin="remote": la voce va solo al telefono."""
text = (text or "").strip()
if not text:
return
if self._busy or self._speaking:
self.interrupt(silent=True)
self._origin = origin
threading.Thread(target=self._run_turn, args=(text,), daemon=True).start()
def say(self, text: str) -> None:
@@ -339,12 +340,22 @@ class Assistant:
return
t = ev.get("type")
if t == "status":
label = STATUS_LABELS.get(ev["status"], "THINKING")
detail = str(ev.get("detail") or "").strip()
if ev["status"] == "working" and detail:
detail = f"strumento: {detail}"
elif ev["status"] == "searching" and not detail:
detail = "cerco sul web"
elif ev["status"] == "memory" and not detail:
detail = "memoria"
if not self._speaking:
self.ui.set_state(STATUS_LABELS.get(ev["status"], "THINKING"))
self.ui.set_state(f"{label} · {detail}" if detail else label)
if ev["status"] == "searching":
self.ui.write_log("SYS: Cerco sul web…")
elif ev["status"] == "working":
self.ui.write_log(f"SYS: Lavoro sul Mac ({ev.get('detail', '')})…")
elif ev["status"] == "loading" and detail:
self.ui.write_log(f"SYS: {detail}…")
elif t == "text":
for s in splitter.push(ev["delta"]):
self._speech_q.put((turn, s))
@@ -401,6 +412,12 @@ class Assistant:
self.ui.write_log(f"ERR: Sintesi vocale — {err}")
continue
if turn == self._turn:
remote = getattr(self, "remote", None)
if remote is not None and remote.has_clients():
try:
remote.on_audio(item, audio)
except Exception as err:
print(f"[remoto] audio: {err}")
self._audio_q.put((turn, item, audio))
def _play_loop(self) -> None:
@@ -412,6 +429,9 @@ class Assistant:
self.player.drain()
self._speaking = False
self._tail_until = time.monotonic() + 0.5
remote = getattr(self, "remote", None)
if remote is not None:
remote.on_turn_end()
if not self._busy and turn == self._turn:
self.ui.set_state("LISTENING" if self._awake else "SLEEPING")
continue
@@ -419,6 +439,9 @@ class Assistant:
continue
self._speaking = True
self.ui.set_state("SPEAKING")
if getattr(self, "_origin", "local") == "remote":
time.sleep(min(0.4, len(audio) / 24000 * 0.1)) # la voce la riproduce il telefono
else:
self.player.play(audio, text, self._abort)
self._last_speech = time.monotonic()
+7 -3
View File
@@ -7,7 +7,7 @@ import anthropic
from avatar.memory_tools import anthropic_tools, memory_prompt, parse_args, run_tool
from avatar.plugins import registry
from .base import Emit, History, persona_text, today_label, user_block
from .base import Meter, Emit, History, persona_text, today_label, user_block
MODEL = "claude-opus-5"
MAX_ROUNDS = 12
@@ -98,7 +98,7 @@ class AnthropicEngine:
full = ""
sources: list[dict] = []
try:
emit({"type": "status", "status": "thinking"})
emit({"type": "status", "status": "thinking", "detail": "penso"})
for _ in range(MAX_ROUNDS):
kwargs: dict = dict(
model=MODEL,
@@ -113,6 +113,7 @@ class AnthropicEngine:
if self._compaction:
kwargs["context_management"] = {"edits": [{"type": "compact_20260112"}]}
announced = False
meter = Meter(emit, unit="parole")
try:
with self.client.beta.messages.stream(**kwargs) as stream:
for event in stream:
@@ -126,10 +127,13 @@ class AnthropicEngine:
emit({"type": "status", "status": "memory"})
elif t == "thinking":
emit({"type": "status", "status": "thinking"})
elif event.type == "content_block_delta" and event.delta.type == "thinking_delta":
meter.tick("thinking", getattr(event.delta, "thinking", "").count(" "))
elif event.type == "content_block_delta" and event.delta.type == "text_delta":
if not announced:
announced = True
emit({"type": "status", "status": "responding"})
meter.tick("responding", 0, force=True)
meter.tick("responding", event.delta.text.count(" "))
full += event.delta.text
emit({"type": "text", "delta": event.delta.text})
message = stream.get_final_message()
+26
View File
@@ -16,6 +16,32 @@ Emit = Callable[[Event], None]
PERSONA_FILE = BASE_DIR / "avatar" / "persona.md"
class Meter:
"""Stato dettagliato durante la generazione: "ragiono · 80 token", "genero la risposta · 120 token · 45/s".
Emette al massimo ogni `every` secondi per non intasare l'interfaccia."""
def __init__(self, emit: "Emit", every: float = 0.5, unit: str = "token") -> None:
self.emit, self.every, self.unit = emit, every, unit
self.n, self.t0, self.last, self.phase = 0, None, 0.0, ""
def tick(self, phase: str, count: int = 1, force: bool = False) -> None:
import time
now = time.monotonic()
if self.t0 is None or phase != self.phase:
self.t0, self.n, self.phase = now, 0, phase
self.n += count
if not force and now - self.last < self.every:
return
self.last = now
rate = self.n / max(0.2, now - self.t0)
if phase == "thinking":
self.emit({"type": "status", "status": "thinking", "detail": f"ragiono · {self.n} {self.unit}" if self.n else "ragiono"})
elif self.n < 8:
self.emit({"type": "status", "status": "responding", "detail": "genero la risposta"})
else:
self.emit({"type": "status", "status": "responding", "detail": f"genero la risposta · {self.n} {self.unit} · {rate:.0f}/s"})
class ChatBackend(Protocol):
def send(self, user_text: str, emit: Emit, abort: threading.Event) -> None: ...
def reset(self) -> None: ...
+8 -2
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from memory import memory_manager as mm
from avatar.memory_tools import memory_prompt
from .base import Emit, History, persona_text, today_label, user_block
from .base import Meter, Emit, History, persona_text, today_label, user_block
MEMORY_TOOLS = ["mcp__avatar"] # tutti gli strumenti del server MCP dell'app (memoria e plugin)
ACCESS = {
@@ -155,6 +155,8 @@ class ClaudeCodeEngine:
self._proc = proc
out = {"text": "", "initialized": False, "error": None, "result": ""}
announced = False
meter = Meter(emit, unit="parole")
emit({"type": "status", "status": "loading", "detail": "avvio Claude Code"})
def watch_abort():
abort.wait()
@@ -178,6 +180,7 @@ class ClaudeCodeEngine:
t = msg.get("type")
if t == "system" and msg.get("subtype") == "init":
out["initialized"] = True
emit({"type": "status", "status": "thinking", "detail": "penso"})
elif t == "stream_event":
ev = msg.get("event") or {}
if ev.get("type") == "content_block_start" and (ev.get("content_block") or {}).get("type") == "tool_use":
@@ -188,12 +191,15 @@ class ClaudeCodeEngine:
emit({"type": "status", "status": "searching"})
else:
emit({"type": "status", "status": "working", "detail": name})
elif ev.get("type") == "content_block_delta" and (ev.get("delta") or {}).get("type") == "thinking_delta":
meter.tick("thinking", str(ev["delta"].get("thinking", "")).count(" "))
elif ev.get("type") == "content_block_delta" and (ev.get("delta") or {}).get("type") == "text_delta":
delta = ev["delta"].get("text", "")
if delta:
if not announced:
announced = True
emit({"type": "status", "status": "responding"})
meter.tick("responding", 0, force=True)
meter.tick("responding", delta.count(" "))
out["text"] += delta
emit({"type": "text", "delta": delta})
elif ev.get("type") == "message_start" and announced:
+11 -4
View File
@@ -16,7 +16,7 @@ from pathlib import Path
from avatar.memory_tools import memory_prompt, openai_tools, parse_args, run_tool
from avatar.plugins import registry
from avatar.websearch import brave_search
from .base import Emit, History, compact_history, persona_text, summary_block, today_label, user_block
from .base import Emit, History, Meter, compact_history, persona_text, summary_block, today_label, user_block
from .openai_compat import SEARCH_TOOL, Aborted
MAX_ROUNDS = 8
@@ -302,7 +302,7 @@ class MLXEngine:
def _ensure_loaded(self, emit: Emit) -> None:
if self.model is not None:
return
emit({"type": "status", "status": "working", "detail": "carico il modello"})
emit({"type": "status", "status": "loading", "detail": "carico il modello"})
self.load_error = None
self.model, self.tokenizer = load_model(self.model_name)
self.fmt = detect_format(self.tokenizer)
@@ -387,20 +387,25 @@ class MLXEngine:
# Cache del prompt: la parte già vista (fotografia del turno precedente) non viene ricalcolata.
tokens, cut, prompt = self._prompt_tokens()
think.inside = prompt.rstrip().endswith(f["think"][0]) # il template ha già aperto il blocco di pensiero
snap = _loaded["snap"]
known = len(snap["tokens"]) if snap and tokens[: len(snap["tokens"])] == snap["tokens"] else 0
emit({"type": "status", "status": "thinking", "detail": f"preparo il contesto · {len(tokens) - known} token nuovi su {len(tokens)}"})
cache = self._prefill(tokens[:-1], min(cut, len(tokens) - 1))
generated: list[int] = []
meter = Meter(emit)
try:
for r in stream_generate(self.model, self.tokenizer, tokens[-1:], max_tokens=MAX_TOKENS, sampler=sampler, prompt_cache=cache):
if abort.is_set():
raise Aborted()
generated.append(r.token)
meter.tick("thinking" if think.inside else "responding")
if DEBUG:
print(r.text, end="", flush=True)
visible = tools.push(think.push(r.text))
if visible:
if not announced:
announced.append(True)
emit({"type": "status", "status": "responding"})
meter.tick("responding", 0, force=True)
text += visible
emit({"type": "text", "delta": visible})
if r.finish_reason == "length":
@@ -421,7 +426,7 @@ class MLXEngine:
full, sources, announced = "", [], []
try:
self._ensure_loaded(emit)
emit({"type": "status", "status": "thinking"})
emit({"type": "status", "status": "thinking", "detail": "penso"})
for rnd in range(MAX_ROUNDS):
with _lock:
round_text, raw_calls, truncated = self._generate(emit, abort, announced)
@@ -443,6 +448,8 @@ class MLXEngine:
full = full.strip() or "(nessuna risposta)"
self.history.save()
emit({"type": "done", "text": full, "sources": sources})
if len(self.history.messages) > 30:
emit({"type": "status", "status": "thinking", "detail": "riassumo la conversazione"})
with _lock:
if compact_history(self.history, self.summarize):
_loaded["snap"] = None # il prompt cambia: la fotografia non vale più
+5 -3
View File
@@ -9,7 +9,7 @@ import openai
from avatar.memory_tools import memory_prompt, openai_tools, parse_args, run_tool
from avatar.plugins import registry
from avatar.websearch import brave_search
from .base import Emit, History, compact_history, persona_text, summary_block, today_label, user_block
from .base import Emit, History, Meter, compact_history, persona_text, summary_block, today_label, user_block
MAX_ROUNDS = 8
MAX_HISTORY = 30
@@ -149,7 +149,7 @@ class OpenAICompatEngine:
full = ""
sources: list[dict] = []
try:
emit({"type": "status", "status": "thinking"})
emit({"type": "status", "status": "thinking", "detail": "penso"})
for rnd in range(MAX_ROUNDS):
tools = self._tools()
kwargs: dict = dict(model=self.model, messages=self._messages(), stream=True)
@@ -164,6 +164,7 @@ class OpenAICompatEngine:
continue
raise
flt, calls, round_text, announced, finish = ThinkFilter(), {}, "", False, None
meter = Meter(emit, unit="parole")
for chunk in stream:
if abort.is_set():
stream.close()
@@ -174,10 +175,11 @@ class OpenAICompatEngine:
delta = choice.delta
if delta and delta.content:
visible = flt.push(delta.content)
meter.tick("thinking" if flt.inside else "responding", delta.content.count(" "))
if visible:
if not announced:
announced = True
emit({"type": "status", "status": "responding"})
meter.tick("responding", 0, force=True)
round_text += visible
full += visible
emit({"type": "text", "delta": visible})
+20 -1
View File
@@ -53,4 +53,23 @@ Sei **Ava**, l'assistente personale di chi ti parla. Vivi in un'app sul suo Mac
## Immagini
- Puoi creare immagini con immagine_genera: traduci la richiesta in una descrizione inglese ricca (soggetto, scena, luce, stile, inquadratura) e avvisa che ci vuole qualche decina di secondi. Quando è pronta, dilla in poche parole senza descrivere i dettagli tecnici.
- Puoi creare immagini con immagine_genera: traduci la richiesta in inglese in modo FEDELE, mantenendo tutti gli elementi che l'utente ha chiesto senza attenuarli, censurarli o sostituirli con formule generiche, e senza aggiungere soggetti non richiesti. Puoi solo completare i dettagli tecnici (luce, inquadratura, stile) se mancano. Se l'utente scrive già il prompt in inglese, passalo tale e quale. Avvisa che ci vuole qualche decina di secondi e, quando è pronta, dilla in poche parole senza descrivere i dettagli tecnici.
## Violino
- Con violino_ascolta e violino_accorda fai da maestra di violino alla figlia dell'utente. Prima di ascoltare annuncia cosa farai ("Suona pure, ti ascolto per quindici secondi") e chiedi cosa sta suonando, così puoi passare la scala o il brano.
- Quando hai i dati, parla a una bambina: prima un complimento sincero e specifico, poi al massimo due consigli concreti e fattibili (dove mettere il dito, arco più lento e pesante, ascoltare la nota prima di suonarla), mai un elenco di errori. Niente numeri tecnici a voce: traduci i cent in "un pochino calante".
- Proponi un esercizio breve e invita a riprovare subito per sentire la differenza.
## Canto
- Con canto_ascolta e canto_nota fai da maestra di canto: stesso stile del violino, voce dolce e incoraggiante. Per chi canta senza accompagnamento conta soprattutto l'intonazione relativa (gli intervalli) e che la tonalità non scivoli; ricordalo nei consigli.
- Con canto_nota proponi giochi brevi: una nota alla volta, poi due note vicine, e festeggia i miglioramenti.
## Documenti
- Con pdf_crea produci documenti PDF (lettere, relazioni, elenchi, verbali, ricette): scrivi tu il contenuto completo in Markdown con titoli ed elenchi, usando i dati che hai raccolto con gli altri strumenti se servono. A voce annuncia solo che il documento è pronto e dove lo trova.
## Sessioni di Claude Code
- Con gli strumenti "claude_…" gestisci i progetti e le sessioni di Claude Code dell'utente: elenchi, lettura e riassunto di una sessione, ricerca, ripresa nel Terminale. Con claude_continua puoi far lavorare Claude Code su un progetto direttamente da qui e riferire il risultato; usa i permessi di modifica solo se l'utente lo chiede, e riassumi la risposta in poche frasi.
+11 -1
View File
@@ -139,7 +139,17 @@ class PluginRegistry:
if tool is None:
return f"Strumento sconosciuto: {name}"
try:
return str(tool.run(dict(args or {}), self.ctx) or "Fatto.")
out = str(tool.run(dict(args or {}), self.ctx) or "Fatto.")
on_image, on_file = self.ctx.get("on_image"), self.ctx.get("on_file")
for line in out.splitlines():
try:
if on_image and line.startswith("IMMAGINE: "):
on_image(line[10:].strip())
elif on_file and line.startswith("FILE: "):
on_file(line[6:].strip())
except Exception as e:
print(f"[plugin] invio al telefono: {e}")
return out
except Exception as err:
if not isinstance(err, (RuntimeError, ValueError)):
traceback.print_exc()
+489
View File
@@ -0,0 +1,489 @@
"""Accesso remoto: app web (PWA) servita in HTTPS sulla rete locale, con abbinamento tramite QR e token.
- HTTPS (porta 8793) con certificato firmato da una CA locale generata al primo avvio (data/remote/): l'iPhone e
Android richiedono un contesto sicuro per microfono e installazione; la CA va installata una volta sul telefono
(scaricabile da http://IP:8791/ca.crt).
- WebSocket /ws: stato, registro, conferme e audio (WAV base64) verso il telefono; testo, interruzioni e conferme
dal telefono. POST /stt: audio registrato dal telefono, decodificato con ffmpeg e trascritto da Whisper.
"""
from __future__ import annotations
import asyncio
import base64
import io
import json
import secrets
import socket
import subprocess
import threading
import time
import wave
from pathlib import Path
import numpy as np
from avatar.settings import BASE_DIR, DATA_DIR, Settings
STATIC = BASE_DIR / "remote"
CERT_DIR = DATA_DIR / "remote"
HTTP_PORT, HTTPS_PORT = 8791, 8793
def lan_ip() -> str:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "127.0.0.1"
def ensure_certs(ip: str) -> tuple[Path, Path, Path]:
"""CA locale + certificato del server con SAN per IP e nome .local; rigenerato se l'IP cambia."""
CERT_DIR.mkdir(parents=True, exist_ok=True)
ca_key, ca_crt = CERT_DIR / "ca.key", CERT_DIR / "ca.crt"
srv_key, srv_crt, srv_san = CERT_DIR / "server.key", CERT_DIR / "server.crt", CERT_DIR / "server.san"
if not ca_crt.exists() or not (CERT_DIR / "ca.v2").exists():
# Requisiti Apple per una CA di fiducia: basicConstraints critico, keyUsage keyCertSign, SKID; RSA 2048, SHA-256
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", str(ca_key), "-out", str(ca_crt),
"-days", "3650", "-subj", "/CN=LuZa CA locale/O=LuZa", "-sha256",
"-addext", "basicConstraints=critical,CA:TRUE", "-addext", "keyUsage=critical,keyCertSign,cRLSign",
"-addext", "subjectKeyIdentifier=hash"], check=True, capture_output=True)
(CERT_DIR / "ca.v2").write_text("1")
srv_san.unlink(missing_ok=True) # il certificato del server va rifirmato con la nuova CA
(CERT_DIR / "ca.cer").write_bytes(subprocess.run(["openssl", "x509", "-in", str(ca_crt), "-outform", "DER"], check=True, capture_output=True).stdout)
host = socket.gethostname()
san = f"IP:{ip},IP:127.0.0.1,DNS:{host},DNS:localhost"
if not srv_crt.exists() or not srv_san.exists() or srv_san.read_text() != san:
csr = CERT_DIR / "server.csr"
ext = CERT_DIR / "server.ext"
ext.write_text(f"subjectAltName={san}\nextendedKeyUsage=serverAuth\nbasicConstraints=CA:FALSE\n"
"keyUsage=critical,digitalSignature,keyEncipherment\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid,issuer\n")
subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", str(srv_key), "-out", str(csr), "-subj", "/CN=LuZa"], check=True, capture_output=True)
subprocess.run(["openssl", "x509", "-req", "-in", str(csr), "-CA", str(ca_crt), "-CAkey", str(ca_key), "-CAcreateserial",
"-out", str(srv_crt), "-days", "820", "-sha256", "-extfile", str(ext)], check=True, capture_output=True)
srv_san.write_text(san)
return ca_crt, srv_crt, srv_key
def wav_bytes(audio: np.ndarray, rate: int = 24000) -> bytes:
pcm = np.clip(audio, -1, 1) if audio.dtype != np.int16 else audio
data = (pcm * 32767).astype(np.int16) if pcm.dtype != np.int16 else pcm
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate); w.writeframes(data.tobytes())
return buf.getvalue()
class RemoteServer:
def __init__(self, assistant, settings: Settings, log=print) -> None:
self.assistant, self.settings, self.log = assistant, settings, log
self.token = str(settings.get("remote_token") or "")
if not self.token:
self.rotate_token()
self.ip = lan_ip()
self._clients: set = set()
self._sse: set = set() # code degli eventi per i client senza WebSocket (app sulla Home di iOS)
self._poll: dict = {} # cid -> (coda, ultimo accesso) per i client a interrogazione ripetuta
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self.state, self.pending_confirm = "LISTENING", None
self.pin, self.pin_expiry = "", 0.0
self.apply_cb = None # chiamata (nel thread Qt) dopo un salvataggio dal telefono
self.error = ""
# ── abbinamento ────────────────────────────────────────────────────────
def rotate_token(self) -> str:
self.token = secrets.token_urlsafe(18)
self.settings.set("remote_token", self.token); self.settings.save()
return self.token
def new_pin(self) -> str:
"""PIN di 6 cifre, valido 10 minuti e monouso: sul telefono si digita al posto della chiave lunga."""
self.pin = f"{secrets.randbelow(900000) + 100000}"
self.pin_expiry = time.time() + 600
return self.pin
def urls(self) -> tuple[str, str, str, str]:
base = f"https://{self.ip}:{HTTPS_PORT}"
return base, self.new_pin(), f"{base}/?k={self.token}", f"{base} (CA: http://{self.ip}:{HTTP_PORT}/ca.cer)"
def has_clients(self) -> bool:
now = time.time()
for cid in [c for c, (_, t) in self._poll.items() if now - t > 90]:
self._poll.pop(cid, None)
return bool(self._clients or self._sse or self._poll)
# ── avvio ──────────────────────────────────────────────────────────────
def start(self) -> None:
self._thread = threading.Thread(target=self._run, daemon=True, name="remote")
self._thread.start()
def stop(self) -> None:
if self._loop:
self._loop.call_soon_threadsafe(self._loop.stop)
def _run(self) -> None:
import ssl
from aiohttp import web
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
try:
ca, crt, key = ensure_certs(self.ip)
except Exception as err:
self.error = f"certificati: {err}"
self.log(f"ERR: Accesso remoto — {self.error}")
return
self.version = str(int(time.time()))
@web.middleware
async def mw(request, handler):
try:
resp = await handler(request)
except web.HTTPException as ex:
resp = ex
self._access(request, ex.status)
raise
self._access(request, getattr(resp, "status", 0))
if "Cache-Control" not in resp.headers:
resp.headers["Cache-Control"] = "no-store"
return resp
app = web.Application(client_max_size=50 * 1024 * 1024, middlewares=[mw])
app.router.add_get("/", self._index)
app.router.add_get("/ws", self._ws)
app.router.add_get("/events", self._events)
app.router.add_get("/poll", self._poll_handler)
app.router.add_post("/cmd", self._cmd)
app.router.add_post("/stt", self._stt)
app.router.add_get("/img", self._img)
app.router.add_get("/ca.crt", self._ca)
app.router.add_get("/ca.cer", self._ca)
app.router.add_get("/health", lambda r: web.json_response({"ok": True, "app": "LuZa"}))
app.router.add_get("/pair", self._pair)
app.router.add_get("/settings", self._settings_get)
app.router.add_post("/settings", self._settings_post)
app.router.add_get("/auth", lambda r: web.json_response({"ok": True}) if self._authed(r) else web.json_response({"ok": False}, status=401))
app.router.add_static("/static", str(STATIC), show_index=False)
app.router.add_get("/manifest.json", self._manifest)
for name in ("sw.js", "icon-192.png", "icon-512.png"):
app.router.add_get(f"/{name}", self._static_file(name))
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain(str(crt), str(key))
runner = web.AppRunner(app)
self._loop.run_until_complete(runner.setup())
try:
self._loop.run_until_complete(web.TCPSite(runner, "0.0.0.0", HTTPS_PORT, ssl_context=ctx).start())
self._loop.run_until_complete(web.TCPSite(runner, "0.0.0.0", HTTP_PORT).start()) # solo CA e reindirizzo
except Exception as err:
self.error = str(err)
self.log(f"ERR: Accesso remoto — porta occupata? {err}")
return
self.log(f"SYS: Accesso remoto pronto su https://{self.ip}:{HTTPS_PORT} (CA: http://{self.ip}:{HTTP_PORT}/ca.crt)")
try:
self._loop.run_forever()
finally:
self._loop.run_until_complete(runner.cleanup())
# ── handler ────────────────────────────────────────────────────────────
def _authed(self, request) -> bool:
k = request.query.get("k") or request.headers.get("Authorization", "").replace("Bearer ", "") or request.cookies.get("luza_k", "")
return secrets.compare_digest(k or "", self.token)
def _access(self, request, status: int) -> None:
try:
with open(CERT_DIR / "access.log", "a", encoding="utf-8") as f:
f.write(f"{time.strftime('%H:%M:%S')} {request.remote} {request.method} {request.path} {status} {'https' if request.secure else 'http'} | {request.headers.get('User-Agent', '')[:90]}\n")
except Exception:
pass
async def _index(self, request):
from aiohttp import web
if request.secure or request.scheme == "https":
html = (STATIC / "index.html").read_text(encoding="utf-8").replace("/static/app.js", f"/static/app.js?v={self.version}")
resp = web.Response(text=html, content_type="text/html", headers={"Cache-Control": "no-store"})
if self._authed(request):
# cookie di abbinamento: consente al manifesto di includere la chiave (app sulla schermata Home)
resp.set_cookie("luza_k", self.token, max_age=365 * 86400, secure=True, httponly=True, samesite="Lax")
return resp
raise web.HTTPFound(f"https://{self.ip}:{HTTPS_PORT}/" + (f"?k={request.query['k']}" if request.query.get("k") else ""))
async def _manifest(self, request):
"""Il manifesto della PWA: l'indirizzo di avvio porta la chiave solo a chi si è già abbinato (cookie)."""
from aiohttp import web
d = json.loads((STATIC / "manifest.json").read_text(encoding="utf-8"))
d["start_url"] = f"/?k={self.token}" if self._authed(request) else "/"
return web.Response(text=json.dumps(d), content_type="application/manifest+json", headers={"Cache-Control": "no-store"})
def _static_file(self, name: str):
from aiohttp import web
async def h(request):
return web.FileResponse(STATIC / name, headers={"Cache-Control": "no-store"})
return h
async def _img(self, request):
"""Consegna un'immagine prodotta dai plugin (solo file dentro la cartella data)."""
from aiohttp import web
if not self._authed(request):
raise web.HTTPUnauthorized()
try:
path = Path(request.query.get("p", "")).resolve()
path.relative_to(DATA_DIR.resolve())
except Exception:
raise web.HTTPForbidden()
if not path.is_file():
raise web.HTTPNotFound()
return web.FileResponse(path, headers={"Cache-Control": "private, max-age=3600"})
def on_image(self, path: str) -> None:
from urllib.parse import quote
if not self.has_clients():
return
p = Path(path)
self.broadcast({"type": "image", "url": f"/img?k={self.token}&p={quote(str(p))}", "name": p.name})
def on_file(self, path: str) -> None:
from urllib.parse import quote
if not self.has_clients():
return
p = Path(path)
self.broadcast({"type": "file", "url": f"/img?k={self.token}&p={quote(str(p))}", "name": p.name})
async def _ca(self, request):
"""CA in formato DER (.cer): è quello che iOS e Android installano senza storie."""
from aiohttp import web
return web.FileResponse(CERT_DIR / "ca.cer", headers={"Content-Type": "application/x-x509-ca-cert",
"Content-Disposition": 'attachment; filename="LuZa-CA.cer"'})
async def _pair(self, request):
from aiohttp import web
pin = (request.query.get("pin") or "").strip()
if pin and self.pin and time.time() < self.pin_expiry and secrets.compare_digest(pin, self.pin):
self.pin = "" # monouso
self.log("SYS: Telefono abbinato con il PIN.")
return web.json_response({"token": self.token})
await asyncio.sleep(1.0) # rallenta i tentativi
return web.json_response({"error": "PIN non valido o scaduto: riapri Remote Control sul Mac"}, status=401)
# ── impostazioni dal telefono: motore, modello, ragionamento, voce ─────
SETTING_KEYS = ("provider", "effort", "mlx_model", "mlx_thinking", "claudecode_model", "local_model",
"tts_engine", "kokoro_voice", "voicebox_profile_id", "immagini_famiglia", "immagini_modello")
def _options(self) -> dict:
from avatar.tts import KOKORO_VOICES
from avatar.engines.mlx_engine import cached_models
opts = {
"provider": [("anthropic", "Claude (cloud)"), ("claudecode", "Claude Code"), ("mlx", "Modello interno (MLX)"), ("local", "Server locale (vLLM)")],
"effort": [("low", "Veloce"), ("medium", "Bilanciata"), ("high", "Approfondita")],
"mlx_model": [(m, m.split("/")[-1]) for m in cached_models()],
"mlx_thinking": [("auto", "Automatico"), ("off", "Spento"), ("on", "Acceso")],
"claudecode_model": [("sonnet", "Sonnet"), ("opus", "Opus"), ("haiku", "Haiku")],
"tts_engine": [("kokoro", "Kokoro (locale, rapida)"), ("voicebox", "Voicebox (clonata)"), ("elevenlabs", "ElevenLabs"), ("chatterbox", "Chatterbox"), ("system", "Voce di sistema")],
"kokoro_voice": list(KOKORO_VOICES.items()),
"immagini_famiglia": [("z-image-turbo", "Z-Image Turbo"), ("schnell", "FLUX schnell"), ("dev", "FLUX dev"), ("qwen", "Qwen-Image")],
}
try:
import sys as _s; _s.path.insert(0, str(BASE_DIR / "plugins"))
import importlib; opts["immagini_modello"] = [("", "predefinito")] + [(m, m.split("/")[-1]) for m in importlib.import_module("immagini").cached_image_models()]
except Exception:
opts["immagini_modello"] = [("", "predefinito")]
try:
from avatar.tts import VoiceboxVoice
opts["voicebox_profile_id"] = VoiceboxVoice.list_profiles() if VoiceboxVoice.alive() else []
except Exception:
opts["voicebox_profile_id"] = []
return opts
async def _settings_get(self, request):
from aiohttp import web
if not self._authed(request):
raise web.HTTPUnauthorized()
s = Settings()
values = {k: s.get(k) for k in self.SETTING_KEYS}
opts = await asyncio.get_event_loop().run_in_executor(None, self._options)
return web.json_response({"values": values, "options": opts, "assistant": getattr(self.assistant, "name", "LuZa")})
async def _settings_post(self, request):
from aiohttp import web
if not self._authed(request):
raise web.HTTPUnauthorized()
d = await request.json()
vals = {k: v for k, v in d.items() if k in self.SETTING_KEYS and isinstance(v, (str, int, float, bool))}
if not vals:
return web.json_response({"ok": False, "error": "nessun valore"})
self.settings.update(vals)
self.log("SYS: Impostazioni cambiate dal telefono: " + ", ".join(f"{k}={v}" for k, v in vals.items()))
if self.apply_cb:
self.apply_cb()
return web.json_response({"ok": True})
async def _stt(self, request):
from aiohttp import web
if not self._authed(request):
raise web.HTTPUnauthorized()
raw = await request.read()
if len(raw) < 1000:
return web.json_response({"text": "", "error": "audio vuoto"})
try:
proc = subprocess.run(["ffmpeg", "-loglevel", "error", "-i", "pipe:0", "-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
input=raw, capture_output=True, timeout=60)
pcm = np.frombuffer(proc.stdout, dtype=np.int16)
except Exception as err:
return web.json_response({"text": "", "error": f"decodifica audio: {err}"})
if pcm.size < 1600:
return web.json_response({"text": "", "error": "audio troppo breve"})
text = await asyncio.get_event_loop().run_in_executor(None, self.assistant.stt.transcribe, pcm)
text = (text or "").strip()
if request.query.get("run") == "1" and text:
self.assistant.ui.write_log(f"You (telefono): {text}")
self.assistant.handle_text(text, origin="remote")
return web.json_response({"text": text})
async def _ws(self, request):
from aiohttp import web
if not self._authed(request):
raise web.HTTPUnauthorized()
ws = web.WebSocketResponse(heartbeat=25)
await ws.prepare(request)
self._clients.add(ws)
self.log("SYS: Telefono collegato.")
try:
await ws.send_json({"type": "state", "state": self.state})
if self.pending_confirm:
await ws.send_json({"type": "confirm", **self.pending_confirm})
async for msg in ws:
if msg.type != 1: # WSMsgType.TEXT
continue
try:
d = json.loads(msg.data)
except Exception:
continue
if d.get("type") == "ping":
await ws.send_json({"type": "pong"})
else:
self._command(d)
finally:
self._clients.discard(ws)
self.log("SYS: Telefono scollegato.")
return ws
def _command(self, d: dict) -> None:
t = d.get("type")
if t == "say" and str(d.get("text", "")).strip():
text = str(d["text"]).strip()
self.assistant.ui.write_log(f"You (telefono): {text}")
self.assistant.handle_text(text, origin="remote")
elif t == "interrupt":
self.assistant.interrupt()
elif t == "confirm":
from core import confirm
confirm.resolve(bool(d.get("ok")))
async def _cmd(self, request):
from aiohttp import web
if not self._authed(request):
raise web.HTTPUnauthorized()
try:
self._command(await request.json())
except Exception as err:
return web.json_response({"ok": False, "error": str(err)})
return web.json_response({"ok": True})
async def _events(self, request):
"""Server-Sent Events: stessi messaggi del WebSocket, su una normale risposta HTTPS."""
from aiohttp import web
if not self._authed(request):
raise web.HTTPUnauthorized()
resp = web.StreamResponse(headers={"Content-Type": "text/event-stream", "Cache-Control": "no-store", "X-Accel-Buffering": "no"})
await resp.prepare(request)
q: asyncio.Queue = asyncio.Queue()
self._sse.add(q)
self.log("SYS: Telefono collegato (eventi).")
try:
await resp.write(f"data: {json.dumps({'type': 'state', 'state': self.state})}\n\n".encode())
if self.pending_confirm:
await resp.write(f"data: {json.dumps({'type': 'confirm', **self.pending_confirm}, ensure_ascii=False)}\n\n".encode())
while True:
try:
data = await asyncio.wait_for(q.get(), timeout=20)
await resp.write(f"data: {data}\n\n".encode())
except asyncio.TimeoutError:
await resp.write(b": ping\n\n")
except (ConnectionResetError, asyncio.CancelledError, Exception):
pass
finally:
self._sse.discard(q)
self.log("SYS: Telefono scollegato (eventi).")
return resp
async def _poll_handler(self, request):
"""Interrogazione ripetuta: risponde con gli eventi in coda, aspettando al massimo 15 s. Solo richieste brevi."""
from aiohttp import web
if not self._authed(request):
raise web.HTTPUnauthorized()
cid = request.query.get("cid") or ""
if not cid:
return web.json_response({"events": []})
new = cid not in self._poll
q = self._poll.get(cid, (asyncio.Queue(), 0))[0]
self._poll[cid] = (q, time.time())
events = []
if new:
self.log("SYS: Telefono collegato (interrogazione).")
events.append({"type": "state", "state": self.state})
if self.pending_confirm:
events.append({"type": "confirm", **self.pending_confirm})
else:
try:
events.append(json.loads(await asyncio.wait_for(q.get(), timeout=15)))
except asyncio.TimeoutError:
pass
while not q.empty():
events.append(json.loads(q.get_nowait()))
return web.json_response({"events": events})
# ── verso i telefoni (chiamabili da qualunque thread) ──────────────────
def broadcast(self, payload: dict) -> None:
if not (self._clients or self._sse) or not self._loop:
return
data = json.dumps(payload, ensure_ascii=False)
async def go():
for ws in list(self._clients):
try:
await ws.send_str(data)
except Exception:
self._clients.discard(ws)
for q in list(self._sse):
q.put_nowait(data)
for q, _ in list(self._poll.values()):
q.put_nowait(data)
try:
asyncio.run_coroutine_threadsafe(go(), self._loop)
except Exception:
pass
def on_state(self, state: str) -> None:
self.state = state
self.broadcast({"type": "state", "state": state})
def on_log(self, text: str) -> None:
self.broadcast({"type": "log", "text": text})
def on_confirm(self, title: str, detail: str) -> None:
self.pending_confirm = {"title": title, "detail": detail}
self.broadcast({"type": "confirm", "title": title, "detail": detail})
def on_confirm_hide(self) -> None:
self.pending_confirm = None
self.broadcast({"type": "confirm_hide"})
def on_audio(self, text: str, audio: np.ndarray, rate: int = 24000) -> None:
if not self._clients:
return
self.broadcast({"type": "audio", "text": text, "wav": base64.b64encode(wav_bytes(audio, rate)).decode()})
def on_turn_end(self) -> None:
self.broadcast({"type": "audio_end"})
+2
View File
@@ -54,6 +54,8 @@ DEFAULTS: dict[str, Any] = {
"homeassistant_url": "http://homeassistant.local:8123", # Home Assistant (token nel portachiavi)
"immagini_famiglia": "z-image-turbo", # z-image-turbo | schnell | dev | qwen (comando mflux e passi predefiniti)
"immagini_modello": "", # repo Hugging Face o cartella; vuoto = predefinito della famiglia
"remote_enabled": True, # app web per il telefono (HTTPS sulla rete locale)
"remote_token": "", # chiave di abbinamento (generata al primo avvio)
}
SECRET_KEYS = ("anthropic_api_key", "local_api_key", "telegram_api_hash", "elevenlabs_api_key", "homeassistant_token")
+35
View File
@@ -70,6 +70,41 @@ def main() -> None:
# Questi due sono attributi della finestra, non della facciata JarvisUI.
ui._win.on_new_conversation = assistant.reset_conversation
# ── Accesso remoto (app web per il telefono): specchia stato, registro e conferme; riceve testo e voce ──
from avatar.remote import RemoteServer
remote = RemoteServer(assistant, settings, ui.write_log)
assistant.remote = remote
_set_state, _write_log, _show_confirm, _hide_confirm = ui.set_state, ui.write_log, ui.show_confirm, ui.hide_confirm
def set_state(state: str) -> None:
_set_state(state); remote.on_state(state)
def write_log(text: str) -> None:
_write_log(text); remote.on_log(text)
def show_confirm(title: str, detail: str) -> None:
_show_confirm(title, detail); remote.on_confirm(title, detail)
def hide_confirm() -> None:
_hide_confirm(); remote.on_confirm_hide()
ui.set_state, ui.write_log, ui.show_confirm, ui.hide_confirm = set_state, write_log, show_confirm, hide_confirm
confirm.bind(ui.show_confirm, ui.hide_confirm, ui.write_log)
registry.ctx["log"] = ui.write_log
registry.ctx["on_image"] = remote.on_image
registry.ctx["on_file"] = remote.on_file
ui._win.on_remote_clicked = remote.urls
from PyQt6.QtCore import QObject, pyqtSignal
class _Bridge(QObject):
apply = pyqtSignal()
bridge = _Bridge(); bridge.apply.connect(assistant.reconfigure)
remote.apply_cb = bridge.apply.emit
ui._win._remote_bridge = bridge
if settings.get("remote_enabled", True):
remote.start()
ui._app.aboutToQuit.connect(remote.stop)
def open_settings() -> None:
SettingsDialog(settings, assistant.reconfigure, parent=ui._win).exec()
+133
View File
@@ -0,0 +1,133 @@
"""Maestra di canto: ascolta la voce, misura intonazione assoluta e relativa (intervalli), deriva di tonalità,
vibrato e sostegno, e propone l'esercizio "canta questa nota" con il suono di riferimento.
Riusa l'analizzatore del plugin violino (librosa/pYIN) con la gamma della voce.
"""
from __future__ import annotations
import math
import sys
import time
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from avatar.settings import DATA_DIR # noqa: E402
import violino # noqa: E402
OUT_DIR = DATA_DIR / "canto"
FMIN, FMAX = 90.0, 1500.0
def _nota_midi(nome: str) -> int | None:
"""'La4', 'Do5', 'Fa#4' → numero MIDI."""
t = (nome or "").strip().lower().replace(" ", "")
for k in sorted(violino.NOTE_IDX, key=len, reverse=True):
if t.startswith(k):
rest = t[len(k):]
alt = 0
if rest.startswith("#"):
alt, rest = 1, rest[1:]
elif rest.startswith("b"):
alt, rest = -1, rest[1:]
try:
octave = int(rest) if rest else 4
except ValueError:
return None
return (octave + 1) * 12 + violino.NOTE_IDX[k] + alt
return None
def _suona(midi: int, secondi: float = 1.5, a4: float = 440.0) -> None:
import sounddevice as sd
f = a4 * 2 ** ((midi - 69) / 12)
t = np.linspace(0, secondi, int(violino.SR * secondi), endpoint=False)
env = np.minimum(1, t / 0.05) * np.minimum(1, (secondi - t) / 0.2)
tone = (0.5 * np.sin(2 * np.pi * f * t) + 0.15 * np.sin(4 * np.pi * f * t)) * env * 0.4
sd.play(tone.astype(np.float32), violino.SR)
sd.wait()
def _testo_canto(rep: dict) -> str:
if rep.get("silenzio"):
return "Non ho sentito la voce: canta un po' più forte o avvicinati al microfono (mezzo metro va bene)."
notes = rep.get("note") or []
if not notes:
return rep.get("msg", "Nessuna nota stabile: prova con note tenute, una vocale aperta come 'a'.")
cents = np.array([x["cents"] for x in notes])
seq = " ".join(f"{violino._nome(x['midi'])}({x['cents']:+.0f})" for x in notes[:40])
out = [f"Registrazione di {rep['durata']:.0f} s, {rep['n_note']} note (nome e scarto in cent):", seq + (" …" if len(notes) > 40 else "")]
# intonazione assoluta e relativa
ivl_err = []
for a, b in zip(notes, notes[1:]):
d = (b["midi_f"] - a["midi_f"]) * 100
ivl_err.append(d - round(d / 100) * 100)
ivl = np.abs(np.array(ivl_err)) if ivl_err else np.array([])
out.append(f"Intonazione assoluta: scarto medio {np.mean(np.abs(cents)):.0f} cent, {np.mean(np.abs(cents) <= 20) * 100:.0f}% delle note entro 20 cent, tendenza {'crescente' if cents.mean() > 8 else 'calante' if cents.mean() < -8 else 'centrata'} ({cents.mean():+.0f} cent).")
if len(ivl):
out.append(f"Intonazione relativa (intervalli tra note consecutive): errore medio {ivl.mean():.0f} cent, {np.mean(ivl <= 25) * 100:.0f}% degli intervalli puliti; intervallo peggiore {ivl.max():.0f} cent.")
# deriva di tonalità: regressione dei cent nel tempo
if len(notes) >= 4:
t = np.array([x["start"] for x in notes]); k = np.polyfit(t, cents, 1)[0]
out.append(f"Deriva della tonalità: {k * 10:+.0f} cent ogni 10 secondi ({'stabile' if abs(k * 10) < 12 else 'scende piano piano' if k < 0 else 'sale piano piano'}).")
lung = [x for x in notes if x["dur"] >= 0.6]
if lung:
vib = np.mean([x["vib"] for x in lung]);
out.append(f"Note tenute: {len(lung)}, oscillazione media {vib:.0f} cent ({'ferma' if vib < 10 else 'vibrato leggero' if vib < 25 else 'traballante: sostegno del fiato'}).")
rng = rep["dinamica_range_db"]
out.append(f"Volume: escursione {rng:.0f} dB {'(uniforme)' if rng < 6 else '(espressivo)' if rng < 18 else '(cali di fiato o note spinte)'}; note troppo brevi {rep['note_corte_pct']:.0f}%.")
if rep.get("scala"):
fuori = rep.get("fuori_scala") or []
out.append(f"Tonalità {rep['scala']}: " + ("tutte le note appartengono alla scala." if not fuori else "note fuori: " + ", ".join(f"{violino._nome(x['midi'])} a {x['start']:.1f} s" for x in fuori[:6]) + "."))
out.append("Interpreta per l'allieva: complimento specifico, poi al massimo due consigli (ascolta la nota prima di cantarla, fiato dal diaframma, vocale aperta), e un esercizio breve.")
return "\n".join(out)
def ascolta(params: dict, ctx: dict) -> str:
secondi = max(5, min(int(params.get("secondi") or 15), 60))
a4 = float(params.get("nota_la") or 440)
y = violino.registra(secondi, ctx)
OUT_DIR.mkdir(parents=True, exist_ok=True)
path = OUT_DIR / f"{time.strftime('%Y%m%d-%H%M%S')}.wav"
try:
import soundfile as sf
sf.write(str(path), y, violino.SR)
except Exception:
pass
rep = violino.analizza(y, violino.SR, a4, str(params.get("tonalita") or ""), FMIN, FMAX)
return _testo_canto(rep) + (f"\nRegistrazione salvata: {path.name}" if path.exists() else "")
def nota(params: dict, ctx: dict) -> str:
"""Fa sentire una nota e ascolta se viene ricantata intonata."""
import librosa
nome = str(params.get("nota") or "La4")
midi = _nota_midi(nome)
if midi is None:
return f"Nota '{nome}' non riconosciuta: usa nomi come Do4, Re4, Mi4, Fa#4, La4, Do5."
a4 = float(params.get("nota_la") or 440)
violino._progress(ctx, f"suono la nota {violino._nome(midi)}")
_suona(midi, 1.5, a4)
y = violino.registra(max(2, min(int(params.get("secondi") or 3), 8)), ctx, attesa=1)
if 20 * math.log10(max(float(np.sqrt(np.mean(y ** 2))), 1e-6)) < -45:
return "Non ho sentito la voce. Riproviamo: canta subito dopo il suono, tenendo la nota."
f0, voiced, prob = librosa.pyin(y, fmin=FMIN, fmax=FMAX, sr=violino.SR)
ok = voiced & (prob >= 0.6)
if not np.any(ok):
return "Ho sentito qualcosa ma non una nota tenuta: prova con una 'a' aperta e ferma."
m = 69 + 12 * np.log2(np.nanmedian(f0[ok]) / a4)
diff = (m - midi) * 100
ottava = ""
if abs(diff) > 700: # cantata all'ottava (voce di bambina o maschile)
diff = diff - round(diff / 1200) * 1200; ottava = " (in un'altra ottava, va bene)"
giudizio = "perfetta" if abs(diff) <= 15 else "quasi: un pelo " + ("alta" if diff > 0 else "bassa") if abs(diff) <= 40 else ("decisamente alta" if diff > 0 else "decisamente bassa")
return f"Nota richiesta {violino._nome(midi)}, cantata a {diff:+.0f} cent{ottava}: {giudizio}. Stabilità {np.nanstd((69 + 12 * np.log2(f0[ok] / a4) - midi) * 100):.0f} cent."
TOOLS = [
{"name": "canto_ascolta", "description": "Ascolta la voce dal microfono per alcuni secondi (default 15, max 60) e misura intonazione assoluta e relativa (intervalli tra le note), deriva della tonalità, note tenute e vibrato, volume e fiato. Con tonalita (es. 'Do maggiore') segnala le note fuori scala. Annuncia prima che stai per ascoltare: ci sono 3 secondi di preparazione. Restituisce dati da trasformare in consigli.",
"parameters": {"type": "object", "properties": {"secondi": {"type": "integer"}, "tonalita": {"type": "string"}, "nota_la": {"type": "number"}}}, "run": ascolta},
{"name": "canto_nota", "description": "Esercizio 'canta questa nota': fa sentire una nota di riferimento dalle casse (es. La4, Do5, Mi4) per 1,5 secondi, poi ascolta 3 secondi e dice se è stata cantata intonata, quanto alta o bassa e quanto ferma.",
"parameters": {"type": "object", "properties": {"nota": {"type": "string"}, "secondi": {"type": "integer"}, "nota_la": {"type": "number"}}}, "run": nota},
]
+315
View File
@@ -0,0 +1,315 @@
"""Sessioni di Claude Code (come l'app GestioneClaude): progetti e sessioni da ~/.claude*/projects, lettura,
ricerca, ripresa nel Terminale e continuazione diretta da LuZa (claude -p --resume) con permessi a scelta.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from avatar.macos import confirm_or_param, CONFERMATO # noqa: E402
from avatar.engines.claude_code import resolve_claude, claude_env # noqa: E402
HOME = Path.home()
_cache: dict = {"t": 0.0, "sessions": []}
LIVELLI = {
"lettura": {"tools": "Read,Glob,Grep,WebSearch,WebFetch", "allowed": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"], "mode": None},
"modifica": {"tools": "default", "allowed": ["Bash", "Read", "Edit", "Write", "MultiEdit", "NotebookEdit", "Glob", "Grep", "WebSearch", "WebFetch", "Agent"], "mode": "acceptEdits"},
}
def _roots() -> list[Path]:
out = []
for d in sorted(HOME.iterdir()):
if d.name.startswith(".claude") and (d / "projects").is_dir():
out.append(d)
return out
def _decode_dir(name: str) -> str:
return name.replace("-", "/") if name.startswith("-") else name
def _text_of(msg) -> str:
if not isinstance(msg, dict):
return ""
c = msg.get("content")
if isinstance(c, str):
return c
if isinstance(c, list):
return "\n".join(b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text")
return ""
def _head(path: Path) -> dict:
"""cwd, primo prompt e data dalle prime righe del file."""
cwd = prompt = ts = None
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
for i, line in enumerate(f):
if i > 80 or (cwd and prompt and ts):
break
try:
o = json.loads(line)
except Exception:
continue
cwd = cwd or o.get("cwd")
ts = ts or o.get("timestamp")
if not prompt and o.get("type") == "user" and not o.get("isSidechain"):
t = _text_of(o.get("message")).strip()
if t and not t.startswith(("<command-", "<local-command", "Caveat:", "[Request interrupted")):
prompt = " ".join(t.split())[:160]
except Exception:
pass
return {"cwd": cwd, "prompt": prompt, "ts": ts}
def scan(force: bool = False) -> list[dict]:
if not force and time.time() - _cache["t"] < 60 and _cache["sessions"]:
return _cache["sessions"]
out = []
for root in _roots():
for pdir in (root / "projects").iterdir():
if not pdir.is_dir():
continue
for f in pdir.glob("*.jsonl"):
try:
st = f.stat()
except OSError:
continue
if st.st_size < 200:
continue
h = _head(f)
out.append({"id": f.stem, "file": f, "root": root.name, "cwd": h["cwd"] or _decode_dir(pdir.name), "prompt": h["prompt"] or "(senza prompt)",
"start": h["ts"], "mtime": st.st_mtime, "size": st.st_size})
out.sort(key=lambda s: -s["mtime"])
_cache.update(t=time.time(), sessions=out)
return out
def _projects(sessions: list[dict]) -> list[dict]:
by: dict[str, dict] = {}
for s in sessions:
p = by.setdefault(s["cwd"], {"path": s["cwd"], "name": Path(s["cwd"]).name or s["cwd"], "n": 0, "last": 0, "size": 0, "roots": set(), "exists": Path(s["cwd"]).is_dir()})
p["n"] += 1; p["last"] = max(p["last"], s["mtime"]); p["size"] += s["size"]; p["roots"].add(s["root"])
return sorted(by.values(), key=lambda p: -p["last"])
def _norm(t: str) -> str:
return re.sub(r"[^a-z0-9]", "", (t or "").lower())
def _fmt_dt(ts: float) -> str:
return datetime.fromtimestamp(ts).strftime("%d/%m %H:%M")
def _find_project(nome: str, sessions: list[dict]) -> dict | None:
n = _norm(nome)
if not n:
return None
projs = _projects(sessions)
for p in projs:
if _norm(p["name"]) == n or _norm(p["path"]) == n:
return p
for p in projs:
if n in _norm(p["name"]) or n in _norm(p["path"]):
return p
return None
def _find_session(rif: str, progetto: str, sessions: list[dict]) -> dict | None:
rif = (rif or "").strip().lower()
pool = sessions
if progetto:
p = _find_project(progetto, sessions)
if p:
pool = [s for s in sessions if s["cwd"] == p["path"]]
if not rif or rif in ("ultima", "recente", "last"):
return pool[0] if pool else None
for s in pool:
if s["id"].lower() == rif or s["id"].lower().startswith(rif):
return s
for s in pool:
if rif in s["prompt"].lower():
return s
return None
def progetti(params: dict, ctx: dict) -> str:
sessions = scan(force=True)
filtro = _norm(str(params.get("filtro") or ""))
projs = [p for p in _projects(sessions) if not filtro or filtro in _norm(p["name"]) or filtro in _norm(p["path"])]
if not projs:
return "Nessun progetto Claude Code trovato."
lim = max(3, min(int(params.get("limite") or 20), 60))
lines = [f"- {p['name']} — {p['n']} sessioni, ultima {_fmt_dt(p['last'])}, {p['size'] / 1e6:.1f} MB{'' if p['exists'] else ' (cartella non più presente)'} [{p['path']}]" for p in projs[:lim]]
return f"Progetti Claude Code ({len(projs)}, {len(sessions)} sessioni in {len(_roots())} profili):\n" + "\n".join(lines)
def sessioni(params: dict, ctx: dict) -> str:
sessions = scan()
nome = str(params.get("progetto") or "")
p = _find_project(nome, sessions) if nome else None
if nome and not p:
return f"Progetto '{nome}' non trovato. Usa claude_progetti per l'elenco."
pool = [s for s in sessions if not p or s["cwd"] == p["path"]]
lim = max(3, min(int(params.get("numero") or 10), 40))
lines = [f"- {s['id'][:8]} · {_fmt_dt(s['mtime'])} · {s['size'] / 1e3:.0f} KB · {s['prompt'][:90]}" + ("" if p else f" [{Path(s['cwd']).name}]") for s in pool[:lim]]
return (f"Sessioni di {p['name']}" if p else "Sessioni recenti") + f" ({len(pool)}):\n" + "\n".join(lines) + "\nRiferisciti a una sessione con le prime cifre dell'id o con 'ultima'."
def _messages(path: Path):
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
try:
o = json.loads(line)
except Exception:
continue
if o.get("isSidechain") or o.get("type") not in ("user", "assistant"):
continue
t = _text_of(o.get("message")).strip()
if not t or t.startswith(("<command-", "<local-command", "Caveat:")):
continue
yield o["type"], t, o.get("timestamp", "")
def leggi(params: dict, ctx: dict) -> str:
sessions = scan()
s = _find_session(str(params.get("sessione") or ""), str(params.get("progetto") or ""), sessions)
if not s:
return "Sessione non trovata: indica progetto e le prime cifre dell'id (vedi claude_sessioni)."
n = max(4, min(int(params.get("numero") or 20), 80))
per = max(200, min(int(params.get("caratteri") or 700), 3000))
msgs = list(_messages(s["file"]))
parte = msgs[:n] if str(params.get("parte") or "fine") == "inizio" else msgs[-n:]
out = [f"Sessione {s['id'][:8]} di {Path(s['cwd']).name} ({len(msgs)} messaggi, {'inizio' if parte is msgs[:n] else 'fine'}):"]
for role, t, ts in parte:
out.append(f"[{'Utente' if role == 'user' else 'Claude'} {ts[11:16] if ts else ''}] {t[:per]}{'…' if len(t) > per else ''}")
return "\n".join(out)
def cerca(params: dict, ctx: dict) -> str:
q = str(params.get("query") or "").strip().lower()
if not q:
return "Errore: serve il testo da cercare."
sessions = scan()
p = _find_project(str(params.get("progetto") or ""), sessions) if params.get("progetto") else None
hits, checked = [], 0
for s in sessions:
if p and s["cwd"] != p["path"]:
continue
if s["size"] > 60_000_000:
continue
checked += 1
try:
for role, t, ts in _messages(s["file"]):
if q in t.lower():
i = t.lower().find(q)
hits.append(f"- {Path(s['cwd']).name} · {s['id'][:8]} · {_fmt_dt(s['mtime'])} [{'Utente' if role == 'user' else 'Claude'}]: …{' '.join(t[max(0, i - 80):i + 120].split())}…")
if len([h for h in hits if s['id'][:8] in h]) >= 2:
break
except Exception:
continue
if len(hits) >= 25:
break
if not hits:
return f"Nessun risultato per '{q}' in {checked} sessioni."
return f"Risultati per '{q}' ({len(hits)}):\n" + "\n".join(hits)
def _terminal(cmd: str) -> None:
script = f'tell application "Terminal"\nactivate\ndo script {json.dumps(cmd)}\nend tell'
subprocess.run(["osascript", "-e", script], capture_output=True, timeout=20)
def riprendi(params: dict, ctx: dict) -> str:
sessions = scan()
s = _find_session(str(params.get("sessione") or ""), str(params.get("progetto") or ""), sessions)
if not s:
return "Sessione non trovata."
env = f"CLAUDE_CONFIG_DIR={json.dumps(str(HOME / s['root']))} " if s["root"] != ".claude" else ""
_terminal(f"cd {json.dumps(s['cwd'])} && {env}claude --resume {s['id']}")
return f"Aperto il Terminale nel progetto {Path(s['cwd']).name} con la sessione {s['id'][:8]} ripresa."
def nuova(params: dict, ctx: dict) -> str:
sessions = scan()
p = _find_project(str(params.get("progetto") or ""), sessions)
path = p["path"] if p else str(Path(str(params.get("progetto") or "")).expanduser())
if not Path(path).is_dir():
return f"Cartella non trovata: {path}"
root = next(iter(sorted(p["roots"])), ".claude") if p else ".claude"
env = f"CLAUDE_CONFIG_DIR={json.dumps(str(HOME / root))} " if root != ".claude" else ""
_terminal(f"cd {json.dumps(path)} && {env}claude")
return f"Aperto il Terminale in {Path(path).name} con una nuova sessione di Claude Code."
def continua(params: dict, ctx: dict) -> str:
"""Manda una richiesta a Claude Code dentro una sessione esistente (o nuova) del progetto, senza Terminale."""
sessions = scan()
richiesta = str(params.get("richiesta") or "").strip()
if not richiesta:
return "Errore: serve la richiesta da fare a Claude."
s = _find_session(str(params.get("sessione") or ""), str(params.get("progetto") or ""), sessions)
p = _find_project(str(params.get("progetto") or ""), sessions) if params.get("progetto") else None
if not s and not p:
return "Indica la sessione (prime cifre dell'id o 'ultima') o il progetto."
cwd = s["cwd"] if s else p["path"]
root = s["root"] if s else next(iter(sorted(p["roots"])), ".claude")
livello = "modifica" if str(params.get("permessi") or "lettura").lower().startswith("mod") else "lettura"
binary = resolve_claude("")
if not binary:
return "Non trovo il comando claude."
def do() -> str:
flags = LIVELLI[livello]
args = [binary, "-p", "--output-format", "json", "--tools", flags["tools"], "--allowedTools", ",".join(flags["allowed"])]
if flags["mode"]:
args += ["--permission-mode", flags["mode"]]
args += ["--resume", s["id"]] if s else []
player = ctx.get("player")
if player is not None and hasattr(player, "set_state"):
player.set_state(f"PROCESSING · Claude Code su {Path(cwd).name}")
env = claude_env(str(HOME / root) if root != ".claude" else "")
try:
r = subprocess.run(args, input=richiesta, cwd=cwd, env=env, capture_output=True, text=True, timeout=900)
except subprocess.TimeoutExpired:
return "Claude Code non ha risposto entro 15 minuti."
try:
d = json.loads(r.stdout.strip().splitlines()[-1])
text = d.get("result") or ""
sid = d.get("session_id", "")
if d.get("is_error"):
return f"Claude Code ha segnalato un errore: {text or r.stderr[-300:]}"
except Exception:
text, sid = (r.stdout or r.stderr)[-2000:], ""
_cache["t"] = 0
return f"Risposta di Claude Code ({Path(cwd).name}, sessione {(sid or (s['id'] if s else ''))[:8]}, permessi: {livello}):\n{text}"
if livello == "modifica":
return confirm_or_param(ctx, params, "claude_continua", f"Far modificare il progetto {Path(cwd).name} a Claude Code?", richiesta[:300], do)
return do()
TOOLS = [
{"name": "claude_progetti", "description": "Elenca i progetti con sessioni di Claude Code (tutti i profili ~/.claude*): numero di sessioni, ultima attività, spazio, percorso. Filtro opzionale per nome.",
"parameters": {"type": "object", "properties": {"filtro": {"type": "string"}, "limite": {"type": "integer"}}}, "run": progetti},
{"name": "claude_sessioni", "description": "Elenca le sessioni di Claude Code di un progetto (o le più recenti di tutti), con id abbreviato, data, dimensione e prima richiesta.",
"parameters": {"type": "object", "properties": {"progetto": {"type": "string"}, "numero": {"type": "integer"}}}, "run": sessioni},
{"name": "claude_leggi", "description": "Legge i messaggi di una sessione di Claude Code (utente e Claude), dalla fine o dall'inizio, per riassumerla o riprendere il filo. sessione = prime cifre dell'id oppure 'ultima' (con progetto).",
"parameters": {"type": "object", "properties": {"sessione": {"type": "string"}, "progetto": {"type": "string"}, "numero": {"type": "integer"}, "parte": {"type": "string", "enum": ["fine", "inizio"]}, "caratteri": {"type": "integer"}}}, "run": leggi},
{"name": "claude_cerca", "description": "Cerca un testo nei messaggi di tutte le sessioni di Claude Code (o di un progetto): utile per ritrovare dove si è parlato di qualcosa.",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}, "progetto": {"type": "string"}}, "required": ["query"]}, "run": cerca},
{"name": "claude_continua", "description": "Fa lavorare Claude Code su un progetto senza aprire il Terminale: manda la richiesta dentro una sessione esistente (sessione = prime cifre dell'id o 'ultima') o in una nuova sessione del progetto, e riporta la risposta. permessi=lettura (default: legge e cerca nel codice) oppure modifica (può cambiare file ed eseguire comandi: chiede conferma sullo schermo). Può richiedere minuti.",
"parameters": {"type": "object", "properties": {"richiesta": {"type": "string"}, "sessione": {"type": "string"}, "progetto": {"type": "string"}, "permessi": {"type": "string", "enum": ["lettura", "modifica"]}, "confermato": CONFERMATO}, "required": ["richiesta"]}, "run": continua},
{"name": "claude_riprendi", "description": "Apre il Terminale nel progetto e riprende una sessione di Claude Code (claude --resume) perché l'utente continui a mano.",
"parameters": {"type": "object", "properties": {"sessione": {"type": "string"}, "progetto": {"type": "string"}}}, "run": riprendi},
{"name": "claude_nuova", "description": "Apre il Terminale in un progetto e avvia una nuova sessione di Claude Code.",
"parameters": {"type": "object", "properties": {"progetto": {"type": "string"}}, "required": ["progetto"]}, "run": nuova},
]
+204
View File
@@ -0,0 +1,204 @@
"""Documenti PDF: impagina in A4 un testo in Markdown (titoli, elenchi, grassetto, corsivo, tabelle, immagini),
lo salva in data/documenti/ e lo apre in Anteprima. Il file arriva anche al telefono.
"""
from __future__ import annotations
import html
import re
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from avatar.settings import DATA_DIR # noqa: E402
OUT_DIR = DATA_DIR / "documenti"
FONT_DIR = Path("/System/Library/Fonts/Supplemental")
def _fonts():
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.fonts import addMapping
if "Arial" in pdfmetrics.getRegisteredFontNames():
return "Arial"
try:
pdfmetrics.registerFont(TTFont("Arial", str(FONT_DIR / "Arial.ttf")))
pdfmetrics.registerFont(TTFont("Arial-Bold", str(FONT_DIR / "Arial Bold.ttf")))
pdfmetrics.registerFont(TTFont("Arial-Italic", str(FONT_DIR / "Arial Italic.ttf")))
pdfmetrics.registerFont(TTFont("Arial-BoldItalic", str(FONT_DIR / "Arial Bold Italic.ttf")))
addMapping("Arial", 0, 0, "Arial"); addMapping("Arial", 1, 0, "Arial-Bold")
addMapping("Arial", 0, 1, "Arial-Italic"); addMapping("Arial", 1, 1, "Arial-BoldItalic")
return "Arial"
except Exception:
return "Helvetica"
def _inline(text: str) -> str:
"""Markdown in linea → mini-HTML di reportlab (grassetto, corsivo, codice, link)."""
t = html.escape(text, quote=False)
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t)
t = re.sub(r"(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)", r"<i>\1</i>", t)
t = re.sub(r"`(.+?)`", r"<font face='Courier'>\1</font>", t)
t = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)", r"<link href='\2' color='#0645ad'>\1</link>", t)
return t
def costruisci(titolo: str, markdown: str, path: Path, autore: str = "", immagini: list[str] | None = None) -> None:
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, ListFlowable, ListItem, Table, TableStyle, Image, PageBreak, HRFlowable
font = _fonts()
st = {
"title": ParagraphStyle("t", fontName=font, fontSize=22, leading=27, spaceAfter=10, textColor=colors.HexColor("#123")),
"h1": ParagraphStyle("h1", fontName=font, fontSize=16, leading=20, spaceBefore=12, spaceAfter=6, textColor=colors.HexColor("#123")),
"h2": ParagraphStyle("h2", fontName=font, fontSize=13, leading=17, spaceBefore=10, spaceAfter=4),
"h3": ParagraphStyle("h3", fontName=font, fontSize=11.5, leading=15, spaceBefore=8, spaceAfter=3),
"p": ParagraphStyle("p", fontName=font, fontSize=10.5, leading=15, spaceAfter=6),
"quote": ParagraphStyle("q", fontName=font, fontSize=10.5, leading=15, leftIndent=14, textColor=colors.HexColor("#444"), spaceAfter=6),
"small": ParagraphStyle("s", fontName=font, fontSize=8.5, leading=11, textColor=colors.HexColor("#777")),
"code": ParagraphStyle("c", fontName="Courier", fontSize=9, leading=12, backColor=colors.HexColor("#f4f4f4"), leftIndent=6, spaceAfter=6),
}
story = []
if titolo:
story += [Paragraph(_inline(titolo), st["title"]), HRFlowable(width="100%", color=colors.HexColor("#123"), thickness=0.8, spaceAfter=8)]
if autore:
story.append(Paragraph(html.escape(autore), st["small"])); story.append(Spacer(1, 6))
lines = (markdown or "").replace("\r", "").split("\n")
i, para, lst, table, code = 0, [], None, None, None
def flush_para():
nonlocal para
if para:
story.append(Paragraph(_inline(" ".join(para)), st["p"])); para = []
def flush_list():
nonlocal lst
if lst:
kind, items = lst
story.append(ListFlowable([ListItem(Paragraph(_inline(x), st["p"]), leftIndent=12) for x in items],
bulletType="1" if kind == "ol" else "bullet", start="1" if kind == "ol" else None, bulletFormat="%s." if kind == "ol" else None, leftIndent=14, bulletFontName=font, bulletFontSize=9))
lst = None
def flush_table():
nonlocal table
if table:
rows = [[Paragraph(_inline(c), st["p"]) for c in r] for r in table]
t = Table(rows, hAlign="LEFT", repeatRows=1)
t.setStyle(TableStyle([("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#999")), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e8eef3")),
("VALIGN", (0, 0), (-1, -1), "TOP"), ("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5)]))
story.append(t); story.append(Spacer(1, 8)); table = None
while i < len(lines):
ln = lines[i].rstrip()
if code is not None:
if ln.strip().startswith("```"):
story.append(Paragraph(html.escape("\n".join(code)).replace("\n", "<br/>"), st["code"])); code = None
else:
code.append(ln)
i += 1; continue
if ln.strip().startswith("```"):
flush_para(); flush_list(); flush_table(); code = []; i += 1; continue
if not ln.strip():
flush_para(); flush_list(); flush_table(); i += 1; continue
if ln.strip() in ("---", "***", "___"):
flush_para(); flush_list(); flush_table(); story.append(HRFlowable(width="100%", color=colors.HexColor("#bbb"), spaceBefore=4, spaceAfter=8)); i += 1; continue
if ln.strip().lower() in ("\\pagebreak", "<pagebreak>", "[pagina]"):
flush_para(); flush_list(); flush_table(); story.append(PageBreak()); i += 1; continue
m = re.match(r"^(#{1,3})\s+(.*)$", ln)
if m:
flush_para(); flush_list(); flush_table()
story.append(Paragraph(_inline(m.group(2)), st[f"h{len(m.group(1))}"])); i += 1; continue
m = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)", ln.strip())
if m:
flush_para(); flush_list(); flush_table()
p = Path(m.group(2)).expanduser()
if p.exists():
img = Image(str(p)); ratio = img.imageHeight / img.imageWidth; w = min(160 * mm, img.imageWidth * 0.264 * mm)
img.drawWidth, img.drawHeight = w, w * ratio; story.append(img)
if m.group(1):
story.append(Paragraph(html.escape(m.group(1)), st["small"]))
story.append(Spacer(1, 8))
i += 1; continue
if ln.strip().startswith("|"):
flush_para(); flush_list()
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
if not all(re.fullmatch(r":?-{2,}:?", c) for c in cells):
table = (table or []) + [cells]
i += 1; continue
m = re.match(r"^\s*([-*•]|\d+[.)])\s+(.*)$", ln)
if m:
flush_para(); flush_table()
kind = "ol" if m.group(1)[0].isdigit() else "ul"
if lst and lst[0] != kind:
flush_list()
lst = (kind, (lst[1] if lst else []) + [m.group(2)]); i += 1; continue
if ln.startswith(">"):
flush_para(); flush_list(); flush_table()
story.append(Paragraph(_inline(ln.lstrip("> ")), st["quote"])); i += 1; continue
flush_list(); flush_table(); para.append(ln.strip()); i += 1
flush_para(); flush_list(); flush_table()
for p in immagini or []:
pp = Path(p).expanduser()
if pp.exists():
img = Image(str(pp)); ratio = img.imageHeight / img.imageWidth; w = min(160 * mm, img.imageWidth * 0.264 * mm)
img.drawWidth, img.drawHeight = w, w * ratio; story += [Spacer(1, 8), img]
def footer(canvas, doc):
canvas.saveState(); canvas.setFont(font, 8); canvas.setFillColor(colors.HexColor("#888"))
canvas.drawRightString(A4[0] - 20 * mm, 12 * mm, f"{doc.page}")
if titolo:
canvas.drawString(20 * mm, 12 * mm, titolo[:80])
canvas.restoreState()
doc = SimpleDocTemplate(str(path), pagesize=A4, leftMargin=20 * mm, rightMargin=20 * mm, topMargin=20 * mm, bottomMargin=20 * mm,
title=titolo or path.stem, author=autore or "LuZa")
doc.build(story or [Paragraph("(documento vuoto)", st["p"])], onFirstPage=footer, onLaterPages=footer)
def crea(params: dict, ctx: dict) -> str:
titolo = str(params.get("titolo") or "").strip()
contenuto = str(params.get("contenuto") or "").strip()
if not contenuto and not titolo:
return "Errore: serve il contenuto del documento (in Markdown)."
OUT_DIR.mkdir(parents=True, exist_ok=True)
nome = re.sub(r"[^a-z0-9]+", "-", str(params.get("nome_file") or titolo or "documento").lower()).strip("-")[:60] or "documento"
path = OUT_DIR / f"{time.strftime('%Y%m%d-%H%M%S')}-{nome}.pdf"
immagini = params.get("immagini") or []
if isinstance(immagini, str):
immagini = [immagini]
try:
costruisci(titolo, contenuto, path, str(params.get("autore") or ""), [str(x) for x in immagini])
except Exception as err:
return f"Impaginazione fallita: {err}"
if params.get("mostra", True) not in (False, "false", "no"):
subprocess.Popen(["open", str(path)])
pagine = ""
try:
from pypdf import PdfReader
pagine = f", {len(PdfReader(str(path)).pages)} pagine"
except Exception:
pass
return f"PDF creato: {path}{pagine}. Aperto in Anteprima.\nFILE: {path}"
def ultimi(params: dict, ctx: dict) -> str:
files = sorted(OUT_DIR.glob("*.pdf"), key=lambda p: p.stat().st_mtime, reverse=True) if OUT_DIR.exists() else []
if not files:
return "Nessun documento creato finora."
if str(params.get("apri", "")).lower() in ("true", "1", "sì", "si"):
subprocess.Popen(["open", str(files[0])])
n = max(1, min(int(params.get("numero") or 5), 20))
return "Ultimi documenti:\n" + "\n".join(f"- {p.name}" for p in files[:n]) + f"\nFILE: {files[0]}"
TOOLS = [
{"name": "pdf_crea", "description": "Crea un documento PDF in A4 da un testo in Markdown: usa # ## ### per i titoli, - o 1. per gli elenchi, **grassetto**, *corsivo*, tabelle con |, ![didascalia](percorso) per le immagini, --- per una riga, [pagina] per il salto pagina. Scrivi tu il contenuto completo e ben strutturato (lettere, relazioni, elenchi, ricette, verbali…). Il PDF viene salvato, aperto in Anteprima e inviato al telefono.",
"parameters": {"type": "object", "properties": {"titolo": {"type": "string"}, "contenuto": {"type": "string", "description": "testo in Markdown"}, "nome_file": {"type": "string"}, "autore": {"type": "string"},
"immagini": {"type": "array", "items": {"type": "string"}, "description": "percorsi di immagini da accodare"}, "mostra": {"type": "boolean"}}, "required": ["contenuto"]}, "run": crea},
{"name": "pdf_ultimi", "description": "Elenca gli ultimi PDF creati (cartella data/documenti); con apri=true apre il più recente.",
"parameters": {"type": "object", "properties": {"numero": {"type": "integer"}, "apri": {"type": "boolean"}}}, "run": ultimi},
]
+1 -1
View File
@@ -148,7 +148,7 @@ def ultima(params: dict, ctx: dict) -> str:
TOOLS = [
{"name": "immagine_genera", "description": "Genera un'immagine in locale (MLX) da una descrizione. Scrivi la descrizione in INGLESE, dettagliata (soggetto, ambiente, luce, stile, inquadratura), anche se l'utente parla italiano. formato: quadrata (default), orizzontale, verticale, schermo, piccola. L'immagine viene salvata e aperta in Anteprima; richiede 10-60 secondi, il primo uso scarica il modello. Con immagine_base (percorso) e forza (0-1) parte da un'immagine esistente.",
{"name": "immagine_genera", "description": "Genera un'immagine in locale (MLX) da una descrizione. Scrivi la descrizione in INGLESE traducendo FEDELMENTE la richiesta dell'utente: stessi soggetti e stessi dettagli, senza attenuare, censurare o cambiare ciò che ha chiesto; aggiungi solo dettagli tecnici (luce, inquadratura, stile) se mancano. Se l'utente ha scritto il prompt in inglese, usalo tale e quale. formato: quadrata (default), orizzontale, verticale, schermo, piccola. L'immagine viene salvata e aperta in Anteprima; richiede 10-60 secondi, il primo uso scarica il modello. Con immagine_base (percorso) e forza (0-1) parte da un'immagine esistente.",
"parameters": {"type": "object", "properties": {"descrizione": {"type": "string"}, "formato": {"type": "string", "enum": ["quadrata", "orizzontale", "verticale", "schermo", "piccola"]},
"passi": {"type": "integer"}, "seme": {"type": "integer"}, "immagine_base": {"type": "string"}, "forza": {"type": "number"}, "mostra": {"type": "boolean"}},
"required": ["descrizione"]}, "run": genera},
+225
View File
@@ -0,0 +1,225 @@
"""Maestro di violino: ascolta dal microfono, misura intonazione, ritmo, dinamica e pulizia del suono e riferisce
i dati al modello, che li trasforma in consigli. Analisi con librosa (pYIN), registrazioni salvate in data/violino/.
"""
from __future__ import annotations
import math
import sys
import time
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from avatar.settings import DATA_DIR # noqa: E402
SR = 44100
OUT_DIR = DATA_DIR / "violino"
CORDE = {"Sol": 196.00, "Re": 293.66, "La": 440.00, "Mi": 659.25}
NOTE_IT = ["Do", "Do#", "Re", "Re#", "Mi", "Fa", "Fa#", "Sol", "Sol#", "La", "La#", "Si"]
NOTE_IDX = {"do": 0, "re": 2, "mi": 4, "fa": 5, "sol": 7, "la": 9, "si": 11}
SCALE_MAJ = [0, 2, 4, 5, 7, 9, 11]
SCALE_MIN = [0, 2, 3, 5, 7, 8, 10]
def _device():
"""Stesso microfono dell'assistente; vuoto = predefinito di sistema."""
try:
from memory import config_manager as cm
d = cm.get_input_device()
return d if d not in ("", None, "default") else None
except Exception:
return None
def _progress(ctx: dict, text: str) -> None:
p = ctx.get("player")
if p is not None and hasattr(p, "set_state"):
try:
p.set_state(f"PROCESSING · {text}")
except Exception:
pass
def registra(secondi: float, ctx: dict, attesa: float = 3.0) -> np.ndarray:
"""Registra dal microfono; prima un breve conto alla rovescia (LuZa sta ancora parlando)."""
import sounddevice as sd
for i in range(int(attesa), 0, -1):
_progress(ctx, f"preparati · {i}")
time.sleep(1)
buf = sd.rec(int(secondi * SR), samplerate=SR, channels=1, dtype="float32", device=_device())
t0 = time.time()
while time.time() - t0 < secondi:
_progress(ctx, f"ascolto il violino · {int(secondi - (time.time() - t0))} s")
time.sleep(0.5)
sd.wait()
_progress(ctx, "analizzo")
return buf[:, 0].copy()
def _nome(midi: int) -> str:
return f"{NOTE_IT[midi % 12]}{midi // 12 - 1}"
def _scala(testo: str) -> set[int] | None:
t = (testo or "").lower().strip()
if not t:
return None
parts = t.replace("#", " #").replace("b", " b").split()
if not parts or parts[0] not in NOTE_IDX:
return None
root = NOTE_IDX[parts[0]]
if "#" in parts[1:2]:
root += 1
elif "b" in parts[1:2]:
root -= 1
minor = "min" in t
return {(root + x) % 12 for x in (SCALE_MIN if minor else SCALE_MAJ)}
def analizza(y: np.ndarray, sr: int = SR, a4: float = 440.0, scala: str = "", fmin: float = 180.0, fmax: float = 2800.0) -> dict:
import librosa
y = y.astype(np.float32)
hop = 512
rms = librosa.feature.rms(y=y, frame_length=2048, hop_length=hop)[0]
db = 20 * np.log10(np.maximum(rms, 1e-6))
if db.max() < -45:
return {"silenzio": True}
f0, voiced, prob = librosa.pyin(y, fmin=fmin, fmax=fmax, sr=sr, frame_length=2048, hop_length=hop)
flat = librosa.feature.spectral_flatness(y=y, n_fft=2048, hop_length=hop)[0]
n = min(len(f0), len(db), len(flat))
f0, voiced, db, flat = f0[:n], voiced[:n], db[:n], flat[:n]
from scipy.signal import medfilt
ok = voiced & np.isfinite(f0) & (prob >= 0.6) & (db >= db.max() - 32)
midi = np.where(ok, 69 + 12 * np.log2(np.maximum(np.nan_to_num(f0, nan=1.0), 1) / a4), np.nan)
# stabilizza il contorno: mediana su 5 frame, poi segmentazione con isteresi (3 frame fuori dal ±60 cent per cambiare nota)
sm = midi.copy()
fin = np.isfinite(midi)
if fin.sum() > 5:
filled = np.interp(np.arange(n), np.flatnonzero(fin), midi[fin])
sm = np.where(fin, medfilt(filled, 5), np.nan)
segs, cur, gap, out_cnt = [], [], 0, 0
for i in range(n):
if np.isnan(sm[i]):
gap += 1
if gap >= 5 and cur:
segs.append(cur); cur = []
continue
gap = 0
if cur and abs(sm[i] - np.nanmedian(sm[cur[-10:]])) * 100 > 60:
out_cnt += 1
if out_cnt >= 3:
segs.append(cur[:-2] if len(cur) > 2 else cur); cur = cur[-2:] + [i]; out_cnt = 0
continue
else:
out_cnt = 0
cur.append(i)
if cur:
segs.append(cur)
# note: scarta i frammenti brevi, unisci segmenti contigui con la stessa nota
notes = []
for idx in segs:
if len(idx) < 8:
continue
idx = np.array(idx)
m = float(np.nanmedian(sm[idx]))
cents_arr = (sm[idx] - round(m)) * 100
note = {"start": idx[0] * hop / sr, "dur": len(idx) * hop / sr, "midi": int(round(m)), "midi_f": m, "cents": float(np.nanmedian(cents_arr)),
"vib": float(np.nanstd(cents_arr)), "db": float(np.mean(db[idx])), "flat": float(np.mean(flat[idx]))}
# stessa nota ripetuta: resta separata se tra le due il volume cala (nuovo attacco d'arco o di voce)
gap_lo = int((notes[-1]["start"] + notes[-1]["dur"]) * sr / hop) if notes else 0
dip = float(notes[-1]["db"] - db[gap_lo:idx[0] + 1].min()) if notes and idx[0] > gap_lo else 0.0
if notes and notes[-1]["midi"] == note["midi"] and note["start"] - (notes[-1]["start"] + notes[-1]["dur"]) < 0.12 and dip < 8:
prev = notes[-1]; w1, w2 = prev["dur"], note["dur"]
prev["cents"] = (prev["cents"] * w1 + note["cents"] * w2) / (w1 + w2)
prev["dur"] = note["start"] + note["dur"] - prev["start"]
prev["vib"] = max(prev["vib"], note["vib"]); prev["flat"] = (prev["flat"] * w1 + note["flat"] * w2) / (w1 + w2)
continue
notes.append(note)
if not notes:
return {"silenzio": False, "note": [], "msg": "Ho sentito suono ma nessuna nota stabile: forse rumore o troppe corde insieme."}
cents = np.array([x["cents"] for x in notes])
durs = np.array([x["dur"] for x in notes])
onsets = np.array([x["start"] for x in notes])
ioi = np.diff(onsets) if len(onsets) > 2 else np.array([])
sc = _scala(scala)
fuori = [x for x in notes if sc is not None and x["midi"] % 12 not in sc]
rep = {
"silenzio": False, "n_note": len(notes), "durata": float(len(y) / sr),
"intonazione_media_abs": float(np.mean(np.abs(cents))), "tendenza": float(np.mean(cents)),
"note_intonate_pct": float(np.mean(np.abs(cents) <= 15) * 100),
"peggiori": sorted(notes, key=lambda x: -abs(x["cents"]))[:3],
"vibrato_note": int(sum(1 for x in notes if x["vib"] > 15 and x["dur"] > 0.35)),
"ritmo_cv": float(np.std(ioi) / np.mean(ioi)) if len(ioi) >= 3 else None,
"dinamica_range_db": float(np.max([x["db"] for x in notes]) - np.min([x["db"] for x in notes])),
"graffio_pct": float(np.mean([x["flat"] > 0.25 for x in notes]) * 100),
"note_corte_pct": float(np.mean(durs < 0.15) * 100),
"fuori_scala": fuori, "scala": scala, "note": notes,
}
return rep
def _testo(rep: dict) -> str:
if rep.get("silenzio"):
return "Non ho sentito il violino: il microfono ha registrato quasi silenzio. Controlla la distanza (ideale 1-2 metri) e riprova."
if not rep.get("note"):
return rep.get("msg", "Nessuna nota rilevata.")
seq = " ".join(f"{_nome(x['midi'])}({x['cents']:+.0f})" for x in rep["note"][:40])
out = [f"Registrazione di {rep['durata']:.0f} s, {rep['n_note']} note rilevate (nome e scarto in cent, + = crescente, - = calante):",
seq + (" …" if rep["n_note"] > 40 else ""),
f"Intonazione: scarto medio {rep['intonazione_media_abs']:.0f} cent, {rep['note_intonate_pct']:.0f}% delle note entro 15 cent, tendenza {'crescente' if rep['tendenza'] > 5 else 'calante' if rep['tendenza'] < -5 else 'centrata'} ({rep['tendenza']:+.0f} cent).",
"Note più stonate: " + ", ".join(f"{_nome(x['midi'])} {x['cents']:+.0f} cent a {x['start']:.1f} s" for x in rep["peggiori"]) + "."]
if rep["ritmo_cv"] is not None:
out.append(f"Ritmo: regolarità {'buona' if rep['ritmo_cv'] < 0.25 else 'discreta' if rep['ritmo_cv'] < 0.45 else 'instabile'} (variazione {rep['ritmo_cv'] * 100:.0f}% tra una nota e l'altra).")
rng = rep["dinamica_range_db"]
giudizio = "molto uniforme" if rng < 6 else "espressiva" if rng < 18 else "sbalzi forti: pressione dell'arco irregolare?"
out.append(f"Dinamica: escursione {rng:.0f} dB ({giudizio}).")
out.append(f"Suono: {rep['graffio_pct']:.0f}% delle note con rumore d'arco evidente (graffio o arco troppo leggero/veloce); note troppo brevi {rep['note_corte_pct']:.0f}%; vibrato su {rep['vibrato_note']} note.")
if rep["scala"]:
if rep["fuori_scala"]:
out.append(f"Scala {rep['scala']}: note fuori scala: " + ", ".join(f"{_nome(x['midi'])} a {x['start']:.1f} s" for x in rep["fuori_scala"][:6]) + " (dita in posizione sbagliata?).")
else:
out.append(f"Scala {rep['scala']}: tutte le note appartengono alla scala.")
out.append("Interpreta questi dati per l'allieva: complimenti sinceri prima, poi al massimo due consigli concreti.")
return "\n".join(out)
def ascolta(params: dict, ctx: dict) -> str:
secondi = max(5, min(int(params.get("secondi") or 15), 60))
a4 = float(params.get("nota_la") or 440)
y = registra(secondi, ctx)
OUT_DIR.mkdir(parents=True, exist_ok=True)
path = OUT_DIR / f"{time.strftime('%Y%m%d-%H%M%S')}.wav"
try:
import soundfile as sf
sf.write(str(path), y, SR)
except Exception:
pass
rep = analizza(y, SR, a4, str(params.get("scala") or ""))
return _testo(rep) + (f"\nRegistrazione salvata: {path.name}" if path.exists() else "")
def accorda(params: dict, ctx: dict) -> str:
import librosa
y = registra(max(3, min(int(params.get("secondi") or 4), 10)), ctx, attesa=2)
rms = float(np.sqrt(np.mean(y ** 2)))
if 20 * math.log10(max(rms, 1e-6)) < -45:
return "Non sento la corda: suonala a vuoto, con l'arco, vicino al microfono."
f0, voiced, _ = librosa.pyin(y, fmin=150.0, fmax=1200.0, sr=SR)
f = float(np.nanmedian(f0[voiced])) if np.any(voiced) else 0.0
if not f:
return "Non riesco a isolare la nota: suona una corda sola, a vuoto."
a4 = float(params.get("nota_la") or 440)
nome, target = min(CORDE.items(), key=lambda kv: abs(math.log2(f / (kv[1] * a4 / 440))))
target *= a4 / 440
cents = 1200 * math.log2(f / target)
verdetto = "intonata" if abs(cents) <= 5 else ("crescente: allenta un po'" if cents > 0 else "calante: tira un po'")
return f"Corda {nome}: {f:.1f} Hz, {cents:+.0f} cent rispetto a {target:.1f} Hz → {verdetto}. (Oltre ±30 cent usa i piroli, altrimenti i tiracantini.)"
TOOLS = [
{"name": "violino_ascolta", "description": "Ascolta il violino dal microfono per alcuni secondi (default 15, max 60) e misura intonazione di ogni nota in cent, tendenza crescente/calante, regolarità del ritmo, dinamica, rumore d'arco e vibrato. Con scala (es. 'Sol maggiore', 'Re minore') segnala le note fuori scala. Prima di chiamarlo di' all'allieva che stai per ascoltare: ci sono 3 secondi di preparazione. Restituisce dati da trasformare in consigli.",
"parameters": {"type": "object", "properties": {"secondi": {"type": "integer"}, "scala": {"type": "string"}, "nota_la": {"type": "number", "description": "frequenza del La di riferimento, 440 o 442"}}}, "run": ascolta},
{"name": "violino_accorda", "description": "Accordatore: ascolta una corda a vuoto (Sol, Re, La, Mi) e dice di quanti cent è crescente o calante e come correggere.",
"parameters": {"type": "object", "properties": {"secondi": {"type": "integer"}, "nota_la": {"type": "number"}}}, "run": accorda},
]
+240
View File
@@ -0,0 +1,240 @@
// LuZa remoto: WebSocket per stato/registro/audio, microfono via MediaRecorder, audio via AudioContext.
const q = new URLSearchParams(location.search);
if (q.get('k')) { try { localStorage.setItem('luza_key', q.get('k')); } catch (e) {} history.replaceState(null, '', location.pathname); }
let KEY = ''; try { KEY = localStorage.getItem('luza_key') || ''; } catch (e) {}
const $ = id => document.getElementById(id);
let ws, actx, playing = false, queue = [], rec, chunks = [], recording = false, currentAva = null;
async function savePair() {
const v = $('key').value.trim();
if (!v) return;
if (/^\d{6}$/.test(v)) { // PIN mostrato da Remote Control: lo scambio con la chiave
try {
const r = await fetch(`/pair?pin=${v}`, {cache: 'no-store'}); const j = await r.json();
if (!r.ok || !j.token) { addMsg(j.error || 'PIN rifiutato', 'err'); return; }
KEY = j.token;
} catch (e) { addMsg('rete: ' + e.message, 'err'); return; }
} else KEY = v;
try { localStorage.setItem('luza_key', KEY); } catch (e) {}
$('key').value = ''; connect();
}
function setConn(t, ok) {
if (ok) document.querySelectorAll('#log .m.err').forEach(e => e.remove()); $('conn').textContent = t + (mode === 'sse' ? ' · eventi' : mode === 'poll' ? ' · interrogazione' : ''); $('conn').style.color = ok ? '#00ff88' : '#ff3366'; }
const HUD = {state: 'LISTENING', level: 0, rec: false};
function setState(s) {
const base = s.split(' ')[0], detail = s.includes(' · ') ? s.split(' · ').slice(1).join(' · ') : '';
HUD.state = base; updateStop();
$('stbase').textContent = base; $('stdetail').textContent = detail ? ' · ' + detail : ({LISTENING: ' · in ascolto', THINKING: ' · penso', PROCESSING: ' · elaboro', SPEAKING: ' · parlo', SLEEPING: ' · a riposo'}[base] || '');
}
function addMsg(text, cls) {
const d = document.createElement('div'); d.className = 'm ' + cls; d.textContent = text; $('log').appendChild(d);
while ($('log').children.length > 200) $('log').removeChild($('log').firstChild);
$('log').scrollTop = $('log').scrollHeight; return d;
}
function onLog(line) {
const m = line.match(/^(You(?: \(telefono\))?|SYS|ERR|FILE|[^:]{1,24}): ?([\s\S]*)$/);
if (!m) { addMsg(line, 'sys'); return; }
const who = m[1], txt = m[2];
if (who.startsWith('You')) addMsg(txt, 'you');
else if (who === 'SYS' || who === 'FILE') addMsg(txt, 'sys');
else if (who === 'ERR') addMsg(txt, 'err');
else addMsg(txt, 'ava');
}
let wsFailures = 0, es = null, mode = 'ws';
function handle(d) {
if (d.type === 'state') setState(d.state);
else if (d.type === 'log') onLog(d.text);
else if (d.type === 'audio') enqueueAudio(d.wav);
else if (d.type === 'confirm') { $('ct').textContent = d.title; $('cd').textContent = d.detail || ''; $('confirm').style.display = 'block'; }
else if (d.type === 'confirm_hide') $('confirm').style.display = 'none';
else if (d.type === 'image') addImage(d.url, d.name);
else if (d.type === 'file') { const m = addMsg('', 'ava file'); const a = document.createElement('a'); a.href = d.url; a.target = '_blank'; a.textContent = '📄 ' + d.name; m.appendChild(a); }
}
function addImage(url, name) {
const d = document.createElement('div'); d.className = 'm ava img';
const img = document.createElement('img'); img.src = url; img.alt = name || 'immagine'; img.loading = 'lazy';
img.onclick = () => { const v = document.createElement('div'); v.className = 'viewer'; const big = document.createElement('img'); big.src = url; v.appendChild(big); v.onclick = () => v.remove(); document.body.appendChild(v); };
d.appendChild(img); $('log').appendChild(d); $('log').scrollTop = $('log').scrollHeight;
}
function unpair(msg) { KEY = ''; try { localStorage.removeItem('luza_key'); } catch (e) {} $('pair').style.display = 'flex'; setConn(msg || 'chiave rifiutata', false); addMsg('Chiave non valida: inquadra di nuovo il QR dal Mac oppure incolla la chiave.', 'err'); }
async function connect() {
if (!KEY) { $('pair').style.display = 'flex'; setConn('non abbinato', false); return; }
$('pair').style.display = 'none';
try { // verifica della chiave con una richiesta breve, prima di aprire i canali
const r = await fetch(`/auth?k=${encodeURIComponent(KEY)}`, {cache: 'no-store'});
if (r.status === 401) return unpair('chiave rifiutata');
} catch (e) { setConn('rete non raggiungibile, riprovo…', false); return setTimeout(connect, 3000); }
if (mode === 'poll') return startPoll();
if (mode === 'sse') return connectSSE();
try { ws && ws.close(); } catch (e) {}
let opened = false;
ws = new WebSocket(`wss://${location.host}/ws?k=${encodeURIComponent(KEY)}`);
ws.onopen = () => { opened = true; wsFailures = 0; setConn('collegato', true); };
ws.onclose = ev => {
if (!opened) wsFailures++;
if (ev.code === 1008) return unpair();
if (wsFailures >= 2) { mode = 'sse'; return connectSSE(); } // WebSocket bloccato (app sulla Home di iOS): canale di riserva
setConn('scollegato, riprovo…', false); setTimeout(connect, 2000);
};
ws.onerror = () => {};
ws.onmessage = ev => handle(JSON.parse(ev.data));
}
let sseFailures = 0, CID = Math.random().toString(36).slice(2), polling = false;
function connectSSE() {
try { es && es.close(); } catch (e) {}
es = new EventSource(`/events?k=${encodeURIComponent(KEY)}`);
es.onopen = () => { sseFailures = 0; setConn('collegato', true); };
es.onmessage = ev => handle(JSON.parse(ev.data));
es.onerror = () => {
es.close(); sseFailures++;
if (sseFailures >= 2) { mode = 'poll'; return startPoll(); } // anche gli eventi sono bloccati: interrogazione ripetuta
setConn('scollegato, riprovo…', false); setTimeout(connectSSE, 2500);
};
}
async function startPoll() {
if (polling) return; polling = true;
setConn('collegato', true);
while (mode === 'poll') {
try {
const r = await fetch(`/poll?k=${encodeURIComponent(KEY)}&cid=${CID}`, {cache: 'no-store'});
if (r.status === 401) { polling = false; return unpair(); }
const j = await r.json(); (j.events || []).forEach(handle); setConn('collegato', true);
} catch (e) { setConn('scollegato, riprovo…', false); await new Promise(res => setTimeout(res, 2500)); }
}
polling = false;
}
function send(obj) {
if (mode === 'ws' && ws && ws.readyState === 1) { ws.send(JSON.stringify(obj)); return; }
fetch(`/cmd?k=${encodeURIComponent(KEY)}`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(obj)})
.then(r => { if (r.status === 401) unpair(); })
.catch(() => addMsg('non collegato', 'err'));
}
function sendText() { const t = $('txt').value.trim(); if (!t) return; unlockAudio(); send({type: 'say', text: t}); $('txt').value = ''; onTyping(); }
function onTyping() { $('sendb').classList.toggle('on', $('txt').value.trim().length > 0); }
function updateStop() { const busy = playing || ['THINKING', 'PROCESSING', 'SPEAKING'].includes(HUD.state); $('stopb').classList.toggle('on', busy); }
function doConfirm(ok) { send({type: 'confirm', ok}); $('confirm').style.display = 'none'; }
// ── audio in uscita: coda di frasi WAV riprodotte in sequenza ─────────────
let analyser = null, adata = null, micAnalyser = null, mdata = null;
function unlockAudio() {
if (!actx) { actx = new (window.AudioContext || window.webkitAudioContext)(); analyser = actx.createAnalyser(); analyser.fftSize = 256; analyser.connect(actx.destination); adata = new Uint8Array(analyser.frequencyBinCount); }
if (actx.state === 'suspended') actx.resume();
}
function enqueueAudio(b64) { queue.push(b64); if (!playing) playNext(); }
async function playNext() {
if (!queue.length) { playing = false; updateStop(); return; }
playing = true; updateStop();
try {
unlockAudio();
const b64 = queue.shift(); const bin = atob(b64); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
const audio = await actx.decodeAudioData(buf.buffer);
const src = actx.createBufferSource(); src.buffer = audio; src.connect(analyser); src.onended = playNext; src.start();
} catch (e) { addMsg('audio non riproducibile: tocca lo schermo e riprova', 'err'); playing = false; }
}
// ── microfono: tocca per registrare, tocca di nuovo per inviare ────────────
async function toggleMic() {
unlockAudio();
if (recording) { recording = false; rec.stop(); $('mic').classList.remove('rec'); return; }
try {
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
const mime = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm'].find(m => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || '';
try { const msrc = actx.createMediaStreamSource(stream); micAnalyser = actx.createAnalyser(); micAnalyser.fftSize = 256; msrc.connect(micAnalyser); mdata = new Uint8Array(micAnalyser.frequencyBinCount); } catch (e) { micAnalyser = null; }
rec = new MediaRecorder(stream, mime ? {mimeType: mime} : undefined); chunks = [];
rec.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
rec.onstop = async () => {
stream.getTracks().forEach(t => t.stop()); micAnalyser = null; HUD.rec = false;
const blob = new Blob(chunks, {type: rec.mimeType || 'audio/webm'});
$('stbase').textContent = 'PROCESSING'; $('stdetail').textContent = ' · trascrivo';
try {
const r = await fetch(`/stt?k=${encodeURIComponent(KEY)}&run=1`, {method: 'POST', body: blob, headers: {'Content-Type': blob.type || 'application/octet-stream'}});
const j = await r.json();
if (!j.text) addMsg(j.error || 'non ho capito, riprova', 'err');
} catch (e) { addMsg('invio audio fallito: ' + e, 'err'); }
};
rec.start(); recording = true; HUD.rec = true; $('mic').classList.add('rec'); $('stbase').textContent = 'REC'; $('stdetail').textContent = ' · registro, tocca di nuovo per inviare';
} catch (e) { addMsg('microfono non disponibile: serve HTTPS con certificato accettato (' + e.message + ')', 'err'); }
}
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
document.addEventListener('touchstart', unlockAudio, {once: true, passive: true});
if (navigator.standalone) addMsg('app sulla Home', 'sys');
if (q.get('demo')) { setTimeout(() => { addMsg('Ciao LuZa, che tempo fa oggi a Pescara?', 'you'); addMsg('Lavoro sul Mac (meteo)…', 'sys'); addMsg('Oggi a Pescara è prevalentemente sereno, con una massima di 22 gradi. Domani un po\' di nuvole.', 'ava'); setState('THINKING · genero la risposta · 120 token · 59/s'); HUD.level = 0.55; }, 300); }
fetch('/health').then(r => r.ok ? null : addMsg('server risponde ' + r.status, 'err')).catch(e => addMsg('rete: ' + e.message, 'err'));
connect();
// ── impostazioni: motore, modello, ragionamento, voce ─────────────────────
const LABELS = {provider: 'Motore', effort: 'Profondità (Claude)', mlx_model: 'Modello interno', mlx_thinking: 'Ragionamento (interno)', claudecode_model: 'Modello Claude Code',
tts_engine: 'Voce', kokoro_voice: 'Voce Kokoro', voicebox_profile_id: 'Profilo Voicebox', immagini_famiglia: 'Immagini: famiglia', immagini_modello: 'Immagini: modello'};
const SHOW_IF = {mlx_model: v => v.provider === 'mlx', mlx_thinking: v => v.provider === 'mlx', claudecode_model: v => v.provider === 'claudecode', effort: v => v.provider !== 'mlx' && v.provider !== 'local',
kokoro_voice: v => v.tts_engine === 'kokoro', voicebox_profile_id: v => v.tts_engine === 'voicebox'};
let sdata = null;
async function openSettings() {
$('settings').style.display = 'block'; $('shint').textContent = 'carico…';
try { const r = await fetch(`/settings?k=${encodeURIComponent(KEY)}`, {cache: 'no-store'}); sdata = await r.json(); } catch (e) { $('shint').textContent = 'errore: ' + e.message; return; }
renderSettings(); $('shint').textContent = '';
}
function renderSettings() {
const f = $('sform'); f.innerHTML = ''; const v = sdata.values;
for (const k of Object.keys(LABELS)) {
const opts = sdata.options[k]; if (!opts || (SHOW_IF[k] && !SHOW_IF[k](v))) continue;
const lab = document.createElement('label'); lab.textContent = LABELS[k]; f.appendChild(lab);
const sel = document.createElement('select'); sel.dataset.key = k;
let found = false;
for (const [val, name] of opts) { const o = document.createElement('option'); o.value = val; o.textContent = name; if (String(val) === String(v[k] ?? '')) { o.selected = true; found = true; } sel.appendChild(o); }
if (!found && v[k]) { const o = document.createElement('option'); o.value = v[k]; o.textContent = v[k] + ' (attuale)'; o.selected = true; sel.appendChild(o); }
sel.onchange = () => { v[k] = sel.value; renderSettings(); }; f.appendChild(sel);
}
}
async function saveSettings() {
if (!sdata) return; const out = {}; document.querySelectorAll('#sform select').forEach(s => out[s.dataset.key] = s.value);
$('shint').textContent = 'applico…';
try { const r = await fetch(`/settings?k=${encodeURIComponent(KEY)}`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(out)}); const j = await r.json();
$('shint').textContent = j.ok ? 'applicato: il Mac ricarica motore e voce' : ('errore: ' + j.error); } catch (e) { $('shint').textContent = 'errore: ' + e.message; }
}
// ── HUD: nucleo con archi rotanti e forma d'onda ──────────────────────────
const COL = {LISTENING: '#00ff88', THINKING: '#ffcc00', PROCESSING: '#ffcc00', SPEAKING: '#ff6b00', SLEEPING: '#3a8a9a', REC: '#ff3366'};
const ringC = $('ring'), waveC = $('wave');
function fitCanvas(c) { const r = window.devicePixelRatio || 1, w = c.clientWidth, h = c.clientHeight; if (c.width !== w * r || c.height !== h * r) { c.width = w * r; c.height = h * r; } return [c.getContext('2d'), w, h, r]; }
function level() {
if (HUD.rec && micAnalyser) { micAnalyser.getByteTimeDomainData(mdata); let m = 0; for (const v of mdata) m = Math.max(m, Math.abs(v - 128)); return Math.min(1, m / 60); }
if (playing && analyser) { analyser.getByteTimeDomainData(adata); let m = 0; for (const v of adata) m = Math.max(m, Math.abs(v - 128)); return Math.min(1, m / 50); }
return 0;
}
let t0 = performance.now();
function drawHUD(now) {
const t = (now - t0) / 1000; const lv = level(); HUD.level += (lv - HUD.level) * 0.35;
const st = HUD.rec ? 'REC' : (playing ? 'SPEAKING' : HUD.state); const col = COL[st] || '#00d4ff';
const busy = st === 'THINKING' || st === 'PROCESSING';
// nucleo
let [g, w, h, r] = fitCanvas(ringC); g.setTransform(r, 0, 0, r, 0, 0); g.clearRect(0, 0, w, h);
const cx = w / 2, cy = h / 2 - 8, R = Math.min(w, h) * 0.34;
const speed = busy ? 2.2 : st === 'SPEAKING' ? 1.6 : 0.5;
g.lineCap = 'round';
const arcs = [[1.0, 0.9, 1, 1.4], [0.86, -0.6, 2, 1.1], [0.72, 1.3, 1, 0.8], [0.58, -1.9, 3, 0.6]];
for (const [rr, dir, n, span] of arcs) {
for (let i = 0; i < n; i++) {
const a0 = t * speed * dir + i * (Math.PI * 2 / n); g.beginPath(); g.arc(cx, cy, R * rr, a0, a0 + span); g.strokeStyle = col; g.globalAlpha = 0.85 - (1 - rr) * 0.9; g.lineWidth = 2; g.shadowColor = col; g.shadowBlur = 10; g.stroke();
}
}
g.globalAlpha = 1; g.shadowBlur = 0;
// tacche
g.strokeStyle = 'rgba(0,212,255,.35)'; g.lineWidth = 1;
for (let i = 0; i < 60; i++) { const a = i / 60 * Math.PI * 2 + t * 0.15; const l = i % 5 === 0 ? 8 : 4; g.beginPath(); g.moveTo(cx + Math.cos(a) * (R * 1.12), cy + Math.sin(a) * (R * 1.12)); g.lineTo(cx + Math.cos(a) * (R * 1.12 + l), cy + Math.sin(a) * (R * 1.12 + l)); g.stroke(); }
// sfera centrale che respira con l'audio
const rad = R * 0.42 * (1 + HUD.level * 0.35 + (busy ? Math.sin(t * 6) * 0.04 : Math.sin(t * 1.5) * 0.02));
const grad = g.createRadialGradient(cx, cy, rad * 0.1, cx, cy, rad); grad.addColorStop(0, col); grad.addColorStop(0.55, col + '66'); grad.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = grad; g.beginPath(); g.arc(cx, cy, rad, 0, Math.PI * 2); g.fill();
g.strokeStyle = col; g.lineWidth = 1.5; g.shadowColor = col; g.shadowBlur = 16; g.beginPath(); g.arc(cx, cy, rad * 0.9, 0, Math.PI * 2); g.stroke(); g.shadowBlur = 0;
// forma d'onda
[g, w, h, r] = fitCanvas(waveC); g.setTransform(r, 0, 0, r, 0, 0); g.clearRect(0, 0, w, h);
const N = 42, bw = w / N;
for (let i = 0; i < N; i++) {
const env = Math.pow(1 - Math.abs(i - (N - 1) / 2) / ((N - 1) / 2), 0.7);
const idle = 2 + 2 * Math.sin(t * 2.2 + i * 0.6);
const hh = Math.max(2, Math.min(h - 4, idle + HUD.level * (h - 6) * env * (0.6 + 0.4 * Math.sin(t * 9 + i))));
g.fillStyle = HUD.level > 0.05 ? col : 'rgba(0,212,255,.25)'; g.fillRect(i * bw + 1, (h - hh) / 2, bw - 2, hh);
}
requestAnimationFrame(drawHUD);
}
requestAnimationFrame(drawHUD);
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+104
View File
@@ -0,0 +1,104 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#02070c">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="LuZa">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icon-192.png">
<title>LuZa</title>
<style>
:root { --bg:#02070c; --pri:#00d4ff; --pri2:#00ffe1; --dim:#4f7f95; --txt:#d6ecff; --acc:#ffcc00; --spk:#ff6b00; --grn:#00ff88; --red:#ff3366; --glass:rgba(4,20,32,.72); --line:rgba(0,212,255,.22); }
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body { margin:0; height:100%; background:var(--bg); color:var(--txt); font-family:Menlo,ui-monospace,SFMono-Regular,monospace; overflow:hidden; }
body { display:flex; flex-direction:column; padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); position:relative; }
/* sfondo: griglia di punti + scanline + vignetta */
body::before { content:""; position:fixed; inset:0; background-image:radial-gradient(rgba(0,212,255,.16) 1px, transparent 1.2px); background-size:22px 22px; pointer-events:none; }
body::after { content:""; position:fixed; inset:0; background:repeating-linear-gradient(0deg, rgba(0,0,0,.18) 0 2px, transparent 2px 4px), radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.65) 100%); pointer-events:none; }
.corner { position:fixed; width:26px; height:26px; border:1px solid var(--line); pointer-events:none; }
.corner.tl { top:calc(env(safe-area-inset-top) + 8px); left:8px; border-right:0; border-bottom:0; } .corner.tr { top:calc(env(safe-area-inset-top) + 8px); right:8px; border-left:0; border-bottom:0; }
.corner.bl { bottom:calc(env(safe-area-inset-bottom) + 8px); left:8px; border-right:0; border-top:0; } .corner.br { bottom:calc(env(safe-area-inset-bottom) + 8px); right:8px; border-left:0; border-top:0; }
header { padding:12px 18px 4px; display:flex; align-items:center; gap:10px; z-index:1; }
header .name { font-weight:bold; letter-spacing:5px; color:var(--pri); text-shadow:0 0 12px rgba(0,212,255,.6); font-size:15px; }
header .sub { font-size:9px; letter-spacing:2px; color:var(--dim); margin-top:2px; }
header .conn { margin-left:auto; font-size:10px; letter-spacing:1px; color:var(--dim); text-transform:uppercase; }
header .conn::before { content:"●"; margin-right:5px; }
#gear { background:transparent; border:1px solid var(--line); color:var(--pri); border-radius:8px; padding:5px 9px; font-size:15px; }
#core { position:relative; height:190px; z-index:1; }
#ring { position:absolute; inset:0; width:100%; height:100%; }
#stateline { position:absolute; left:0; right:0; bottom:8px; text-align:center; font-size:11px; letter-spacing:2px; color:var(--dim); text-transform:uppercase; }
#stateline b { color:var(--pri); font-weight:normal; }
#wave { display:block; width:100%; height:34px; z-index:1; }
#log { flex:1; overflow-y:auto; padding:6px 12px 10px; display:flex; flex-direction:column; gap:7px; z-index:1; -webkit-overflow-scrolling:touch; }
.m { max-width:88%; padding:9px 12px; border-radius:4px; font-size:14px; line-height:1.4; white-space:pre-wrap; word-break:break-word; position:relative; background:var(--glass); backdrop-filter:blur(6px); border:1px solid var(--line); }
.m.you { align-self:flex-end; border-color:rgba(0,255,136,.35); border-right:2px solid var(--grn); }
.m.ava { align-self:flex-start; border-left:2px solid var(--pri); box-shadow:0 0 14px rgba(0,212,255,.08); }
.m.ava::before { content:"LUZA"; display:block; font-size:9px; letter-spacing:2px; color:var(--pri); opacity:.8; margin-bottom:3px; }
.m.you::before { content:"TU"; display:block; font-size:9px; letter-spacing:2px; color:var(--grn); opacity:.8; margin-bottom:3px; text-align:right; }
.m.img { padding:4px; max-width:78%; } .m.img::before { display:none; } .m.img img { display:block; width:100%; border-radius:3px; }
.viewer { position:fixed; inset:0; background:rgba(0,4,8,.96); display:flex; align-items:center; justify-content:center; z-index:50; }
.viewer img { max-width:100%; max-height:100%; object-fit:contain; box-shadow:0 0 30px rgba(0,212,255,.25); }
.m.file a { color:var(--pri); text-decoration:none; font-weight:bold; } .m.file::before { display:none; }
.m.sys { align-self:center; font-size:10px; letter-spacing:1px; color:var(--dim); background:transparent; border:0; padding:1px 8px; backdrop-filter:none; }
.m.err { align-self:center; font-size:12px; color:var(--red); border-color:rgba(255,51,102,.4); }
#confirm { display:none; margin:0 12px 8px; padding:12px; border:1px solid var(--acc); border-radius:4px; background:rgba(40,32,0,.8); box-shadow:0 0 18px rgba(255,204,0,.25); z-index:1; }
#confirm .t { color:var(--acc); font-weight:bold; letter-spacing:1px; margin-bottom:4px; } #confirm .d { font-size:12px; white-space:pre-wrap; margin-bottom:10px; }
footer { display:flex; gap:10px; padding:10px 14px 14px; align-items:center; border-top:1px solid var(--line); background:linear-gradient(180deg, rgba(2,7,12,.1), rgba(2,7,12,.96)); z-index:1; }
.capsule { flex:1; min-width:0; display:flex; align-items:center; background:rgba(0,18,30,.75); border:1px solid var(--line); border-radius:24px; padding:3px 4px 3px 16px; transition:border-color .2s, box-shadow .2s; }
.capsule:focus-within { border-color:var(--pri); box-shadow:0 0 14px rgba(0,212,255,.22); }
.capsule input { flex:1; min-width:0; background:transparent; border:0; color:var(--txt); font:inherit; font-size:15px; padding:10px 0; outline:none; caret-color:var(--pri); }
.icon { width:38px; height:38px; border-radius:50%; border:1px solid transparent; background:transparent; color:var(--pri); display:flex; align-items:center; justify-content:center; padding:0; }
.icon svg { width:20px; height:20px; fill:currentColor; }
.icon.send { opacity:.35; transition:opacity .15s, background .15s; } .icon.send.on { opacity:1; background:rgba(0,212,255,.12); }
.icon.stop { display:none; color:var(--red); border-color:rgba(255,51,102,.5); background:rgba(40,0,14,.7); box-shadow:0 0 12px rgba(255,51,102,.3); width:44px; height:44px; }
.icon.stop.on { display:flex; }
.micbtn { position:relative; width:56px; height:56px; border-radius:50%; border:1px solid var(--grn); background:radial-gradient(circle at 50% 40%, rgba(0,255,136,.18), rgba(0,20,30,.9) 70%); color:var(--grn); display:flex; align-items:center; justify-content:center; padding:0; box-shadow:0 0 16px rgba(0,255,136,.25), inset 0 0 12px rgba(0,255,136,.12); }
.micbtn svg { width:26px; height:26px; fill:currentColor; }
.micbtn .halo { position:absolute; inset:-6px; border-radius:50%; border:1px solid rgba(0,255,136,.35); animation:halo 2.4s ease-out infinite; pointer-events:none; }
@keyframes halo { 0% { transform:scale(.9); opacity:.6; } 100% { transform:scale(1.35); opacity:0; } }
.micbtn.rec { border-color:var(--red); color:var(--red); background:radial-gradient(circle at 50% 40%, rgba(255,51,102,.35), rgba(40,0,14,.95) 70%); box-shadow:0 0 22px rgba(255,51,102,.55); }
.micbtn.rec .halo { border-color:rgba(255,51,102,.6); animation-duration:1s; }
button:active { transform:scale(.94); }
#pair button, #settings button, #confirm button { background:rgba(0,20,32,.7); border:1px solid var(--line); color:var(--txt); border-radius:4px; font:inherit; font-size:13px; padding:10px 14px; letter-spacing:1px; }
#pair button.pri, #settings button.pri, #confirm button.pri { border-color:var(--pri); color:var(--pri); box-shadow:0 0 10px rgba(0,212,255,.2); }
@keyframes blink { 50% { opacity:.55; } }
#pair { display:none; flex-direction:column; gap:12px; padding:26px 22px; text-align:center; z-index:1; }
#pair .box { border:1px solid var(--line); background:var(--glass); padding:18px; border-radius:4px; }
#pair input { width:100%; font-size:26px; text-align:center; letter-spacing:8px; }
#settings { display:none; margin:0 12px 8px; padding:12px; border:1px solid var(--line); border-radius:4px; background:var(--glass); backdrop-filter:blur(8px); font-size:13px; z-index:1; max-height:55vh; overflow-y:auto; }
#settings .h { color:var(--pri); letter-spacing:3px; font-size:11px; margin-bottom:8px; }
#sform label { display:block; margin:8px 0 3px; color:var(--dim); font-size:10px; letter-spacing:1px; text-transform:uppercase; }
#sform select { width:100%; background:rgba(0,10,18,.9); color:var(--txt); border:1px solid var(--line); border-radius:4px; padding:9px; font:inherit; font-size:14px; }
.boot { animation:boot .8s ease-out; } @keyframes boot { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
</style>
</head>
<body>
<div class="corner tl"></div><div class="corner tr"></div><div class="corner bl"></div><div class="corner br"></div>
<header class="boot"><div><div class="name">LUZA</div><div class="sub">ASSISTENTE PERSONALE · REMOTO</div></div><span id="conn" class="conn">…</span><button id="gear" onclick="openSettings()">⚙</button></header>
<div id="pair">
<div class="box"><div style="color:var(--pri);letter-spacing:3px;font-size:11px;margin-bottom:10px">ABBINAMENTO</div>
<div style="font-size:13px;color:var(--dim);margin-bottom:12px">Sul Mac premi <b style="color:var(--txt)">Remote Control</b> e digita il PIN di 6 cifre, oppure inquadra il QR.</div>
<input id="key" type="text" inputmode="numeric" placeholder="······" autocapitalize="off" autocorrect="off">
<div style="height:10px"></div><button class="pri" onclick="savePair()">COLLEGA</button></div>
</div>
<div id="core" class="boot"><canvas id="ring"></canvas><div id="stateline"><b id="stbase">LISTENING</b><span id="stdetail"></span></div></div>
<canvas id="wave" class="boot"></canvas>
<div id="settings"><div class="h">MOTORE E VOCE</div><div id="sform"></div>
<div style="margin-top:12px; display:flex; gap:8px"><button class="pri" onclick="saveSettings()">SALVA E APPLICA</button><button onclick="$('settings').style.display='none'">CHIUDI</button></div>
<div id="shint" style="margin-top:6px; color:var(--dim); font-size:11px"></div></div>
<div id="log"></div>
<div id="confirm"><div class="t" id="ct"></div><div class="d" id="cd"></div><button class="pri" onclick="doConfirm(true)">CONFERMA</button> <button onclick="doConfirm(false)">ANNULLA</button></div>
<footer>
<div class="capsule">
<input id="txt" type="text" placeholder="Scrivi a LuZa…" enterkeyhint="send" oninput="onTyping()" onkeydown="if(event.key==='Enter')sendText()">
<button id="sendb" class="icon send" onclick="sendText()" aria-label="Invia"><svg viewBox="0 0 24 24"><path d="M3 11.5 21 3l-7.5 18-2.3-7.2L3 11.5z"/></svg></button>
</div>
<button id="stopb" class="icon stop" onclick="send({type:'interrupt'})" aria-label="Interrompi"><svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>
<button id="mic" class="micbtn" onclick="toggleMic()" aria-label="Parla"><span class="halo"></span><svg viewBox="0 0 24 24"><path d="M12 15a4 4 0 0 0 4-4V6a4 4 0 0 0-8 0v5a4 4 0 0 0 4 4z"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
</footer>
<script src="/static/app.js"></script>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
{ "name": "LuZa", "short_name": "LuZa", "start_url": "/", "display": "standalone", "background_color": "#030a10", "theme_color": "#030a10",
"icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } ] }
+4
View File
@@ -0,0 +1,4 @@
// Service worker minimo: rende l'app installabile; la pagina resta sempre aggiornata dalla rete.
self.addEventListener('install', e => self.skipWaiting());
self.addEventListener('activate', e => self.clients.claim());
self.addEventListener('fetch', e => {});
+72 -28
View File
@@ -872,22 +872,29 @@ class HudCanvas(QWidget):
# status text
sy = _sy_status
if self.muted:
txt, col = "⊘ MUTED", qcol(C.MUTED_C)
elif self.speaking:
# Lo stato può avere un dettaglio dopo " · " (es. "THINKING · genero la risposta · 120 token · 59/s").
base, _, detail = self.state.partition(" · ")
detail = f" · {detail}" if detail else ""
if self.speaking:
txt, col = "● SPEAKING", qcol(C.ACC)
elif self.state == "THINKING":
elif base == "THINKING":
sym = "◈" if self._blink else "◇"
txt, col = f"{sym} THINKING", qcol(C.ACC2)
elif self.state == "PROCESSING":
txt, col = f"{sym} THINKING{detail}", qcol(C.ACC2)
elif base == "PROCESSING":
sym = "▷" if self._blink else "▶"
txt, col = f"{sym} PROCESSING", qcol(C.ACC2)
elif self.state.split(" ")[0] == "LISTENING":
txt, col = f"{sym} PROCESSING{detail}", qcol(C.ACC2)
elif base == "LISTENING":
sym = "●" if self._blink else "○"
txt, col = f"{sym} LISTENING", qcol(C.GREEN)
else:
sym = "●" if self._blink else "○"
txt, col = f"{sym} {self.state}", qcol(C.PRI)
if self.muted:
# microfono spento: a riposo si vede MUTED, durante il lavoro resta il dettaglio con il segno ⊘
if base in ("LISTENING", "SLEEPING") and not self.speaking:
txt, col = "⊘ MUTED", qcol(C.MUTED_C)
else:
txt = "⊘ " + txt
p.setPen(QPen(col, 1))
p.setFont(QFont("Menlo", 14, QFont.Weight.Bold))
@@ -1789,11 +1796,12 @@ class CustomizeOverlay(QWidget):
class PluginManagerOverlay(QWidget):
"""Floating overlay — lists discovered plugins with per-plugin ON/OFF toggles."""
"""Floating overlay — lists discovered plugins with per-plugin ON/OFF toggles.
Elenco scorrevole (non supera l'altezza della finestra), nome leggibile e descrizione sotto."""
_OW = 420
_OW = 560
def __init__(self, plugins: list[dict], parent=None):
def __init__(self, plugins: list[dict], parent=None, max_height: int = 0):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(f"""
@@ -1809,25 +1817,47 @@ class PluginManagerOverlay(QWidget):
lay.setContentsMargins(20, 16, 20, 16)
lay.setSpacing(6)
hdr = QLabel("🧩 PLUGIN MANAGER")
hdr = QLabel("🧩 PLUGIN")
hdr.setFont(QFont("Menlo", 15, QFont.Weight.Bold))
hdr.setStyleSheet(f"color: {C.PRI}; background: transparent;")
lay.addWidget(hdr)
sub = QLabel(f"{sum(1 for p in plugins if p.get('enabled') and p.get('valid'))} attivi su {len(plugins)} · clic su ON/OFF per cambiare")
sub.setFont(QFont("Menlo", 10)); sub.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
lay.addWidget(sub)
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;")
lay.addWidget(sep)
body = QWidget(); body.setStyleSheet("background: transparent;")
blay = QVBoxLayout(body); blay.setContentsMargins(0, 0, 8, 0); blay.setSpacing(4)
if not plugins:
empty = QLabel("No plugins found in /plugins.")
empty = QLabel("Nessun plugin trovato nella cartella plugins/.")
empty.setFont(QFont("Menlo", 11))
empty.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
lay.addWidget(empty)
blay.addWidget(empty)
locali = sorted((p for p in plugins if not str(p["name"]).startswith("mcp:")), key=lambda p: p["name"])
esterni = sorted((p for p in plugins if str(p["name"]).startswith("mcp:")), key=lambda p: p["name"])
for p in locali:
blay.addLayout(self._build_row(p))
if esterni:
t = QLabel("SERVER MCP"); t.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
t.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent; margin-top: 8px;")
blay.addWidget(t)
for p in esterni:
blay.addLayout(self._build_row(p))
blay.addStretch(1)
for p in plugins:
lay.addLayout(self._build_row(p))
scroll = QScrollArea(); scroll.setWidget(body); scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame); scroll.setStyleSheet("QScrollArea { background: transparent; } QScrollArea > QWidget > QWidget { background: transparent; }")
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
if max_height:
avail = max(160, max_height - 150)
scroll.setMaximumHeight(avail)
scroll.setMinimumHeight(min(avail, body.sizeHint().height() + 8)) # usa l'altezza disponibile prima di scorrere
lay.addWidget(scroll, 1)
lay.addSpacing(4)
close_btn = QPushButton("CLOSE")
close_btn = QPushButton("CHIUDI")
close_btn.setFixedHeight(30)
close_btn.setFont(QFont("Menlo", 12))
close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -1841,23 +1871,37 @@ class PluginManagerOverlay(QWidget):
close_btn.clicked.connect(self.hide)
lay.addWidget(close_btn)
self.adjustSize()
if max_height:
self.setMaximumHeight(max_height)
@staticmethod
def _titolo(name: str) -> str:
if name.startswith("mcp:"):
return name[4:]
speciali = {"home_assistant": "Casa (Home Assistant)", "server_assistenza": "Assistenza server", "whatsapp_archivio": "WhatsApp archivio",
"whatsapp_live": "WhatsApp in tempo reale", "comandi_rapidi": "Comandi rapidi", "sms": "SMS", "mac": "Mac", "app": "App"}
return speciali.get(name, name.replace("_", " ").capitalize())
def _build_row(self, p: dict) -> QHBoxLayout:
row = QHBoxLayout(); row.setSpacing(6)
label_text = p["name"] if p["valid"] else f"{p['name']} (⚠ {p['file']})"
lbl = QLabel(label_text)
lbl.setFont(QFont("Menlo", 11))
row = QHBoxLayout(); row.setSpacing(10)
col = QVBoxLayout(); col.setSpacing(0)
lbl = QLabel(self._titolo(p["name"]) if p["valid"] else f"{self._titolo(p['name'])} (⚠ non caricato)")
lbl.setFont(QFont("Menlo", 12, QFont.Weight.Bold))
lbl.setStyleSheet(f"color: {C.TEXT if p['valid'] else C.TEXT_DIM}; background: transparent;")
lbl.setToolTip(p["description"] if p["valid"] else p["error"])
lbl.setWordWrap(False)
row.addWidget(lbl, stretch=1)
col.addWidget(lbl)
desc = (p["description"] if p["valid"] else p["error"]) or ""
desc = " ".join(desc.split())
d = QLabel(desc); d.setFont(QFont("Menlo", 10)); d.setWordWrap(True)
d.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
d.setMaximumWidth(self._OW - 150)
col.addWidget(d)
row.addLayout(col, stretch=1)
btn = QPushButton()
btn.setFixedSize(72, 24)
btn.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
if not p["valid"]:
btn.setText("BROKEN")
btn.setText("ERRORE")
btn.setEnabled(False)
btn.setStyleSheet(f"""
QPushButton {{
@@ -1869,7 +1913,7 @@ class PluginManagerOverlay(QWidget):
btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._style_toggle(btn, p["enabled"])
btn.clicked.connect(lambda _, name=p["name"], b=btn: self._toggle(name, b))
row.addWidget(btn)
row.addWidget(btn, alignment=Qt.AlignmentFlag.AlignTop)
return row
def _style_toggle(self, btn: QPushButton, enabled: bool):
@@ -5096,7 +5140,7 @@ class MainWindow(QMainWindow):
def _open_plugin_manager(self):
plugins = self.get_plugins() if self.get_plugins else []
cw = self.centralWidget()
ov = PluginManagerOverlay(plugins, parent=cw)
ov = PluginManagerOverlay(plugins, parent=cw, max_height=cw.height() - 40)
ov.adjustSize()
ov.setGeometry(
(cw.width() - ov.width()) // 2,