diff --git a/avatar/avatar3d.py b/avatar/avatar3d.py index e90acb3..a5cabfd 100644 --- a/avatar/avatar3d.py +++ b/avatar/avatar3d.py @@ -84,7 +84,7 @@ class Avatar3DView(QWebEngineView): # ── API thread-safe (valori semplici, letti dal timer sul thread Qt) ── def set_state(self, state: str) -> None: - self._state = str(state or "LISTENING") + self._state = str(state or "LISTENING").replace("'", "’") def set_audio_level(self, level: float) -> None: try: diff --git a/avatar/settings_dialog.py b/avatar/settings_dialog.py index 4cd000b..2d19826 100644 --- a/avatar/settings_dialog.py +++ b/avatar/settings_dialog.py @@ -45,9 +45,16 @@ def _combo(items, current) -> QComboBox: c.addItem(label, value) idx = c.findData(current) c.setCurrentIndex(idx if idx >= 0 else 0) + # le etichette lunghe non allargano la finestra: il menu si apre comunque a larghezza piena + c.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon); c.setMinimumContentsLength(22) return c +def _note(text: str) -> QLabel: + """Etichetta descrittiva che va a capo invece di allargare la finestra.""" + lbl = QLabel(text); lbl.setWordWrap(True); return lbl + + class SettingsDialog(QDialog): _async = pyqtSignal(str, object) @@ -88,7 +95,7 @@ class SettingsDialog(QDialog): self.api_key = QLineEdit(); self.api_key.setEchoMode(QLineEdit.EchoMode.Password) self.api_key.setPlaceholderText("•••••• (salvata, lascia vuoto per non cambiarla)" if s.get_secret("anthropic_api_key") else "sk-ant-…") f.addRow("Chiave API Anthropic", self.api_key) - f.addRow("", QLabel("Creala su console.anthropic.com. Viene salvata nel portachiavi di macOS.")) + f.addRow("", _note("Creala su console.anthropic.com. Viene salvata nel portachiavi di macOS.")) self.stack.addWidget(w) # Locale w = QWidget(); f = QFormLayout(w) @@ -183,7 +190,7 @@ class SettingsDialog(QDialog): # ── Telegram ────────────────────────────────────────────────────── form3 = QFormLayout() - form3.addRow(QLabel("Telegram (account personale): credenziali da my.telegram.org > API development tools")) + form3.addRow(_note("Telegram (account personale): credenziali da my.telegram.org > API development tools")) row = QHBoxLayout() self.tg_id = QLineEdit(str(s.get("telegram_api_id") or "")); self.tg_id.setPlaceholderText("api id"); row.addWidget(self.tg_id) self.tg_hash = QLineEdit(); self.tg_hash.setEchoMode(QLineEdit.EchoMode.Password) @@ -207,7 +214,7 @@ class SettingsDialog(QDialog): # ── WhatsApp (dispositivo collegato, Baileys) ───────────────────── form4 = QFormLayout() - form4.addRow(QLabel("WhatsApp in tempo reale (client non ufficiale: possibile blocco del numero, a tuo rischio)")) + form4.addRow(_note("WhatsApp in tempo reale (client non ufficiale: possibile blocco del numero, a tuo rischio)")) row = QHBoxLayout() self.wa_live = QCheckBox("Attivo all'avvio"); self.wa_live.setChecked(bool(s.get("whatsapp_live"))); row.addWidget(self.wa_live) self.wa_annuncia = QCheckBox("Annuncia a voce i messaggi in arrivo"); self.wa_annuncia.setChecked(bool(s.get("whatsapp_annuncia"))); row.addWidget(self.wa_annuncia) @@ -224,7 +231,7 @@ class SettingsDialog(QDialog): # ── Assistenza server (bot sysadmin + monitoraggio Sentinella) ──── form_srv = QFormLayout() - form_srv.addRow(QLabel("Assistenza server: bot Telegram sysadmin e monitoraggio Sentinella sul server ponte (accesso SSH con chiave)")) + form_srv.addRow(_note("Assistenza server: bot Telegram sysadmin e monitoraggio Sentinella sul server ponte (accesso SSH con chiave)")) self.srv_ssh = QLineEdit(str(s.get("server_assist_ssh") or "")); self.srv_ssh.setPlaceholderText("root@host del server ponte") form_srv.addRow("SSH server ponte", self.srv_ssh) self.srv_dir = QLineEdit(str(s.get("server_assist_dir") or "")); self.srv_dir.setPlaceholderText("/opt/aiserverassistance") @@ -250,7 +257,7 @@ class SettingsDialog(QDialog): # ── Casa (Home Assistant) ───────────────────────────────────────── form_ha = QFormLayout() - form_ha.addRow(QLabel("Home Assistant: crea un token di accesso a lunga durata dal tuo profilo (Sicurezza) e incollalo qui. Viene salvato nel portachiavi.")) + form_ha.addRow(_note("Home Assistant: crea un token di accesso a lunga durata dal tuo profilo (Sicurezza) e incollalo qui. Viene salvato nel portachiavi.")) self.ha_url = QLineEdit(str(s.get("homeassistant_url") or "")); self.ha_url.setPlaceholderText("http://homeassistant.local:8123") form_ha.addRow("Indirizzo", self.ha_url) row = QHBoxLayout() @@ -265,30 +272,40 @@ class SettingsDialog(QDialog): from PyQt6.QtWidgets import QPlainTextEdit from .mcp_client import CONFIG_FILE as MCP_FILE, manager as mcp_manager form_mcp = QFormLayout() - form_mcp.addRow(QLabel("Server MCP esterni: stesso formato di Claude Desktop (mcpServers con command/args/env, oppure url/headers). " + form_mcp.addRow(_note("Server MCP esterni: stesso formato di Claude Desktop (mcpServers con command/args/env, oppure url/headers). " "Gli strumenti diventano disponibili a tutti i motori; con Claude Code i server vengono passati direttamente a claude.")) self.mcp_text = QPlainTextEdit() try: self.mcp_text.setPlainText(MCP_FILE.read_text(encoding="utf-8")) except Exception: self.mcp_text.setPlainText('{\n "mcpServers": {\n }\n}\n') - self.mcp_text.setMinimumHeight(220) + self.mcp_text.setMinimumHeight(180); self.mcp_text.setMaximumHeight(260) + self.mcp_text.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) form_mcp.addRow(self.mcp_text) row = QHBoxLayout() b = QPushButton("Applica e ricollega"); b.clicked.connect(self._mcp_apply); row.addWidget(b) b = QPushButton("Esempio"); b.clicked.connect(self._mcp_example); row.addWidget(b) row.addStretch() form_mcp.addRow(row) - self.mcp_hint = QLabel(mcp_manager.status()); self.mcp_hint.setWordWrap(True); form_mcp.addRow(self.mcp_hint) + self.mcp_hint = QLabel(mcp_manager.status()); self.mcp_hint.setWordWrap(True); self.mcp_hint.setMinimumWidth(200); form_mcp.addRow(self.mcp_hint) add(pg_tool, form_mcp) # ── Immagini (mflux) ────────────────────────────────────────────── form_img = QFormLayout() - form_img.addRow(QLabel("Generazione immagini in locale (mflux, MLX). Il primo uso scarica il modello da Hugging Face.")) + form_img.addRow(_note("Generazione immagini in locale (mflux, MLX). Il primo uso scarica il modello da Hugging Face.")) self.img_fam = _combo([("z-image-turbo", "Z-Image Turbo (veloce, 8 passi, consigliato)"), ("schnell", "FLUX.1 schnell (4 passi)"), ("dev", "FLUX.1 dev (lento, licenza non commerciale)"), ("qwen", "Qwen-Image (pesante, ottimo col testo)")], s.get("immagini_famiglia") or "z-image-turbo") form_img.addRow("Famiglia", self.img_fam) - self.img_model = QLineEdit(str(s.get("immagini_modello") or "")); self.img_model.setPlaceholderText("vuoto = predefinito (es. mflux-community/z-image-turbo-mflux-q4)") - form_img.addRow("Modello (repo Hugging Face)", self.img_model) + self.img_model = QComboBox(); self.img_model.setEditable(True) + try: + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "plugins")) + import importlib + self.img_model.addItems([""] + importlib.import_module("immagini").cached_image_models()) + except Exception: + self.img_model.addItem("") + self.img_model.setEditText(str(s.get("immagini_modello") or "")) + self.img_model.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon); self.img_model.setMinimumContentsLength(30) + self.img_model.lineEdit().setPlaceholderText("vuoto = predefinito della famiglia; un nome nuovo viene scaricato al primo uso") + form_img.addRow("Modello (elenco: già scaricati)", self.img_model) add(pg_tool, form_img) btns = QHBoxLayout(); btns.addStretch() @@ -299,7 +316,8 @@ class SettingsDialog(QDialog): outer.addLayout(btns) screen = QApplication.primaryScreen().availableGeometry() if QApplication.primaryScreen() else None h = int(screen.height() * 0.85) if screen else 800 - self.resize(820, min(900, h)) + w = min(1000, int(screen.width() * 0.9)) if screen else 1000 + self.resize(w, min(900, h)) self._async.connect(self._on_async) self._check_cc() @@ -569,7 +587,7 @@ class SettingsDialog(QDialog): "server_assist_dir": self.srv_dir.text().strip() or "/opt/aiserverassistance", "server_assist_bot": self.srv_bot.text().strip().lstrip("@") or "luzaserver_bot", "homeassistant_url": self.ha_url.text().strip().rstrip("/"), - "immagini_famiglia": self.img_fam.currentData() or "z-image-turbo", "immagini_modello": self.img_model.text().strip(), + "immagini_famiglia": self.img_fam.currentData() or "z-image-turbo", "immagini_modello": self.img_model.currentText().strip(), } if self.ha_token.text().strip(): values["homeassistant_token"] = self.ha_token.text().strip() diff --git a/avatar3d/app.js b/avatar3d/app.js index c00ce1a..35baf07 100644 --- a/avatar3d/app.js +++ b/avatar3d/app.js @@ -194,7 +194,7 @@ function drawOverlay() { levels.push(dispLevel); levels.shift(); const col = COLORS[state.name] || '#00d4ff'; stateEl.style.color = col; stateEl.style.textShadow = `0 0 8px ${col}88`; - stateEl.textContent = '● ' + state.name; + stateEl.textContent = '● ' + (state.label || state.name); wctx.clearRect(0, 0, wave.width, wave.height); const n = levels.length, bw = wave.width / n; for (let i = 0; i < n; i++) { @@ -206,8 +206,8 @@ function drawOverlay() { } window.avatar3d = { - update(level, open, width, name) { state.level = level; state.open = open; state.width = width; if (name) state.name = name; }, - setState(name) { state.name = name; }, + update(level, open, width, name) { state.level = level; state.open = open; state.width = width; if (name) { state.label = name; state.name = name.split(' ')[0]; } }, + setState(name) { state.label = name; state.name = name.split(' ')[0]; }, glance(dx, dy, hold) { smooth.glanceX = dx * 0.3; smooth.glanceY = -dy * 0.2; smooth.glanceUntil = clock.elapsedTime + (hold || 1.1); }, setFraming(f) { state.framing = f; frame(); }, status() { return { loaded: Boolean(model), animation: Boolean(mixer), head: Boolean(head), blendShapes: mouth.hasShapes, morphs: morphMeshes.length, eyes: eyes.length }; }, diff --git a/plugins/immagini.py b/plugins/immagini.py index 6e16c6e..b587773 100644 --- a/plugins/immagini.py +++ b/plugins/immagini.py @@ -27,6 +27,19 @@ FORMATI = {"quadrata": (1024, 1024), "orizzontale": (1280, 768), "verticale": (7 _last: dict = {"path": ""} +def cached_image_models() -> list[str]: + """Modelli per immagini già presenti nella cache di Hugging Face (mflux, FLUX, Z-Image, Qwen-Image).""" + hub = Path.home() / ".cache" / "huggingface" / "hub" + out = [] + if hub.exists(): + for d in sorted(hub.glob("models--*")): + name = d.name[len("models--"):].replace("--", "/") + low = name.lower() + if any(k in low for k in ("mflux", "flux", "z-image", "qwen-image")) and (d / "snapshots").exists(): + out.append(name) + return out + + def _cfg() -> tuple[str, str]: s = Settings() fam = str(s.get("immagini_famiglia") or "z-image-turbo") @@ -34,6 +47,55 @@ def _cfg() -> tuple[str, str]: return fam, repo +def _progress(ctx: dict, text: str) -> None: + """Mostra l'avanzamento sul volto (stato del HUD) e nel registro.""" + player = ctx.get("player") + if player is not None and hasattr(player, "set_state"): + try: + player.set_state(f"PROCESSING · {text}") + except Exception: + pass + if ctx.get("log"): + ctx["log"](f"[immagini] {text}") + + +def _run_with_progress(cmd: list[str], steps: int, ctx: dict, timeout: int = 1800): + """Esegue mflux leggendo la barra di avanzamento (download dei pesi e passi di diffusione).""" + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=0) + buf, lines, last, t0 = "", [], "", time.time() + _progress(ctx, "immagine: avvio") + try: + while True: + ch = proc.stdout.read(1) + if not ch: + break + if time.time() - t0 > timeout: + proc.kill() + return None, "", "" + if ch in ("\r", "\n"): + line = buf.strip(); buf = "" + if not line: + continue + lines.append(line) + m = re.search(r"Fetching (\d+) files:\s*(\d+)%", line) + if m: + msg = f"download modello {m.group(2)}%" + elif (m := re.search(r"(\d+)/(\d+)\s*\[", line)): + k, n = int(m.group(1)), int(m.group(2)) + msg = f"immagine {k}/{n}" if k < n else "immagine: salvataggio" + else: + continue + if msg != last: + last = msg + _progress(ctx, msg) + else: + buf += ch + finally: + proc.wait() + tail = " | ".join(l for l in lines[-3:] if "it/s" not in l and "s/it" not in l) + return proc, tail, "" + + def genera(params: dict, ctx: dict) -> str: prompt = str(params.get("descrizione", "")).strip() if not prompt: @@ -61,16 +123,13 @@ def genera(params: dict, ctx: dict) -> str: if ctx.get("log"): ctx["log"](f"[immagini] {fam} {w}x{h} {steps} passi: {prompt[:80]}") t = time.time() - try: - proc = subprocess.run(cmd, capture_output=True, text=True, timeout=1800) - except subprocess.TimeoutExpired: + proc, tail, err = _run_with_progress(cmd, steps, ctx) + if proc is None: return "Generazione interrotta: troppo lenta (oltre 30 minuti)." if proc.returncode != 0 or not out.exists(): - err = (proc.stderr or proc.stdout).strip().splitlines() - tail = " | ".join(err[-3:]) if err else f"codice {proc.returncode}" if "not found" in tail.lower() or "401" in tail or "403" in tail: tail += " (controlla il nome del modello nelle impostazioni, o accetta la licenza su Hugging Face)" - return f"Generazione fallita: {tail[:400]}" + return f"Generazione fallita: {tail[:400] or f'codice {proc.returncode}'}" _last["path"] = str(out) secs = time.time() - t if params.get("mostra", True) not in (False, "false", "no"): diff --git a/ui.py b/ui.py index 8ae36fc..dde7ebc 100644 --- a/ui.py +++ b/ui.py @@ -632,7 +632,7 @@ class HudCanvas(QWidget): # either way because the animation state keeps stepping at 60 Hz. self._paint_tick = (self._paint_tick + 1) % 6 active = (self.speaking or amp > 0.02 - or self.state in ("THINKING", "PROCESSING")) + or self.state.split(" ")[0] in ("THINKING", "PROCESSING")) if _blinked or (self._paint_tick % 2 == 0 if active else self._paint_tick % 3 == 0): # Nothing is on screen when the window is hidden or minimised, so @@ -665,9 +665,9 @@ class HudCanvas(QWidget): return qcol(C.MUTED_C), qcol(C.MUTED_C) if self.speaking: return qcol(C.PRI), qcol(C.ACC) - if self.state in ("THINKING", "PROCESSING"): + if self.state.split(" ")[0] in ("THINKING", "PROCESSING"): return qcol(C.PRI), qcol(C.ACC2) - if self.state == "LISTENING": + if self.state.split(" ")[0] == "LISTENING": return qcol(C.PRI), qcol(C.GREEN) return qcol(C.PRI), qcol(C.PRI_DIM) @@ -763,7 +763,7 @@ class HudCanvas(QWidget): # 6. Sweeping arcs. Long spans, not dashes — the original's grandeur # came from a few big strokes. Speed is the state: idle drifts, # thinking hurries, speaking runs. - rate = 1.0 + (1.9 if self.state in ("THINKING", "PROCESSING") else 0.0) \ + rate = 1.0 + (1.9 if self.state.split(" ")[0] in ("THINKING", "PROCESSING") else 0.0) \ + (1.2 if self.speaking else 0.0) for k, (rr, span, count, dirn, col, a, wid) in enumerate(( (0.955, 118, 2, +1, acc, 0.75, 2.0), @@ -852,9 +852,9 @@ class HudCanvas(QWidget): _main = qcol(C.PRI) if self.speaking: _acc = qcol(C.ACC) - elif self.state in ("THINKING", "PROCESSING"): + elif self.state.split(" ")[0] in ("THINKING", "PROCESSING"): _acc = qcol(C.ACC2) - elif self.state == "LISTENING": + elif self.state.split(" ")[0] == "LISTENING": _acc = qcol(C.GREEN) else: _acc = qcol(C.PRI) @@ -882,7 +882,7 @@ class HudCanvas(QWidget): elif self.state == "PROCESSING": sym = "▷" if self._blink else "▶" txt, col = f"{sym} PROCESSING", qcol(C.ACC2) - elif self.state == "LISTENING": + elif self.state.split(" ")[0] == "LISTENING": sym = "●" if self._blink else "○" txt, col = f"{sym} LISTENING", qcol(C.GREEN) else: