JARVIS/jarvis-launcher.sh
sito 8e4bc8ad94 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.
2026-08-16 15:34:37 +02:00

188 lines
7.4 KiB
Bash
Executable file

#!/usr/bin/env bash
# JARVIS — lanzador del asistente local (Newelle)
#
# sin argumentos abre JARVIS a pantalla completa, sin pasar por el chat
# --chat abre Newelle normal, con sus pestañas y su historial
# --stop la cierra y libera el modelo
# --status que corre, con que cerebro y cuanto ocupa
#
# El lanzador NO decide el motor: lo lee de gsettings (language-model) y se
# adapta. Asi no hay que tocarlo cada vez que se cambia de cerebro, que es
# justo lo que lo dejo desincronizado antes: precargaba Ollama mientras la
# app usaba su llamacpp interno, ocupando 2.9 GB de VRAM para nada.
#
# Y el MODELO tampoco, por lo mismo. Estaba escrito a mano aqui y volvio a
# pasar exactamente lo de antes: al cambiar la app a qwen3.5:4b-jarvis, el
# lanzador seguia precargando qwen3.5:4b. Los dos no caben en 3717 MiB, asi
# que ollama tenia que descargar uno para cargar el otro con el panel ya
# esperando: 6,55 s hasta la primera respuesta contra 3,70 con los dos de
# acuerdo. El sintoma no era "va lento", era "no se abre el panel solo".
set -uo pipefail
APPID=io.github.qwersyk.Newelle
OLLAMA_URL=http://localhost:11434
# La salida de la app iba entera a /dev/null. Cuando el panel dejo de abrirse,
# el traceback que lo explicaba —una linea— no estaba en ningun sitio, y el
# fallo se diagnostico dos veces mal antes de mirarlo. Ahora se guarda.
# misma idea que en los scripts de voz: la raiz sale de donde vive esto
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
REGISTRO=$RAIZ/jarvis-app.log
: > "$REGISTRO" 2>/dev/null || REGISTRO=/dev/null
GGUF=$HOME/.var/app/$APPID/config/models/custom_models/qwen3.5-4b.gguf
# Elige rama: //master es la version propia (jarvis-skin, con reactor arc);
# //stable es la de Flathub, la red de seguridad.
#
# El chequeo mira la typelib de Vte en lib/: si un build deja mal el libdir,
# la app arranca y muere con "Namespace Vte not available". Asi el icono
# nunca abre una version rota, y se pasa solo a la propia cuando este sana.
pick_branch() {
local base=$HOME/.local/share/flatpak/app/$APPID/x86_64
if [ -f "$base/master/active/files/lib/girepository-1.0/Vte-3.91.typelib" ]; then
echo "master"
else
echo "stable"
fi
}
RAMA=$(pick_branch)
motor() {
flatpak run --command=gsettings "$APPID//$RAMA" get "$APPID" language-model 2>/dev/null \
| tr -d "'" | tr -d '\n'
}
# El modelo que la app tiene elegido de verdad, leido de sus ajustes. Si no se
# puede leer se cae al 4b pelado, que es mejor que precargar algo que no existe.
modelo() {
flatpak run --command=gsettings "$APPID//$RAMA" get "$APPID" llm-settings 2>/dev/null \
| python3 -c "
import json, sys
s = sys.stdin.read().strip()
try:
d = json.loads(s[1:-1].replace('\\\\\\\\', '\\\\').replace(\"\\\\'\", \"'\"))
print(d.get('ollama', {}).get('model') or 'qwen3.5:4b')
except Exception:
print('qwen3.5:4b')
" 2>/dev/null || echo qwen3.5:4b
}
MODEL=$(modelo)
notify() { command -v notify-send >/dev/null && notify-send -i jarvis "JARVIS" "$1"; }
ollama_up() { curl -s --max-time 2 "$OLLAMA_URL/api/version" >/dev/null 2>&1; }
app_corriendo() { pgrep -f "bin/newelle" >/dev/null; }
# El llama-server interno de la app, no el de Ollama: el suyo vive en /app/bin.
servidor_interno() { pgrep -f "/app/bin/llama-server" | head -1; }
case "${1:-start}" in
--stop)
# flatpak kill y no pkill: el patron "bin/newelle" tambien casa con la
# propia linea de comandos cuando esto se invoca desde un bash -c inline,
# y el script se mataba a si mismo antes de llegar aqui.
flatpak kill "$APPID" 2>/dev/null || pkill -f "bin/newelle" 2>/dev/null
if [ "$(motor)" = "ollama" ] && ollama_up; then
curl -s "$OLLAMA_URL/api/generate" \
-d "{\"model\":\"$MODEL\",\"keep_alive\":0}" >/dev/null 2>&1
notify "Detenido, VRAM liberada"
else
notify "Detenido"
fi
echo "JARVIS detenido."
;;
--status|--status-notify)
MOTOR=$(motor)
app_corriendo && S1="app: abierta (//$RAMA)" || S1="app: cerrada"
S2="cerebro: ${MOTOR:-?}"
if [ "$MOTOR" = "ollama" ]; then
S3=$(curl -s "$OLLAMA_URL/api/ps" 2>/dev/null \
| python3 -c 'import json,sys
ms = json.load(sys.stdin).get("models", [])
print("\n".join("cargado: %s %.1f GB (%.1f en GPU)"
% (m["name"], m.get("size",0)/1e9, m.get("size_vram",0)/1e9) for m in ms)
or "sin modelos cargados")' 2>/dev/null)
[ -n "$S3" ] || S3="ollama: caido"
else
PID=$(servidor_interno)
if [ -n "$PID" ]; then
S3="cargado en RAM ($(awk '/VmRSS/{printf "%.1f GB", $2/1048576}' \
"/proc/$PID/status" 2>/dev/null))"
else
S3="sin modelos cargados"
fi
fi
S4=$(nvidia-smi --query-gpu=memory.used,memory.free --format=csv,noheader 2>/dev/null \
| sed 's/^/VRAM usada\/libre: /')
if [ "$1" = "--status-notify" ]; then
notify "$S1 · $S2"$'\n'"$S3"$'\n'"$S4"
else
printf '%s\n%s\n\n%s\n%s\n' "$S1" "$S2" "$S3" "$S4"
fi
;;
*)
MOTOR=$(motor)
if [ "$MOTOR" = "ollama" ]; then
if ! ollama_up; then
systemctl start ollama 2>/dev/null || sudo systemctl start ollama
for _ in $(seq 20); do ollama_up && break; sleep 0.5; done
fi
ollama_up || { notify "Ollama no arranca"; echo "ERROR: ollama no responde" >&2; exit 1; }
# precargar en GPU para que la primera respuesta no tarde.
# think:false o se va a 40 s pensando en voz alta.
curl -s "$OLLAMA_URL/api/generate" \
-d "{\"model\":\"$MODEL\",\"prompt\":\"ok\",\"stream\":false,\"think\":false,\"keep_alive\":\"30m\"}" \
>/dev/null 2>&1 &
else
[ -f "$GGUF" ] || { notify "Falta el modelo: $(basename "$GGUF")"
echo "ERROR: no esta $GGUF" >&2; exit 1; }
fi
# --chat abre Newelle tal cual; sin argumentos va derecho al asistente
DIRECTO=si
[ "${1:-}" = "--chat" ] && DIRECTO=no
if app_corriendo; then
command -v xdotool >/dev/null && \
xdotool search --name "^newelle$" windowactivate 2>/dev/null
elif [ "$DIRECTO" = "no" ]; then
exec flatpak run "$APPID//$RAMA"
else
# En segundo plano y no con exec: hay que esperar a que exista la ventana
# antes de pedir la pestaña. --run-action se procesa antes de que la
# ventana este construida, asi que lanzarlo de golpe no serviria.
#
# Y esperar al PROCESO no basta: el proceso existe en cuanto python
# arranca, varios segundos antes de que haya ventana. Con `sleep 3` a
# ojo, un arranque un poco mas lento pierde la carrera y la accion
# revienta con "'MyApp' object has no attribute 'win'" — la app abre,
# pero en el chat y sin panel. Se espera a la VENTANA, que es la
# condicion de verdad.
flatpak run "$APPID//$RAMA" >>"$REGISTRO" 2>&1 &
for _ in $(seq 60); do
if command -v xdotool >/dev/null; then
xdotool search --name "^newelle$" >/dev/null 2>&1 && break
else
pgrep -f "bin/newelle" >/dev/null && break
fi
sleep 0.5
done
# sin xdotool no hay forma de saberlo: se vuelve al margen a ojo
command -v xdotool >/dev/null || sleep 3
fi
if [ "$DIRECTO" = "si" ]; then
# jarvis_full y no jarvis+hud: el segundo alterna, asi que pulsar el
# icono con la app ya abierta te sacaria de pantalla completa.
flatpak run "$APPID//$RAMA" --run-action=jarvis_full >>"$REGISTRO" 2>&1
fi
;;
esac