From 69f34da5a290b0f12b65242ae6ad21491d182a71 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 5 Oct 2026 12:45:07 +0200 Subject: docs(ai): implementation plan for the AI module Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-10-05-ai-module.md | 836 +++++++++++++++++++++++++ 1 file changed, 836 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-05-ai-module.md (limited to 'docs') diff --git a/docs/superpowers/plans/2026-10-05-ai-module.md b/docs/superpowers/plans/2026-10-05-ai-module.md new file mode 100644 index 0000000..6814d95 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-ai-module.md @@ -0,0 +1,836 @@ +# AI Module Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A drawer module showing the local AI engines (read-only) and the apps using them (start/stop). + +**Architecture:** `ai-state.sh` sweeps the stack once and prints six TSV lines. `AiModule.qml` polls it every 3s while the drawer tile exists, and runs start/stop through each project's own control with `Quickshell.execDetached`. Same shape as `desktop/modules/kdeconnect/`. + +**Tech Stack:** bash, curl, jq, pgrep; Quickshell 0.3.1 QML. + +Spec: `docs/superpowers/specs/2026-10-05-ai-module-design.md`. + +Rules from `AGENTS.md` that apply here: +- GPLv2 header in every source file (copy it from any existing file below). +- No absolute home paths in committed files; use `$HOME` / `~`. +- Never start a detached `qs` from a tool call, never `pkill -f`. The user's desktop shell hot-reloads on save; verify with its log, ask the user for anything visual. +- Never probe llama `/slots` without `autoload=false`: it loads the model into VRAM. + +--- + +### Task 1: `ai-state.sh` with its test + +**Files:** +- Create: `desktop/modules/ai/test-ai-state.sh` +- Create: `desktop/modules/ai/ai-state.sh` +- Modify: `docs/superpowers/specs/2026-10-05-ai-module-design.md` (fanfictioner detection wording) + +- [ ] **Step 1: Write the failing test** + +`desktop/modules/ai/test-ai-state.sh`: + +```bash +#!/bin/bash +# +# Copyright (C) 2026 Danilo M. +# +# 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 ]] +``` + +Then `chmod +x desktop/modules/ai/test-ai-state.sh`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `desktop/modules/ai/test-ai-state.sh` +Expected: every check FAILs (bash reports `ai-state.sh: No such file or directory`), last line `0 passed, 8 failed` or similar, exit 1. + +- [ ] **Step 3: Write the script** + +`desktop/modules/ai/ai-state.sh`: + +```bash +#!/bin/bash +# +# Copyright (C) 2026 Danilo M. +# +# 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: +# +# enginellamaup|downidle|busymodel +# enginesdup|downidle|busymodel +# appassistantrunning|stopped- +# appchatrunning|stopped- +# appimggenrunning|stoppedmodel +# appfanficrunning|stoppedpid +# +# 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 +``` + +Then `chmod +x desktop/modules/ai/ai-state.sh`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `desktop/modules/ai/test-ai-state.sh` +Expected: `8 passed, 0 failed`, exit 0. + +- [ ] **Step 5: Check the fanfictioner pattern against both real launch forms** + +The stub pgrep does not exercise the regex, so check it with grep (same ERE dialect): + +```bash +re='^[^ ]*python3[^ ]* [^ ]*fanfictioner( |$)' +printf '%s\n' 'python3 /home/you/bin/fanfictioner story.txt' \ + 'python3 ./fanfictioner --base krea2 story.txt' \ + 'vim fanfictioner' \ + 'python3 /home/you/bin/fanfictioner-old x' | grep -cE "$re" +``` +Expected: `2`. + +- [ ] **Step 6: Run against the live stack, read-only** + +Run: `desktop/modules/ai/ai-state.sh` +Expected: six lines. llama `up`, model whatever `curl -s localhost:8181/models` reports loaded (or `-`). Then confirm the probe loaded nothing: +`curl -s localhost:8181/models | jq -r '.data[] | .id + " " + .status.value'` shows the same loaded set as before the run. + +- [ ] **Step 7: Fix the spec wording** + +In `docs/superpowers/specs/2026-10-05-ai-module-design.md`, the fanfictioner table row's status cell, replace +`` `pgrep -u $USER -f` on the script path `` with +`` `pgrep -u $USER -f` on `python3 .../fanfictioner` `` +(it runs from a `~/bin` symlink or the repo, so no single path matches), and in the `ai-state.sh` bullet replace +`Paths to `assistant.sh` and the fanfictioner script default to` with `The path to `assistant.sh` defaults to`, and `are overridable` with `is overridable`. + +- [ ] **Step 8: Commit** + +```bash +git add desktop/modules/ai/ai-state.sh desktop/modules/ai/test-ai-state.sh docs/superpowers/specs/2026-10-05-ai-module-design.md +git commit -F - <<'EOF' +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 +EOF +``` + +--- + +### Task 2: the module, tile, row and page + +**Files:** +- Create: `desktop/modules/ai/AiModule.qml` +- Create: `desktop/modules/ai/AiTile.qml` +- Create: `desktop/modules/ai/AiRow.qml` +- Create: `desktop/modules/ai/AiPage.qml` +- Modify: `desktop/shell.qml` (import and grid list) + +No unit test: QML here has no harness. Verification is the running shell's log plus the user's eyes (Step 7). + +- [ ] **Step 1: `AiModule.qml`** + +```qml +// Copyright (C) 2026 Danilo M. +// +// 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. + +import Quickshell +import Quickshell.Io +import QtQuick +import "../.." + +// Not always active: the stack is polled while the drawer is open and not +// otherwise. The poll's lifetime is the tile's lifetime; see AiTile.qml. +Module { + id: mod + + name: "ai" + label: "AI" + icon: "" + alwaysActive: false + + // Rebuilt from ai-state.sh on every poll, in the script's order. See the + // script for the line protocol; the field order here must match it. + property var engines: [] + property var apps: [] + + readonly property var runningApps: apps.filter(a => a.running) + + // Something is holding VRAM: a loaded llama model, a running sd-server, + // or an app. + active: runningApps.length > 0 + || engines.some(e => e.up && (e.name === "sd" || e.model !== "")) + + readonly property var labels: ({ + llama: "llama.cpp", + sd: "stable-diffusion.cpp", + assistant: "Desktop assistant", + chat: "Local chat", + imggen: "imggen", + fanfic: "Fanfictioner", + }) + + readonly property string assistantSh: + Quickshell.env("HOME") + "/Programming/GIT/desktop-assistant/assistant.sh" + + // Each project's own control. Fanfictioner has no start, a run needs a + // story file, and its stop needs the pid, see stop(). + readonly property var controls: ({ + assistant: { start: [assistantSh, "start"], stop: [assistantSh, "stop"] }, + chat: { start: ["llamachat", "--daemon"], stop: ["llamachat", "--quit"] }, + imggen: { start: ["imggen", "start"], stop: ["imggen", "stop"] }, + }) + + function canStart(app) { return controls[app.name] !== undefined; } + + function start(app) { + if (canStart(app)) exec(controls[app.name].start); + } + + function stop(app) { + if (app.name !== "fanfic") { + exec(controls[app.name].stop); + return; + } + // The sd-cli children first, then the script, by pid only: a name + // match would take unrelated sd-cli and python processes with it. + if (!/^[0-9]+$/.test(app.detail)) return; + exec(["sh", "-c", 'pkill -TERM -P "$1"; kill -TERM "$1"', "sh", app.detail]); + } + + // Detached, so a start outlives a quickshell reload. No exit code comes + // back; the next poll shows whether it worked. + function exec(cmd) { + Quickshell.execDetached(cmd); + repoll.restart(); + } + + property Timer repoll: Timer { + interval: 1000 + onTriggered: mod.refresh() + } + + // The tile is created when the drawer panel loads, grid or page, and + // destroyed when it unloads, so its presence is the poll's on/off switch. + property bool polling: false + onPollingChanged: if (polling) refresh() + + function refresh() { + stateProc.engines = []; + stateProc.apps = []; + stateProc.running = false; + stateProc.running = true; + } + + property Process stateProc: Process { + property var engines: [] + property var apps: [] + command: ["bash", `${Quickshell.shellDir}/modules/ai/ai-state.sh`] + stdout: SplitParser { + onRead: line => { + const f = line.split("\t"); + const v = s => s === "-" ? "" : s; + if (f[0] === "engine" && f.length === 5) + stateProc.engines.push({ + name: f[1], up: f[2] === "up", busy: f[3] === "busy", model: v(f[4]), + }); + else if (f[0] === "app" && f.length === 4) + stateProc.apps.push({ + name: f[1], running: f[2] === "running", detail: v(f[3]), + }); + } + } + // A run that printed nothing (bash itself failing) keeps the last + // state rather than blanking the page. + onExited: { + if (stateProc.engines.length + stateProc.apps.length === 0) return; + mod.engines = stateProc.engines; + mod.apps = stateProc.apps; + } + } + + property Timer pollTimer: Timer { + interval: 3000 + repeat: true + running: mod.polling + onTriggered: mod.refresh() + } + + tileContent: Component { AiTile { ai: mod } } + + page: Component { + Page { + title: "AI" + AiPage { width: parent.width; ai: mod } + } + } +} +``` + +- [ ] **Step 2: `AiTile.qml`** + +```qml +// Copyright (C) 2026 Danilo M. +// +// 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. + +import QtQuick +import "../.." + +// The state line under the tile label, and the poll's lifetime: the tile +// exists exactly while the drawer panel is loaded, so it turns polling on and +// off. +Item { + id: tile + + required property var ai + + width: parent ? parent.width : implicitWidth + implicitHeight: line.implicitHeight + + Component.onCompleted: ai.polling = true + Component.onDestruction: ai.polling = false + + Text { + id: line + width: parent.width + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: tile.ai.active ? Theme.text : Theme.subtext + text: { + const parts = []; + const n = tile.ai.runningApps.length; + if (n > 0) parts.push(n === 1 ? "1 app" : n + " apps"); + const busy = tile.ai.engines.filter(e => e.busy).map(e => e.name); + if (busy.length > 0) parts.push(busy.join(", ") + " busy"); + return parts.length > 0 ? parts.join(" · ") : "Idle"; + } + } +} +``` + +- [ ] **Step 3: `AiRow.qml`** + +```qml +// Copyright (C) 2026 Danilo M. +// +// 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. + +import QtQuick +import "../.." + +// One engine or app: a state dot, a name, a detail line, and at most one +// button. An empty action means no button, which is every engine row. +Rectangle { + id: row + + property string title + property string detail + property bool on: false + property bool busy: false + property string action: "" + property bool danger: false + signal triggered + + implicitHeight: Math.max(texts.implicitHeight, btn.visible ? btn.implicitHeight : 0) + 16 + radius: 8 + color: row.on ? Qt.alpha(Theme.accent, 0.10) : Qt.alpha(Theme.surface, 0.35) + + Rectangle { + id: dot + width: 8; height: 8; radius: 4 + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + color: row.busy ? Theme.yellow : row.on ? Theme.green : Theme.subtext + } + + Column { + id: texts + anchors { + left: dot.right; leftMargin: 10 + right: btn.visible ? btn.left : parent.right; rightMargin: 10 + verticalCenter: parent.verticalCenter + } + spacing: 2 + + Text { + width: parent.width + elide: Text.ElideRight + text: row.title + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: row.on } + color: row.on ? Theme.text : Theme.subtext + } + + Text { + width: parent.width + elide: Text.ElideRight + text: row.detail + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + } + + Button { + id: btn + anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter } + visible: row.action !== "" + text: row.action + danger: row.danger + onClicked: row.triggered() + } +} +``` + +- [ ] **Step 4: `AiPage.qml`** + +```qml +// Copyright (C) 2026 Danilo M. +// +// 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. + +import QtQuick +import "../.." + +// Engines are read-only: stopping either would take its web UI down with it. +Column { + id: page + + required property var ai + + spacing: 8 + + Text { + width: page.width + text: "Engines" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.ai.engines + + AiRow { + required property var modelData + width: page.width + title: page.ai.labels[modelData.name] ?? modelData.name + on: modelData.up + busy: modelData.busy + detail: { + if (!modelData.up) return "Not running"; + if (modelData.model === "") + return modelData.name === "llama" ? "No model loaded" : "Running"; + return (modelData.busy ? "Generating" : "Idle") + " · " + modelData.model; + } + } + } + + Item { width: 1; height: 6 } + + Text { + width: page.width + text: "Apps" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.ai.apps + + AiRow { + required property var modelData + width: page.width + title: page.ai.labels[modelData.name] ?? modelData.name + on: modelData.running + detail: { + if (!modelData.running) return "Stopped"; + if (modelData.detail === "") return "Running"; + return "Running · " + (modelData.name === "fanfic" ? "pid " : "") + modelData.detail; + } + action: modelData.running ? "Stop" : page.ai.canStart(modelData) ? "Start" : "" + danger: modelData.running + onTriggered: modelData.running ? page.ai.stop(modelData) : page.ai.start(modelData) + } + } +} +``` + +- [ ] **Step 5: Register in `desktop/shell.qml`** + +Add the import in alphabetical position, before `import "modules/appearance"`: + +```qml +import "modules/ai" +``` + +and append to the `modules:` list, after `VmModule {},`: + +```qml + AiModule {}, +``` + +Editing `shell.qml` also forces the running shell to rescan the new directory (AGENTS.md, Reloading). + +- [ ] **Step 6: Check the running shell's log** + +The user's desktop shell hot-reloads on save. Run: +`qs log -t 40` with the desktop instance selected (see `qs log --help` for the instance flag; `qs list` names the running instances). +Expected: a reload line after the edit, no `ReferenceError`, `TypeError` or `is not a type` mentioning `modules/ai`. The module's code only runs once the drawer opens, so a clean log here is necessary, not sufficient (AGENTS.md: the `mod: mod` trap passed a clean smoke check). + +Then: `qs -p desktop ipc call drawer open ai` (reaches the running instance, starts nothing), and read the log again for runtime errors. + +- [ ] **Step 7: Ask the user to look** + +Ask the user to confirm, with the drawer open on the AI page: +1. The tile glyph (``, nf-fa-robot) reads as a robot. +2. Engines show llama's state and model; sd shows Not running unless `sd-webui` is up. +3. Stop on local chat stops it and the row flips within ~3s; Start brings it back. + +Fix whatever they report before committing. + +- [ ] **Step 8: Commit** + +```bash +git add desktop/modules/ai/AiModule.qml desktop/modules/ai/AiTile.qml desktop/modules/ai/AiRow.qml desktop/modules/ai/AiPage.qml desktop/shell.qml +git commit -F - <<'EOF' +feat(ai): drawer page for the local AI stack + +Engines are read-only, since stopping llama-server or sd-server takes +its web UI down too. Apps start and stop through their own controls, +detached so a start outlives a quickshell reload. Fanfictioner has no +start (a run needs a story) and stops by pid, children first, so its +sd-cli runs go with it and nothing else does. + +Co-Authored-By: Claude Opus 5.5 +EOF +``` + +--- + +### Task 3: documentation + +**Files:** +- Create: `desktop/modules/ai/README.md` +- Modify: `desktop/README.md` (module list, and the "each carry their own README" sentence) + +- [ ] **Step 1: `desktop/modules/ai/README.md`** + +```markdown +# ai + +What is using the local AI stack, and start/stop for the projects built on it. + +The tile counts running apps and names any engine that is generating, or says +`Idle`. The page lists the two engines, then the apps. + +## Engines + +llama.cpp (`llama-server`, router mode, port 8181) and stable-diffusion.cpp +(`sd-server`, started by `~/bin/sd-webui`). Both rows are read-only: each +engine also serves a web UI, and stopping it would take that down. + +llama's state comes from its API: `/models` for the loaded model, then +`/slots?model=&autoload=false` for whether a slot is processing. The +`autoload=false` matters: a plain `/slots?model=` on an unloaded model loads +it into VRAM, which is how this was found. + +sd-server has no progress endpoint. Its log, `~/.cache/sd-server.log`, streams +progress bars while it generates, so the row reads busy while the log was +written in the last 5s. A step slower than that reads idle. + +## Apps + +| App | Status | Start | Stop | +| --- | --- | --- | --- | +| desktop-assistant | `assistant.sh status` | `assistant.sh start` | `assistant.sh stop` | +| local chat | `llamachat --ping` | `llamachat --daemon` | `llamachat --quit` | +| imggen | `imggen status` | `imggen start` (default model) | `imggen stop` | +| fanfictioner | `pgrep` on `python3 .../fanfictioner` | none | TERM its children, then it, by pid | + +Each runs detached, so no exit code comes back; the next poll shows whether it +worked. `assistant.sh status` fails when either the assistant or its overlay +is down, so a dead overlay reads as stopped and Start restarts it. + +## Service lifetime + +`alwaysActive: false`. `ai-state.sh` runs every 3s while the drawer is open, +gated on the tile's lifetime the same way kdeconnect is, and 1s after any +action. + +## Test + + ./test-ai-state.sh + +Stubs every external command on PATH; touches nothing live. +``` + +- [ ] **Step 2: `desktop/README.md`** + +In the module list, after the `modules/appearance/` line, add: + +``` + modules/ai/ local AI engines, start/stop for the apps using them +``` + +and change the sentence `Sound, mail, vm, network, bluetooth and kdeconnect each carry their own README.` to `Sound, mail, vm, network, bluetooth, kdeconnect and ai each carry their own README.` + +- [ ] **Step 3: Commit** + +```bash +git add desktop/modules/ai/README.md desktop/README.md +git commit -F - <<'EOF' +docs(ai): module README, drawer module list + +Co-Authored-By: Claude Opus 5.5 +EOF +``` -- cgit v1.2.3