JARVIS/nucleo/cara/hud/hud.js
sito fde0fc1c72 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).
2026-08-16 16:31:11 +02:00

984 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* HUD de JARVIS.
*
* Python empuja eventos con window.jarvis.recibe({tipo, datos}) y el HUD
* devuelve pulsaciones por window.webkit.messageHandlers.
*
* Los niveles llegan a ~15 por segundo para no cargar el puente; el dibujo
* va a 60 con requestAnimationFrame e interpola entre envios, asi que se ve
* fluido sin pagarlo en llamadas. */
(() => {
"use strict";
const BANDAS = 12;
const MAX_ACCIONES = 8;
// El reactor no lleva colores propios: los lee de las variables del CSS, asi
// que cambiar la paleta repinta tambien el canvas sin tocar JavaScript. Un
// solo tema —naranja neon sobre negro— y por eso ya no hay lista ni rotacion.
const VARIABLES = {
reposo: ["--r-vivo", "--r-hondo", "--r-nucleo"],
escuchando: ["--r-vivo", "--r-hondo", "--r-nucleo"],
hablando: ["--r-habla", "--r-habla-hondo", "--r-habla-nucleo"],
parado: ["--r-parado", "--r-parado-hondo", "--r-parado-nucleo"],
};
let COLORES = {};
function leeColores() {
const css = getComputedStyle(document.documentElement);
COLORES = {};
for (const [estado, vars] of Object.entries(VARIABLES)) {
const [vivo, hondo, nucleo] = vars.map((v) => css.getPropertyValue(v).trim());
COLORES[estado] = { vivo, hondo, nucleo };
}
}
// La lista de voces vive en Python (utility/voces.py), no aqui: el boton solo
// pide "la siguiente" y pinta lo que le contesten. Asi el rotulo no puede
// decir una voz distinta de la que esta puesta de verdad.
function ponVoz(datos) {
const boton = document.getElementById("voz");
if (!boton || !datos) return;
boton.querySelector(".rotulo").textContent = "voz: " + (datos.nombre || "?");
boton.dataset.voz = datos.id || "";
// el motivo del fallo se lee al pasar por encima, no ocupa sitio en pantalla
boton.title = datos.error
? "No pude cambiar de voz: " + datos.error
: "Cambiar la voz. La estrena diciendo una frase";
boton.classList.toggle("fallo", Boolean(datos.error));
}
// Igual que la voz: la lista vive en Python (utility/cerebro.py). Pero este
// boton marca ademas si lo que se dice SALE de la maquina, porque eso no se
// puede saber mirando el panel de ninguna otra forma.
function ponCerebro(datos) {
const boton = document.getElementById("cerebro");
if (!boton || !datos) return;
boton.querySelector(".rotulo").textContent = "cerebro: " + (datos.nombre || "?");
boton.dataset.cerebro = datos.id || "";
boton.classList.toggle("fuera", Boolean(datos.fuera));
boton.classList.toggle("fallo", Boolean(datos.error));
boton.title = datos.error
? "No pude cambiar de cerebro: " + datos.error
: (datos.fuera
? "Piensa con Claude (" + (datos.detalle || "") + "). La voz sigue "
+ "siendo local, pero lo que preguntes sale de esta maquina."
: "Piensa aqui dentro (" + (datos.detalle || "") + "). Nada sale de "
+ "la maquina.");
if (datos.nombre && datos.nombre !== "\u2014") {
aConsola("cerebro: " + datos.nombre
+ (datos.fuera ? " (las preguntas salen de la maquina)"
: " (todo local)"), "orden");
}
}
function ponDicho(texto, atendido, motivo) {
elDicho.textContent = texto || "";
elDicho.classList.toggle("visible", Boolean(texto));
elDicho.classList.toggle("desoido", !atendido);
elDicho.dataset.motivo = atendido ? "" : (motivo || "");
}
// La conversacion tambien va a la consola. Bajo el reactor solo se ve la
// ultima frase; aqui queda el hilo entero con su hora, que es lo que hace
// falta para entender por que no reacciono hace dos minutos.
function aConsolaDicho(texto, atendido, motivo) {
if (!texto) return;
if (atendido) {
aConsola("> " + texto, "orden");
} else {
aConsola("> " + texto, "malo");
if (motivo) aConsola(" " + motivo, "malo");
}
}
const ETIQUETAS = {
reposo: "en reposo",
// "escuchando" prometia mas de lo que hacia: el microfono esta abierto,
// pero mientras no se le llame por su nombre no atiende nada. Decirlo evita
// la duda de si el problema es el microfono.
dormido: "diga jarvis",
escuchando: "escuchando",
pensando: "pensando",
preguntando: "conteste",
hablando: "respondiendo",
parado: "desconectado",
};
const nivelesObjetivo = new Array(BANDAS).fill(0.04);
const nivelesActuales = new Array(BANDAS).fill(0.04);
let estado = "parado";
let fase = 0;
const lienzo = document.getElementById("reactor");
const ctx = lienzo.getContext("2d");
const elEstado = document.getElementById("estado");
const elDicho = document.getElementById("dicho");
const elRegistro = document.getElementById("registro");
/* ------------------------------------------------------------ dibujo */
function ajustarLienzo() {
const escala = window.devicePixelRatio || 1;
const lado = lienzo.clientWidth;
lienzo.width = lado * escala;
lienzo.height = lado * escala;
ctx.setTransform(escala, 0, 0, escala, 0, 0);
}
function dibujar() {
const lado = lienzo.clientWidth;
const cx = lado / 2;
const cy = lado / 2;
const color = COLORES[estado] || COLORES.reposo;
ctx.clearRect(0, 0, lado, lado);
let media = 0;
for (let i = 0; i < BANDAS; i++) {
nivelesActuales[i] += (nivelesObjetivo[i] - nivelesActuales[i]) * 0.25;
media += nivelesActuales[i];
}
media /= BANDAS;
const radioBase = lado * 0.17;
const largoMax = lado * 0.20;
// halo: crece con la voz, da la sensacion de energia sin dibujar nada mas
const halo = ctx.createRadialGradient(cx, cy, radioBase * 0.2, cx, cy,
radioBase + largoMax * (0.5 + media));
halo.addColorStop(0, hexA(color.vivo, 0.20 + media * 0.30));
halo.addColorStop(0.55, hexA(color.vivo, 0.06));
halo.addColorStop(1, hexA(color.vivo, 0));
ctx.fillStyle = halo;
ctx.fillRect(0, 0, lado, lado);
// anillos de fondo, quietos: dan escala a lo que se mueve
ctx.strokeStyle = hexA(color.vivo, 0.10);
ctx.lineWidth = 1;
for (const f of [0.62, 0.82, 1.0]) {
ctx.beginPath();
ctx.arc(cx, cy, (radioBase + largoMax) * f, 0, Math.PI * 2);
ctx.stroke();
}
// espigas: una por banda del analizador
ctx.lineCap = "round";
for (let i = 0; i < BANDAS; i++) {
const ang = (i / BANDAS) * Math.PI * 2 + fase - Math.PI / 2;
const nivel = Math.min(1, nivelesActuales[i]);
const r0 = radioBase;
const r1 = radioBase + largoMax * (0.10 + nivel * 0.9);
const grad = ctx.createLinearGradient(
cx + Math.cos(ang) * r0, cy + Math.sin(ang) * r0,
cx + Math.cos(ang) * r1, cy + Math.sin(ang) * r1);
grad.addColorStop(0, hexA(color.vivo, 0.95));
grad.addColorStop(1, hexA(color.hondo, 0.15));
ctx.strokeStyle = grad;
ctx.lineWidth = Math.max(2, lado * 0.010);
ctx.beginPath();
ctx.moveTo(cx + Math.cos(ang) * r0, cy + Math.sin(ang) * r0);
ctx.lineTo(cx + Math.cos(ang) * r1, cy + Math.sin(ang) * r1);
ctx.stroke();
}
// tres arcos girando a distinta velocidad y sentido: nunca parece congelado
ctx.lineWidth = Math.max(1.5, lado * 0.005);
const arcos = [
{ r: 1.30, giro: 1.0, largo: 1.5, alfa: 0.55 },
{ r: 1.52, giro: -0.6, largo: 1.0, alfa: 0.38 },
{ r: 1.72, giro: 0.35, largo: 2.2, alfa: 0.22 },
];
for (const a of arcos) {
const inicio = fase * a.giro;
ctx.strokeStyle = hexA(color.vivo, a.alfa);
ctx.beginPath();
ctx.arc(cx, cy, radioBase * a.r, inicio, inicio + a.largo);
ctx.stroke();
}
// triangulo del icono
const rt = radioBase * 0.66;
ctx.strokeStyle = hexA(color.nucleo, 0.75);
ctx.lineWidth = Math.max(1.5, lado * 0.004);
ctx.beginPath();
for (let i = 0; i < 3; i++) {
const ang = (i / 3) * Math.PI * 2 - Math.PI / 2;
const x = cx + Math.cos(ang) * rt;
const y = cy + Math.sin(ang) * rt;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
// nucleo latiendo
const rn = radioBase * (0.26 + media * 0.14);
const gn = ctx.createRadialGradient(cx, cy, 0, cx, cy, rn * 2.4);
gn.addColorStop(0, hexA(color.nucleo, 1));
gn.addColorStop(0.4, hexA(color.vivo, 0.65));
gn.addColorStop(1, hexA(color.vivo, 0));
ctx.fillStyle = gn;
ctx.beginPath();
ctx.arc(cx, cy, rn * 2.4, 0, Math.PI * 2);
ctx.fill();
fase += estado === "hablando" ? 0.020 : 0.007;
requestAnimationFrame(dibujar);
}
function hexA(hex, alfa) {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alfa})`;
}
/* ------------------------------------------------------------ eventos */
function ponEstado(nuevo) {
if (nuevo === estado) return;
estado = nuevo;
elEstado.textContent = ETIQUETAS[nuevo] || nuevo;
elEstado.className = "estado " + nuevo;
}
function ponMedidor(clave, porcentaje, texto) {
const fila = document.querySelector(`.medidor[data-clave="${clave}"]`);
if (!fila) return;
fila.querySelector("i").style.width = Math.min(100, porcentaje) + "%";
fila.querySelector(".cifra").textContent = texto;
}
// El rotulo dice lo que PASA, no lo que hace el boton: leer "escuchando" y
// ver el punto encendido no deja lugar a dudas; "silenciar" obligaria a
// deducir si es lo que hace o lo que esta puesto.
const ROTULOS = {
parar: { on: "parar", off: "reanudar" },
// El rotulo dice como ESTA: "escuchando" es que atiende todo lo que oiga,
// "solo con nombre" es que hay que llamarle antes de cada orden.
sueno: { on: "escuchando", off: "solo con nombre" },
};
function ponRotulo(boton) {
const textos = ROTULOS[boton.dataset.accion];
if (!textos) return;
boton.querySelector(".rotulo").textContent = textos[boton.dataset.estado] || "";
}
function ponControles(datos) {
for (const [accion, encendido] of Object.entries(datos)) {
const boton = document.querySelector(`.controles button[data-accion="${accion}"]`);
if (!boton) continue;
boton.dataset.estado = encendido ? "on" : "off";
ponRotulo(boton);
}
// Parado no es el estado de un boton, es el estado del asistente: se marca
// en el documento entero. Un HUD encendido con un boton pequeño en rojo se
// lee como "funcionando"; apagado no se puede confundir.
if (datos.parar !== undefined) {
document.body.classList.toggle("parado", !datos.parar);
if (!datos.parar) ponEstado("parado");
}
}
// Formatos: cada constante se lee de un vistazo o no sirve de nada, asi que
// las unidades se eligen segun la magnitud en vez de soltar el numero crudo.
const CONSTANTES = {
temperatura: (v) => `${v.toFixed(0)} °C`,
carga: (v) => v.map((x) => x.toFixed(2)).join(" "),
uptime: (v) => (v < 3600 ? `${(v / 60).toFixed(0)} min`
: v < 86400 ? `${(v / 3600).toFixed(1)} h`
: `${(v / 86400).toFixed(1)} dias`),
bateria: (v) => `${v.toFixed(0)} %`,
red: (v) => `${fmtKB(v.baja)}${fmtKB(v.sube)}`,
};
function fmtKB(kb) {
return kb >= 1024 ? `${(kb / 1024).toFixed(1)}M` : `${kb.toFixed(0)}K`;
}
function ponConstantes(datos) {
for (const [clave, formato] of Object.entries(CONSTANTES)) {
if (datos[clave] === undefined) continue;
const fila = document.querySelector(`.constantes li[data-clave="${clave}"]`);
if (!fila) continue;
fila.querySelector("b").textContent = formato(datos[clave]);
// 80 grados es donde empieza a preocupar en un portatil
if (clave === "temperatura") fila.classList.toggle("alerta", datos[clave] >= 80);
}
}
// Las medidas de JARVIS: latencia, a donde va cada orden, y el RAG. Salen del
// diario y las manda el panel cada 5 s por el canal "medidas".
const MEDIDAS = {
lat_p50: (v) => `${v.toFixed(1)} s`,
lat_p95: (v) => `${v.toFixed(1)} s`,
pct_cerebro: (v) => `${v} %`,
pct_ruido: (v) => `${v} %`,
vol24: (v) => `${v}`,
rag: (v) => `${v.hit5} %`,
};
function ponMedidas(datos) {
if (!datos || !datos.n) return;
const caja = document.getElementById("rendimiento");
if (!caja) return;
for (const [clave, formato] of Object.entries(MEDIDAS)) {
if (datos[clave] === undefined) continue;
const fila = caja.querySelector(`li[data-clave="${clave}"]`);
if (!fila) continue;
fila.querySelector("b").textContent = formato(datos[clave]);
}
// latencia p95: por encima de 5 s la mediana ya se nota al hablar
const p95 = caja.querySelector('li[data-clave="lat_p95"]');
if (p95) p95.classList.toggle("alerta", datos.lat_p95 >= 8);
// ruido alto = Whisper oyendose a si mismo, envenena el diario
const rr = caja.querySelector('li[data-clave="pct_ruido"]');
if (rr) rr.classList.toggle("alerta", datos.pct_ruido >= 25);
// la barra de reparto: cerebro (lento) vs catalogo (instantaneo) vs ruido
const barra = document.getElementById("reparto");
if (barra) {
const set = (sel, f) => {
const t = barra.querySelector(sel);
if (t) t.style.setProperty("--f", Math.max(0, f));
};
set(".cerebro", datos.pct_cerebro);
set(".catalogo", datos.pct_catalogo);
set(".ruido", datos.pct_ruido);
barra.title = `al cerebro ${datos.pct_cerebro}% · catalogo ${datos.pct_catalogo}% · ruido ${datos.pct_ruido}% (ultimas ${datos.n})`;
}
}
function ponNucleos(cargas) {
const caja = document.getElementById("nucleos");
while (caja.children.length < cargas.length) caja.appendChild(document.createElement("span"));
cargas.forEach((carga, i) => {
const celda = caja.children[i];
celda.style.setProperty("--carga", Math.min(100, carga) + "%");
celda.title = `nucleo ${i}: ${carga.toFixed(0)} %`;
});
}
function ponProcesos(lista) {
const caja = document.getElementById("procesos");
caja.textContent = "";
for (const p of lista) {
const li = document.createElement("li");
// el propio asistente se marca: es el que interesa vigilar
if (/newelle|ollama|llama|python/.test(p.nombre)) li.className = "propio";
const n = document.createElement("span");
n.className = "nombre";
n.textContent = p.nombre;
n.title = `pid ${p.pid}`;
const c = document.createElement("span");
c.className = "pcpu";
c.textContent = p.cpu.toFixed(0) + "%";
const m = document.createElement("span");
m.className = "pmem";
m.textContent = p.mem.toFixed(1) + "%";
li.append(n, c, m);
caja.appendChild(li);
}
}
/* ------------------------------------------------------- catalogo */
let CATALOGO = [];
function pintaCatalogo(filtro = "") {
const caja = document.getElementById("catalogo");
caja.textContent = "";
const f = filtro.trim().toLowerCase();
let total = 0;
for (const { grupo, acciones } of CATALOGO) {
const encajan = acciones.filter(
(a) => !f || a.frase.includes(f) || a.id.includes(f) || grupo.includes(f));
if (!encajan.length) continue;
const titulo = document.createElement("p");
titulo.className = "grupo";
titulo.textContent = `${grupo} · ${encajan.length}`;
caja.appendChild(titulo);
for (const a of encajan) {
total++;
const fila = document.createElement("div");
fila.className = "catalogo-item";
fila.title = "Pulsa para que te la explique en voz alta";
fila.tabIndex = 0;
fila.setAttribute("role", "button");
// Pulsar una accion la EXPLICA, no la ejecuta. Es la diferencia entre
// una lista de referencia y un campo de minas: uno mira "sabe hacer"
// justo cuando no sabe que hace cada cosa, y ahi el clic no puede
// disparar un comando en la maquina.
const explica = () => {
try {
window.webkit.messageHandlers.accion.postMessage("explica:" + a.id);
} catch (e) { /* suelto en el navegador: no hay a quien preguntar */ }
};
fila.addEventListener("click", explica);
fila.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); explica(); }
});
const texto = document.createElement("span");
texto.textContent = a.frase;
fila.appendChild(texto);
// las que esperan algo detras se marcan: si no, uno dice la frase a
// secas, no pasa nada y parece que la accion esta rota
if (a.captura) {
const hueco = document.createElement("span");
hueco.className = "hueco";
hueco.textContent = "+ que";
fila.appendChild(hueco);
}
if (a.confirmar) {
const aviso = document.createElement("span");
aviso.className = "aviso";
aviso.textContent = "⚠";
aviso.title = "pide confirmacion antes de hacerlo";
fila.appendChild(aviso);
}
caja.appendChild(fila);
}
}
if (!total) {
const vacio = document.createElement("p");
vacio.className = "vacio";
vacio.textContent = "nada encaja con ese filtro";
caja.appendChild(vacio);
}
}
// Lista numerada de resultados de una busqueda: se puede leer y luego decir
// "abre el tercero". Va en la vista de acciones hechas, encabezada por el
// titulo de la busqueda.
function muestraCandado(datos) {
const c = document.getElementById("candado");
const msg = document.getElementById("candado-msg");
msg.textContent = datos.mensaje || "Introduzca la contraseña";
msg.classList.toggle("error", !!datos.error);
c.classList.remove("oculto");
const clave = document.getElementById("candado-clave");
clave.value = "";
clave.focus();
}
function enviaClave() {
const clave = document.getElementById("candado-clave");
if (!clave.value) return;
try {
window.webkit.messageHandlers.accion.postMessage("desbloquear:" + clave.value);
} catch (e) { /* suelto en el navegador: no hay a quien mandarla */ }
clave.value = "";
document.getElementById("candado-msg").textContent = "Comprobando...";
}
function muestraResultados(datos) {
const li = document.createElement("li");
li.className = "resultados";
const h = document.createElement("span");
h.className = "herramienta";
h.textContent = datos.titulo || "resultados";
li.appendChild(h);
const ol = document.createElement("ol");
for (const item of (datos.items || [])) {
const el = document.createElement("li");
el.value = item.n;
el.textContent = item.texto;
ol.appendChild(el);
}
li.appendChild(ol);
elRegistro.prepend(li);
while (elRegistro.children.length > MAX_ACCIONES) {
elRegistro.lastElementChild.remove();
}
cambiaVista("registro");
}
/* ------------------------------------------------------- la consola ---
Lo que echa de menos cualquiera que venga de una terminal: ver que esta
pasando MIENTRAS pasa, y poder cortarlo. Las tarjetas dicen el estado;
esto dice el detalle, linea a linea y en orden. */
const elConsola = document.getElementById("consola");
const elConsolaEstado = document.getElementById("consola-estado");
const MAX_LINEAS_CONSOLA = 400; // mas que esto no se lee y pesa
function aConsola(texto, clase) {
if (!elConsola || !texto) return;
const linea = document.createElement("span");
linea.className = "linea " + (clase || "");
const hora = new Date().toTimeString().slice(0, 8);
linea.textContent = hora + " " + texto;
elConsola.appendChild(linea);
while (elConsola.childElementCount > MAX_LINEAS_CONSOLA) {
elConsola.firstElementChild.remove();
}
// pegada abajo, como una terminal: si el usuario ha subido a leer, se
// respeta y no se le arrastra el scroll
const pegada = elConsola.scrollHeight - elConsola.scrollTop
- elConsola.clientHeight < 60;
if (pegada) elConsola.scrollTop = elConsola.scrollHeight;
}
function consolaEstado(texto, ocupado) {
if (!elConsolaEstado) return;
elConsolaEstado.textContent = texto;
elConsolaEstado.classList.toggle("ocupado", Boolean(ocupado));
const b = document.getElementById("consola-parar");
if (b) b.disabled = !ocupado;
}
/* --------------------------------------------------- tareas en marcha */
// Cuanto se queda en pantalla una tarea que termino bien y fue rapida.
// Las lentas y las que fallan NO se van solas: si algo tardo doce segundos
// uno quiere leer que salio, y si fallo, mas todavia.
const RAPIDA_S = 2.0;
const DESVANECE_MS = 3000;
const elTareas = document.getElementById("tareas");
const tarjetas = new Map(); // id -> {nodo, reloj, fin}
function mandaTarea(orden, id) {
try {
window.webkit.messageHandlers.accion.postMessage(`${orden}:${id}`);
} catch (e) {
// suelto en el navegador, sin Python detras: se simula para poder ver
// el diseño sin levantar la aplicacion entera
if (orden === "cerrar_tarea") quitaTarjeta(id);
}
}
function creaTarjeta(t) {
const nodo = document.createElement("div");
nodo.className = "tarea";
nodo.dataset.id = t.id;
const titulo = document.createElement("div");
titulo.className = "titulo";
titulo.textContent = t.titulo;
titulo.title = t.titulo;
const fila = document.createElement("div");
fila.className = "fila";
const reloj = document.createElement("span");
reloj.className = "reloj";
const giro = document.createElement("i");
giro.className = "giro";
reloj.append(giro, document.createTextNode("0.0s"));
const parar = document.createElement("button");
parar.textContent = "■";
parar.title = "Parar esta tarea";
parar.addEventListener("click", () => mandaTarea("parar_tarea", t.id));
const cerrar = document.createElement("button");
cerrar.textContent = "×";
cerrar.title = "Cerrar la tarjeta (si sigue viva, la para)";
cerrar.addEventListener("click", () => mandaTarea("cerrar_tarea", t.id));
const botones = document.createElement("span");
botones.append(parar, cerrar);
fila.append(reloj, botones);
const eco = document.createElement("div");
eco.className = "eco";
nodo.append(titulo, fila, eco);
elTareas.append(nodo);
const ficha = { nodo, reloj, parar, eco, fin: null };
tarjetas.set(t.id, ficha);
return ficha;
}
function ponTarea(t) {
const nueva = !tarjetas.has(t.id);
const ficha = tarjetas.get(t.id) || creaTarjeta(t);
// la consola cuenta el detalle que no cabe en la tarjeta
if (nueva) aConsola("$ " + t.titulo, "orden");
const ultima = (t.lineas && t.lineas.length) ? t.lineas[t.lineas.length - 1] : "";
if (ultima && ficha.ultimoEco !== ultima) { ficha.ultimoEco = ultima; aConsola(" " + ultima); }
if (t.estado !== "corriendo" && ficha.estadoPrevio !== t.estado) {
ficha.estadoPrevio = t.estado;
aConsola(" → " + t.estado + " (" + t.segundos.toFixed(1) + "s)",
t.estado === "fallo" ? "malo" : "bueno");
}
const vivas = [...tarjetas.values()].filter((f) => f.fin === null).length;
consolaEstado(vivas ? vivas + " en marcha" : "en reposo", vivas > 0);
const { nodo, eco, parar } = ficha;
nodo.dataset.estado = t.estado;
eco.textContent = (t.lineas && t.lineas.length)
? t.lineas[t.lineas.length - 1] : "";
if (t.estado === "corriendo") {
ficha.fin = null;
parar.disabled = false;
ponReloj(ficha, t.segundos);
return;
}
// Terminada: el reloj se congela en lo que tardo y el boton de parar ya
// no tiene nada que parar.
ficha.fin = t.segundos;
parar.disabled = true;
ponReloj(ficha, t.segundos);
if (t.estado === "hecha" && t.segundos <= RAPIDA_S) {
// Lo instantaneo no merece una tarjeta permanente: aparece, confirma
// que se hizo, y se va sola.
setTimeout(() => {
nodo.classList.add("desvanece");
setTimeout(() => quitaTarjeta(t.id), 520);
}, DESVANECE_MS);
}
}
function ponReloj(ficha, segundos) {
// El nodo de texto es el segundo hijo: el primero es el punto que late
ficha.reloj.lastChild.textContent = segundos.toFixed(1) + "s";
}
function quitaTarjeta(id) {
const ficha = tarjetas.get(id);
if (!ficha) return;
ficha.nodo.remove();
tarjetas.delete(id);
}
// El reloj de las vivas lo lleva el HUD, no Python: si el tiempo solo
// avanzase con los avisos del puente, una tarea callada —un `find` largo que
// no escribe nada— tendria el contador congelado y pareceria colgada.
setInterval(() => {
for (const [id, ficha] of tarjetas) {
if (ficha.fin !== null) continue;
const t = parseFloat(ficha.reloj.lastChild.textContent) || 0;
ponReloj(ficha, t + 0.1);
}
}, 100);
function anotaAccion(datos) {
const li = document.createElement("li");
if (datos.error) li.className = "error";
const h = document.createElement("span");
h.className = "herramienta";
h.textContent = datos.herramienta || "accion";
const s = document.createElement("span");
s.className = "salida";
s.textContent = datos.salida || "";
li.append(h, s);
elRegistro.prepend(li);
// al ejecutar algo se salta sola a la vista de lo hecho: es lo que uno
// quiere ver en ese momento
cambiaVista("registro");
while (elRegistro.children.length > MAX_ACCIONES) {
elRegistro.lastElementChild.remove();
}
}
window.jarvis = {
recibe(mensaje) {
const { tipo, datos } = mensaje;
if (tipo === "pulso") {
for (let i = 0; i < BANDAS && i < datos.niveles.length; i++) {
nivelesObjetivo[i] = datos.niveles[i];
}
ponEstado(datos.estado);
} else if (tipo === "estado") {
ponEstado(datos);
} else if (tipo === "dicho") {
// Lo que ha oido, tanto si le hace caso como si no. Lo segundo importa
// igual o mas: sin verlo, un JARVIS que te oye pero te ignora —porque
// no le has llamado o porque esta bloqueado— es indistinguible de un
// microfono roto.
ponDicho(datos.texto, datos.atendido !== false, datos.motivo);
aConsolaDicho(datos.texto, datos.atendido !== false, datos.motivo);
} else if (tipo === "preguntando") {
// Una pregunta en el aire cambia lo que significa hablar: lo siguiente
// que digas es la RESPUESTA, no una orden. Si no se ve, uno contesta
// "informes" y no entiende por que no ha buscado nada.
document.documentElement.classList.toggle("preguntando",
Boolean(datos.texto));
if (datos.texto) {
ponEstado("preguntando");
aConsola("? " + datos.texto, "orden");
}
} else if (tipo === "respuesta") {
ponDicho(datos.texto, true, "");
aConsola(" " + datos.texto, "bueno");
} else if (tipo === "accion") {
anotaAccion(datos);
} else if (tipo === "tarea") {
ponTarea(datos);
} else if (tipo === "tarea_fuera") {
quitaTarjeta(datos.id);
} else if (tipo === "bloqueo") {
muestraCandado(datos);
} else if (tipo === "desbloqueado") {
document.getElementById("candado").classList.add("oculto");
} else if (tipo === "resultados") {
muestraResultados(datos);
} else if (tipo === "voz") {
ponVoz(datos);
} else if (tipo === "cerebro") {
ponCerebro(datos);
} else if (tipo === "catalogo") {
CATALOGO = datos;
pintaCatalogo(document.getElementById("filtro").value);
} else if (tipo === "controles") {
ponControles(datos);
} 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);
if (datos.swap) {
ponMedidor("swap", 100 * datos.swap.usada / datos.swap.total,
`${datos.swap.usada.toFixed(1)}/${datos.swap.total.toFixed(0)} GB`);
}
if (datos.disco) {
ponMedidor("disco", 100 * datos.disco.usado / datos.disco.total,
`${datos.disco.usado.toFixed(2)}/${datos.disco.total.toFixed(1)} TB`);
}
if (datos.procesos) ponProcesos(datos.procesos);
if (datos.cpu !== undefined) {
ponMedidor("cpu", datos.cpu, datos.cpu.toFixed(0) + " %");
}
if (datos.ram) {
ponMedidor("ram", 100 * datos.ram.usada / datos.ram.total,
`${datos.ram.usada.toFixed(0)}/${datos.ram.total.toFixed(0)} GB`);
}
if (datos.gpu) {
ponMedidor("gpu", datos.gpu.uso, datos.gpu.uso.toFixed(0) + " %");
ponMedidor("vram",
100 * datos.gpu.vram_usada / datos.gpu.vram_total,
`${datos.gpu.vram_usada.toFixed(1)}/${datos.gpu.vram_total.toFixed(1)} GB`);
}
}
},
};
/* ------------------------------------------------------------ arranque */
// Los interruptores NO se pintan al pulsarlos: se manda la orden y se espera
// a que Python conteste con el estado real (evento "controles"). Asi el
// boton nunca dice "silenciado" con el microfono abierto.
document.querySelectorAll(".controles button[data-accion]").forEach((boton) => {
boton.addEventListener("click", () => {
try {
window.webkit.messageHandlers.accion.postMessage(boton.dataset.accion);
} catch (e) {
// suelto en el navegador: no hay Python, se conmuta a mano para poder
// ver como queda el diseño
if (boton.dataset.estado) {
boton.dataset.estado = boton.dataset.estado === "on" ? "off" : "on";
ponRotulo(boton);
}
}
});
});
function cambiaVista(cual) {
for (const b of document.querySelectorAll(".pestanas button")) {
b.classList.toggle("activa", b.dataset.vista === cual);
}
for (const v of document.querySelectorAll(".vista")) {
v.classList.toggle("oculta", v.id !== "vista-" + cual);
}
}
document.querySelectorAll(".pestanas button").forEach((b) => {
b.addEventListener("click", () => cambiaVista(b.dataset.vista));
});
document.getElementById("filtro").addEventListener("input", (e) => {
pintaCatalogo(e.target.value);
});
document.getElementById("candado-ok").addEventListener("click", enviaClave);
document.getElementById("candado-clave").addEventListener("keydown", (e) => {
if (e.key === "Enter") enviaClave();
});
// Cortar lo que este corriendo: el boton y Ctrl+C, que es lo que los dedos
// hacen solos cuando algo se atasca.
function cortarTodo() {
let n = 0;
for (const [id, ficha] of tarjetas) {
if (ficha.fin === null) { mandaTarea("parar_tarea", id); n++; }
}
aConsola(n ? "^C cortando " + n + " tarea(s)" : "^C no hay nada corriendo", "malo");
}
const botonCortar = document.getElementById("consola-parar");
if (botonCortar) botonCortar.addEventListener("click", cortarTodo);
document.addEventListener("keydown", (ev) => {
if (ev.ctrlKey && (ev.key === "c" || ev.key === "C")) {
// si hay texto seleccionado es que quieren copiar, no cortar
if (String(window.getSelection() || "").length) return;
ev.preventDefault();
cortarTodo();
}
});
// 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) => {
const b = ev.currentTarget;
b.disabled = true;
b.querySelector(".rotulo").textContent = "probando";
setTimeout(() => {
b.disabled = false;
b.querySelector(".rotulo").textContent = "prueba";
}, 4000);
try {
window.webkit.messageHandlers.accion.postMessage("prueba");
} catch (e) { /* suelto en el navegador: no hay Python que lance nada */ }
});
// No se pinta al pulsar: rota Python y contesta con la voz que quedo. Mientras
// llega, se avisa de que esta en ello, que cambiar de motor tarda un momento.
document.getElementById("voz").addEventListener("click", (ev) => {
ev.currentTarget.querySelector(".rotulo").textContent = "voz: ...";
try {
window.webkit.messageHandlers.accion.postMessage("voz:siguiente");
} catch (e) { /* suelto en el navegador: no hay Python que rote nada */ }
});
const botonCerebro = document.getElementById("cerebro");
if (botonCerebro) botonCerebro.addEventListener("click", (ev) => {
ev.currentTarget.querySelector(".rotulo").textContent = "cerebro: ...";
try {
window.webkit.messageHandlers.accion.postMessage("cerebro:siguiente");
} catch (e) { /* suelto en el navegador: no hay Python que rote nada */ }
});
window.addEventListener("resize", ajustarLienzo);
ajustarLienzo();
leeColores();
requestAnimationFrame(dibujar);
try {
window.webkit.messageHandlers.listo.postMessage("listo");
} catch (e) {
// Abierto en un navegador normal: se llena solo con datos falsos, para
// poder iterar el diseño sin arrancar Newelle.
ponEstado("escuchando");
setInterval(() => {
for (let i = 0; i < BANDAS; i++) nivelesObjetivo[i] = Math.random() * 0.9;
}, 120);
elDicho.textContent = "how much disk space is left?";
elDicho.classList.add("visible");
[
{ herramienta: "run_command", salida: "df -h /\n/dev/nvme0n1p2 916G 70G 846G 8% /" },
{ herramienta: "system_info", salida: "62 GB RAM · 16 nucleos · NVIDIA T1200 4 GB" },
{ herramienta: "websearch", salida: "3 resultados descartados por poco fiables" },
].forEach(anotaAccion);
cambiaVista("catalogo");
window.jarvis.recibe({ tipo: "catalogo", datos: [
{ grupo: "sistema", acciones: [
{ id: "disco_libre", frase: "cuanto espacio queda", captura: false, confirmar: false },
{ id: "memoria", frase: "cuanta memoria", captura: false, confirmar: false },
{ id: "temperatura", frase: "que temperatura hay", captura: false, confirmar: false },
]},
{ grupo: "ficheros", acciones: [
{ id: "buscar_fichero", frase: "busca el fichero", captura: true, confirmar: false },
{ id: "proceso_matar", frase: "mata el proceso", captura: true, confirmar: true },
]},
{ grupo: "firefox", acciones: [
{ id: "ff_buscar_youtube", frase: "busca en youtube", captura: true, confirmar: false },
{ id: "ff_gmail", frase: "abre el correo", captura: false, confirmar: false },
]},
]});
window.jarvis.recibe({ tipo: "telemetria", datos: {
cpu: 34,
ram: { usada: 18.4, total: 62.5 },
gpu: { uso: 71, vram_usada: 2.9, vram_total: 4.0 },
nucleos: Array.from({ length: 16 }, (_, i) => 20 + ((i * 37) % 70)),
swap: { usada: 0.4, total: 1.0 },
disco: { usado: 1.01, total: 1.9 },
temperatura: 59,
carga: [1.81, 2.96, 4.08],
uptime: 21600,
bateria: 100,
red: { baja: 1840.5, sube: 220.2 },
procesos: [
{ pid: 2693, nombre: "ollama", cpu: 182.4, mem: 5.8 },
{ pid: 1436, nombre: "newelle", cpu: 33.5, mem: 1.8 },
{ pid: 9422, nombre: "firefox-esr", cpu: 12.1, mem: 4.2 },
{ pid: 3829, nombre: "gnome-shell", cpu: 6.3, mem: 2.1 },
{ pid: 1201, nombre: "pipewire", cpu: 2.0, mem: 0.3 },
],
}});
}
})();