App web per il telefono (HTTPS, PIN, WebSocket/SSE/interrogazione, voce, immagini, PDF, impostazioni), stati dettagliati, maestre di violino e canto, PDF, sessioni Claude Code, plugin manager rifatto
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
a078dff608
commit
706c80cc20
23 files changed
+1943
-49
No files matched your search
+29
-6
@@ -22,7 +22,7 @@ from .tts import SentenceSplitter, clean_for_speech, make_voice
|
||||
END = object()
|
||||
STATUS_LABELS = {
|
||||
"thinking": "THINKING", "searching": "PROCESSING", "memory": "PROCESSING",
|
||||
"working": "PROCESSING", "responding": "THINKING",
|
||||
"working": "PROCESSING", "responding": "THINKING", "loading": "PROCESSING",
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ class Assistant:
|
||||
print(f"[scheduler] {err}")
|
||||
|
||||
def _warmup(self) -> None:
|
||||
self.ui.set_state("PROCESSING")
|
||||
self.ui.set_state("PROCESSING · avvio")
|
||||
self.ui.write_log(f"SYS: {self.name} si sta avviando: carico voce e riconoscimento vocale…")
|
||||
try:
|
||||
self.voice.load()
|
||||
@@ -290,7 +290,7 @@ class Assistant:
|
||||
|
||||
def _on_utterance(self, audio: np.ndarray) -> None:
|
||||
self._last_speech = time.monotonic()
|
||||
self.ui.set_state("PROCESSING")
|
||||
self.ui.set_state("PROCESSING · trascrivo")
|
||||
try:
|
||||
text = self.stt.transcribe(audio)
|
||||
except Exception as err:
|
||||
@@ -305,13 +305,14 @@ class Assistant:
|
||||
self.handle_text(text)
|
||||
|
||||
# ── Turno di conversazione ───────────────────────────────────────────
|
||||
def handle_text(self, text: str) -> None:
|
||||
"""Chiamabile da qualunque thread (UI, microfono, quiz…)."""
|
||||
def handle_text(self, text: str, origin: str = "local") -> None:
|
||||
"""Chiamabile da qualunque thread (UI, microfono, telefono…). origin="remote": la voce va solo al telefono."""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
if self._busy or self._speaking:
|
||||
self.interrupt(silent=True)
|
||||
self._origin = origin
|
||||
threading.Thread(target=self._run_turn, args=(text,), daemon=True).start()
|
||||
|
||||
def say(self, text: str) -> None:
|
||||
@@ -339,12 +340,22 @@ class Assistant:
|
||||
return
|
||||
t = ev.get("type")
|
||||
if t == "status":
|
||||
label = STATUS_LABELS.get(ev["status"], "THINKING")
|
||||
detail = str(ev.get("detail") or "").strip()
|
||||
if ev["status"] == "working" and detail:
|
||||
detail = f"strumento: {detail}"
|
||||
elif ev["status"] == "searching" and not detail:
|
||||
detail = "cerco sul web"
|
||||
elif ev["status"] == "memory" and not detail:
|
||||
detail = "memoria"
|
||||
if not self._speaking:
|
||||
self.ui.set_state(STATUS_LABELS.get(ev["status"], "THINKING"))
|
||||
self.ui.set_state(f"{label} · {detail}" if detail else label)
|
||||
if ev["status"] == "searching":
|
||||
self.ui.write_log("SYS: Cerco sul web…")
|
||||
elif ev["status"] == "working":
|
||||
self.ui.write_log(f"SYS: Lavoro sul Mac ({ev.get('detail', '')})…")
|
||||
elif ev["status"] == "loading" and detail:
|
||||
self.ui.write_log(f"SYS: {detail}…")
|
||||
elif t == "text":
|
||||
for s in splitter.push(ev["delta"]):
|
||||
self._speech_q.put((turn, s))
|
||||
@@ -401,6 +412,12 @@ class Assistant:
|
||||
self.ui.write_log(f"ERR: Sintesi vocale — {err}")
|
||||
continue
|
||||
if turn == self._turn:
|
||||
remote = getattr(self, "remote", None)
|
||||
if remote is not None and remote.has_clients():
|
||||
try:
|
||||
remote.on_audio(item, audio)
|
||||
except Exception as err:
|
||||
print(f"[remoto] audio: {err}")
|
||||
self._audio_q.put((turn, item, audio))
|
||||
|
||||
def _play_loop(self) -> None:
|
||||
@@ -412,6 +429,9 @@ class Assistant:
|
||||
self.player.drain()
|
||||
self._speaking = False
|
||||
self._tail_until = time.monotonic() + 0.5
|
||||
remote = getattr(self, "remote", None)
|
||||
if remote is not None:
|
||||
remote.on_turn_end()
|
||||
if not self._busy and turn == self._turn:
|
||||
self.ui.set_state("LISTENING" if self._awake else "SLEEPING")
|
||||
continue
|
||||
@@ -419,6 +439,9 @@ class Assistant:
|
||||
continue
|
||||
self._speaking = True
|
||||
self.ui.set_state("SPEAKING")
|
||||
if getattr(self, "_origin", "local") == "remote":
|
||||
time.sleep(min(0.4, len(audio) / 24000 * 0.1)) # la voce la riproduce il telefono
|
||||
else:
|
||||
self.player.play(audio, text, self._abort)
|
||||
self._last_speech = time.monotonic()
|
||||
|
||||
|
||||
@@ -7,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()
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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ù
|
||||
|
||||
@@ -9,7 +9,7 @@ import openai
|
||||
from avatar.memory_tools import memory_prompt, openai_tools, parse_args, run_tool
|
||||
from avatar.plugins import registry
|
||||
from avatar.websearch import brave_search
|
||||
from .base import Emit, History, compact_history, persona_text, summary_block, today_label, user_block
|
||||
from .base import Emit, History, Meter, compact_history, persona_text, summary_block, today_label, user_block
|
||||
|
||||
MAX_ROUNDS = 8
|
||||
MAX_HISTORY = 30
|
||||
@@ -149,7 +149,7 @@ class OpenAICompatEngine:
|
||||
full = ""
|
||||
sources: list[dict] = []
|
||||
try:
|
||||
emit({"type": "status", "status": "thinking"})
|
||||
emit({"type": "status", "status": "thinking", "detail": "penso"})
|
||||
for rnd in range(MAX_ROUNDS):
|
||||
tools = self._tools()
|
||||
kwargs: dict = dict(model=self.model, messages=self._messages(), stream=True)
|
||||
@@ -164,6 +164,7 @@ class OpenAICompatEngine:
|
||||
continue
|
||||
raise
|
||||
flt, calls, round_text, announced, finish = ThinkFilter(), {}, "", False, None
|
||||
meter = Meter(emit, unit="parole")
|
||||
for chunk in stream:
|
||||
if abort.is_set():
|
||||
stream.close()
|
||||
@@ -174,10 +175,11 @@ class OpenAICompatEngine:
|
||||
delta = choice.delta
|
||||
if delta and delta.content:
|
||||
visible = flt.push(delta.content)
|
||||
meter.tick("thinking" if flt.inside else "responding", delta.content.count(" "))
|
||||
if visible:
|
||||
if not announced:
|
||||
announced = True
|
||||
emit({"type": "status", "status": "responding"})
|
||||
meter.tick("responding", 0, force=True)
|
||||
round_text += visible
|
||||
full += visible
|
||||
emit({"type": "text", "delta": visible})
|
||||
|
||||
+20
-1
@@ -53,4 +53,23 @@ Sei **Ava**, l'assistente personale di chi ti parla. Vivi in un'app sul suo Mac
|
||||
|
||||
## Immagini
|
||||
|
||||
- Puoi creare immagini con immagine_genera: traduci la richiesta in una descrizione inglese ricca (soggetto, scena, luce, stile, inquadratura) e avvisa che ci vuole qualche decina di secondi. Quando è pronta, dilla in poche parole senza descrivere i dettagli tecnici.
|
||||
- Puoi creare immagini con immagine_genera: traduci la richiesta in inglese in modo FEDELE, mantenendo tutti gli elementi che l'utente ha chiesto senza attenuarli, censurarli o sostituirli con formule generiche, e senza aggiungere soggetti non richiesti. Puoi solo completare i dettagli tecnici (luce, inquadratura, stile) se mancano. Se l'utente scrive già il prompt in inglese, passalo tale e quale. Avvisa che ci vuole qualche decina di secondi e, quando è pronta, dilla in poche parole senza descrivere i dettagli tecnici.
|
||||
|
||||
## Violino
|
||||
|
||||
- Con violino_ascolta e violino_accorda fai da maestra di violino alla figlia dell'utente. Prima di ascoltare annuncia cosa farai ("Suona pure, ti ascolto per quindici secondi") e chiedi cosa sta suonando, così puoi passare la scala o il brano.
|
||||
- Quando hai i dati, parla a una bambina: prima un complimento sincero e specifico, poi al massimo due consigli concreti e fattibili (dove mettere il dito, arco più lento e pesante, ascoltare la nota prima di suonarla), mai un elenco di errori. Niente numeri tecnici a voce: traduci i cent in "un pochino calante".
|
||||
- Proponi un esercizio breve e invita a riprovare subito per sentire la differenza.
|
||||
|
||||
## Canto
|
||||
|
||||
- Con canto_ascolta e canto_nota fai da maestra di canto: stesso stile del violino, voce dolce e incoraggiante. Per chi canta senza accompagnamento conta soprattutto l'intonazione relativa (gli intervalli) e che la tonalità non scivoli; ricordalo nei consigli.
|
||||
- Con canto_nota proponi giochi brevi: una nota alla volta, poi due note vicine, e festeggia i miglioramenti.
|
||||
|
||||
## Documenti
|
||||
|
||||
- Con pdf_crea produci documenti PDF (lettere, relazioni, elenchi, verbali, ricette): scrivi tu il contenuto completo in Markdown con titoli ed elenchi, usando i dati che hai raccolto con gli altri strumenti se servono. A voce annuncia solo che il documento è pronto e dove lo trova.
|
||||
|
||||
## Sessioni di Claude Code
|
||||
|
||||
- Con gli strumenti "claude_…" gestisci i progetti e le sessioni di Claude Code dell'utente: elenchi, lettura e riassunto di una sessione, ricerca, ripresa nel Terminale. Con claude_continua puoi far lavorare Claude Code su un progetto direttamente da qui e riferire il risultato; usa i permessi di modifica solo se l'utente lo chiede, e riassumi la risposta in poche frasi.
|
||||
+11
-1
@@ -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()
|
||||
|
||||
@@ -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"})
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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},
|
||||
]
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Sessioni di Claude Code (come l'app GestioneClaude): progetti e sessioni da ~/.claude*/projects, lettura,
|
||||
ricerca, ripresa nel Terminale e continuazione diretta da LuZa (claude -p --resume) con permessi a scelta.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from avatar.macos import confirm_or_param, CONFERMATO # noqa: E402
|
||||
from avatar.engines.claude_code import resolve_claude, claude_env # noqa: E402
|
||||
|
||||
HOME = Path.home()
|
||||
_cache: dict = {"t": 0.0, "sessions": []}
|
||||
LIVELLI = {
|
||||
"lettura": {"tools": "Read,Glob,Grep,WebSearch,WebFetch", "allowed": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"], "mode": None},
|
||||
"modifica": {"tools": "default", "allowed": ["Bash", "Read", "Edit", "Write", "MultiEdit", "NotebookEdit", "Glob", "Grep", "WebSearch", "WebFetch", "Agent"], "mode": "acceptEdits"},
|
||||
}
|
||||
|
||||
|
||||
def _roots() -> list[Path]:
|
||||
out = []
|
||||
for d in sorted(HOME.iterdir()):
|
||||
if d.name.startswith(".claude") and (d / "projects").is_dir():
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _decode_dir(name: str) -> str:
|
||||
return name.replace("-", "/") if name.startswith("-") else name
|
||||
|
||||
|
||||
def _text_of(msg) -> str:
|
||||
if not isinstance(msg, dict):
|
||||
return ""
|
||||
c = msg.get("content")
|
||||
if isinstance(c, str):
|
||||
return c
|
||||
if isinstance(c, list):
|
||||
return "\n".join(b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text")
|
||||
return ""
|
||||
|
||||
|
||||
def _head(path: Path) -> dict:
|
||||
"""cwd, primo prompt e data dalle prime righe del file."""
|
||||
cwd = prompt = ts = None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
for i, line in enumerate(f):
|
||||
if i > 80 or (cwd and prompt and ts):
|
||||
break
|
||||
try:
|
||||
o = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
cwd = cwd or o.get("cwd")
|
||||
ts = ts or o.get("timestamp")
|
||||
if not prompt and o.get("type") == "user" and not o.get("isSidechain"):
|
||||
t = _text_of(o.get("message")).strip()
|
||||
if t and not t.startswith(("<command-", "<local-command", "Caveat:", "[Request interrupted")):
|
||||
prompt = " ".join(t.split())[:160]
|
||||
except Exception:
|
||||
pass
|
||||
return {"cwd": cwd, "prompt": prompt, "ts": ts}
|
||||
|
||||
|
||||
def scan(force: bool = False) -> list[dict]:
|
||||
if not force and time.time() - _cache["t"] < 60 and _cache["sessions"]:
|
||||
return _cache["sessions"]
|
||||
out = []
|
||||
for root in _roots():
|
||||
for pdir in (root / "projects").iterdir():
|
||||
if not pdir.is_dir():
|
||||
continue
|
||||
for f in pdir.glob("*.jsonl"):
|
||||
try:
|
||||
st = f.stat()
|
||||
except OSError:
|
||||
continue
|
||||
if st.st_size < 200:
|
||||
continue
|
||||
h = _head(f)
|
||||
out.append({"id": f.stem, "file": f, "root": root.name, "cwd": h["cwd"] or _decode_dir(pdir.name), "prompt": h["prompt"] or "(senza prompt)",
|
||||
"start": h["ts"], "mtime": st.st_mtime, "size": st.st_size})
|
||||
out.sort(key=lambda s: -s["mtime"])
|
||||
_cache.update(t=time.time(), sessions=out)
|
||||
return out
|
||||
|
||||
|
||||
def _projects(sessions: list[dict]) -> list[dict]:
|
||||
by: dict[str, dict] = {}
|
||||
for s in sessions:
|
||||
p = by.setdefault(s["cwd"], {"path": s["cwd"], "name": Path(s["cwd"]).name or s["cwd"], "n": 0, "last": 0, "size": 0, "roots": set(), "exists": Path(s["cwd"]).is_dir()})
|
||||
p["n"] += 1; p["last"] = max(p["last"], s["mtime"]); p["size"] += s["size"]; p["roots"].add(s["root"])
|
||||
return sorted(by.values(), key=lambda p: -p["last"])
|
||||
|
||||
|
||||
def _norm(t: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", (t or "").lower())
|
||||
|
||||
|
||||
def _fmt_dt(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts).strftime("%d/%m %H:%M")
|
||||
|
||||
|
||||
def _find_project(nome: str, sessions: list[dict]) -> dict | None:
|
||||
n = _norm(nome)
|
||||
if not n:
|
||||
return None
|
||||
projs = _projects(sessions)
|
||||
for p in projs:
|
||||
if _norm(p["name"]) == n or _norm(p["path"]) == n:
|
||||
return p
|
||||
for p in projs:
|
||||
if n in _norm(p["name"]) or n in _norm(p["path"]):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _find_session(rif: str, progetto: str, sessions: list[dict]) -> dict | None:
|
||||
rif = (rif or "").strip().lower()
|
||||
pool = sessions
|
||||
if progetto:
|
||||
p = _find_project(progetto, sessions)
|
||||
if p:
|
||||
pool = [s for s in sessions if s["cwd"] == p["path"]]
|
||||
if not rif or rif in ("ultima", "recente", "last"):
|
||||
return pool[0] if pool else None
|
||||
for s in pool:
|
||||
if s["id"].lower() == rif or s["id"].lower().startswith(rif):
|
||||
return s
|
||||
for s in pool:
|
||||
if rif in s["prompt"].lower():
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def progetti(params: dict, ctx: dict) -> str:
|
||||
sessions = scan(force=True)
|
||||
filtro = _norm(str(params.get("filtro") or ""))
|
||||
projs = [p for p in _projects(sessions) if not filtro or filtro in _norm(p["name"]) or filtro in _norm(p["path"])]
|
||||
if not projs:
|
||||
return "Nessun progetto Claude Code trovato."
|
||||
lim = max(3, min(int(params.get("limite") or 20), 60))
|
||||
lines = [f"- {p['name']} — {p['n']} sessioni, ultima {_fmt_dt(p['last'])}, {p['size'] / 1e6:.1f} MB{'' if p['exists'] else ' (cartella non più presente)'} [{p['path']}]" for p in projs[:lim]]
|
||||
return f"Progetti Claude Code ({len(projs)}, {len(sessions)} sessioni in {len(_roots())} profili):\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def sessioni(params: dict, ctx: dict) -> str:
|
||||
sessions = scan()
|
||||
nome = str(params.get("progetto") or "")
|
||||
p = _find_project(nome, sessions) if nome else None
|
||||
if nome and not p:
|
||||
return f"Progetto '{nome}' non trovato. Usa claude_progetti per l'elenco."
|
||||
pool = [s for s in sessions if not p or s["cwd"] == p["path"]]
|
||||
lim = max(3, min(int(params.get("numero") or 10), 40))
|
||||
lines = [f"- {s['id'][:8]} · {_fmt_dt(s['mtime'])} · {s['size'] / 1e3:.0f} KB · {s['prompt'][:90]}" + ("" if p else f" [{Path(s['cwd']).name}]") for s in pool[:lim]]
|
||||
return (f"Sessioni di {p['name']}" if p else "Sessioni recenti") + f" ({len(pool)}):\n" + "\n".join(lines) + "\nRiferisciti a una sessione con le prime cifre dell'id o con 'ultima'."
|
||||
|
||||
|
||||
def _messages(path: Path):
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
try:
|
||||
o = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if o.get("isSidechain") or o.get("type") not in ("user", "assistant"):
|
||||
continue
|
||||
t = _text_of(o.get("message")).strip()
|
||||
if not t or t.startswith(("<command-", "<local-command", "Caveat:")):
|
||||
continue
|
||||
yield o["type"], t, o.get("timestamp", "")
|
||||
|
||||
|
||||
def leggi(params: dict, ctx: dict) -> str:
|
||||
sessions = scan()
|
||||
s = _find_session(str(params.get("sessione") or ""), str(params.get("progetto") or ""), sessions)
|
||||
if not s:
|
||||
return "Sessione non trovata: indica progetto e le prime cifre dell'id (vedi claude_sessioni)."
|
||||
n = max(4, min(int(params.get("numero") or 20), 80))
|
||||
per = max(200, min(int(params.get("caratteri") or 700), 3000))
|
||||
msgs = list(_messages(s["file"]))
|
||||
parte = msgs[:n] if str(params.get("parte") or "fine") == "inizio" else msgs[-n:]
|
||||
out = [f"Sessione {s['id'][:8]} di {Path(s['cwd']).name} ({len(msgs)} messaggi, {'inizio' if parte is msgs[:n] else 'fine'}):"]
|
||||
for role, t, ts in parte:
|
||||
out.append(f"[{'Utente' if role == 'user' else 'Claude'} {ts[11:16] if ts else ''}] {t[:per]}{'…' if len(t) > per else ''}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def cerca(params: dict, ctx: dict) -> str:
|
||||
q = str(params.get("query") or "").strip().lower()
|
||||
if not q:
|
||||
return "Errore: serve il testo da cercare."
|
||||
sessions = scan()
|
||||
p = _find_project(str(params.get("progetto") or ""), sessions) if params.get("progetto") else None
|
||||
hits, checked = [], 0
|
||||
for s in sessions:
|
||||
if p and s["cwd"] != p["path"]:
|
||||
continue
|
||||
if s["size"] > 60_000_000:
|
||||
continue
|
||||
checked += 1
|
||||
try:
|
||||
for role, t, ts in _messages(s["file"]):
|
||||
if q in t.lower():
|
||||
i = t.lower().find(q)
|
||||
hits.append(f"- {Path(s['cwd']).name} · {s['id'][:8]} · {_fmt_dt(s['mtime'])} [{'Utente' if role == 'user' else 'Claude'}]: …{' '.join(t[max(0, i - 80):i + 120].split())}…")
|
||||
if len([h for h in hits if s['id'][:8] in h]) >= 2:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if len(hits) >= 25:
|
||||
break
|
||||
if not hits:
|
||||
return f"Nessun risultato per '{q}' in {checked} sessioni."
|
||||
return f"Risultati per '{q}' ({len(hits)}):\n" + "\n".join(hits)
|
||||
|
||||
|
||||
def _terminal(cmd: str) -> None:
|
||||
script = f'tell application "Terminal"\nactivate\ndo script {json.dumps(cmd)}\nend tell'
|
||||
subprocess.run(["osascript", "-e", script], capture_output=True, timeout=20)
|
||||
|
||||
|
||||
def riprendi(params: dict, ctx: dict) -> str:
|
||||
sessions = scan()
|
||||
s = _find_session(str(params.get("sessione") or ""), str(params.get("progetto") or ""), sessions)
|
||||
if not s:
|
||||
return "Sessione non trovata."
|
||||
env = f"CLAUDE_CONFIG_DIR={json.dumps(str(HOME / s['root']))} " if s["root"] != ".claude" else ""
|
||||
_terminal(f"cd {json.dumps(s['cwd'])} && {env}claude --resume {s['id']}")
|
||||
return f"Aperto il Terminale nel progetto {Path(s['cwd']).name} con la sessione {s['id'][:8]} ripresa."
|
||||
|
||||
|
||||
def nuova(params: dict, ctx: dict) -> str:
|
||||
sessions = scan()
|
||||
p = _find_project(str(params.get("progetto") or ""), sessions)
|
||||
path = p["path"] if p else str(Path(str(params.get("progetto") or "")).expanduser())
|
||||
if not Path(path).is_dir():
|
||||
return f"Cartella non trovata: {path}"
|
||||
root = next(iter(sorted(p["roots"])), ".claude") if p else ".claude"
|
||||
env = f"CLAUDE_CONFIG_DIR={json.dumps(str(HOME / root))} " if root != ".claude" else ""
|
||||
_terminal(f"cd {json.dumps(path)} && {env}claude")
|
||||
return f"Aperto il Terminale in {Path(path).name} con una nuova sessione di Claude Code."
|
||||
|
||||
|
||||
def continua(params: dict, ctx: dict) -> str:
|
||||
"""Manda una richiesta a Claude Code dentro una sessione esistente (o nuova) del progetto, senza Terminale."""
|
||||
sessions = scan()
|
||||
richiesta = str(params.get("richiesta") or "").strip()
|
||||
if not richiesta:
|
||||
return "Errore: serve la richiesta da fare a Claude."
|
||||
s = _find_session(str(params.get("sessione") or ""), str(params.get("progetto") or ""), sessions)
|
||||
p = _find_project(str(params.get("progetto") or ""), sessions) if params.get("progetto") else None
|
||||
if not s and not p:
|
||||
return "Indica la sessione (prime cifre dell'id o 'ultima') o il progetto."
|
||||
cwd = s["cwd"] if s else p["path"]
|
||||
root = s["root"] if s else next(iter(sorted(p["roots"])), ".claude")
|
||||
livello = "modifica" if str(params.get("permessi") or "lettura").lower().startswith("mod") else "lettura"
|
||||
binary = resolve_claude("")
|
||||
if not binary:
|
||||
return "Non trovo il comando claude."
|
||||
|
||||
def do() -> str:
|
||||
flags = LIVELLI[livello]
|
||||
args = [binary, "-p", "--output-format", "json", "--tools", flags["tools"], "--allowedTools", ",".join(flags["allowed"])]
|
||||
if flags["mode"]:
|
||||
args += ["--permission-mode", flags["mode"]]
|
||||
args += ["--resume", s["id"]] if s else []
|
||||
player = ctx.get("player")
|
||||
if player is not None and hasattr(player, "set_state"):
|
||||
player.set_state(f"PROCESSING · Claude Code su {Path(cwd).name}")
|
||||
env = claude_env(str(HOME / root) if root != ".claude" else "")
|
||||
try:
|
||||
r = subprocess.run(args, input=richiesta, cwd=cwd, env=env, capture_output=True, text=True, timeout=900)
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Claude Code non ha risposto entro 15 minuti."
|
||||
try:
|
||||
d = json.loads(r.stdout.strip().splitlines()[-1])
|
||||
text = d.get("result") or ""
|
||||
sid = d.get("session_id", "")
|
||||
if d.get("is_error"):
|
||||
return f"Claude Code ha segnalato un errore: {text or r.stderr[-300:]}"
|
||||
except Exception:
|
||||
text, sid = (r.stdout or r.stderr)[-2000:], ""
|
||||
_cache["t"] = 0
|
||||
return f"Risposta di Claude Code ({Path(cwd).name}, sessione {(sid or (s['id'] if s else ''))[:8]}, permessi: {livello}):\n{text}"
|
||||
|
||||
if livello == "modifica":
|
||||
return confirm_or_param(ctx, params, "claude_continua", f"Far modificare il progetto {Path(cwd).name} a Claude Code?", richiesta[:300], do)
|
||||
return do()
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{"name": "claude_progetti", "description": "Elenca i progetti con sessioni di Claude Code (tutti i profili ~/.claude*): numero di sessioni, ultima attività, spazio, percorso. Filtro opzionale per nome.",
|
||||
"parameters": {"type": "object", "properties": {"filtro": {"type": "string"}, "limite": {"type": "integer"}}}, "run": progetti},
|
||||
{"name": "claude_sessioni", "description": "Elenca le sessioni di Claude Code di un progetto (o le più recenti di tutti), con id abbreviato, data, dimensione e prima richiesta.",
|
||||
"parameters": {"type": "object", "properties": {"progetto": {"type": "string"}, "numero": {"type": "integer"}}}, "run": sessioni},
|
||||
{"name": "claude_leggi", "description": "Legge i messaggi di una sessione di Claude Code (utente e Claude), dalla fine o dall'inizio, per riassumerla o riprendere il filo. sessione = prime cifre dell'id oppure 'ultima' (con progetto).",
|
||||
"parameters": {"type": "object", "properties": {"sessione": {"type": "string"}, "progetto": {"type": "string"}, "numero": {"type": "integer"}, "parte": {"type": "string", "enum": ["fine", "inizio"]}, "caratteri": {"type": "integer"}}}, "run": leggi},
|
||||
{"name": "claude_cerca", "description": "Cerca un testo nei messaggi di tutte le sessioni di Claude Code (o di un progetto): utile per ritrovare dove si è parlato di qualcosa.",
|
||||
"parameters": {"type": "object", "properties": {"query": {"type": "string"}, "progetto": {"type": "string"}}, "required": ["query"]}, "run": cerca},
|
||||
{"name": "claude_continua", "description": "Fa lavorare Claude Code su un progetto senza aprire il Terminale: manda la richiesta dentro una sessione esistente (sessione = prime cifre dell'id o 'ultima') o in una nuova sessione del progetto, e riporta la risposta. permessi=lettura (default: legge e cerca nel codice) oppure modifica (può cambiare file ed eseguire comandi: chiede conferma sullo schermo). Può richiedere minuti.",
|
||||
"parameters": {"type": "object", "properties": {"richiesta": {"type": "string"}, "sessione": {"type": "string"}, "progetto": {"type": "string"}, "permessi": {"type": "string", "enum": ["lettura", "modifica"]}, "confermato": CONFERMATO}, "required": ["richiesta"]}, "run": continua},
|
||||
{"name": "claude_riprendi", "description": "Apre il Terminale nel progetto e riprende una sessione di Claude Code (claude --resume) perché l'utente continui a mano.",
|
||||
"parameters": {"type": "object", "properties": {"sessione": {"type": "string"}, "progetto": {"type": "string"}}}, "run": riprendi},
|
||||
{"name": "claude_nuova", "description": "Apre il Terminale in un progetto e avvia una nuova sessione di Claude Code.",
|
||||
"parameters": {"type": "object", "properties": {"progetto": {"type": "string"}}, "required": ["progetto"]}, "run": nuova},
|
||||
]
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Documenti PDF: impagina in A4 un testo in Markdown (titoli, elenchi, grassetto, corsivo, tabelle, immagini),
|
||||
lo salva in data/documenti/ e lo apre in Anteprima. Il file arriva anche al telefono.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from avatar.settings import DATA_DIR # noqa: E402
|
||||
|
||||
OUT_DIR = DATA_DIR / "documenti"
|
||||
FONT_DIR = Path("/System/Library/Fonts/Supplemental")
|
||||
|
||||
|
||||
def _fonts():
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.lib.fonts import addMapping
|
||||
if "Arial" in pdfmetrics.getRegisteredFontNames():
|
||||
return "Arial"
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont("Arial", str(FONT_DIR / "Arial.ttf")))
|
||||
pdfmetrics.registerFont(TTFont("Arial-Bold", str(FONT_DIR / "Arial Bold.ttf")))
|
||||
pdfmetrics.registerFont(TTFont("Arial-Italic", str(FONT_DIR / "Arial Italic.ttf")))
|
||||
pdfmetrics.registerFont(TTFont("Arial-BoldItalic", str(FONT_DIR / "Arial Bold Italic.ttf")))
|
||||
addMapping("Arial", 0, 0, "Arial"); addMapping("Arial", 1, 0, "Arial-Bold")
|
||||
addMapping("Arial", 0, 1, "Arial-Italic"); addMapping("Arial", 1, 1, "Arial-BoldItalic")
|
||||
return "Arial"
|
||||
except Exception:
|
||||
return "Helvetica"
|
||||
|
||||
|
||||
def _inline(text: str) -> str:
|
||||
"""Markdown in linea → mini-HTML di reportlab (grassetto, corsivo, codice, link)."""
|
||||
t = html.escape(text, quote=False)
|
||||
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t)
|
||||
t = re.sub(r"(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)", r"<i>\1</i>", t)
|
||||
t = re.sub(r"`(.+?)`", r"<font face='Courier'>\1</font>", t)
|
||||
t = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)", r"<link href='\2' color='#0645ad'>\1</link>", t)
|
||||
return t
|
||||
|
||||
|
||||
def costruisci(titolo: str, markdown: str, path: Path, autore: str = "", immagini: list[str] | None = None) -> None:
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib import colors
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, ListFlowable, ListItem, Table, TableStyle, Image, PageBreak, HRFlowable
|
||||
font = _fonts()
|
||||
st = {
|
||||
"title": ParagraphStyle("t", fontName=font, fontSize=22, leading=27, spaceAfter=10, textColor=colors.HexColor("#123")),
|
||||
"h1": ParagraphStyle("h1", fontName=font, fontSize=16, leading=20, spaceBefore=12, spaceAfter=6, textColor=colors.HexColor("#123")),
|
||||
"h2": ParagraphStyle("h2", fontName=font, fontSize=13, leading=17, spaceBefore=10, spaceAfter=4),
|
||||
"h3": ParagraphStyle("h3", fontName=font, fontSize=11.5, leading=15, spaceBefore=8, spaceAfter=3),
|
||||
"p": ParagraphStyle("p", fontName=font, fontSize=10.5, leading=15, spaceAfter=6),
|
||||
"quote": ParagraphStyle("q", fontName=font, fontSize=10.5, leading=15, leftIndent=14, textColor=colors.HexColor("#444"), spaceAfter=6),
|
||||
"small": ParagraphStyle("s", fontName=font, fontSize=8.5, leading=11, textColor=colors.HexColor("#777")),
|
||||
"code": ParagraphStyle("c", fontName="Courier", fontSize=9, leading=12, backColor=colors.HexColor("#f4f4f4"), leftIndent=6, spaceAfter=6),
|
||||
}
|
||||
story = []
|
||||
if titolo:
|
||||
story += [Paragraph(_inline(titolo), st["title"]), HRFlowable(width="100%", color=colors.HexColor("#123"), thickness=0.8, spaceAfter=8)]
|
||||
if autore:
|
||||
story.append(Paragraph(html.escape(autore), st["small"])); story.append(Spacer(1, 6))
|
||||
lines = (markdown or "").replace("\r", "").split("\n")
|
||||
i, para, lst, table, code = 0, [], None, None, None
|
||||
|
||||
def flush_para():
|
||||
nonlocal para
|
||||
if para:
|
||||
story.append(Paragraph(_inline(" ".join(para)), st["p"])); para = []
|
||||
|
||||
def flush_list():
|
||||
nonlocal lst
|
||||
if lst:
|
||||
kind, items = lst
|
||||
story.append(ListFlowable([ListItem(Paragraph(_inline(x), st["p"]), leftIndent=12) for x in items],
|
||||
bulletType="1" if kind == "ol" else "bullet", start="1" if kind == "ol" else None, bulletFormat="%s." if kind == "ol" else None, leftIndent=14, bulletFontName=font, bulletFontSize=9))
|
||||
lst = None
|
||||
|
||||
def flush_table():
|
||||
nonlocal table
|
||||
if table:
|
||||
rows = [[Paragraph(_inline(c), st["p"]) for c in r] for r in table]
|
||||
t = Table(rows, hAlign="LEFT", repeatRows=1)
|
||||
t.setStyle(TableStyle([("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#999")), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e8eef3")),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"), ("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5)]))
|
||||
story.append(t); story.append(Spacer(1, 8)); table = None
|
||||
|
||||
while i < len(lines):
|
||||
ln = lines[i].rstrip()
|
||||
if code is not None:
|
||||
if ln.strip().startswith("```"):
|
||||
story.append(Paragraph(html.escape("\n".join(code)).replace("\n", "<br/>"), st["code"])); code = None
|
||||
else:
|
||||
code.append(ln)
|
||||
i += 1; continue
|
||||
if ln.strip().startswith("```"):
|
||||
flush_para(); flush_list(); flush_table(); code = []; i += 1; continue
|
||||
if not ln.strip():
|
||||
flush_para(); flush_list(); flush_table(); i += 1; continue
|
||||
if ln.strip() in ("---", "***", "___"):
|
||||
flush_para(); flush_list(); flush_table(); story.append(HRFlowable(width="100%", color=colors.HexColor("#bbb"), spaceBefore=4, spaceAfter=8)); i += 1; continue
|
||||
if ln.strip().lower() in ("\\pagebreak", "<pagebreak>", "[pagina]"):
|
||||
flush_para(); flush_list(); flush_table(); story.append(PageBreak()); i += 1; continue
|
||||
m = re.match(r"^(#{1,3})\s+(.*)$", ln)
|
||||
if m:
|
||||
flush_para(); flush_list(); flush_table()
|
||||
story.append(Paragraph(_inline(m.group(2)), st[f"h{len(m.group(1))}"])); i += 1; continue
|
||||
m = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)", ln.strip())
|
||||
if m:
|
||||
flush_para(); flush_list(); flush_table()
|
||||
p = Path(m.group(2)).expanduser()
|
||||
if p.exists():
|
||||
img = Image(str(p)); ratio = img.imageHeight / img.imageWidth; w = min(160 * mm, img.imageWidth * 0.264 * mm)
|
||||
img.drawWidth, img.drawHeight = w, w * ratio; story.append(img)
|
||||
if m.group(1):
|
||||
story.append(Paragraph(html.escape(m.group(1)), st["small"]))
|
||||
story.append(Spacer(1, 8))
|
||||
i += 1; continue
|
||||
if ln.strip().startswith("|"):
|
||||
flush_para(); flush_list()
|
||||
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
|
||||
if not all(re.fullmatch(r":?-{2,}:?", c) for c in cells):
|
||||
table = (table or []) + [cells]
|
||||
i += 1; continue
|
||||
m = re.match(r"^\s*([-*•]|\d+[.)])\s+(.*)$", ln)
|
||||
if m:
|
||||
flush_para(); flush_table()
|
||||
kind = "ol" if m.group(1)[0].isdigit() else "ul"
|
||||
if lst and lst[0] != kind:
|
||||
flush_list()
|
||||
lst = (kind, (lst[1] if lst else []) + [m.group(2)]); i += 1; continue
|
||||
if ln.startswith(">"):
|
||||
flush_para(); flush_list(); flush_table()
|
||||
story.append(Paragraph(_inline(ln.lstrip("> ")), st["quote"])); i += 1; continue
|
||||
flush_list(); flush_table(); para.append(ln.strip()); i += 1
|
||||
flush_para(); flush_list(); flush_table()
|
||||
for p in immagini or []:
|
||||
pp = Path(p).expanduser()
|
||||
if pp.exists():
|
||||
img = Image(str(pp)); ratio = img.imageHeight / img.imageWidth; w = min(160 * mm, img.imageWidth * 0.264 * mm)
|
||||
img.drawWidth, img.drawHeight = w, w * ratio; story += [Spacer(1, 8), img]
|
||||
|
||||
def footer(canvas, doc):
|
||||
canvas.saveState(); canvas.setFont(font, 8); canvas.setFillColor(colors.HexColor("#888"))
|
||||
canvas.drawRightString(A4[0] - 20 * mm, 12 * mm, f"{doc.page}")
|
||||
if titolo:
|
||||
canvas.drawString(20 * mm, 12 * mm, titolo[:80])
|
||||
canvas.restoreState()
|
||||
|
||||
doc = SimpleDocTemplate(str(path), pagesize=A4, leftMargin=20 * mm, rightMargin=20 * mm, topMargin=20 * mm, bottomMargin=20 * mm,
|
||||
title=titolo or path.stem, author=autore or "LuZa")
|
||||
doc.build(story or [Paragraph("(documento vuoto)", st["p"])], onFirstPage=footer, onLaterPages=footer)
|
||||
|
||||
|
||||
def crea(params: dict, ctx: dict) -> str:
|
||||
titolo = str(params.get("titolo") or "").strip()
|
||||
contenuto = str(params.get("contenuto") or "").strip()
|
||||
if not contenuto and not titolo:
|
||||
return "Errore: serve il contenuto del documento (in Markdown)."
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
nome = re.sub(r"[^a-z0-9]+", "-", str(params.get("nome_file") or titolo or "documento").lower()).strip("-")[:60] or "documento"
|
||||
path = OUT_DIR / f"{time.strftime('%Y%m%d-%H%M%S')}-{nome}.pdf"
|
||||
immagini = params.get("immagini") or []
|
||||
if isinstance(immagini, str):
|
||||
immagini = [immagini]
|
||||
try:
|
||||
costruisci(titolo, contenuto, path, str(params.get("autore") or ""), [str(x) for x in immagini])
|
||||
except Exception as err:
|
||||
return f"Impaginazione fallita: {err}"
|
||||
if params.get("mostra", True) not in (False, "false", "no"):
|
||||
subprocess.Popen(["open", str(path)])
|
||||
pagine = ""
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
pagine = f", {len(PdfReader(str(path)).pages)} pagine"
|
||||
except Exception:
|
||||
pass
|
||||
return f"PDF creato: {path}{pagine}. Aperto in Anteprima.\nFILE: {path}"
|
||||
|
||||
|
||||
def ultimi(params: dict, ctx: dict) -> str:
|
||||
files = sorted(OUT_DIR.glob("*.pdf"), key=lambda p: p.stat().st_mtime, reverse=True) if OUT_DIR.exists() else []
|
||||
if not files:
|
||||
return "Nessun documento creato finora."
|
||||
if str(params.get("apri", "")).lower() in ("true", "1", "sì", "si"):
|
||||
subprocess.Popen(["open", str(files[0])])
|
||||
n = max(1, min(int(params.get("numero") or 5), 20))
|
||||
return "Ultimi documenti:\n" + "\n".join(f"- {p.name}" for p in files[:n]) + f"\nFILE: {files[0]}"
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{"name": "pdf_crea", "description": "Crea un documento PDF in A4 da un testo in Markdown: usa # ## ### per i titoli, - o 1. per gli elenchi, **grassetto**, *corsivo*, tabelle con |,  per le immagini, --- per una riga, [pagina] per il salto pagina. Scrivi tu il contenuto completo e ben strutturato (lettere, relazioni, elenchi, ricette, verbali…). Il PDF viene salvato, aperto in Anteprima e inviato al telefono.",
|
||||
"parameters": {"type": "object", "properties": {"titolo": {"type": "string"}, "contenuto": {"type": "string", "description": "testo in Markdown"}, "nome_file": {"type": "string"}, "autore": {"type": "string"},
|
||||
"immagini": {"type": "array", "items": {"type": "string"}, "description": "percorsi di immagini da accodare"}, "mostra": {"type": "boolean"}}, "required": ["contenuto"]}, "run": crea},
|
||||
{"name": "pdf_ultimi", "description": "Elenca gli ultimi PDF creati (cartella data/documenti); con apri=true apre il più recente.",
|
||||
"parameters": {"type": "object", "properties": {"numero": {"type": "integer"}, "apri": {"type": "boolean"}}}, "run": ultimi},
|
||||
]
|
||||
+1
-1
@@ -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},
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Maestro di violino: ascolta dal microfono, misura intonazione, ritmo, dinamica e pulizia del suono e riferisce
|
||||
i dati al modello, che li trasforma in consigli. Analisi con librosa (pYIN), registrazioni salvate in data/violino/.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from avatar.settings import DATA_DIR # noqa: E402
|
||||
|
||||
SR = 44100
|
||||
OUT_DIR = DATA_DIR / "violino"
|
||||
CORDE = {"Sol": 196.00, "Re": 293.66, "La": 440.00, "Mi": 659.25}
|
||||
NOTE_IT = ["Do", "Do#", "Re", "Re#", "Mi", "Fa", "Fa#", "Sol", "Sol#", "La", "La#", "Si"]
|
||||
NOTE_IDX = {"do": 0, "re": 2, "mi": 4, "fa": 5, "sol": 7, "la": 9, "si": 11}
|
||||
SCALE_MAJ = [0, 2, 4, 5, 7, 9, 11]
|
||||
SCALE_MIN = [0, 2, 3, 5, 7, 8, 10]
|
||||
|
||||
|
||||
def _device():
|
||||
"""Stesso microfono dell'assistente; vuoto = predefinito di sistema."""
|
||||
try:
|
||||
from memory import config_manager as cm
|
||||
d = cm.get_input_device()
|
||||
return d if d not in ("", None, "default") else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _progress(ctx: dict, text: str) -> None:
|
||||
p = ctx.get("player")
|
||||
if p is not None and hasattr(p, "set_state"):
|
||||
try:
|
||||
p.set_state(f"PROCESSING · {text}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def registra(secondi: float, ctx: dict, attesa: float = 3.0) -> np.ndarray:
|
||||
"""Registra dal microfono; prima un breve conto alla rovescia (LuZa sta ancora parlando)."""
|
||||
import sounddevice as sd
|
||||
for i in range(int(attesa), 0, -1):
|
||||
_progress(ctx, f"preparati · {i}")
|
||||
time.sleep(1)
|
||||
buf = sd.rec(int(secondi * SR), samplerate=SR, channels=1, dtype="float32", device=_device())
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < secondi:
|
||||
_progress(ctx, f"ascolto il violino · {int(secondi - (time.time() - t0))} s")
|
||||
time.sleep(0.5)
|
||||
sd.wait()
|
||||
_progress(ctx, "analizzo")
|
||||
return buf[:, 0].copy()
|
||||
|
||||
|
||||
def _nome(midi: int) -> str:
|
||||
return f"{NOTE_IT[midi % 12]}{midi // 12 - 1}"
|
||||
|
||||
|
||||
def _scala(testo: str) -> set[int] | None:
|
||||
t = (testo or "").lower().strip()
|
||||
if not t:
|
||||
return None
|
||||
parts = t.replace("#", " #").replace("b", " b").split()
|
||||
if not parts or parts[0] not in NOTE_IDX:
|
||||
return None
|
||||
root = NOTE_IDX[parts[0]]
|
||||
if "#" in parts[1:2]:
|
||||
root += 1
|
||||
elif "b" in parts[1:2]:
|
||||
root -= 1
|
||||
minor = "min" in t
|
||||
return {(root + x) % 12 for x in (SCALE_MIN if minor else SCALE_MAJ)}
|
||||
|
||||
|
||||
def analizza(y: np.ndarray, sr: int = SR, a4: float = 440.0, scala: str = "", fmin: float = 180.0, fmax: float = 2800.0) -> dict:
|
||||
import librosa
|
||||
y = y.astype(np.float32)
|
||||
hop = 512
|
||||
rms = librosa.feature.rms(y=y, frame_length=2048, hop_length=hop)[0]
|
||||
db = 20 * np.log10(np.maximum(rms, 1e-6))
|
||||
if db.max() < -45:
|
||||
return {"silenzio": True}
|
||||
f0, voiced, prob = librosa.pyin(y, fmin=fmin, fmax=fmax, sr=sr, frame_length=2048, hop_length=hop)
|
||||
flat = librosa.feature.spectral_flatness(y=y, n_fft=2048, hop_length=hop)[0]
|
||||
n = min(len(f0), len(db), len(flat))
|
||||
f0, voiced, db, flat = f0[:n], voiced[:n], db[:n], flat[:n]
|
||||
from scipy.signal import medfilt
|
||||
ok = voiced & np.isfinite(f0) & (prob >= 0.6) & (db >= db.max() - 32)
|
||||
midi = np.where(ok, 69 + 12 * np.log2(np.maximum(np.nan_to_num(f0, nan=1.0), 1) / a4), np.nan)
|
||||
# stabilizza il contorno: mediana su 5 frame, poi segmentazione con isteresi (3 frame fuori dal ±60 cent per cambiare nota)
|
||||
sm = midi.copy()
|
||||
fin = np.isfinite(midi)
|
||||
if fin.sum() > 5:
|
||||
filled = np.interp(np.arange(n), np.flatnonzero(fin), midi[fin])
|
||||
sm = np.where(fin, medfilt(filled, 5), np.nan)
|
||||
segs, cur, gap, out_cnt = [], [], 0, 0
|
||||
for i in range(n):
|
||||
if np.isnan(sm[i]):
|
||||
gap += 1
|
||||
if gap >= 5 and cur:
|
||||
segs.append(cur); cur = []
|
||||
continue
|
||||
gap = 0
|
||||
if cur and abs(sm[i] - np.nanmedian(sm[cur[-10:]])) * 100 > 60:
|
||||
out_cnt += 1
|
||||
if out_cnt >= 3:
|
||||
segs.append(cur[:-2] if len(cur) > 2 else cur); cur = cur[-2:] + [i]; out_cnt = 0
|
||||
continue
|
||||
else:
|
||||
out_cnt = 0
|
||||
cur.append(i)
|
||||
if cur:
|
||||
segs.append(cur)
|
||||
# note: scarta i frammenti brevi, unisci segmenti contigui con la stessa nota
|
||||
notes = []
|
||||
for idx in segs:
|
||||
if len(idx) < 8:
|
||||
continue
|
||||
idx = np.array(idx)
|
||||
m = float(np.nanmedian(sm[idx]))
|
||||
cents_arr = (sm[idx] - round(m)) * 100
|
||||
note = {"start": idx[0] * hop / sr, "dur": len(idx) * hop / sr, "midi": int(round(m)), "midi_f": m, "cents": float(np.nanmedian(cents_arr)),
|
||||
"vib": float(np.nanstd(cents_arr)), "db": float(np.mean(db[idx])), "flat": float(np.mean(flat[idx]))}
|
||||
# stessa nota ripetuta: resta separata se tra le due il volume cala (nuovo attacco d'arco o di voce)
|
||||
gap_lo = int((notes[-1]["start"] + notes[-1]["dur"]) * sr / hop) if notes else 0
|
||||
dip = float(notes[-1]["db"] - db[gap_lo:idx[0] + 1].min()) if notes and idx[0] > gap_lo else 0.0
|
||||
if notes and notes[-1]["midi"] == note["midi"] and note["start"] - (notes[-1]["start"] + notes[-1]["dur"]) < 0.12 and dip < 8:
|
||||
prev = notes[-1]; w1, w2 = prev["dur"], note["dur"]
|
||||
prev["cents"] = (prev["cents"] * w1 + note["cents"] * w2) / (w1 + w2)
|
||||
prev["dur"] = note["start"] + note["dur"] - prev["start"]
|
||||
prev["vib"] = max(prev["vib"], note["vib"]); prev["flat"] = (prev["flat"] * w1 + note["flat"] * w2) / (w1 + w2)
|
||||
continue
|
||||
notes.append(note)
|
||||
if not notes:
|
||||
return {"silenzio": False, "note": [], "msg": "Ho sentito suono ma nessuna nota stabile: forse rumore o troppe corde insieme."}
|
||||
cents = np.array([x["cents"] for x in notes])
|
||||
durs = np.array([x["dur"] for x in notes])
|
||||
onsets = np.array([x["start"] for x in notes])
|
||||
ioi = np.diff(onsets) if len(onsets) > 2 else np.array([])
|
||||
sc = _scala(scala)
|
||||
fuori = [x for x in notes if sc is not None and x["midi"] % 12 not in sc]
|
||||
rep = {
|
||||
"silenzio": False, "n_note": len(notes), "durata": float(len(y) / sr),
|
||||
"intonazione_media_abs": float(np.mean(np.abs(cents))), "tendenza": float(np.mean(cents)),
|
||||
"note_intonate_pct": float(np.mean(np.abs(cents) <= 15) * 100),
|
||||
"peggiori": sorted(notes, key=lambda x: -abs(x["cents"]))[:3],
|
||||
"vibrato_note": int(sum(1 for x in notes if x["vib"] > 15 and x["dur"] > 0.35)),
|
||||
"ritmo_cv": float(np.std(ioi) / np.mean(ioi)) if len(ioi) >= 3 else None,
|
||||
"dinamica_range_db": float(np.max([x["db"] for x in notes]) - np.min([x["db"] for x in notes])),
|
||||
"graffio_pct": float(np.mean([x["flat"] > 0.25 for x in notes]) * 100),
|
||||
"note_corte_pct": float(np.mean(durs < 0.15) * 100),
|
||||
"fuori_scala": fuori, "scala": scala, "note": notes,
|
||||
}
|
||||
return rep
|
||||
|
||||
|
||||
def _testo(rep: dict) -> str:
|
||||
if rep.get("silenzio"):
|
||||
return "Non ho sentito il violino: il microfono ha registrato quasi silenzio. Controlla la distanza (ideale 1-2 metri) e riprova."
|
||||
if not rep.get("note"):
|
||||
return rep.get("msg", "Nessuna nota rilevata.")
|
||||
seq = " ".join(f"{_nome(x['midi'])}({x['cents']:+.0f})" for x in rep["note"][:40])
|
||||
out = [f"Registrazione di {rep['durata']:.0f} s, {rep['n_note']} note rilevate (nome e scarto in cent, + = crescente, - = calante):",
|
||||
seq + (" …" if rep["n_note"] > 40 else ""),
|
||||
f"Intonazione: scarto medio {rep['intonazione_media_abs']:.0f} cent, {rep['note_intonate_pct']:.0f}% delle note entro 15 cent, tendenza {'crescente' if rep['tendenza'] > 5 else 'calante' if rep['tendenza'] < -5 else 'centrata'} ({rep['tendenza']:+.0f} cent).",
|
||||
"Note più stonate: " + ", ".join(f"{_nome(x['midi'])} {x['cents']:+.0f} cent a {x['start']:.1f} s" for x in rep["peggiori"]) + "."]
|
||||
if rep["ritmo_cv"] is not None:
|
||||
out.append(f"Ritmo: regolarità {'buona' if rep['ritmo_cv'] < 0.25 else 'discreta' if rep['ritmo_cv'] < 0.45 else 'instabile'} (variazione {rep['ritmo_cv'] * 100:.0f}% tra una nota e l'altra).")
|
||||
rng = rep["dinamica_range_db"]
|
||||
giudizio = "molto uniforme" if rng < 6 else "espressiva" if rng < 18 else "sbalzi forti: pressione dell'arco irregolare?"
|
||||
out.append(f"Dinamica: escursione {rng:.0f} dB ({giudizio}).")
|
||||
out.append(f"Suono: {rep['graffio_pct']:.0f}% delle note con rumore d'arco evidente (graffio o arco troppo leggero/veloce); note troppo brevi {rep['note_corte_pct']:.0f}%; vibrato su {rep['vibrato_note']} note.")
|
||||
if rep["scala"]:
|
||||
if rep["fuori_scala"]:
|
||||
out.append(f"Scala {rep['scala']}: note fuori scala: " + ", ".join(f"{_nome(x['midi'])} a {x['start']:.1f} s" for x in rep["fuori_scala"][:6]) + " (dita in posizione sbagliata?).")
|
||||
else:
|
||||
out.append(f"Scala {rep['scala']}: tutte le note appartengono alla scala.")
|
||||
out.append("Interpreta questi dati per l'allieva: complimenti sinceri prima, poi al massimo due consigli concreti.")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def ascolta(params: dict, ctx: dict) -> str:
|
||||
secondi = max(5, min(int(params.get("secondi") or 15), 60))
|
||||
a4 = float(params.get("nota_la") or 440)
|
||||
y = registra(secondi, ctx)
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = OUT_DIR / f"{time.strftime('%Y%m%d-%H%M%S')}.wav"
|
||||
try:
|
||||
import soundfile as sf
|
||||
sf.write(str(path), y, SR)
|
||||
except Exception:
|
||||
pass
|
||||
rep = analizza(y, SR, a4, str(params.get("scala") or ""))
|
||||
return _testo(rep) + (f"\nRegistrazione salvata: {path.name}" if path.exists() else "")
|
||||
|
||||
|
||||
def accorda(params: dict, ctx: dict) -> str:
|
||||
import librosa
|
||||
y = registra(max(3, min(int(params.get("secondi") or 4), 10)), ctx, attesa=2)
|
||||
rms = float(np.sqrt(np.mean(y ** 2)))
|
||||
if 20 * math.log10(max(rms, 1e-6)) < -45:
|
||||
return "Non sento la corda: suonala a vuoto, con l'arco, vicino al microfono."
|
||||
f0, voiced, _ = librosa.pyin(y, fmin=150.0, fmax=1200.0, sr=SR)
|
||||
f = float(np.nanmedian(f0[voiced])) if np.any(voiced) else 0.0
|
||||
if not f:
|
||||
return "Non riesco a isolare la nota: suona una corda sola, a vuoto."
|
||||
a4 = float(params.get("nota_la") or 440)
|
||||
nome, target = min(CORDE.items(), key=lambda kv: abs(math.log2(f / (kv[1] * a4 / 440))))
|
||||
target *= a4 / 440
|
||||
cents = 1200 * math.log2(f / target)
|
||||
verdetto = "intonata" if abs(cents) <= 5 else ("crescente: allenta un po'" if cents > 0 else "calante: tira un po'")
|
||||
return f"Corda {nome}: {f:.1f} Hz, {cents:+.0f} cent rispetto a {target:.1f} Hz → {verdetto}. (Oltre ±30 cent usa i piroli, altrimenti i tiracantini.)"
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{"name": "violino_ascolta", "description": "Ascolta il violino dal microfono per alcuni secondi (default 15, max 60) e misura intonazione di ogni nota in cent, tendenza crescente/calante, regolarità del ritmo, dinamica, rumore d'arco e vibrato. Con scala (es. 'Sol maggiore', 'Re minore') segnala le note fuori scala. Prima di chiamarlo di' all'allieva che stai per ascoltare: ci sono 3 secondi di preparazione. Restituisce dati da trasformare in consigli.",
|
||||
"parameters": {"type": "object", "properties": {"secondi": {"type": "integer"}, "scala": {"type": "string"}, "nota_la": {"type": "number", "description": "frequenza del La di riferimento, 440 o 442"}}}, "run": ascolta},
|
||||
{"name": "violino_accorda", "description": "Accordatore: ascolta una corda a vuoto (Sol, Re, La, Mi) e dice di quanti cent è crescente o calante e come correggere.",
|
||||
"parameters": {"type": "object", "properties": {"secondi": {"type": "integer"}, "nota_la": {"type": "number"}}}, "run": accorda},
|
||||
]
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
// LuZa remoto: WebSocket per stato/registro/audio, microfono via MediaRecorder, audio via AudioContext.
|
||||
const q = new URLSearchParams(location.search);
|
||||
if (q.get('k')) { try { localStorage.setItem('luza_key', q.get('k')); } catch (e) {} history.replaceState(null, '', location.pathname); }
|
||||
let KEY = ''; try { KEY = localStorage.getItem('luza_key') || ''; } catch (e) {}
|
||||
const $ = id => document.getElementById(id);
|
||||
let ws, actx, playing = false, queue = [], rec, chunks = [], recording = false, currentAva = null;
|
||||
|
||||
async function savePair() {
|
||||
const v = $('key').value.trim();
|
||||
if (!v) return;
|
||||
if (/^\d{6}$/.test(v)) { // PIN mostrato da Remote Control: lo scambio con la chiave
|
||||
try {
|
||||
const r = await fetch(`/pair?pin=${v}`, {cache: 'no-store'}); const j = await r.json();
|
||||
if (!r.ok || !j.token) { addMsg(j.error || 'PIN rifiutato', 'err'); return; }
|
||||
KEY = j.token;
|
||||
} catch (e) { addMsg('rete: ' + e.message, 'err'); return; }
|
||||
} else KEY = v;
|
||||
try { localStorage.setItem('luza_key', KEY); } catch (e) {}
|
||||
$('key').value = ''; connect();
|
||||
}
|
||||
function setConn(t, ok) {
|
||||
if (ok) document.querySelectorAll('#log .m.err').forEach(e => e.remove()); $('conn').textContent = t + (mode === 'sse' ? ' · eventi' : mode === 'poll' ? ' · interrogazione' : ''); $('conn').style.color = ok ? '#00ff88' : '#ff3366'; }
|
||||
const HUD = {state: 'LISTENING', level: 0, rec: false};
|
||||
function setState(s) {
|
||||
const base = s.split(' ')[0], detail = s.includes(' · ') ? s.split(' · ').slice(1).join(' · ') : '';
|
||||
HUD.state = base; updateStop();
|
||||
$('stbase').textContent = base; $('stdetail').textContent = detail ? ' · ' + detail : ({LISTENING: ' · in ascolto', THINKING: ' · penso', PROCESSING: ' · elaboro', SPEAKING: ' · parlo', SLEEPING: ' · a riposo'}[base] || '');
|
||||
}
|
||||
function addMsg(text, cls) {
|
||||
const d = document.createElement('div'); d.className = 'm ' + cls; d.textContent = text; $('log').appendChild(d);
|
||||
while ($('log').children.length > 200) $('log').removeChild($('log').firstChild);
|
||||
$('log').scrollTop = $('log').scrollHeight; return d;
|
||||
}
|
||||
function onLog(line) {
|
||||
const m = line.match(/^(You(?: \(telefono\))?|SYS|ERR|FILE|[^:]{1,24}): ?([\s\S]*)$/);
|
||||
if (!m) { addMsg(line, 'sys'); return; }
|
||||
const who = m[1], txt = m[2];
|
||||
if (who.startsWith('You')) addMsg(txt, 'you');
|
||||
else if (who === 'SYS' || who === 'FILE') addMsg(txt, 'sys');
|
||||
else if (who === 'ERR') addMsg(txt, 'err');
|
||||
else addMsg(txt, 'ava');
|
||||
}
|
||||
let wsFailures = 0, es = null, mode = 'ws';
|
||||
function handle(d) {
|
||||
if (d.type === 'state') setState(d.state);
|
||||
else if (d.type === 'log') onLog(d.text);
|
||||
else if (d.type === 'audio') enqueueAudio(d.wav);
|
||||
else if (d.type === 'confirm') { $('ct').textContent = d.title; $('cd').textContent = d.detail || ''; $('confirm').style.display = 'block'; }
|
||||
else if (d.type === 'confirm_hide') $('confirm').style.display = 'none';
|
||||
else if (d.type === 'image') addImage(d.url, d.name);
|
||||
else if (d.type === 'file') { const m = addMsg('', 'ava file'); const a = document.createElement('a'); a.href = d.url; a.target = '_blank'; a.textContent = '📄 ' + d.name; m.appendChild(a); }
|
||||
}
|
||||
function addImage(url, name) {
|
||||
const d = document.createElement('div'); d.className = 'm ava img';
|
||||
const img = document.createElement('img'); img.src = url; img.alt = name || 'immagine'; img.loading = 'lazy';
|
||||
img.onclick = () => { const v = document.createElement('div'); v.className = 'viewer'; const big = document.createElement('img'); big.src = url; v.appendChild(big); v.onclick = () => v.remove(); document.body.appendChild(v); };
|
||||
d.appendChild(img); $('log').appendChild(d); $('log').scrollTop = $('log').scrollHeight;
|
||||
}
|
||||
function unpair(msg) { KEY = ''; try { localStorage.removeItem('luza_key'); } catch (e) {} $('pair').style.display = 'flex'; setConn(msg || 'chiave rifiutata', false); addMsg('Chiave non valida: inquadra di nuovo il QR dal Mac oppure incolla la chiave.', 'err'); }
|
||||
async function connect() {
|
||||
if (!KEY) { $('pair').style.display = 'flex'; setConn('non abbinato', false); return; }
|
||||
$('pair').style.display = 'none';
|
||||
try { // verifica della chiave con una richiesta breve, prima di aprire i canali
|
||||
const r = await fetch(`/auth?k=${encodeURIComponent(KEY)}`, {cache: 'no-store'});
|
||||
if (r.status === 401) return unpair('chiave rifiutata');
|
||||
} catch (e) { setConn('rete non raggiungibile, riprovo…', false); return setTimeout(connect, 3000); }
|
||||
if (mode === 'poll') return startPoll();
|
||||
if (mode === 'sse') return connectSSE();
|
||||
try { ws && ws.close(); } catch (e) {}
|
||||
let opened = false;
|
||||
ws = new WebSocket(`wss://${location.host}/ws?k=${encodeURIComponent(KEY)}`);
|
||||
ws.onopen = () => { opened = true; wsFailures = 0; setConn('collegato', true); };
|
||||
ws.onclose = ev => {
|
||||
if (!opened) wsFailures++;
|
||||
if (ev.code === 1008) return unpair();
|
||||
if (wsFailures >= 2) { mode = 'sse'; return connectSSE(); } // WebSocket bloccato (app sulla Home di iOS): canale di riserva
|
||||
setConn('scollegato, riprovo…', false); setTimeout(connect, 2000);
|
||||
};
|
||||
ws.onerror = () => {};
|
||||
ws.onmessage = ev => handle(JSON.parse(ev.data));
|
||||
}
|
||||
let sseFailures = 0, CID = Math.random().toString(36).slice(2), polling = false;
|
||||
function connectSSE() {
|
||||
try { es && es.close(); } catch (e) {}
|
||||
es = new EventSource(`/events?k=${encodeURIComponent(KEY)}`);
|
||||
es.onopen = () => { sseFailures = 0; setConn('collegato', true); };
|
||||
es.onmessage = ev => handle(JSON.parse(ev.data));
|
||||
es.onerror = () => {
|
||||
es.close(); sseFailures++;
|
||||
if (sseFailures >= 2) { mode = 'poll'; return startPoll(); } // anche gli eventi sono bloccati: interrogazione ripetuta
|
||||
setConn('scollegato, riprovo…', false); setTimeout(connectSSE, 2500);
|
||||
};
|
||||
}
|
||||
async function startPoll() {
|
||||
if (polling) return; polling = true;
|
||||
setConn('collegato', true);
|
||||
while (mode === 'poll') {
|
||||
try {
|
||||
const r = await fetch(`/poll?k=${encodeURIComponent(KEY)}&cid=${CID}`, {cache: 'no-store'});
|
||||
if (r.status === 401) { polling = false; return unpair(); }
|
||||
const j = await r.json(); (j.events || []).forEach(handle); setConn('collegato', true);
|
||||
} catch (e) { setConn('scollegato, riprovo…', false); await new Promise(res => setTimeout(res, 2500)); }
|
||||
}
|
||||
polling = false;
|
||||
}
|
||||
function send(obj) {
|
||||
if (mode === 'ws' && ws && ws.readyState === 1) { ws.send(JSON.stringify(obj)); return; }
|
||||
fetch(`/cmd?k=${encodeURIComponent(KEY)}`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(obj)})
|
||||
.then(r => { if (r.status === 401) unpair(); })
|
||||
.catch(() => addMsg('non collegato', 'err'));
|
||||
}
|
||||
function sendText() { const t = $('txt').value.trim(); if (!t) return; unlockAudio(); send({type: 'say', text: t}); $('txt').value = ''; onTyping(); }
|
||||
function onTyping() { $('sendb').classList.toggle('on', $('txt').value.trim().length > 0); }
|
||||
function updateStop() { const busy = playing || ['THINKING', 'PROCESSING', 'SPEAKING'].includes(HUD.state); $('stopb').classList.toggle('on', busy); }
|
||||
function doConfirm(ok) { send({type: 'confirm', ok}); $('confirm').style.display = 'none'; }
|
||||
|
||||
// ── audio in uscita: coda di frasi WAV riprodotte in sequenza ─────────────
|
||||
let analyser = null, adata = null, micAnalyser = null, mdata = null;
|
||||
function unlockAudio() {
|
||||
if (!actx) { actx = new (window.AudioContext || window.webkitAudioContext)(); analyser = actx.createAnalyser(); analyser.fftSize = 256; analyser.connect(actx.destination); adata = new Uint8Array(analyser.frequencyBinCount); }
|
||||
if (actx.state === 'suspended') actx.resume();
|
||||
}
|
||||
function enqueueAudio(b64) { queue.push(b64); if (!playing) playNext(); }
|
||||
async function playNext() {
|
||||
if (!queue.length) { playing = false; updateStop(); return; }
|
||||
playing = true; updateStop();
|
||||
try {
|
||||
unlockAudio();
|
||||
const b64 = queue.shift(); const bin = atob(b64); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||
const audio = await actx.decodeAudioData(buf.buffer);
|
||||
const src = actx.createBufferSource(); src.buffer = audio; src.connect(analyser); src.onended = playNext; src.start();
|
||||
} catch (e) { addMsg('audio non riproducibile: tocca lo schermo e riprova', 'err'); playing = false; }
|
||||
}
|
||||
|
||||
// ── microfono: tocca per registrare, tocca di nuovo per inviare ────────────
|
||||
async function toggleMic() {
|
||||
unlockAudio();
|
||||
if (recording) { recording = false; rec.stop(); $('mic').classList.remove('rec'); return; }
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
const mime = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm'].find(m => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || '';
|
||||
try { const msrc = actx.createMediaStreamSource(stream); micAnalyser = actx.createAnalyser(); micAnalyser.fftSize = 256; msrc.connect(micAnalyser); mdata = new Uint8Array(micAnalyser.frequencyBinCount); } catch (e) { micAnalyser = null; }
|
||||
rec = new MediaRecorder(stream, mime ? {mimeType: mime} : undefined); chunks = [];
|
||||
rec.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
|
||||
rec.onstop = async () => {
|
||||
stream.getTracks().forEach(t => t.stop()); micAnalyser = null; HUD.rec = false;
|
||||
const blob = new Blob(chunks, {type: rec.mimeType || 'audio/webm'});
|
||||
$('stbase').textContent = 'PROCESSING'; $('stdetail').textContent = ' · trascrivo';
|
||||
try {
|
||||
const r = await fetch(`/stt?k=${encodeURIComponent(KEY)}&run=1`, {method: 'POST', body: blob, headers: {'Content-Type': blob.type || 'application/octet-stream'}});
|
||||
const j = await r.json();
|
||||
if (!j.text) addMsg(j.error || 'non ho capito, riprova', 'err');
|
||||
} catch (e) { addMsg('invio audio fallito: ' + e, 'err'); }
|
||||
};
|
||||
rec.start(); recording = true; HUD.rec = true; $('mic').classList.add('rec'); $('stbase').textContent = 'REC'; $('stdetail').textContent = ' · registro, tocca di nuovo per inviare';
|
||||
} catch (e) { addMsg('microfono non disponibile: serve HTTPS con certificato accettato (' + e.message + ')', 'err'); }
|
||||
}
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
document.addEventListener('touchstart', unlockAudio, {once: true, passive: true});
|
||||
if (navigator.standalone) addMsg('app sulla Home', 'sys');
|
||||
if (q.get('demo')) { setTimeout(() => { addMsg('Ciao LuZa, che tempo fa oggi a Pescara?', 'you'); addMsg('Lavoro sul Mac (meteo)…', 'sys'); addMsg('Oggi a Pescara è prevalentemente sereno, con una massima di 22 gradi. Domani un po\' di nuvole.', 'ava'); setState('THINKING · genero la risposta · 120 token · 59/s'); HUD.level = 0.55; }, 300); }
|
||||
fetch('/health').then(r => r.ok ? null : addMsg('server risponde ' + r.status, 'err')).catch(e => addMsg('rete: ' + e.message, 'err'));
|
||||
connect();
|
||||
|
||||
// ── impostazioni: motore, modello, ragionamento, voce ─────────────────────
|
||||
const LABELS = {provider: 'Motore', effort: 'Profondità (Claude)', mlx_model: 'Modello interno', mlx_thinking: 'Ragionamento (interno)', claudecode_model: 'Modello Claude Code',
|
||||
tts_engine: 'Voce', kokoro_voice: 'Voce Kokoro', voicebox_profile_id: 'Profilo Voicebox', immagini_famiglia: 'Immagini: famiglia', immagini_modello: 'Immagini: modello'};
|
||||
const SHOW_IF = {mlx_model: v => v.provider === 'mlx', mlx_thinking: v => v.provider === 'mlx', claudecode_model: v => v.provider === 'claudecode', effort: v => v.provider !== 'mlx' && v.provider !== 'local',
|
||||
kokoro_voice: v => v.tts_engine === 'kokoro', voicebox_profile_id: v => v.tts_engine === 'voicebox'};
|
||||
let sdata = null;
|
||||
async function openSettings() {
|
||||
$('settings').style.display = 'block'; $('shint').textContent = 'carico…';
|
||||
try { const r = await fetch(`/settings?k=${encodeURIComponent(KEY)}`, {cache: 'no-store'}); sdata = await r.json(); } catch (e) { $('shint').textContent = 'errore: ' + e.message; return; }
|
||||
renderSettings(); $('shint').textContent = '';
|
||||
}
|
||||
function renderSettings() {
|
||||
const f = $('sform'); f.innerHTML = ''; const v = sdata.values;
|
||||
for (const k of Object.keys(LABELS)) {
|
||||
const opts = sdata.options[k]; if (!opts || (SHOW_IF[k] && !SHOW_IF[k](v))) continue;
|
||||
const lab = document.createElement('label'); lab.textContent = LABELS[k]; f.appendChild(lab);
|
||||
const sel = document.createElement('select'); sel.dataset.key = k;
|
||||
let found = false;
|
||||
for (const [val, name] of opts) { const o = document.createElement('option'); o.value = val; o.textContent = name; if (String(val) === String(v[k] ?? '')) { o.selected = true; found = true; } sel.appendChild(o); }
|
||||
if (!found && v[k]) { const o = document.createElement('option'); o.value = v[k]; o.textContent = v[k] + ' (attuale)'; o.selected = true; sel.appendChild(o); }
|
||||
sel.onchange = () => { v[k] = sel.value; renderSettings(); }; f.appendChild(sel);
|
||||
}
|
||||
}
|
||||
async function saveSettings() {
|
||||
if (!sdata) return; const out = {}; document.querySelectorAll('#sform select').forEach(s => out[s.dataset.key] = s.value);
|
||||
$('shint').textContent = 'applico…';
|
||||
try { const r = await fetch(`/settings?k=${encodeURIComponent(KEY)}`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(out)}); const j = await r.json();
|
||||
$('shint').textContent = j.ok ? 'applicato: il Mac ricarica motore e voce' : ('errore: ' + j.error); } catch (e) { $('shint').textContent = 'errore: ' + e.message; }
|
||||
}
|
||||
|
||||
// ── HUD: nucleo con archi rotanti e forma d'onda ──────────────────────────
|
||||
const COL = {LISTENING: '#00ff88', THINKING: '#ffcc00', PROCESSING: '#ffcc00', SPEAKING: '#ff6b00', SLEEPING: '#3a8a9a', REC: '#ff3366'};
|
||||
const ringC = $('ring'), waveC = $('wave');
|
||||
function fitCanvas(c) { const r = window.devicePixelRatio || 1, w = c.clientWidth, h = c.clientHeight; if (c.width !== w * r || c.height !== h * r) { c.width = w * r; c.height = h * r; } return [c.getContext('2d'), w, h, r]; }
|
||||
function level() {
|
||||
if (HUD.rec && micAnalyser) { micAnalyser.getByteTimeDomainData(mdata); let m = 0; for (const v of mdata) m = Math.max(m, Math.abs(v - 128)); return Math.min(1, m / 60); }
|
||||
if (playing && analyser) { analyser.getByteTimeDomainData(adata); let m = 0; for (const v of adata) m = Math.max(m, Math.abs(v - 128)); return Math.min(1, m / 50); }
|
||||
return 0;
|
||||
}
|
||||
let t0 = performance.now();
|
||||
function drawHUD(now) {
|
||||
const t = (now - t0) / 1000; const lv = level(); HUD.level += (lv - HUD.level) * 0.35;
|
||||
const st = HUD.rec ? 'REC' : (playing ? 'SPEAKING' : HUD.state); const col = COL[st] || '#00d4ff';
|
||||
const busy = st === 'THINKING' || st === 'PROCESSING';
|
||||
// nucleo
|
||||
let [g, w, h, r] = fitCanvas(ringC); g.setTransform(r, 0, 0, r, 0, 0); g.clearRect(0, 0, w, h);
|
||||
const cx = w / 2, cy = h / 2 - 8, R = Math.min(w, h) * 0.34;
|
||||
const speed = busy ? 2.2 : st === 'SPEAKING' ? 1.6 : 0.5;
|
||||
g.lineCap = 'round';
|
||||
const arcs = [[1.0, 0.9, 1, 1.4], [0.86, -0.6, 2, 1.1], [0.72, 1.3, 1, 0.8], [0.58, -1.9, 3, 0.6]];
|
||||
for (const [rr, dir, n, span] of arcs) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a0 = t * speed * dir + i * (Math.PI * 2 / n); g.beginPath(); g.arc(cx, cy, R * rr, a0, a0 + span); g.strokeStyle = col; g.globalAlpha = 0.85 - (1 - rr) * 0.9; g.lineWidth = 2; g.shadowColor = col; g.shadowBlur = 10; g.stroke();
|
||||
}
|
||||
}
|
||||
g.globalAlpha = 1; g.shadowBlur = 0;
|
||||
// tacche
|
||||
g.strokeStyle = 'rgba(0,212,255,.35)'; g.lineWidth = 1;
|
||||
for (let i = 0; i < 60; i++) { const a = i / 60 * Math.PI * 2 + t * 0.15; const l = i % 5 === 0 ? 8 : 4; g.beginPath(); g.moveTo(cx + Math.cos(a) * (R * 1.12), cy + Math.sin(a) * (R * 1.12)); g.lineTo(cx + Math.cos(a) * (R * 1.12 + l), cy + Math.sin(a) * (R * 1.12 + l)); g.stroke(); }
|
||||
// sfera centrale che respira con l'audio
|
||||
const rad = R * 0.42 * (1 + HUD.level * 0.35 + (busy ? Math.sin(t * 6) * 0.04 : Math.sin(t * 1.5) * 0.02));
|
||||
const grad = g.createRadialGradient(cx, cy, rad * 0.1, cx, cy, rad); grad.addColorStop(0, col); grad.addColorStop(0.55, col + '66'); grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
g.fillStyle = grad; g.beginPath(); g.arc(cx, cy, rad, 0, Math.PI * 2); g.fill();
|
||||
g.strokeStyle = col; g.lineWidth = 1.5; g.shadowColor = col; g.shadowBlur = 16; g.beginPath(); g.arc(cx, cy, rad * 0.9, 0, Math.PI * 2); g.stroke(); g.shadowBlur = 0;
|
||||
// forma d'onda
|
||||
[g, w, h, r] = fitCanvas(waveC); g.setTransform(r, 0, 0, r, 0, 0); g.clearRect(0, 0, w, h);
|
||||
const N = 42, bw = w / N;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const env = Math.pow(1 - Math.abs(i - (N - 1) / 2) / ((N - 1) / 2), 0.7);
|
||||
const idle = 2 + 2 * Math.sin(t * 2.2 + i * 0.6);
|
||||
const hh = Math.max(2, Math.min(h - 4, idle + HUD.level * (h - 6) * env * (0.6 + 0.4 * Math.sin(t * 9 + i))));
|
||||
g.fillStyle = HUD.level > 0.05 ? col : 'rgba(0,212,255,.25)'; g.fillRect(i * bw + 1, (h - hh) / 2, bw - 2, hh);
|
||||
}
|
||||
requestAnimationFrame(drawHUD);
|
||||
}
|
||||
requestAnimationFrame(drawHUD);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,104 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||
<meta name="theme-color" content="#02070c">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="LuZa">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/icon-192.png">
|
||||
<title>LuZa</title>
|
||||
<style>
|
||||
:root { --bg:#02070c; --pri:#00d4ff; --pri2:#00ffe1; --dim:#4f7f95; --txt:#d6ecff; --acc:#ffcc00; --spk:#ff6b00; --grn:#00ff88; --red:#ff3366; --glass:rgba(4,20,32,.72); --line:rgba(0,212,255,.22); }
|
||||
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
|
||||
html,body { margin:0; height:100%; background:var(--bg); color:var(--txt); font-family:Menlo,ui-monospace,SFMono-Regular,monospace; overflow:hidden; }
|
||||
body { display:flex; flex-direction:column; padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); position:relative; }
|
||||
/* sfondo: griglia di punti + scanline + vignetta */
|
||||
body::before { content:""; position:fixed; inset:0; background-image:radial-gradient(rgba(0,212,255,.16) 1px, transparent 1.2px); background-size:22px 22px; pointer-events:none; }
|
||||
body::after { content:""; position:fixed; inset:0; background:repeating-linear-gradient(0deg, rgba(0,0,0,.18) 0 2px, transparent 2px 4px), radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.65) 100%); pointer-events:none; }
|
||||
.corner { position:fixed; width:26px; height:26px; border:1px solid var(--line); pointer-events:none; }
|
||||
.corner.tl { top:calc(env(safe-area-inset-top) + 8px); left:8px; border-right:0; border-bottom:0; } .corner.tr { top:calc(env(safe-area-inset-top) + 8px); right:8px; border-left:0; border-bottom:0; }
|
||||
.corner.bl { bottom:calc(env(safe-area-inset-bottom) + 8px); left:8px; border-right:0; border-top:0; } .corner.br { bottom:calc(env(safe-area-inset-bottom) + 8px); right:8px; border-left:0; border-top:0; }
|
||||
header { padding:12px 18px 4px; display:flex; align-items:center; gap:10px; z-index:1; }
|
||||
header .name { font-weight:bold; letter-spacing:5px; color:var(--pri); text-shadow:0 0 12px rgba(0,212,255,.6); font-size:15px; }
|
||||
header .sub { font-size:9px; letter-spacing:2px; color:var(--dim); margin-top:2px; }
|
||||
header .conn { margin-left:auto; font-size:10px; letter-spacing:1px; color:var(--dim); text-transform:uppercase; }
|
||||
header .conn::before { content:"●"; margin-right:5px; }
|
||||
#gear { background:transparent; border:1px solid var(--line); color:var(--pri); border-radius:8px; padding:5px 9px; font-size:15px; }
|
||||
#core { position:relative; height:190px; z-index:1; }
|
||||
#ring { position:absolute; inset:0; width:100%; height:100%; }
|
||||
#stateline { position:absolute; left:0; right:0; bottom:8px; text-align:center; font-size:11px; letter-spacing:2px; color:var(--dim); text-transform:uppercase; }
|
||||
#stateline b { color:var(--pri); font-weight:normal; }
|
||||
#wave { display:block; width:100%; height:34px; z-index:1; }
|
||||
#log { flex:1; overflow-y:auto; padding:6px 12px 10px; display:flex; flex-direction:column; gap:7px; z-index:1; -webkit-overflow-scrolling:touch; }
|
||||
.m { max-width:88%; padding:9px 12px; border-radius:4px; font-size:14px; line-height:1.4; white-space:pre-wrap; word-break:break-word; position:relative; background:var(--glass); backdrop-filter:blur(6px); border:1px solid var(--line); }
|
||||
.m.you { align-self:flex-end; border-color:rgba(0,255,136,.35); border-right:2px solid var(--grn); }
|
||||
.m.ava { align-self:flex-start; border-left:2px solid var(--pri); box-shadow:0 0 14px rgba(0,212,255,.08); }
|
||||
.m.ava::before { content:"LUZA"; display:block; font-size:9px; letter-spacing:2px; color:var(--pri); opacity:.8; margin-bottom:3px; }
|
||||
.m.you::before { content:"TU"; display:block; font-size:9px; letter-spacing:2px; color:var(--grn); opacity:.8; margin-bottom:3px; text-align:right; }
|
||||
.m.img { padding:4px; max-width:78%; } .m.img::before { display:none; } .m.img img { display:block; width:100%; border-radius:3px; }
|
||||
.viewer { position:fixed; inset:0; background:rgba(0,4,8,.96); display:flex; align-items:center; justify-content:center; z-index:50; }
|
||||
.viewer img { max-width:100%; max-height:100%; object-fit:contain; box-shadow:0 0 30px rgba(0,212,255,.25); }
|
||||
.m.file a { color:var(--pri); text-decoration:none; font-weight:bold; } .m.file::before { display:none; }
|
||||
.m.sys { align-self:center; font-size:10px; letter-spacing:1px; color:var(--dim); background:transparent; border:0; padding:1px 8px; backdrop-filter:none; }
|
||||
.m.err { align-self:center; font-size:12px; color:var(--red); border-color:rgba(255,51,102,.4); }
|
||||
#confirm { display:none; margin:0 12px 8px; padding:12px; border:1px solid var(--acc); border-radius:4px; background:rgba(40,32,0,.8); box-shadow:0 0 18px rgba(255,204,0,.25); z-index:1; }
|
||||
#confirm .t { color:var(--acc); font-weight:bold; letter-spacing:1px; margin-bottom:4px; } #confirm .d { font-size:12px; white-space:pre-wrap; margin-bottom:10px; }
|
||||
footer { display:flex; gap:10px; padding:10px 14px 14px; align-items:center; border-top:1px solid var(--line); background:linear-gradient(180deg, rgba(2,7,12,.1), rgba(2,7,12,.96)); z-index:1; }
|
||||
.capsule { flex:1; min-width:0; display:flex; align-items:center; background:rgba(0,18,30,.75); border:1px solid var(--line); border-radius:24px; padding:3px 4px 3px 16px; transition:border-color .2s, box-shadow .2s; }
|
||||
.capsule:focus-within { border-color:var(--pri); box-shadow:0 0 14px rgba(0,212,255,.22); }
|
||||
.capsule input { flex:1; min-width:0; background:transparent; border:0; color:var(--txt); font:inherit; font-size:15px; padding:10px 0; outline:none; caret-color:var(--pri); }
|
||||
.icon { width:38px; height:38px; border-radius:50%; border:1px solid transparent; background:transparent; color:var(--pri); display:flex; align-items:center; justify-content:center; padding:0; }
|
||||
.icon svg { width:20px; height:20px; fill:currentColor; }
|
||||
.icon.send { opacity:.35; transition:opacity .15s, background .15s; } .icon.send.on { opacity:1; background:rgba(0,212,255,.12); }
|
||||
.icon.stop { display:none; color:var(--red); border-color:rgba(255,51,102,.5); background:rgba(40,0,14,.7); box-shadow:0 0 12px rgba(255,51,102,.3); width:44px; height:44px; }
|
||||
.icon.stop.on { display:flex; }
|
||||
.micbtn { position:relative; width:56px; height:56px; border-radius:50%; border:1px solid var(--grn); background:radial-gradient(circle at 50% 40%, rgba(0,255,136,.18), rgba(0,20,30,.9) 70%); color:var(--grn); display:flex; align-items:center; justify-content:center; padding:0; box-shadow:0 0 16px rgba(0,255,136,.25), inset 0 0 12px rgba(0,255,136,.12); }
|
||||
.micbtn svg { width:26px; height:26px; fill:currentColor; }
|
||||
.micbtn .halo { position:absolute; inset:-6px; border-radius:50%; border:1px solid rgba(0,255,136,.35); animation:halo 2.4s ease-out infinite; pointer-events:none; }
|
||||
@keyframes halo { 0% { transform:scale(.9); opacity:.6; } 100% { transform:scale(1.35); opacity:0; } }
|
||||
.micbtn.rec { border-color:var(--red); color:var(--red); background:radial-gradient(circle at 50% 40%, rgba(255,51,102,.35), rgba(40,0,14,.95) 70%); box-shadow:0 0 22px rgba(255,51,102,.55); }
|
||||
.micbtn.rec .halo { border-color:rgba(255,51,102,.6); animation-duration:1s; }
|
||||
button:active { transform:scale(.94); }
|
||||
#pair button, #settings button, #confirm button { background:rgba(0,20,32,.7); border:1px solid var(--line); color:var(--txt); border-radius:4px; font:inherit; font-size:13px; padding:10px 14px; letter-spacing:1px; }
|
||||
#pair button.pri, #settings button.pri, #confirm button.pri { border-color:var(--pri); color:var(--pri); box-shadow:0 0 10px rgba(0,212,255,.2); }
|
||||
@keyframes blink { 50% { opacity:.55; } }
|
||||
#pair { display:none; flex-direction:column; gap:12px; padding:26px 22px; text-align:center; z-index:1; }
|
||||
#pair .box { border:1px solid var(--line); background:var(--glass); padding:18px; border-radius:4px; }
|
||||
#pair input { width:100%; font-size:26px; text-align:center; letter-spacing:8px; }
|
||||
#settings { display:none; margin:0 12px 8px; padding:12px; border:1px solid var(--line); border-radius:4px; background:var(--glass); backdrop-filter:blur(8px); font-size:13px; z-index:1; max-height:55vh; overflow-y:auto; }
|
||||
#settings .h { color:var(--pri); letter-spacing:3px; font-size:11px; margin-bottom:8px; }
|
||||
#sform label { display:block; margin:8px 0 3px; color:var(--dim); font-size:10px; letter-spacing:1px; text-transform:uppercase; }
|
||||
#sform select { width:100%; background:rgba(0,10,18,.9); color:var(--txt); border:1px solid var(--line); border-radius:4px; padding:9px; font:inherit; font-size:14px; }
|
||||
.boot { animation:boot .8s ease-out; } @keyframes boot { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="corner tl"></div><div class="corner tr"></div><div class="corner bl"></div><div class="corner br"></div>
|
||||
<header class="boot"><div><div class="name">LUZA</div><div class="sub">ASSISTENTE PERSONALE · REMOTO</div></div><span id="conn" class="conn">…</span><button id="gear" onclick="openSettings()">⚙</button></header>
|
||||
<div id="pair">
|
||||
<div class="box"><div style="color:var(--pri);letter-spacing:3px;font-size:11px;margin-bottom:10px">ABBINAMENTO</div>
|
||||
<div style="font-size:13px;color:var(--dim);margin-bottom:12px">Sul Mac premi <b style="color:var(--txt)">Remote Control</b> e digita il PIN di 6 cifre, oppure inquadra il QR.</div>
|
||||
<input id="key" type="text" inputmode="numeric" placeholder="······" autocapitalize="off" autocorrect="off">
|
||||
<div style="height:10px"></div><button class="pri" onclick="savePair()">COLLEGA</button></div>
|
||||
</div>
|
||||
<div id="core" class="boot"><canvas id="ring"></canvas><div id="stateline"><b id="stbase">LISTENING</b><span id="stdetail"></span></div></div>
|
||||
<canvas id="wave" class="boot"></canvas>
|
||||
<div id="settings"><div class="h">MOTORE E VOCE</div><div id="sform"></div>
|
||||
<div style="margin-top:12px; display:flex; gap:8px"><button class="pri" onclick="saveSettings()">SALVA E APPLICA</button><button onclick="$('settings').style.display='none'">CHIUDI</button></div>
|
||||
<div id="shint" style="margin-top:6px; color:var(--dim); font-size:11px"></div></div>
|
||||
<div id="log"></div>
|
||||
<div id="confirm"><div class="t" id="ct"></div><div class="d" id="cd"></div><button class="pri" onclick="doConfirm(true)">CONFERMA</button> <button onclick="doConfirm(false)">ANNULLA</button></div>
|
||||
<footer>
|
||||
<div class="capsule">
|
||||
<input id="txt" type="text" placeholder="Scrivi a LuZa…" enterkeyhint="send" oninput="onTyping()" onkeydown="if(event.key==='Enter')sendText()">
|
||||
<button id="sendb" class="icon send" onclick="sendText()" aria-label="Invia"><svg viewBox="0 0 24 24"><path d="M3 11.5 21 3l-7.5 18-2.3-7.2L3 11.5z"/></svg></button>
|
||||
</div>
|
||||
<button id="stopb" class="icon stop" onclick="send({type:'interrupt'})" aria-label="Interrompi"><svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>
|
||||
<button id="mic" class="micbtn" onclick="toggleMic()" aria-label="Parla"><span class="halo"></span><svg viewBox="0 0 24 24"><path d="M12 15a4 4 0 0 0 4-4V6a4 4 0 0 0-8 0v5a4 4 0 0 0 4 4z"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
|
||||
</footer>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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" } ] }
|
||||
@@ -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 => {});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user