/* 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 }, ], }}); } })();