JARVIS: asistente de voz local para Linux
Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin cuentas ni claves. Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper comparten tarjeta y solo caben porque estan dimensionados para ello. El RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve de una cache de frases. Incluye instalador (install.sh), requisitos, y documentacion del stack, del manejo de root y de las acciones. Los apuntes indexados y el diario NO se incluyen: son privados y el .gitignore los bloquea.
This commit is contained in:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
0
nucleo/cara/__init__.py
Normal file
0
nucleo/cara/__init__.py
Normal file
948
nucleo/cara/hud/hud.js
Normal file
948
nucleo/cara/hud/hud.js
Normal file
|
|
@ -0,0 +1,948 @@
|
|||
/* 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 },
|
||||
],
|
||||
}});
|
||||
}
|
||||
})();
|
||||
179
nucleo/cara/hud/index.html
Normal file
179
nucleo/cara/hud/index.html
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<!DOCTYPE html>
|
||||
<!-- HUD de JARVIS. Se carga desde config/jarvis-hud/, asi que este fichero
|
||||
se puede editar y recargar la pestaña sin recompilar el flatpak. -->
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>JARVIS</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="hud">
|
||||
|
||||
<!-- columna izquierda: constantes de la maquina -->
|
||||
<aside class="telemetria" id="telemetria">
|
||||
<h2>sistema</h2>
|
||||
<div class="medidor" data-clave="cpu">
|
||||
<span class="etiqueta">cpu</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
<div class="medidor" data-clave="ram">
|
||||
<span class="etiqueta">ram</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
<div class="medidor" data-clave="gpu">
|
||||
<span class="etiqueta">gpu</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
<div class="medidor" data-clave="vram">
|
||||
<span class="etiqueta">vram</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
|
||||
<div class="medidor" data-clave="swap">
|
||||
<span class="etiqueta">swap</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
<div class="medidor" data-clave="disco">
|
||||
<span class="etiqueta">disco</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
|
||||
<h2 class="separado">nucleos</h2>
|
||||
<div class="nucleos" id="nucleos"></div>
|
||||
|
||||
<!-- constantes: no llevan barra porque no son porcentajes de nada -->
|
||||
<ul class="constantes" id="constantes">
|
||||
<li data-clave="temperatura"><span>temp</span><b>--</b></li>
|
||||
<li data-clave="carga"><span>carga</span><b>--</b></li>
|
||||
<li data-clave="red"><span>red</span><b>--</b></li>
|
||||
<li data-clave="uptime"><span>encendido</span><b>--</b></li>
|
||||
<li data-clave="bateria"><span>bateria</span><b>--</b></li>
|
||||
</ul>
|
||||
|
||||
<h2 class="separado">procesos</h2>
|
||||
<ul class="procesos" id="procesos"></ul>
|
||||
|
||||
<!-- rendimiento del propio JARVIS: sale del diario, no del sistema -->
|
||||
<h2 class="separado">rendimiento</h2>
|
||||
<div class="reparto" id="reparto" title="a donde va cada orden">
|
||||
<span class="tramo cerebro" style="--f:0"></span>
|
||||
<span class="tramo catalogo" style="--f:0"></span>
|
||||
<span class="tramo ruido" style="--f:0"></span>
|
||||
</div>
|
||||
<ul class="constantes" id="rendimiento">
|
||||
<li data-clave="lat_p50"><span>latencia med.</span><b>--</b></li>
|
||||
<li data-clave="lat_p95"><span>latencia p95</span><b>--</b></li>
|
||||
<li data-clave="pct_cerebro"><span>al cerebro</span><b>--</b></li>
|
||||
<li data-clave="pct_ruido"><span>ruido</span><b>--</b></li>
|
||||
<li data-clave="vol24"><span>frases 24h</span><b>--</b></li>
|
||||
<li data-clave="rag"><span>RAG hit@5</span><b>--</b></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- centro: el reactor, el estado y lo que hay en marcha -->
|
||||
<section class="nucleo">
|
||||
<canvas id="reactor" width="520" height="520"></canvas>
|
||||
<p class="estado" id="estado">en reposo</p>
|
||||
<p class="dicho" id="dicho"></p>
|
||||
|
||||
<!-- una tarjetita por orden en marcha. Van aqui debajo y no flotando
|
||||
sobre el reactor para que quepan varias sin taparlo. -->
|
||||
<div class="tareas" id="tareas"></div>
|
||||
</section>
|
||||
|
||||
<!-- columna derecha: lo que va haciendo -->
|
||||
<aside class="acciones">
|
||||
<h2>
|
||||
acciones
|
||||
<span class="pestanas">
|
||||
<button data-vista="catalogo" class="activa">sabe hacer</button>
|
||||
<button data-vista="registro">hechas</button>
|
||||
<button data-vista="consola">consola</button>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<!-- lo que sabe hacer: sale de las acciones rapidas, no esta escrito aqui -->
|
||||
<div class="vista" id="vista-catalogo">
|
||||
<input id="filtro" type="search" placeholder="filtrar..." autocomplete="off">
|
||||
<div id="catalogo"></div>
|
||||
</div>
|
||||
|
||||
<!-- lo que va haciendo -->
|
||||
<div class="vista oculta" id="vista-registro">
|
||||
<ul id="registro"></ul>
|
||||
</div>
|
||||
|
||||
<!-- La consola: lo que esta pasando AHORA, linea a linea, como en una
|
||||
terminal. Con su boton de cortar, que es lo que se echa de menos
|
||||
cuando algo se queda pensando y no sabes ni que esta haciendo. -->
|
||||
<div class="vista oculta" id="vista-consola">
|
||||
<div class="consola-barra">
|
||||
<span id="consola-estado">en reposo</span>
|
||||
<button id="consola-parar" title="Cortar lo que se este ejecutando (Ctrl+C)">■ cortar</button>
|
||||
</div>
|
||||
<pre id="consola"></pre>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<nav class="controles">
|
||||
<button data-accion="parar" data-estado="on" class="principal"
|
||||
title="Cierra el microfono y corta la voz. Pulsa otra vez para reanudar">
|
||||
<span class="punto"></span>
|
||||
<span class="rotulo">parar</span>
|
||||
</button>
|
||||
|
||||
<!-- Rota entre las voces disponibles y estrena la nueva diciendo una frase.
|
||||
El rotulo lo pone Python al confirmar el cambio, no el clic. -->
|
||||
<button id="voz" class="secundario" title="Cambiar la voz. La estrena diciendo una frase">
|
||||
<span class="rotulo">voz</span>
|
||||
</button>
|
||||
|
||||
<!-- Dormir / despertar. El rotulo dice como ESTA, no lo que hace el boton:
|
||||
leer "dormido" y ver el punto apagado no deja lugar a dudas; "despertar"
|
||||
obligaria a deducir si es lo que hace o lo que esta puesto. -->
|
||||
<button data-accion="sueno" data-estado="on" class="secundario"
|
||||
title="Atiende todo lo que oiga. Pulsa para exigir que le llames «JARVIS» antes de cada orden — util si hay una tele cerca">
|
||||
<span class="punto"></span>
|
||||
<span class="rotulo">escuchando</span>
|
||||
</button>
|
||||
|
||||
<!-- Con que piensa. Se marca aparte cuando sale de la maquina: es el unico
|
||||
boton del panel que cambia donde van a parar las palabras del usuario,
|
||||
y eso no puede quedar escondido en un tooltip. -->
|
||||
<button id="cerebro" class="secundario" title="Cambiar con que piensa JARVIS">
|
||||
<span class="rotulo">cerebro</span>
|
||||
</button>
|
||||
|
||||
<!-- Lanza varias acciones de verdad a la vez, para ver las tarjetas
|
||||
trabajando sin tener que darle ordenes por voz. Es la forma de
|
||||
comprobar de un vistazo que el panel hace lo que dice. -->
|
||||
<button id="prueba" class="secundario" title="Lanza varias tareas reales a la vez para verlas trabajar">
|
||||
<span class="rotulo">prueba</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- candado: tapa todo hasta que se teclea la contraseña -->
|
||||
<div class="candado oculto" id="candado">
|
||||
<div class="candado-caja">
|
||||
<div class="candado-icono">▲</div>
|
||||
<p class="candado-msg" id="candado-msg">Introduzca la contraseña</p>
|
||||
<input id="candado-clave" type="password" autocomplete="off"
|
||||
placeholder="Contraseña" autofocus>
|
||||
<button id="candado-ok">Desbloquear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- el puente va antes: hud.js pregunta por window.webkit al cargarse -->
|
||||
<script src="puente.js"></script>
|
||||
<script src="hud.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
97
nucleo/cara/hud/puente.js
Normal file
97
nucleo/cara/hud/puente.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/* El puente entre el HUD y el nucleo, por WebSocket.
|
||||
*
|
||||
* En la version sobre Newelle el HUD vivia dentro de un widget WebKit y hablaba
|
||||
* por window.webkit.messageHandlers. Fuera del flatpak no hay tal widget, pero
|
||||
* hud.js usa ese puente en ocho sitios y no merece la pena tocarlos: aqui se
|
||||
* SUPLANTA window.webkit con un objeto que manda lo mismo por el socket.
|
||||
*
|
||||
* Asi hud.js se queda exactamente igual que estaba —cero diff sobre 2.064
|
||||
* lineas que ya funcionaban— y ademas sigue abriendose suelto en un navegador
|
||||
* para mirar el diseño, que es como se ha estado trabajando en el.
|
||||
*
|
||||
* Este fichero se carga ANTES que hud.js: cuando hud.js pregunte por
|
||||
* window.webkit, ya esta.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Ya estamos dentro de WebKit (queda de la epoca de Newelle): no tocar nada.
|
||||
if (window.webkit && window.webkit.messageHandlers) return;
|
||||
|
||||
const URL = "ws://" + (location.host || "127.0.0.1:8790") + "/puente";
|
||||
|
||||
let socket = null;
|
||||
let cola = []; // lo que se pulse antes de que abra el socket
|
||||
let esperaReconexion = 500;
|
||||
|
||||
function manda(canal, dato) {
|
||||
const mensaje = JSON.stringify({ canal: canal, dato: String(dato) });
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(mensaje);
|
||||
} else {
|
||||
// No se pierde: un boton pulsado mientras reconecta tiene que llegar
|
||||
// igual, o el panel parece que se ha colgado. Con tope, que si el nucleo
|
||||
// esta caido de verdad no queremos acumular mil pulsaciones.
|
||||
if (cola.length < 50) cola.push(mensaje);
|
||||
}
|
||||
}
|
||||
|
||||
// El mismo interfaz que ofrecia WebKit, para que hud.js no note el cambio.
|
||||
window.webkit = {
|
||||
messageHandlers: {
|
||||
accion: { postMessage: (d) => manda("accion", d) },
|
||||
listo: { postMessage: (d) => manda("listo", d) },
|
||||
},
|
||||
};
|
||||
|
||||
function conecta() {
|
||||
try {
|
||||
socket = new WebSocket(URL);
|
||||
} catch (e) {
|
||||
reintenta();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
esperaReconexion = 500;
|
||||
marcaCaido(false);
|
||||
const pendientes = cola;
|
||||
cola = [];
|
||||
pendientes.forEach((m) => socket.send(m));
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (ev) => {
|
||||
let mensaje;
|
||||
try {
|
||||
mensaje = JSON.parse(ev.data);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
// El formato {tipo, datos} es el mismo que empujaba Python por
|
||||
// evaluate_javascript, asi que recibe() no cambia.
|
||||
if (window.jarvis && window.jarvis.recibe) window.jarvis.recibe(mensaje);
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => { marcaCaido(true); reintenta(); });
|
||||
socket.addEventListener("error", () => { try { socket.close(); } catch (e) {} });
|
||||
}
|
||||
|
||||
function reintenta() {
|
||||
// Espera creciente hasta 8 s: reintentar cada 500 ms contra un nucleo
|
||||
// parado calienta el portatil para nada.
|
||||
setTimeout(conecta, esperaReconexion);
|
||||
esperaReconexion = Math.min(esperaReconexion * 2, 8000);
|
||||
}
|
||||
|
||||
// Que se vea que el panel esta hablando solo. Un HUD con datos congelados y
|
||||
// sin avisar es peor que uno que dice claramente que perdio el hilo.
|
||||
function marcaCaido(caido) {
|
||||
document.documentElement.classList.toggle("sin-nucleo", caido);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", conecta);
|
||||
} else {
|
||||
conecta();
|
||||
}
|
||||
})();
|
||||
1233
nucleo/cara/hud/style.css
Normal file
1233
nucleo/cara/hud/style.css
Normal file
File diff suppressed because it is too large
Load diff
575
nucleo/cara/panel.py
Normal file
575
nucleo/cara/panel.py
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
"""Lo que une el nucleo con la cara: que se empuja al HUD y que se hace al pulsar.
|
||||
|
||||
`servidor.py` solo mueve bytes; aqui vive el significado. Es el equivalente de
|
||||
lo que hacia `jarvis.py` dentro de Newelle —el fichero de 1.244 lineas que era
|
||||
lo unico acoplado al framework— pero sin GTK, sin WebKit y sin el ciclo de vida
|
||||
de la aplicacion de por medio.
|
||||
|
||||
## Lo que se empuja y cada cuanto
|
||||
|
||||
telemetria 1 s cpu, memoria, grafica, disco, procesos
|
||||
pulso 15/s solo mientras escucha o habla; es lo que mueve el reactor
|
||||
estado cuando cambia
|
||||
tarea cuando cambia una
|
||||
|
||||
La telemetria cuesta procesos (`nvidia-smi`, `ps`), asi que va en su propio hilo
|
||||
y a su ritmo, no en el bucle de voz. Y el pulso solo se manda si hay alguien
|
||||
mirando: sin panel abierto son quince mensajes por segundo a nadie.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from manos.preguntar import confirmacion, es_si, pregunta_por
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _lee(ruta, defecto=""):
|
||||
try:
|
||||
with open(ruta) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return defecto
|
||||
|
||||
|
||||
def _manda(orden, tope=4000):
|
||||
try:
|
||||
r = subprocess.run(orden, capture_output=True, text=True, timeout=10)
|
||||
return (r.stdout or "").strip()[:tope]
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
|
||||
class Telemetria:
|
||||
"""Las constantes de la maquina, leidas baratas.
|
||||
|
||||
Todo de /proc salvo la grafica, que necesita nvidia-smi. Se lee /proc a
|
||||
mano en vez de usar psutil para no arrastrar una dependencia por cuatro
|
||||
numeros que estan en ficheros de texto.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._cpu_previo = None
|
||||
self._nucleos_previo = None
|
||||
self._hay_nvidia = shutil.which("nvidia-smi") is not None
|
||||
|
||||
def _cpu(self):
|
||||
try:
|
||||
with open("/proc/stat") as f:
|
||||
lineas = [l for l in f if l.startswith("cpu")]
|
||||
except OSError:
|
||||
return 0, []
|
||||
def uso(campos):
|
||||
n = [int(x) for x in campos[1:11]]
|
||||
ocupado = sum(n) - n[3] - n[4]
|
||||
return ocupado, sum(n)
|
||||
ahora = [uso(l.split()) for l in lineas]
|
||||
previo, self._cpu_previo = self._cpu_previo, ahora
|
||||
if not previo or len(previo) != len(ahora):
|
||||
return 0, [0] * (len(ahora) - 1)
|
||||
def pct(a, b):
|
||||
d_ocu, d_tot = a[0] - b[0], a[1] - b[1]
|
||||
return round(100 * d_ocu / d_tot) if d_tot > 0 else 0
|
||||
return pct(ahora[0], previo[0]), [pct(a, b) for a, b in
|
||||
zip(ahora[1:], previo[1:])]
|
||||
|
||||
def _memoria(self):
|
||||
datos = {}
|
||||
try:
|
||||
with open("/proc/meminfo") as f:
|
||||
for l in f:
|
||||
k, _, v = l.partition(":")
|
||||
datos[k] = int(v.split()[0]) // 1024 # MB
|
||||
except OSError:
|
||||
return 0, 0, 0, 0
|
||||
total = datos.get("MemTotal", 0)
|
||||
libre = datos.get("MemAvailable", 0)
|
||||
stotal = datos.get("SwapTotal", 0)
|
||||
slibre = datos.get("SwapFree", 0)
|
||||
return total, total - libre, stotal, stotal - slibre
|
||||
|
||||
def _grafica(self):
|
||||
if not self._hay_nvidia:
|
||||
return None
|
||||
s = _manda(["nvidia-smi",
|
||||
"--query-gpu=utilization.gpu,memory.used,memory.total",
|
||||
"--format=csv,noheader,nounits"])
|
||||
try:
|
||||
u, usada, total = (int(x.strip()) for x in s.split(",")[:3])
|
||||
return {"uso": u, "usada": usada, "total": total}
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
def _temperatura(self):
|
||||
mejor = 0
|
||||
for zona in range(12):
|
||||
t = _lee(f"/sys/class/thermal/thermal_zone{zona}/temp")
|
||||
if t.isdigit():
|
||||
mejor = max(mejor, int(t) // 1000)
|
||||
return mejor
|
||||
|
||||
def _procesos(self, tope=5):
|
||||
"""Los que mas CPU comen.
|
||||
|
||||
cpu y mem van como NUMEROS, no como las cadenas que devuelve ps. El HUD
|
||||
hace `p.cpu.toFixed(0)`, y una cadena no tiene toFixed: la excepcion
|
||||
aborta el manejador entero de telemetria y se lleva por delante todo lo
|
||||
que se pinta despues —cpu, ram y grafica se quedaban en "--"—. El
|
||||
sintoma no apuntaba a los procesos en absoluto.
|
||||
"""
|
||||
s = _manda(["ps", "-eo", "pid,comm,pcpu,pmem", "--sort=-pcpu"])
|
||||
salida = []
|
||||
for l in s.splitlines()[1:tope + 1]:
|
||||
c = l.split(None, 3)
|
||||
if len(c) < 4:
|
||||
continue
|
||||
try:
|
||||
salida.append({"pid": int(c[0]), "nombre": c[1][:18],
|
||||
"cpu": float(c[2]), "mem": float(c[3].split()[0])})
|
||||
except ValueError:
|
||||
continue
|
||||
return salida
|
||||
|
||||
def _disco(self):
|
||||
try:
|
||||
st = os.statvfs("/")
|
||||
total = st.f_blocks * st.f_frsize / 1e9
|
||||
libre = st.f_bavail * st.f_frsize / 1e9
|
||||
return round(total - libre, 1), round(total, 1)
|
||||
except OSError:
|
||||
return 0, 0
|
||||
|
||||
def _red(self):
|
||||
"""Bytes acumulados por todas las interfaces menos la de loopback."""
|
||||
baja = sube = 0
|
||||
try:
|
||||
with open("/proc/net/dev") as f:
|
||||
for l in f.readlines()[2:]:
|
||||
nombre, _, resto = l.partition(":")
|
||||
if nombre.strip() == "lo":
|
||||
continue
|
||||
c = resto.split()
|
||||
baja += int(c[0]); sube += int(c[8])
|
||||
except (OSError, IndexError, ValueError):
|
||||
return None
|
||||
previo, self._red_previo = getattr(self, "_red_previo", None), (baja, sube, time.time())
|
||||
if not previo:
|
||||
return {"baja": 0, "sube": 0}
|
||||
dt = max(self._red_previo[2] - previo[2], 0.001)
|
||||
return {"baja": max(0, (baja - previo[0]) / dt),
|
||||
"sube": max(0, (sube - previo[1]) / dt)}
|
||||
|
||||
def _bateria(self):
|
||||
for n in range(3):
|
||||
p = _lee(f"/sys/class/power_supply/BAT{n}/capacity")
|
||||
if p.isdigit():
|
||||
return int(p)
|
||||
return None
|
||||
|
||||
def lee(self) -> dict:
|
||||
"""Las unidades son las que espera el HUD, no las de /proc.
|
||||
|
||||
Se convierte aqui y no en JavaScript porque el formato lo fija la
|
||||
pantalla: hud.js escribe "GB" y "TB" literalmente al lado del numero
|
||||
(ponMedidor), asi que mandar megas se ve como "63488/64256 GB" y parece
|
||||
que el panel esta roto cuando lo que esta mal es quien lo llena.
|
||||
"""
|
||||
cpu, nucleos = self._cpu()
|
||||
mtotal, musada, stotal, susada = self._memoria()
|
||||
dusado, dtotal = self._disco()
|
||||
arriba = _lee("/proc/uptime", "0").split()[0]
|
||||
datos = {
|
||||
"cpu": cpu,
|
||||
"nucleos": nucleos,
|
||||
"ram": {"usada": musada / 1024, "total": mtotal / 1024}, # GB
|
||||
"swap": {"usada": susada / 1024, "total": max(stotal / 1024, 0.01)},
|
||||
"disco": {"usado": dusado / 1000, "total": dtotal / 1000}, # TB
|
||||
"temperatura": self._temperatura(),
|
||||
"carga": [float(x) for x in _lee("/proc/loadavg", "0 0 0").split()[:3]],
|
||||
"uptime": float(arriba) if arriba else 0, # segundos
|
||||
"procesos": self._procesos(),
|
||||
}
|
||||
g = self._grafica()
|
||||
if g:
|
||||
datos["gpu"] = {"uso": g["uso"],
|
||||
"vram_usada": g["usada"] / 1024, # GB
|
||||
"vram_total": g["total"] / 1024}
|
||||
red = self._red()
|
||||
if red:
|
||||
datos["red"] = red
|
||||
bat = self._bateria()
|
||||
if bat is not None:
|
||||
datos["bateria"] = bat
|
||||
return datos
|
||||
|
||||
|
||||
class Panel:
|
||||
"""El nucleo visto desde la cara."""
|
||||
|
||||
def __init__(self, cara, boca, manos, cerebro=None, estado_voz=None,
|
||||
candado=True, centinela=None):
|
||||
self.cara = cara
|
||||
self.boca = boca
|
||||
self.manos = manos
|
||||
self.cerebro = cerebro
|
||||
self.centinela = centinela # quien decide si hay que llamarle
|
||||
self.estado_voz = estado_voz # callable -> "escuchando"|"hablando"|...
|
||||
self.telemetria = Telemetria()
|
||||
self._parar = threading.Event()
|
||||
|
||||
# Las tarjetas: una por orden en marcha, con su reloj y su boton de
|
||||
# parar. El prefijo va vacio porque aqui ya estamos en el host; dentro
|
||||
# del flatpak era `flatpak-spawn --host --watch-bus`.
|
||||
from manos.tareas import GestorTareas
|
||||
self.tareas = GestorTareas(al_cambiar=self._tarea_cambio, prefijo_host=[])
|
||||
|
||||
# El candado. Tapa el panel y, sobre todo, hace que no obedezca: sin
|
||||
# esto cualquiera que pase por delante del portatil abierto le puede
|
||||
# dar ordenes a una maquina con sudo.
|
||||
#
|
||||
# La contraseña es la del sistema, comprobada con `sudo -S -v`, que NO
|
||||
# ejecuta nada: solo valida. Asi no hay una contraseña propia que
|
||||
# guardar en ningun sitio, que es una cosa menos que se puede filtrar.
|
||||
self.candado = candado
|
||||
self.desbloqueado = not candado
|
||||
self._intentos = 0
|
||||
|
||||
# Lo que se ha preguntado y esta esperando respuesta hablada.
|
||||
# (accion, para_que, hasta_cuando). para_que es "argumento" o "confirmar".
|
||||
self.pendiente = None
|
||||
|
||||
# -------------------------------------------------------------- empujar
|
||||
|
||||
def arranca(self):
|
||||
threading.Thread(target=self._bucle_telemetria, daemon=True).start()
|
||||
if self.candado and not self.desbloqueado:
|
||||
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
|
||||
self.manda_catalogo()
|
||||
self.manda_controles()
|
||||
self.cara.manda("voz", {"nombre": self.boca.voz, "id": self.boca.voz})
|
||||
if self.cerebro:
|
||||
self.cara.manda("cerebro", {"nombre": "local", "id": "local",
|
||||
"detalle": self.cerebro.modelo,
|
||||
"fuera": False})
|
||||
|
||||
def _bucle_telemetria(self):
|
||||
tic = 0
|
||||
while not self._parar.is_set():
|
||||
# Sin nadie mirando no se gasta un nvidia-smi por segundo.
|
||||
if self.cara.conectados:
|
||||
try:
|
||||
self.cara.manda("telemetria", self.telemetria.lee())
|
||||
except Exception:
|
||||
pass
|
||||
# Las medidas de JARVIS (latencia, reparto, RAG) cambian mucho
|
||||
# mas despacio que la CPU: se recalculan cada 5 s, no cada uno.
|
||||
# Salen del diario, que es leer un fichero, no del sistema.
|
||||
if tic % 5 == 0:
|
||||
try:
|
||||
from manos.medidas import lee_medidas
|
||||
self.cara.manda("medidas", lee_medidas())
|
||||
except Exception:
|
||||
pass
|
||||
tic += 1
|
||||
self._parar.wait(1.0)
|
||||
|
||||
def manda_catalogo(self):
|
||||
"""Lo que sabe hacer, agrupado, para la pestaña de la derecha."""
|
||||
grupos = {}
|
||||
for a in self.manos.catalogo.acciones:
|
||||
if not a.frases:
|
||||
continue
|
||||
grupos.setdefault(a.grupo or "otras", []).append({
|
||||
"id": a.id,
|
||||
"frase": a.frases[0],
|
||||
"argumento": a.captura,
|
||||
"confirma": a.confirmar,
|
||||
})
|
||||
datos = [{"grupo": g, "acciones": v} for g, v in
|
||||
sorted(grupos.items(), key=lambda x: -len(x[1]))]
|
||||
self.cara.manda("catalogo", datos)
|
||||
|
||||
def _tarea_cambio(self, tarea):
|
||||
"""Llamado desde el hilo de la tarea cada vez que hay algo que enseñar."""
|
||||
try:
|
||||
self.cara.manda("tarea", tarea.resumen())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def lanza(self, accion, argumento=""):
|
||||
"""Ejecuta una accion como TAREA, con tarjeta en el panel.
|
||||
|
||||
Se usa para lo que tarda. Lo instantaneo —el disco, la hora— no merece
|
||||
una tarjeta: aparece, parpadea y se va antes de que nadie la lea.
|
||||
"""
|
||||
return self.tareas.lanza(accion, argumento)
|
||||
|
||||
def manda_controles(self):
|
||||
"""El estado real de los interruptores, para que el rotulo no mienta.
|
||||
|
||||
Los botones NO se pintan al pulsarlos: se manda la orden y se espera a
|
||||
que el nucleo conteste con como quedo. Asi el boton nunca dice
|
||||
"despierto" con el centinela dormido.
|
||||
"""
|
||||
self.cara.manda("controles", {
|
||||
# encendido = atiende todo; apagado = solo si le llaman
|
||||
"sueno": not (self.centinela and self.centinela.activo),
|
||||
})
|
||||
|
||||
def estado(self, cual: str):
|
||||
self.cara.manda("estado", cual)
|
||||
|
||||
def dicho(self, texto: str, atendido=True, motivo=""):
|
||||
"""Lo que se ha oido, SIEMPRE, tambien cuando se descarta.
|
||||
|
||||
Esto es lo que faltaba y por lo que el panel parecia muerto: se oia, se
|
||||
transcribia, y si no llevaba la palabra de activacion se tiraba en
|
||||
silencio. Desde la pantalla eso es indistinguible de un microfono
|
||||
estropeado, y no hay forma de saber si el problema es que no te oye, que
|
||||
te oye mal, o que te oye bien y no te hace caso.
|
||||
|
||||
El diccionario y no la cadena pelada: el HUD lee datos.texto.
|
||||
"""
|
||||
self.cara.manda("dicho", {"texto": texto, "atendido": atendido,
|
||||
"motivo": motivo})
|
||||
|
||||
def respuesta(self, texto: str):
|
||||
self.cara.manda("respuesta", {"texto": texto})
|
||||
|
||||
def para(self):
|
||||
self._parar.set()
|
||||
try:
|
||||
self.tareas.para_todas()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --------------------------------------------------------------- pulsar
|
||||
|
||||
def pulsado(self, canal: str, dato: str):
|
||||
"""Lo que llega del panel. Corre en su propio hilo, puede tardar."""
|
||||
if canal == "listo":
|
||||
self.arranca()
|
||||
return
|
||||
if canal != "accion":
|
||||
return
|
||||
|
||||
# Desbloquear es lo unico que se atiende con el candado puesto.
|
||||
if dato.startswith("desbloquear:"):
|
||||
self._intenta_desbloquear(dato.split(":", 1)[1])
|
||||
return
|
||||
|
||||
# Todo lo demas son ORDENES, y con el candado puesto no hay ordenes.
|
||||
if self.candado and not self.desbloqueado:
|
||||
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
|
||||
return
|
||||
|
||||
if dato == "voz:siguiente":
|
||||
nueva = self.boca.siguiente()
|
||||
self.cara.manda("voz", {"nombre": nueva, "id": nueva})
|
||||
self.boca.di("Buenas noches, señor.")
|
||||
|
||||
elif dato == "prueba":
|
||||
threading.Thread(target=self._tanda_de_prueba, daemon=True).start()
|
||||
|
||||
elif dato.startswith("explica:"):
|
||||
self._explica(dato.split(":", 1)[1])
|
||||
|
||||
elif dato.startswith("parar_tarea:"):
|
||||
self.tareas.para(dato.split(":", 1)[1])
|
||||
|
||||
elif dato.startswith("cerrar_tarea:"):
|
||||
ident = dato.split(":", 1)[1]
|
||||
self.tareas.cierra(ident)
|
||||
self.cara.manda("tarea_fuera", {"id": ident})
|
||||
|
||||
elif dato == "sueno":
|
||||
# Cambia si hace falta llamarle por su nombre.
|
||||
#
|
||||
# Por defecto NO hace falta: medido sobre 412 frases reales, el
|
||||
# ruido ambiente no dispara nada, asi que exigir el nombre en cada
|
||||
# frase era peaje sin contrapartida. El interruptor se queda para
|
||||
# cuando haya una tele cerca.
|
||||
if not self.centinela:
|
||||
return
|
||||
self.centinela.activo = not self.centinela.activo
|
||||
if self.centinela.activo:
|
||||
self.centinela.duerme()
|
||||
self.boca.di_si_libre("Llameme por mi nombre, señor.")
|
||||
else:
|
||||
self.boca.di_si_libre("Le escucho, señor.")
|
||||
self.manda_controles()
|
||||
self.estado("escuchando" if not self.centinela.activo else "dormido")
|
||||
|
||||
elif dato.startswith("parar"):
|
||||
# Cortar lo que este pasando AHORA: las tareas en marcha y la voz.
|
||||
n = 0
|
||||
for t in self.tareas.todas():
|
||||
if t.estado == "corriendo" and self.tareas.para(t.id):
|
||||
n += 1
|
||||
self.boca.di_si_libre(f"Cortadas {n} tareas, señor." if n
|
||||
else "No habia nada corriendo, señor.")
|
||||
|
||||
# Cuanto se espera la respuesta a una pregunta antes de darla por olvidada.
|
||||
# Corto cansa —hay que pensar que fichero era—; largo hace que una frase
|
||||
# suelta media conversacion despues se tome por la respuesta.
|
||||
ESPERA_S = 45
|
||||
|
||||
def _explica(self, ident: str):
|
||||
"""Pulsar una accion PREGUNTA lo que falta y la hace.
|
||||
|
||||
No recita la frase que la dispara. "Diga «busca el fichero» y a
|
||||
continuacion lo que busca" es un manual de instrucciones, y uno pulsa
|
||||
justo porque no quiere leerse el manual.
|
||||
|
||||
espera argumento -> pregunta cual y lo ejecuta con lo que digas
|
||||
no tiene vuelta -> pide confirmacion antes
|
||||
ninguna cosa -> la hace, que es a lo que has pulsado
|
||||
|
||||
Lo destructivo sigue sin dispararse de un clic: pasa por un si o un no
|
||||
hablado, que es la misma red que tenia antes pero sin obligar a leer.
|
||||
"""
|
||||
accion = next((a for a in self.manos.catalogo.acciones if a.id == ident), None)
|
||||
if accion is None:
|
||||
return
|
||||
|
||||
if accion.captura:
|
||||
self._pregunta(accion, "argumento", pregunta_por(accion))
|
||||
elif accion.confirmar:
|
||||
self._pregunta(accion, "confirmar", confirmacion(accion))
|
||||
else:
|
||||
self.tareas.lanza(accion, "")
|
||||
|
||||
def _pregunta(self, accion, para_que, texto, argumento=""):
|
||||
self._argumento_pendiente = argumento
|
||||
self.pendiente = (accion, para_que, time.monotonic() + self.ESPERA_S,
|
||||
getattr(self, "_argumento_pendiente", ""))
|
||||
self.cara.manda("preguntando", {"texto": texto, "accion": accion.id})
|
||||
self.cara.manda("respuesta", {"texto": texto})
|
||||
self.boca.di(texto)
|
||||
|
||||
def esperando(self):
|
||||
"""La pregunta en el aire, o None si no hay o ya caduco."""
|
||||
if not self.pendiente:
|
||||
return None
|
||||
if time.monotonic() > self.pendiente[2]:
|
||||
self.olvida()
|
||||
return None
|
||||
return self.pendiente
|
||||
|
||||
def olvida(self):
|
||||
self.pendiente = None
|
||||
self.cara.manda("preguntando", {"texto": ""})
|
||||
|
||||
def responde(self, texto: str) -> bool:
|
||||
"""Contesta a lo preguntado. Devuelve si consumio la frase.
|
||||
|
||||
Se llama desde el bucle de voz ANTES del emparejador: mientras hay una
|
||||
pregunta en el aire, lo que se dice es la respuesta, no una orden nueva.
|
||||
"""
|
||||
pend = self.esperando()
|
||||
if not pend:
|
||||
return False
|
||||
accion, para_que, _, argumento = pend
|
||||
|
||||
if para_que == "confirmar":
|
||||
respuesta = es_si(texto)
|
||||
if respuesta is None:
|
||||
self.boca.di("No le he entendido. ¿Si o no, señor?")
|
||||
return True
|
||||
self.olvida()
|
||||
if respuesta:
|
||||
self.tareas.lanza(accion, argumento)
|
||||
else:
|
||||
self.boca.di("De acuerdo, lo dejo.")
|
||||
return True
|
||||
|
||||
# argumento: cualquier cosa que no sea una cancelacion vale
|
||||
if es_si(texto) is False:
|
||||
self.olvida()
|
||||
self.boca.di("De acuerdo, lo dejo.")
|
||||
return True
|
||||
self.olvida()
|
||||
arg = texto.strip()
|
||||
|
||||
# Si ademas no tiene vuelta atras, se confirma AHORA que ya se sabe
|
||||
# sobre que. Hay siete acciones que piden argumento y confirmacion
|
||||
# —"mata el proceso", "que puertos tiene abiertos"— y preguntando solo
|
||||
# el argumento se saltaban la red entera. Y confirmar antes de saber el
|
||||
# argumento seria pedir un cheque en blanco: "¿mato el proceso?" "¿cual?"
|
||||
if accion.confirmar:
|
||||
self._pregunta(accion, "confirmar",
|
||||
f"«{accion.frases[0]} {arg}». Esto no tiene vuelta "
|
||||
f"atras. ¿Confirma, señor?", argumento=arg)
|
||||
return True
|
||||
|
||||
self.tareas.lanza(accion, arg)
|
||||
return True
|
||||
|
||||
def _intenta_desbloquear(self, clave: str):
|
||||
if not clave:
|
||||
return
|
||||
# La misma GestorRoot de las manos: si la contraseña es buena, se queda
|
||||
# ahi y el cerebro puede hacer sudo durante la sesion. Desbloquear el
|
||||
# panel y abrir el root son el mismo gesto, con la misma contraseña.
|
||||
try:
|
||||
valida = self.manos.root.valida(clave)
|
||||
except Exception as e:
|
||||
self.cara.manda("bloqueo", {"mensaje": f"No se pudo comprobar: {e}",
|
||||
"error": True})
|
||||
return
|
||||
|
||||
if not valida:
|
||||
self._intentos += 1
|
||||
# Sin bloqueo por intentos ni espera creciente: la contraseña es la
|
||||
# del sistema y quien la teclea esta sentado delante del portatil.
|
||||
# Contarlos sirve para que el mensaje sea util, no para castigar.
|
||||
self.cara.manda("bloqueo", {
|
||||
"mensaje": "No es esa" + (f" ({self._intentos} intentos)"
|
||||
if self._intentos > 1 else ""),
|
||||
"error": True})
|
||||
return
|
||||
|
||||
self._intentos = 0
|
||||
self.desbloqueado = True
|
||||
self.manos.root.recuerda(clave) # root disponible para la sesion
|
||||
self.cara.manda("desbloqueado", True)
|
||||
self.boca.di_si_libre("A su servicio, señor.")
|
||||
|
||||
def bloquea(self):
|
||||
"""Echa el candado, aunque se arrancara sin el.
|
||||
|
||||
Que `--sin-candado` sea un arranque comodo no significa que uno no
|
||||
pueda querer cerrarlo al levantarse de la silla. Por eso esto ACTIVA el
|
||||
candado en vez de comprobar si estaba activo: negarse a cerrar porque
|
||||
no estaba puesto seria justo lo contrario de lo que se pide.
|
||||
"""
|
||||
self.candado = True
|
||||
self.desbloqueado = False
|
||||
self._intentos = 0
|
||||
self.manos.root.olvida() # y se cierra el root con el candado
|
||||
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
|
||||
|
||||
# Elegidas para que se vea el ciclo entero: una que acaba al instante, dos
|
||||
# que tardan un poco, y una larga a la que da tiempo a pulsarle el boton de
|
||||
# parar. Todas de solo lectura.
|
||||
# Cada una comprobada contra el catalogo: una que no encaje no lanza
|
||||
# tarjeta y la tanda queda coja sin decir por que.
|
||||
PRUEBA = ["cuanto espacio queda", "cuanta memoria", "que temperatura hay",
|
||||
"que esta consumiendo", "cuantos procesos hay"]
|
||||
|
||||
def _tanda_de_prueba(self):
|
||||
"""Lanza varias a la vez para ver las tarjetas trabajando.
|
||||
|
||||
Van como TAREAS y no ejecutadas a pelo: el objetivo es justo ver el
|
||||
ciclo entero —aparecer, contar segundos, terminar— y poder pulsarle el
|
||||
boton de parar a la que tarda.
|
||||
"""
|
||||
for orden in self.PRUEBA:
|
||||
accion, argumento = self.manos.busca(orden)
|
||||
if accion is None:
|
||||
continue
|
||||
self.tareas.lanza(accion, argumento)
|
||||
time.sleep(0.25)
|
||||
173
nucleo/cara/servidor.py
Normal file
173
nucleo/cara/servidor.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""La cara: sirve el HUD y lo mantiene al dia por WebSocket.
|
||||
|
||||
## Por que un servidor y no una ventana
|
||||
|
||||
En Newelle el HUD era un widget WebKit dentro de la aplicacion. Sacandolo a un
|
||||
servidor local se gana mas de lo que parece:
|
||||
|
||||
- el panel es una pagina normal: se abre con `chromium --app=`, en cualquier
|
||||
navegador, o desde el movil o una tablet en la misma red;
|
||||
- se puede mirar el diseño sin arrancar nada, que es como se ha trabajado en
|
||||
el todo el tiempo;
|
||||
- desaparece GTK de la cara, que era la ultima atadura al framework.
|
||||
|
||||
El formato de mensajes no cambia —{tipo, datos}, igual que empujaba Python por
|
||||
evaluate_javascript— asi que `hud.js` se queda sin tocar. Lo unico nuevo es
|
||||
`puente.js`, que suplanta `window.webkit` con el socket.
|
||||
|
||||
## Escucha solo en local
|
||||
|
||||
127.0.0.1 a proposito. Este panel ejecuta ordenes en la maquina: abrirlo a la
|
||||
red seria dar una terminal a cualquiera del wifi. Para verlo desde el movil, un
|
||||
tunel ssh, que ademas obliga a autenticarse.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import threading
|
||||
|
||||
HUD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hud")
|
||||
PUERTO = int(os.environ.get("JARVIS_PUERTO", "8790"))
|
||||
|
||||
FICHEROS = {
|
||||
"/": "index.html",
|
||||
"/index.html": "index.html",
|
||||
"/hud.js": "hud.js",
|
||||
"/puente.js": "puente.js",
|
||||
"/style.css": "style.css",
|
||||
}
|
||||
|
||||
|
||||
class Cara:
|
||||
"""El panel: se le empujan eventos y devuelve lo que se pulsa."""
|
||||
|
||||
def __init__(self, al_pulsar=None, puerto=PUERTO):
|
||||
self.al_pulsar = al_pulsar # (canal, dato) -> None
|
||||
self.puerto = puerto
|
||||
self._clientes = set()
|
||||
self._bucle = None
|
||||
self._hilo = None
|
||||
self._ultimo = {} # el estado, para el que llegue tarde
|
||||
|
||||
# ------------------------------------------------------------- arrancar
|
||||
|
||||
def arranca(self):
|
||||
"""Levanta el servidor en un hilo aparte y vuelve enseguida.
|
||||
|
||||
Devuelve la URL, o None si no se pudo. El motivo mas comun es que ya
|
||||
haya otro JARVIS abierto: sin este manejo, el fallo salia como una traza
|
||||
de asyncio dentro de un hilo, con "address already in use" enterrado en
|
||||
la linea veinte, y el proceso seguia vivo sin panel.
|
||||
"""
|
||||
listo = threading.Event()
|
||||
|
||||
def corre():
|
||||
self._bucle = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._bucle)
|
||||
try:
|
||||
self._bucle.run_until_complete(self._sirve(listo))
|
||||
except OSError as e:
|
||||
self._error = e
|
||||
listo.set()
|
||||
return
|
||||
self._bucle.run_forever()
|
||||
|
||||
self._error = None
|
||||
self._hilo = threading.Thread(target=corre, daemon=True)
|
||||
self._hilo.start()
|
||||
listo.wait(timeout=10)
|
||||
if self._error is not None:
|
||||
import errno
|
||||
if self._error.errno == errno.EADDRINUSE:
|
||||
print(f" el puerto {self.puerto} ya esta cogido: ¿hay otro "
|
||||
f"JARVIS abierto? (arranca.sh --stop, o JARVIS_PUERTO=otro)")
|
||||
else:
|
||||
print(f" no se pudo levantar el panel: {self._error}")
|
||||
return None
|
||||
return f"http://127.0.0.1:{self.puerto}/"
|
||||
|
||||
async def _sirve(self, listo):
|
||||
import websockets
|
||||
from websockets.http11 import Response
|
||||
from websockets.datastructures import Headers
|
||||
|
||||
async def http(conexion, peticion):
|
||||
"""Sirve los ficheros del HUD; deja pasar el WebSocket."""
|
||||
ruta = peticion.path.split("?")[0]
|
||||
if ruta == "/puente":
|
||||
return None # que siga el apreton de manos
|
||||
nombre = FICHEROS.get(ruta)
|
||||
if not nombre:
|
||||
return Response(404, "Not Found", Headers(), b"no esta\n")
|
||||
try:
|
||||
with open(os.path.join(HUD, nombre), "rb") as f:
|
||||
cuerpo = f.read()
|
||||
except OSError:
|
||||
return Response(404, "Not Found", Headers(), b"no esta\n")
|
||||
tipo = mimetypes.guess_type(nombre)[0] or "application/octet-stream"
|
||||
cabeceras = Headers({
|
||||
"Content-Type": tipo + ("; charset=utf-8" if "text" in tipo
|
||||
or "javascript" in tipo else ""),
|
||||
"Content-Length": str(len(cuerpo)),
|
||||
# Sin cache: el HUD se edita en caliente y recargar tiene que
|
||||
# traer lo nuevo, no lo de hace media hora.
|
||||
"Cache-Control": "no-store",
|
||||
})
|
||||
return Response(200, "OK", cabeceras, cuerpo)
|
||||
|
||||
self._servidor = await websockets.serve(
|
||||
self._cliente, "127.0.0.1", self.puerto, process_request=http)
|
||||
listo.set()
|
||||
|
||||
# -------------------------------------------------------------- clientes
|
||||
|
||||
async def _cliente(self, ws):
|
||||
self._clientes.add(ws)
|
||||
try:
|
||||
# Al conectar se le manda el estado que ya habia. Sin esto, abrir el
|
||||
# panel a media sesion enseña un HUD vacio hasta que algo cambie.
|
||||
for mensaje in self._ultimo.values():
|
||||
await ws.send(json.dumps(mensaje))
|
||||
async for crudo in ws:
|
||||
try:
|
||||
m = json.loads(crudo)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if self.al_pulsar:
|
||||
# En otro hilo: atender una pulsacion puede tardar (hablar,
|
||||
# cambiar de voz, lanzar una tarea) y el bucle del socket no
|
||||
# puede quedarse esperando o el panel se congela entero.
|
||||
threading.Thread(
|
||||
target=self.al_pulsar,
|
||||
args=(m.get("canal", ""), m.get("dato", "")),
|
||||
daemon=True).start()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._clientes.discard(ws)
|
||||
|
||||
# --------------------------------------------------------------- empujar
|
||||
|
||||
def manda(self, tipo: str, datos):
|
||||
"""Empuja un evento al panel. Se puede llamar desde cualquier hilo."""
|
||||
mensaje = {"tipo": tipo, "datos": datos}
|
||||
# Se recuerda lo ultimo de cada tipo salvo lo que caduca al instante:
|
||||
# el pulso de la voz a 15 por segundo no tiene sentido reenviarlo.
|
||||
if tipo not in ("pulso",):
|
||||
self._ultimo[tipo] = mensaje
|
||||
if not self._bucle or not self._clientes:
|
||||
return
|
||||
crudo = json.dumps(mensaje, ensure_ascii=False)
|
||||
asyncio.run_coroutine_threadsafe(self._reparte(crudo), self._bucle)
|
||||
|
||||
async def _reparte(self, crudo):
|
||||
for ws in list(self._clientes):
|
||||
try:
|
||||
await ws.send(crudo)
|
||||
except Exception:
|
||||
self._clientes.discard(ws)
|
||||
|
||||
@property
|
||||
def conectados(self) -> int:
|
||||
return len(self._clientes)
|
||||
Loading…
Add table
Add a link
Reference in a new issue