- docs/panel.png y docs/demo.webm: el panel en funcionamiento (telemetria, reactor, catalogo de acciones y las metricas de rendimiento). - docs/rag.md: el metodo del RAG, el pipeline de enriquecimiento y los resultados medidos (hit@1 48->82%, hit@5 77->95%), incluido el resultado negativo de que un embedder transformer no compensa. El indice NO se publica: contiene los apuntes del usuario. - hud.js: datos de ejemplo de la seccion de rendimiento para el modo demo.
957 lines
35 KiB
JavaScript
957 lines
35 KiB
JavaScript
/* 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 === "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();
|
||
}
|
||
});
|
||
|
||
// 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 },
|
||
],
|
||
}});
|
||
|
||
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 },
|
||
}});
|
||
}
|
||
})();
|