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
188
jarvis-launcher.sh
Executable file
188
jarvis-launcher.sh
Executable file
|
|
@ -0,0 +1,188 @@
|
|||
#!/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
|
||||
Loading…
Add table
Add a link
Reference in a new issue