diff options
| -rwxr-xr-x | desktop/modules/ai/ai-state.sh | 106 | ||||
| -rwxr-xr-x | desktop/modules/ai/test-ai-state.sh | 136 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-10-05-ai-module-design.md | 6 |
3 files changed, 245 insertions, 3 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 ]] diff --git a/docs/superpowers/specs/2026-10-05-ai-module-design.md b/docs/superpowers/specs/2026-10-05-ai-module-design.md index cef2e88..b1f0f7e 100644 --- a/docs/superpowers/specs/2026-10-05-ai-module-design.md +++ b/docs/superpowers/specs/2026-10-05-ai-module-design.md @@ -20,7 +20,7 @@ Two kinds of thing are shown: | desktop-assistant | `assistant.sh status`, exit code | `assistant.sh start` | `assistant.sh stop` | | local-chat | `llamachat --ping`, exit code | `llamachat --daemon` | `llamachat --quit` | | imggen | `imggen status`, `daemon down` means stopped | `imggen start` | `imggen stop` | -| fanfictioner | `pgrep -u $USER -f` on the script path | none | kill its PID and its children by PID | +| fanfictioner | `pgrep -u $USER -f` on `python3 .../fanfictioner` | none | kill its PID and its children by PID | The module uses each project's existing control surface. No project gains a new script. imggen starts its default model (`sdxl`); there is no picker. @@ -68,8 +68,8 @@ All under `desktop/modules/ai/`, the shape of `modules/kdeconnect/`. A probe that fails reports its row as down or stopped; the script always exits 0 with all six lines. Every external command (`curl`, `pgrep`, the four controls, `stat`) is called by name so the test can stub it on PATH. - Paths to `assistant.sh` and the fanfictioner script default to - `~/Programming/GIT/...` and are overridable by environment variable, as + The path to `assistant.sh` defaults to + `~/Programming/GIT/...` and is overridable by environment variable, as imggen does with `IMGGEN_DIR`. - `AiModule.qml`: `name: "ai"`, a `Process` running the script every 3s while the tile exists, which is exactly while the drawer panel is loaded, |
