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
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