aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-05 12:50:37 +0200
committerDanilo M. <danix@danix.xyz>2026-10-05 12:50:37 +0200
commitc4e3406c8241ada9b9d7069149f88ab55c3b5952 (patch)
treeed89a14c83f6c9a858523b2abb66d37fe123ed46 /desktop
parent69f34da5a290b0f12b65242ae6ad21491d182a71 (diff)
downloadquickshell-c4e3406c8241ada9b9d7069149f88ab55c3b5952.tar.gz
quickshell-c4e3406c8241ada9b9d7069149f88ab55c3b5952.zip
feat(ai): state sweep for the AI stack
Six TSV lines per sweep: llama and sd engines, four apps. llama's slot probe passes autoload=false, since a plain /slots?model= loads the model into VRAM. sd-server has no progress endpoint, so busy is its log having been written in the last 5s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop')
-rwxr-xr-xdesktop/modules/ai/ai-state.sh106
-rwxr-xr-xdesktop/modules/ai/test-ai-state.sh136
2 files changed, 242 insertions, 0 deletions
diff --git a/desktop/modules/ai/ai-state.sh b/desktop/modules/ai/ai-state.sh
new file mode 100755
index 0000000..b3b9642
--- /dev/null
+++ b/desktop/modules/ai/ai-state.sh
@@ -0,0 +1,106 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# One sweep of the local AI stack for the drawer's AI page. Always prints
+# these six lines, tab separated, and exits 0:
+#
+# engine<TAB>llama<TAB>up|down<TAB>idle|busy<TAB>model
+# engine<TAB>sd<TAB>up|down<TAB>idle|busy<TAB>model
+# app<TAB>assistant<TAB>running|stopped<TAB>-
+# app<TAB>chat<TAB>running|stopped<TAB>-
+# app<TAB>imggen<TAB>running|stopped<TAB>model
+# app<TAB>fanfic<TAB>running|stopped<TAB>pid
+#
+# An empty value is "-". A probe that fails reads as down or stopped, not as
+# an error: every row is something that may simply not be running.
+#
+# The test stubs curl, pgrep, llamachat and imggen on PATH and points
+# AI_ASSISTANT and AI_SD_LOG at fixtures; see test-ai-state.sh.
+
+set -u
+
+LLAMA="${AI_LLAMA_URL:-http://127.0.0.1:8181}"
+SD_LOG="${AI_SD_LOG:-${XDG_CACHE_HOME:-$HOME/.cache}/sd-server.log}"
+ASSISTANT="${AI_ASSISTANT:-$HOME/Programming/GIT/desktop-assistant/assistant.sh}"
+
+row() { local IFS=$'\t'; printf '%s\n' "$*"; }
+
+llama() {
+ local models id busy=idle
+ models="$(curl -sf -m 2 "$LLAMA/models")" || { row engine llama down idle -; return; }
+ id="$(jq -r 'first(.data[] | select(.status.value == "loaded") | .id) // empty' \
+ <<<"$models" 2>/dev/null)"
+ # autoload=false is not optional: without it this probe loads an unloaded
+ # model into VRAM, and the model can unload between the two requests.
+ if [[ -n "$id" ]] &&
+ curl -sf -m 2 -G --data-urlencode "model=$id" -d autoload=false "$LLAMA/slots" |
+ jq -e 'any(.[]; .is_processing)' >/dev/null 2>&1; then
+ busy=busy
+ fi
+ row engine llama up "$busy" "${id:--}"
+}
+
+sd() {
+ local line words i model=- busy=idle mtime
+ line="$(pgrep -axo sd-server)" || { row engine sd down idle -; return; }
+ read -ra words <<<"$line"
+ for ((i = 1; i < ${#words[@]} - 1; i++)); do
+ case "${words[i]}" in
+ --diffusion-model|-m) model="${words[i + 1]##*/}" ;;
+ esac
+ done
+ # ponytail: sd-server has no progress endpoint, but its log streams
+ # progress bars while generating, so a log written in the last 5s means
+ # busy. A step slower than 5s reads as idle.
+ mtime="$(stat -c %Y "$SD_LOG" 2>/dev/null)" || mtime=0
+ (( $(date +%s) - mtime < 5 )) && busy=busy
+ row engine sd up "$busy" "$model"
+}
+
+apps() {
+ local out pid
+
+ if "$ASSISTANT" status >/dev/null 2>&1; then
+ row app assistant running -
+ else
+ row app assistant stopped -
+ fi
+
+ if llamachat --ping >/dev/null 2>&1; then
+ row app chat running -
+ else
+ row app chat stopped -
+ fi
+
+ # imggen status exits 0 either way: the daemon's JSON when it is up,
+ # "daemon down" when it is not.
+ out="$(imggen status 2>/dev/null)"
+ if [[ "$out" == "{"* ]]; then
+ row app imggen running "$(jq -r '.model // "-"' <<<"$out" 2>/dev/null || echo -)"
+ else
+ row app imggen stopped -
+ fi
+
+ # It runs through env, so the command line is python3 then the script,
+ # by path from ~/bin or by ./fanfictioner from the repo.
+ if pid="$(pgrep -u "$USER" -fo '^[^ ]*python3[^ ]* [^ ]*fanfictioner( |$)')"; then
+ row app fanfic running "$pid"
+ else
+ row app fanfic stopped -
+ fi
+}
+
+llama
+sd
+apps
+exit 0
diff --git a/desktop/modules/ai/test-ai-state.sh b/desktop/modules/ai/test-ai-state.sh
new file mode 100755
index 0000000..bb6038d
--- /dev/null
+++ b/desktop/modules/ai/test-ai-state.sh
@@ -0,0 +1,136 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The one runnable check for ai-state.sh. It puts stub curl, pgrep,
+# llamachat and imggen on PATH and a stub assistant.sh beside them, then runs
+# the real script, so nothing here touches the live stack. T_* variables pick
+# the state each stub reports.
+#
+# Usage: ./test-ai-state.sh (exit 0 = all passed)
+
+set -u
+
+here="$(cd "$(dirname "$0")" && pwd)"
+stub="$(mktemp -d)"
+trap 'rm -rf "$stub"' EXIT
+
+# The last argument is the URL. /slots refuses a probe without
+# autoload=false, because the real server would load the model for it.
+cat > "$stub/curl" <<'STUB'
+#!/bin/bash
+url="${!#}"
+case "$url" in
+ */models)
+ case "${T_LLAMA:-down}" in
+ down) exit 7 ;;
+ unloaded) echo '{"data":[{"id":"A","status":{"value":"unloaded"}}]}' ;;
+ *) echo '{"data":[{"id":"A","status":{"value":"unloaded"}},{"id":"Gemma","status":{"value":"loaded"}}]}' ;;
+ esac ;;
+ */slots)
+ [[ " $* " == *" autoload=false "* ]] || exit 99
+ [[ " $* " == *" model=Gemma "* ]] || exit 22
+ if [[ "$T_LLAMA" == busy ]]; then
+ echo '[{"id":0,"is_processing":false},{"id":1,"is_processing":true}]'
+ else
+ echo '[{"id":0,"is_processing":false}]'
+ fi ;;
+ *) exit 6 ;;
+esac
+STUB
+
+cat > "$stub/pgrep" <<'STUB'
+#!/bin/bash
+case "$*" in
+ *sd-server*)
+ [[ -n "${T_SD:-}" ]] || exit 1
+ echo "4242 sd-server --listen-port 7860 --diffusion-model /data/SD/z_image_turbo-Q8_0.gguf --vae /data/SD/vae/flux1-ae.safetensors" ;;
+ *fanfictioner*)
+ [[ -n "${T_FANFIC:-}" ]] || exit 1
+ echo 31337 ;;
+ *) exit 1 ;;
+esac
+STUB
+
+cat > "$stub/llamachat" <<'STUB'
+#!/bin/bash
+[[ "$1" == --ping && -n "${T_CHAT:-}" ]]
+STUB
+
+# The real imggen exits 0 either way and says which on stdout.
+cat > "$stub/imggen" <<'STUB'
+#!/bin/bash
+[[ "$1" == status ]] || exit 1
+if [[ -n "${T_IMG:-}" ]]; then echo '{"model": "realvis", "ready": true}'; else echo "daemon down"; fi
+STUB
+
+cat > "$stub/assistant.sh" <<'STUB'
+#!/bin/bash
+[[ "$1" == status && -n "${T_ASSIST:-}" ]]
+STUB
+
+chmod +x "$stub"/*
+log="$stub/sd-server.log"
+: > "$log"
+
+pass=0
+fail=0
+
+check() {
+ local name="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ pass=$((pass + 1))
+ else
+ fail=$((fail + 1))
+ printf 'FAIL: %s\n want: %q\n got: %q\n' "$name" "$want" "$got"
+ fi
+}
+
+# One line of the protocol, tab joined.
+l() { local IFS=$'\t'; printf '%s\n' "$*"; }
+
+run() {
+ env PATH="$stub:$PATH" AI_ASSISTANT="$stub/assistant.sh" AI_SD_LOG="$log" "$@" \
+ bash "$here/ai-state.sh"
+}
+
+apps_down="$(l app assistant stopped -; l app chat stopped -; l app imggen stopped -; l app fanfic stopped -)"
+
+want="$(l engine llama down idle -; l engine sd down idle -)
+$apps_down"
+check "everything down" "$want" "$(run)"
+run >/dev/null
+check "everything down exits zero" 0 $?
+
+got="$(run T_LLAMA=unloaded | head -n1)"
+check "llama up, no model loaded" "$(l engine llama up idle -)" "$got"
+
+got="$(run T_LLAMA=idle | head -n1)"
+check "llama loaded, idle" "$(l engine llama up idle Gemma)" "$got"
+
+got="$(run T_LLAMA=busy | head -n1)"
+check "llama loaded, generating" "$(l engine llama up busy Gemma)" "$got"
+
+touch "$log"
+got="$(run T_SD=1 | sed -n 2p)"
+check "sd running, log just written" "$(l engine sd up busy z_image_turbo-Q8_0.gguf)" "$got"
+
+touch -d '1 minute ago' "$log"
+got="$(run T_SD=1 | sed -n 2p)"
+check "sd running, log quiet" "$(l engine sd up idle z_image_turbo-Q8_0.gguf)" "$got"
+
+want="$(l app assistant running -; l app chat running -; l app imggen running realvis; l app fanfic running 31337)"
+got="$(run T_ASSIST=1 T_CHAT=1 T_IMG=1 T_FANFIC=1 | tail -n4)"
+check "every app running" "$want" "$got"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]