From 706c80cc209be6e62354b489b2a2e14c1f7d944d Mon Sep 17 00:00:00 2001 From: luciano Date: Mon, 28 Sep 2026 00:20:46 +0200 Subject: [PATCH] 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 --- avatar/assistant.py | 37 ++- avatar/engines/anthropic_engine.py | 10 +- avatar/engines/base.py | 26 ++ avatar/engines/claude_code.py | 10 +- avatar/engines/mlx_engine.py | 15 +- avatar/engines/openai_compat.py | 8 +- avatar/persona.md | 21 +- avatar/plugins.py | 12 +- avatar/remote.py | 489 +++++++++++++++++++++++++++++ avatar/settings.py | 2 + main.py | 35 +++ plugins/canto.py | 133 ++++++++ plugins/claude_sessioni.py | 315 +++++++++++++++++++ plugins/documenti.py | 204 ++++++++++++ plugins/immagini.py | 2 +- plugins/violino.py | 225 +++++++++++++ remote/app.js | 240 ++++++++++++++ remote/icon-192.png | Bin 0 -> 3066 bytes remote/icon-512.png | Bin 0 -> 8935 bytes remote/index.html | 104 ++++++ remote/manifest.json | 2 + remote/sw.js | 4 + ui.py | 100 ++++-- 23 files changed, 1944 insertions(+), 50 deletions(-) create mode 100644 avatar/remote.py create mode 100644 plugins/canto.py create mode 100644 plugins/claude_sessioni.py create mode 100644 plugins/documenti.py create mode 100644 plugins/violino.py create mode 100644 remote/app.js create mode 100644 remote/icon-192.png create mode 100644 remote/icon-512.png create mode 100644 remote/index.html create mode 100644 remote/manifest.json create mode 100644 remote/sw.js diff --git a/avatar/assistant.py b/avatar/assistant.py index 3137361..d421de6 100644 --- a/avatar/assistant.py +++ b/avatar/assistant.py @@ -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,7 +439,10 @@ class Assistant: continue self._speaking = True self.ui.set_state("SPEAKING") - self.player.play(audio, text, self._abort) + 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() def interrupt(self, silent: bool = False) -> None: diff --git a/avatar/engines/anthropic_engine.py b/avatar/engines/anthropic_engine.py index 90df6ad..8bbda40 100644 --- a/avatar/engines/anthropic_engine.py +++ b/avatar/engines/anthropic_engine.py @@ -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() diff --git a/avatar/engines/base.py b/avatar/engines/base.py index 89eff4c..548aa39 100644 --- a/avatar/engines/base.py +++ b/avatar/engines/base.py @@ -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: ... diff --git a/avatar/engines/claude_code.py b/avatar/engines/claude_code.py index 54b3385..f3a7a76 100644 --- a/avatar/engines/claude_code.py +++ b/avatar/engines/claude_code.py @@ -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: diff --git a/avatar/engines/mlx_engine.py b/avatar/engines/mlx_engine.py index 0148ed3..ac6afa9 100644 --- a/avatar/engines/mlx_engine.py +++ b/avatar/engines/mlx_engine.py @@ -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ù diff --git a/avatar/engines/openai_compat.py b/avatar/engines/openai_compat.py index 666f520..3eb93e2 100644 --- a/avatar/engines/openai_compat.py +++ b/avatar/engines/openai_compat.py @@ -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}) diff --git a/avatar/persona.md b/avatar/persona.md index 9586bd5..222a3bb 100644 --- a/avatar/persona.md +++ b/avatar/persona.md @@ -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. diff --git a/avatar/plugins.py b/avatar/plugins.py index 4084736..2a27b22 100644 --- a/avatar/plugins.py +++ b/avatar/plugins.py @@ -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() diff --git a/avatar/remote.py b/avatar/remote.py new file mode 100644 index 0000000..b305baf --- /dev/null +++ b/avatar/remote.py @@ -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"}) diff --git a/avatar/settings.py b/avatar/settings.py index 7a3e811..7110e83 100644 --- a/avatar/settings.py +++ b/avatar/settings.py @@ -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") diff --git a/main.py b/main.py index 0fc268e..68542f7 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/plugins/canto.py b/plugins/canto.py new file mode 100644 index 0000000..72ce46a --- /dev/null +++ b/plugins/canto.py @@ -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}, +] diff --git a/plugins/claude_sessioni.py b/plugins/claude_sessioni.py new file mode 100644 index 0000000..568cdd7 --- /dev/null +++ b/plugins/claude_sessioni.py @@ -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((" 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((" 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}, +] diff --git a/plugins/documenti.py b/plugins/documenti.py new file mode 100644 index 0000000..7c7f59c --- /dev/null +++ b/plugins/documenti.py @@ -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"\1", t) + t = re.sub(r"(?\1", t) + t = re.sub(r"`(.+?)`", r"\1", t) + t = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)", r"\1", 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", "
"), 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", "", "[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}, +] diff --git a/plugins/immagini.py b/plugins/immagini.py index b587773..611b1ba 100644 --- a/plugins/immagini.py +++ b/plugins/immagini.py @@ -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}, diff --git a/plugins/violino.py b/plugins/violino.py new file mode 100644 index 0000000..27cd865 --- /dev/null +++ b/plugins/violino.py @@ -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}, +] diff --git a/remote/app.js b/remote/app.js new file mode 100644 index 0000000..fae0d04 --- /dev/null +++ b/remote/app.js @@ -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); diff --git a/remote/icon-192.png b/remote/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..a028fac8d33a0bcff3b0357a3fe0e12935990947 GIT binary patch literal 3066 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 R5?j-ASen|HRAJp>{sXapaMu6; literal 0 HcmV?d00001 diff --git a/remote/icon-512.png b/remote/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..eea77f8169849157c43da65158f7b4217b9022ca GIT binary patch literal 8935 zcmdUV_dA@y*ZwSvO`=BcEkYuCL`krEM50G;DME`^=d+GiT1sGv~~GKao0G>J%4QE&>23H14YC0YC&j5&^{d z-%A$dQUG{9Yp5t1_@u5*`Zh2=B(!b1Kf_tptlSn5n2j=y;$n$yGXyD*ysyFaUyh0N~Pr0|1W#0F9&pfLZ{ujz|>% zWF`Ut1l|8TjfS#UD1e*c2l44%`8VMn;ycMfdhIn?Ph^_O{}|0WDBi)yz`=@*ZB|c| zHM$A`{``n_D+{@c=0kvYS5u>=Z*Bn_ZlEI&6*zz(ri%n<6N3}eI5N>FfTkHF_`ijV z1_7j#z)>p~c^o4VsLEbT4!weWg8&ARb;*aLsgyKre(2m7gA_Id4YAU<(g-C_#-MJU}Km|eLZmC&$a*!jJj_2 z616JIQ<_152}b>y=Qo9_f?y2gMI?admv<=j^;`-k52FUKYk4s``&}~#9xFJK`aUCN zRN$s`UI9A{CzE3nT<1(oDORKge14l-WUh@z^2U@P0F^F6=~rL4JP}4POgPZ#(4qy4 zentb*MO~}(_X(KVAZQZ)amVypOZ!y(7fu;rxLcp?U4CTR!_mcCLWGH}@UU$%I69%m z<9qEZ^Il>^^^yAsl~xgk26SicgwT>7@_-*jrxUv&5)>J{;D!1UnJqgxH#jLfmkm%^ zMS^m=T+Sq%!44A)x4_qCp|I5|v&2V)5jKBT&b*LsoBc%!oV5;-{2rRByx~^y@@6y# znqGk5a>=%Y69E>=_`;5L8MG`$#NYhXKbwc!M7I5{pJZ;zSo?OyOw7i34JD%3Dtm?Q}&78>-}FCJ{h1c$w-% zL>zOA95$*)@Es?c!Ld}p<ZQWaSSOrY(Au)%4JnLoC8T& zaEcsSy!b8x6wnWG7SJ&3Fc4*4H9&DOK1E(bpqF(CL2Peft#jNiu0t781emaJaW8QJ z=@6%Aev}!XJWT;5ERGo}y&)GMg~d?}aSD(!>i{Ay;vxl|E8>`&L`X(R#=&e&2g?jy zrg|4jE*cK(I3O7#vo!)1r!cioR#TFS1bF{r?Oif*te@+qE3y)S&eI{lisN9=A{s_7 zQyE8)x6{BjhM*Z#wWAxTfmIMBj3I9)ft6n`QozB*Gri!LWJo7G4kpP1irQE+kV`e> z4vN6Q_qgXdnK92mYkM2aN(K0bEmPG*lb;)&eMJ13m$(QCdimf=2H2*OY7_$fg6W8? zq;>wo!$lYdM}?y$4Hmy33!s5f?I2)=6bx~0&@*RVn*hCS7#b+5OWUoV}p)MYGU*_3Uoz}+cxQD>*v628NMpWK7patoC z65#g3JJ%)C;UGi-*wckeQR6lKtAgjDETP3I1G|gc+*IiMvvC(gw_!MSN@5qSR>JI@ zKU8sj2sX$=nP*|84L?OdM=f zwT#jO4(}>(Ammkkvz8Gu*8D5=aCxcw*Qkz9wLDRtrfT6Y1oNBi0;6@}p5l$G&fW&D ztR0xKM#ahtyc7&Rk8|lwL~@Wf-aUa?Yr7Gl|B-Z@`xGB~?z7R$AV~p63eGyxgkZS& z0~T~|i&o;bWGH&a@t%ip8&&W}f`=5$(8&oBVVd7cV)Qih_81OCyz-4rZqmc8^X*pd zig|r1kCny!!4GCES0|$gY3~(A@C@I@x_2NSX_U!hU7v`rB=~Zw%ynCH&PF2uMgwc2 zKwHYXGp?STF3iv=QLE~reF(G0{-nlmFOQQIlzR0$i=27I`X9(An8Eoqtqx(hQDu5e zjn-tmzHmox;OT_jia^LXv8`qjwAMIx>{4HM z)6wd@@_tA7skK`)P81TWr+8tRlWpzJHr1C_70OxQ0QVaIYFl+*5;AB`E%K1_;{;Dl zDIS#1$VlVhk}d^f3oeAct+xtTvUM@Y)+Qj^OYAc5D{HM|5g>kak2}}tICMKlQ)@2b zoq$)CiwrdY@j2ae4yF0coc*zZ3jvrwUhlTEgh*$rIK3 zMXj$!<)$$G8%=y0XXI#Q%W)-s#pLlY>(qj1{% z^q->d8GH6!$MNFt<3-YX5{VVTqPQRN$n`sIM z)@dH^ZR#g?$MdCra5jh#4OV))tp?ctak6m^FNQXP_^RwxUXdYZVg57a(@h4Nk|RUx zAG0CL@We)TEhwF?|5O~{x%PS74?DYQizT_4e5=iJsIcD9y({_8@@haZm)zwDeb zh}>T|JB;mVy0TUubo5sq%Rv@x86bY*k-UceV5GuJRcNyvq6C1U4l!xrI%m$b{Kt(s zkL8;$8>IB*{hCh}tnlvIQ5_woXJeg9TSuC6k;%mtd(gJamn`i?6|8z0jjwZa9_p8} zQilor$#ntIU+q3<9WpOj#=0#l%*#n#WW8a^g+l3j>{QPNkgCn8|1Lc1%sN7S{-P(p zG469FB>uqVQ;f{*o@bknYP(apcr z=S-$;!{&1c?Wps|B(TdC6UPobFWHkFj0fdxF!1hrRJ@o`bdu-CuK7ys_IfALh6lxG zySAr%C`m)DfQENgu~$=5UVKYjRwy4$T4uc+sb$f6QXBdr`R4&85J(tXzq?x{ufoM%lgbMJ&1=4GRbkcbd|QdOU&nUW9Q^g! zAcIu($5)EHMvba>w`}@I+9VFP`O2CGpp?qzpi-y z7uix5y0mUbwE3D7FF-5hYBqXX@%}W6t+sgL6FtEzInul`ICPLF4IPeAIk(2|t-e(G z^*tkM)X}XkzSXpA%==~&Uks#~JvhXlQ`VnRAYONRSpG3gxWxFbWu^cPLFcJ#%X8C#y`WE1_(KRfz2&GL?8%vzRHc_kWxeH#r3Pq4`?8;dnU6lcIX{0ND7Y{A z3=S2qv!}5IM%hhk7~j1Nx}ZUy)a~8XYCu+RcF=E~%sigIXcQAZ<`%wF8J5n_Q=LP= zszM&Zyn8w*-S;`<*%RM^o=<<-Y;%i?wY6%E1NOshoXPAV@Ib8~ZBK7}J!{msY18ed zYbvXHw*JV|iB;xdv9MOC1GE==!hpztqq)h4z`gkvoOaJtV#b;7j%EJJDpKC(E)iZt z=1{3Gw;mc|JJD@yo02VTFYUU~r1I|m1v$WFYahKj=uJ8N;)ZPl%Vg%Hk$~<5Pwgya zU-g(OL*nK2CcgY;uIbdPQ!mXrebsNg=V$C@%($UdFE5ysPw}PTjyyjhim8elO3JG2 z(^BOut6lXp$WVqis?V6NsK@A8amF8JND3#$PKj`$kXpV5of!^Kf8!j0L0)HUxbb12 zSUC%}BB%9Zon~K$+0}NVj4G5>(JAo|P5!fEsmkd{rxdO;k9y;+NQALXhySCBej+Z8 ze(aV7@zdN}Q|9b0B~JgUEj4XVyn|#niD+8uN?DWDXxs8GePCW4^|{%jZ9e7rc}Nam z_=HT0+&}jVz0=^cSamoQe*TO=Ge?@Mv23rifrZ?_9eglAJG>w?SrKqg ztENELudQ#z^S|{&FAZ|@-Zbl25>abg7lN|2234g&i9VG4jm9MO(3kDW*e2F1>Zy6- z2dg7F{{pY4_LkA44qq6*`yo|AQR+i{OXBy8 zojueDL6WR0+dYpXVQ;hTX~8X;64ry3+)u`Dd?TiYaFIz{ASF!9HV1qKY98) zd_4imO)fsc`Vp`3f=uo?b=ha0Yu8KqE4{@}%qM?UPDKQ|zI9QZPzrF=Obgo@emqiU zRX?`gw_yVZH|>OV9(Jbqvaeo5<6jG^gjnWZ(a6qtP5qlCly}gN)q#-TD3ODce`D%B z$AjvA0%?>C`em8XUj{`+oR27~#!v14Iosb#f`URu#tpXY>L1CeJLLjNrl^EqRG-m# z``(m-FPEAgpC1o57FXVsBYm73h@Vg9bIDn^yJb-Q!{NAyS0r%^Y~ zPT!P;miE@D6q|&1=SiuQRtA-;xsCDFt@B<_q&$W4JS|`nlPcw5c_BfAzi?(EB%V>mjas)IPsaRq$_6I|U3- zL^IkyST-{%JDj@fIf%*1worFRS$Rv`n$$zddHC+D$n3h#6wJTzdeF(0E4z3Q94JT* z2VdLwr~04g-I|)1pnO=r6jRG;V3hGXo$J$}{hgPF9#wl%j~V*??Hj{3vzoOF9laj@ z+Y|X)O{55rJ@tODOc_^W`mt|D_3t=cd(noeIl*b!|spm%%bh1h~cTA2M}w|&ALg%Q1< zUi#BA#_x*vWwCv>&%Y(^Pu+mhN+0XB=GGN&j|G*n_Ra)!-4Q=XNi&^|W;aLKF?=>;KWB3RVbz|`(Pz)7y4>Kfok3pYsq8jdYQJ6T zep-2ef&&jM|7p^rmc1QK_dk^ZQg^RT@Q!_@&DDF!QKWEo-=i-Q%94LDZaJgcsfQS+ z-!J$%PWthUOqPYMrH*sH%7NipwmsIdSgL=?x2o1@up1xKgNwMwjXtB09{>0{hH_uJ z#jo8+hjMUy0@a1sPyhS_KGpZ;jQJ(wi|XBFp4yMB8#ZA2-mY~AiVWxZ%lqQiLWM*Txx~*Qd;JDt-rvj*T>Uu9 zUzi-JG-kb7ZpvG%j9DJBB4_)^;n&D0m%}QO?A&0!iXv}%1(3J4>Y^)2F!`p#xwN$} z^at**Z8e$AU($b1QRB1qnH9fAW+=fkotHM|rd~B4Q|pNRtv?Ul&2~q0P9;DON0E}R;}0rrRrA@e7%mIxn5IYFZtaKy<&SQVA7#7R zMjUc7)U`otp~ikaX{k$#oObF1O`3P=-T3~!wQQ12>@@ARhn2<`^B3xUl{^6Vo@5(} zJ+vCRnHgqZq*;6;-pdtdX8g2oS3gS)oX<44@Y1d?c=0Q7t65p%#(*U+(4ml7$_gfZ z3Jnpq&X@7>OT@I2J3gQ~_fjhT9fZWfM6dgQ}>iNx*b74VMVN5bDpIeA>0?7)7nJEc#zzA$ZH zBSu!=yS8QMgN)oqX)Spls^msmywzG1XPr^Nf_2rI?qSJrz5fS)T=(KPhnCJInG;EA zF?yA}3W=9~+Qtv*3b}6=Oj7CmY~o`Xd-_KKy7qD&CipK}9-e#>x4Nl9F*p(qC3ITN zyZ;76wiPU;t+f1%RIGa=IE-*{Yv;W`%*Qk&?$nmH{ftW_@*U z?D}{tt^{l<9b$Q)UFb7M54|XlkD}dv70)-ABWV40bqQoe6jJmKyR>qC#ZV3nT{wrA zIyrx{3_NP&iyol0-n#)M@u@Nv4Kfd!PQP#lG+l&Q`|Xd?rURS@$sD&RfOj+kmw%FG zAgDmv_~Xh&P%NtsaNd!Xf&hR}S8-szVh`IeZtcp(KZPWB$ieupc7Pd~eP#g_qh{=Z zwCpS?MuFb;_uDrx>qqJrz=@#4Em8n70VNr_eo`1r71WqV+@Jur>ooyR{~R?0Ag`7k zXn8k*n$NR77a&@rWc4Nt&6(Rd1rS$)VA4gift#-KP&*D%be9D`aA`aTCuclbKuzwf z>gXnf1)NvS{|C49ALRgwo^3!nYgY67=9T}gdM4G8a-YmWVp|&-Z@3C0NX%1!!?dSo8iXxWiCy@(w~c1E3Lg zkPu|5zRiF}J%*fA3_}vU1E3$iGGTy_X3}6_kac+q7aI=E9w7x5;h_D~<>l4p|10+zTsYX9 z?g3oHV6otp1)-7RKW@MvsnJ&rf}08ZGN2uRV(31h01XRK5X6eW@cSxC0EQ$52Rub6 zV_2NVds-z}5RQmS4EP6xGora&nN)crfHfBa!s=&8kym$UVLdLo;T+(@kw>a9T1woa z+m$Iq;X4V^E}Q{r=xWW!Qv%vwK>##N7=IPc2*}~E8S9Pw=if;HGk^y#PnS>=b73Iz z;z5M65F!Zb@-;V6_so|e8Yi$J3O49N6h8i-01%*jdC1DD_uRJhtRvgYe&A^Y1vW#%m?rTQZyzgVL099W!FK2Rf~p=OBkF@l`-TbJOj2>d0*K{>;af(RUA{}zT0 zEjX3o0Uwn}fnnCsLNu@=Wd_E<%Ne(cxKItO7hOzbf`I7OnkX~C7erl(GCK972Bz)M zMl?XVBY?$IpmV3FSr?8Zg`TbyH9vx3nRBfBAsObk$(qVT6GL+gH9TC69(av#zA z0W|}Qw!?dXBj;#542Xu3gQkdOXYMd`XVH92`V7}W&^c9zq^a3gESElX{6(!p!$hg{ zzTh+|hfIe}_6LH@ww`toMwg!?7BEE_tXy#V8KNYKgsBdq+{cA>z&)f9u*^QX4Kakz z?b(KA_;mmLhtNb~Yus=*wXE|Y&hrb4Gu~JF^<`axg*p}HV%3PN+AEza-Np*kfMF&( zm>{hg@Pj%|)xJ5}Rt2G^OOR8h!l*&iD#gO5WCYNhcN6Iefeps!_dS85hidjPOJtDa z$LA$Ww4Fh!r!ff(NBS6~K8KDC+-tV!jVvjkB}xs}xJfY@p$!l+uH7VZJiv-zUeLoK zfRXD`@HQF5{7obs=Qn#GPJUeo5#a&;n1>Wvmkolz#;ZsZ^diMUkUMd)GVp2bed=;0 zC|a7i_XZJ0`R`vi--VU7|B%sUbiutYEpO@1nQK9KFLyaCoarGi2)6diVv5i7VbdRs z0f@U||3Ya<|GL26iV$&0hYGQ3T*ikglPW_SoW2$<`Y4WGos5JFC>fo|ZoT@x>~cc~ zVg%;jrEAlWuJ}S+E1G^+AoY$l)lUvMC=t|?S0#FR`4)5=RU>>+p#Nj!LJ0%m#b$3W zeCI%;5D>FSru@Wq8ZpcRbok;i9D@M{;Q(2BH~5RrzAi+d)N)^lQk+z`L;xm5CVX@@ z6)gh1SG_zKTt}6I0CZ7z>{#eIRUu`TA2@xwfOnw)E%XCRd|m#ABEVP%su0a{KwaZF zZifUZQ7uq|jW@MEnJqsh;Lx`g*RQ`M24J4+|DSI!AV>#|7&wEu_*AezCGZ(QM<&ov L)l&JaWF7oJfzi0S literal 0 HcmV?d00001 diff --git a/remote/index.html b/remote/index.html new file mode 100644 index 0000000..3b6d1a2 --- /dev/null +++ b/remote/index.html @@ -0,0 +1,104 @@ + + + + + + + + + + + +LuZa + + + +
+
LUZA
ASSISTENTE PERSONALE · REMOTO
…
+
+
ABBINAMENTO
+
Sul Mac premi Remote Control e digita il PIN di 6 cifre, oppure inquadra il QR.
+ +
+
+
LISTENING
+ +
MOTORE E VOCE
+
+
+
+
+
+
+ + +
+ + +
+ + + diff --git a/remote/manifest.json b/remote/manifest.json new file mode 100644 index 0000000..7fb838a --- /dev/null +++ b/remote/manifest.json @@ -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" } ] } diff --git a/remote/sw.js b/remote/sw.js new file mode 100644 index 0000000..61911c6 --- /dev/null +++ b/remote/sw.js @@ -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 => {}); diff --git a/ui.py b/ui.py index dde7ebc..4aa3d8e 100644 --- a/ui.py +++ b/ui.py @@ -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,