Boton cerrar y cerrar la pestaña apaga JARVIS

Cerrar el panel dejaba el proceso vivo (whisper agarrando la grafica, el
microfono escuchando). Ahora hay un boton "cerrar" (dos clics) y cerrar la
ultima pestaña apaga JARVIS del todo: para la voz, suelta whisper y cierra
el proceso, via SIGINT (la misma limpieza que Ctrl+C).
This commit is contained in:
sito 2026-08-16 16:31:11 +02:00
parent 3a2a1bb111
commit fde0fc1c72
6 changed files with 95 additions and 10 deletions

View file

@ -758,6 +758,13 @@
} else if (tipo === "medidas") {
ponMedidas(datos);
} else if (tipo === "cerrando") {
// JARVIS se esta apagando: cerrar la ventana del panel. En chromium
// --app window.close() cierra la ventana de la aplicacion.
document.body.classList.add("parado");
ponEstado("parado");
setTimeout(() => { try { window.close(); } catch (e) {} }, 300);
} else if (tipo === "telemetria") {
if (datos.nucleos) ponNucleos(datos.nucleos);
ponConstantes(datos);
@ -851,6 +858,35 @@
}
});
// Cerrar JARVIS del todo. Dos clics para no apagarlo por un roce: el primero
// arma ("pulsa otra vez"), el segundo lo manda. Si no se confirma en 3 s,
// vuelve a como estaba. Al confirmar, Python contesta "cerrando" y la ventana
// se cierra sola.
(function () {
const b = document.getElementById("cerrar");
if (!b) return;
const rotulo = b.querySelector(".rotulo");
let armado = false, reloj = null;
b.addEventListener("click", () => {
if (!armado) {
armado = true;
b.classList.add("armado");
rotulo.textContent = "¿seguro?";
reloj = setTimeout(() => {
armado = false;
b.classList.remove("armado");
rotulo.textContent = "cerrar";
}, 3000);
return;
}
clearTimeout(reloj);
rotulo.textContent = "cerrando";
try {
window.webkit.messageHandlers.accion.postMessage("cerrar");
} catch (e) { /* suelto en el navegador: no hay Python que apagar */ }
});
})();
// Lanza la tanda de prueba. No cambia nada en el boton: lo que hay que mirar
// son las tarjetas, que es justo lo que se quiere comprobar.
document.getElementById("prueba").addEventListener("click", (ev) => {
@ -944,14 +980,5 @@
{ pid: 1201, nombre: "pipewire", cpu: 2.0, mem: 0.3 },
],
}});
window.jarvis.recibe({ tipo: "medidas", datos: {
n: 240,
lat_p50: 2.1, lat_p95: 4.8, lat_max: 7.2,
lat_cerebro: 2.7, lat_catalogo: 0.2,
pct_cerebro: 44, pct_catalogo: 53, pct_ruido: 3,
vol24: 87, porhora: [2, 0, 1, 4, 6, 3, 5, 8, 4, 2, 1, 3],
rag: { hit5: 95, hit1: 82 },
}});
}
})();

View file

@ -159,6 +159,13 @@
<button id="prueba" class="secundario" title="Lanza varias tareas reales a la vez para verlas trabajar">
<span class="rotulo">prueba</span>
</button>
<!-- Apaga JARVIS del todo: para la voz, suelta el microfono y la grafica, y
cierra el proceso. Dos clics para no apagarlo por un roce. -->
<button id="cerrar" class="secundario cerrar"
title="Apagar JARVIS: cierra el panel, para la voz y suelta la gráfica">
<span class="rotulo">cerrar</span>
</button>
</nav>
<!-- candado: tapa todo hasta que se teclea la contraseña -->

View file

@ -659,6 +659,13 @@ h2.separado { margin-top: 26px; }
border-color: color-mix(in srgb, var(--mal) 60%, transparent);
}
/* El boton de cerrar, armado tras el primer clic: rojo, para que se vea que el
segundo clic apaga de verdad. */
.controles button#cerrar.armado {
color: var(--mal, #ff7a6b);
border-color: var(--mal, #ff7a6b);
}
.controles button:hover {
color: var(--brillo);
border-color: var(--acento);

View file

@ -18,6 +18,7 @@ mirando: sin panel abierto son quince mensajes por segundo a nadie.
"""
import os
import shutil
import signal
import subprocess
import threading
import time
@ -360,6 +361,20 @@ class Panel:
except Exception:
pass
def cerrar(self):
"""Apaga JARVIS entero: la escucha, la voz, whisper-server y el proceso.
Cerrar la pestaña o pulsar «cerrar» tiene que soltar de verdad el
microfono y la grafica, no dejar el proceso escuchando en segundo plano.
Se manda SIGINT al propio proceso: eso dispara el mismo apagado que
Ctrl+C el finally del bucle de escucha para whisper, y atexit lo remata
asi que se reutiliza la limpieza que ya estaba probada, sin duplicarla.
Corre desde un hilo del servidor; la señal la atiende el hilo principal.
"""
self.parado = True # que la escucha no atienda nada mas ya
self.para() # corta los hilos y las tareas del panel
os.kill(os.getpid(), signal.SIGINT)
# --------------------------------------------------------------- pulsar
def pulsado(self, canal: str, dato: str):
@ -417,6 +432,13 @@ class Panel:
self.manda_controles()
self.estado("escuchando" if not self.centinela.activo else "dormido")
elif dato == "cerrar":
# Apagar JARVIS del todo. Se responde primero al HUD para que la
# ventana sepa cerrarse, y luego se mata el proceso.
self.cara.manda("cerrando", True)
self.cerrar()
return
elif dato.startswith("parar"):
# El boton alterna PAUSA. No es solo "cortar lo de ahora": el titulo
# promete "cierra el microfono y corta la voz; pulsa otra vez para

View file

@ -42,10 +42,17 @@ FICHEROS = {
class Cara:
"""El panel: se le empujan eventos y devuelve lo que se pulsa."""
def __init__(self, al_pulsar=None, puerto=PUERTO):
# Segundos que se espera tras cerrarse la ultima pestaña antes de apagar.
# Da margen a una RECARGA (que cierra el socket y lo reabre enseguida) para
# no confundirla con cerrar la ventana de verdad.
GRACIA = 4
def __init__(self, al_pulsar=None, al_cerrar=None, puerto=PUERTO):
self.al_pulsar = al_pulsar # (canal, dato) -> None
self.al_cerrar = al_cerrar # se cerro la ultima pestaña -> apagar
self.puerto = puerto
self._clientes = set()
self._hubo_cliente = False # para no apagar antes de abrir el panel
self._bucle = None
self._hilo = None
self._ultimo = {} # el estado, para el que llegue tarde
@ -124,6 +131,7 @@ class Cara:
async def _cliente(self, ws):
self._clientes.add(ws)
self._hubo_cliente = True
try:
# Al conectar se le manda el estado que ya habia. Sin esto, abrir el
# panel a media sesion enseña un HUD vacio hasta que algo cambie.
@ -146,6 +154,19 @@ class Cara:
pass
finally:
self._clientes.discard(ws)
# Se cerro una pestaña. Si no queda ninguna, se espera un poco (por
# si es una recarga) y, si sigue sin haber panel abierto, se apaga
# JARVIS: cerrar la ventana suelta el microfono y la grafica.
if self._hubo_cliente and not self._clientes and self.al_cerrar:
try:
asyncio.get_running_loop().call_later(
self.GRACIA, self._quiza_cerrar)
except RuntimeError:
pass
def _quiza_cerrar(self):
if not self._clientes and self.al_cerrar:
threading.Thread(target=self.al_cerrar, daemon=True).start()
# --------------------------------------------------------------- empujar

View file

@ -227,6 +227,7 @@ def panel(boca: Boca, cerebro=None, escuchando=False, modelo="small",
p = Panel(cara, boca, manos, cerebro, candado=not sin_candado,
centinela=centinela)
cara.al_pulsar = p.pulsado
cara.al_cerrar = p.cerrar # cerrar la ultima pestaña apaga JARVIS
url = cara.arranca()
if not url:
return 1