aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
Diffstat (limited to 'desktop')
-rw-r--r--desktop/companion/frames.txt99
-rwxr-xr-xdesktop/companion/gen-frames150
-rwxr-xr-xdesktop/companion/prep-frames45
-rw-r--r--desktop/modules/ai/AiModule.qml18
-rw-r--r--desktop/modules/ai/AiPage.qml2
-rw-r--r--desktop/modules/ai/AiRow.qml18
-rw-r--r--desktop/modules/ai/README.md16
7 files changed, 336 insertions, 12 deletions
diff --git a/desktop/companion/frames.txt b/desktop/companion/frames.txt
new file mode 100644
index 0000000..0019db1
--- /dev/null
+++ b/desktop/companion/frames.txt
@@ -0,0 +1,99 @@
+# 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 companion's frames, for gen-frames. One frame per line:
+#
+# <output> <- <reference> : <prompt>
+#
+# Both are paths in the art directory (~/.local/share/keystone/companion/2b)
+# without .png. The reference is the image FLUX.2 klein edits; the output is
+# written over. Lines run top to bottom, so a frame made from another comes
+# after it. ${NAME} expands a NAME = ... line defined above it. Blank lines
+# and lines starting with # are skipped.
+#
+# dress/base is the one frame not made here: a klein edit of base-sitting.png,
+# recorded in recipe.txt. Every other dress frame is made from it, and every
+# leotard frame from its own dress frame, so the pose carries over.
+
+KEEP = She keeps her black gloves on both hands, her puffy black juliet sleeves, her black dress with its skirt, her black thighhigh boots, her black blindfold and hairband, her lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
+POSE = keep her pose exactly, standing, arms relaxed at her sides.
+# Strong expressions made klein turn the blindfold into an eye mask with the
+# eyes showing through (mad, smirk, blush3, sleepy). The ones that read
+# through her eyes take it off instead (BARE); the rest name it (EYES).
+BARE = She has taken her blindfold off: both eyes are uncovered and show clearly, pale blue-grey, and carry the expression; no blindfold, no mask, nothing over her eyes; her black hairband stays.
+KEEP_BARE = She keeps her black gloves on both hands, her puffy black juliet sleeves, her black dress with its skirt, her black thighhigh boots, her black hairband, her lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
+EYES = Her black blindfold stays on as one solid band of cloth fully covering both eyes: no eyes and no eyeholes show; the expression is in her mouth, cheeks and brows above the band.
+# After the self-destruct the blindfold is gone with the skirt: the whole
+# leotard set shows her eyes.
+LEOTARD = Her skirt is gone, revealing a plain white leotard underneath, and her blindfold is gone too: both eyes uncovered and clearly visible, pale blue-grey, matching her expression. Everything else stays exactly the same: pose, expression, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips, the small mole under her mouth, chibi proportions, art style and white background.
+
+# --- expressions
+dress/smile <- dress/base : Change only her expression to a small warm closed-mouth smile; ${POSE} ${EYES} ${KEEP}
+dress/mad <- dress/base : Change only her expression to annoyed and angry, a frown and a puffed cheek; ${POSE} ${BARE} ${KEEP_BARE}
+dress/smirk <- dress/base : Change only her expression to a teasing one-sided smirk; ${POSE} Both eyes fully visible and uncovered; the blindfold is nowhere on her head or face, not pushed up, not over one eye. ${BARE} ${KEEP_BARE}
+dress/blush1 <- dress/base : Change only her expression: a light pink blush on her cheeks and a shy small smile; ${POSE} ${EYES} ${KEEP}
+dress/blush2 <- dress/base : Change only her expression: a strong red blush across her cheeks and a flustered wavy mouth; ${POSE} ${EYES} ${KEEP}
+dress/blush3 <- dress/base : Change only her expression: her whole face bright red, small steam puffs above her head, very flustered; ${POSE} ${BARE} ${KEEP_BARE}
+dress/sleepy <- dress/base : Change only her expression to sleepy and drowsy, head tilted slightly, mouth relaxed; ${POSE} ${BARE} ${KEEP_BARE}
+
+# --- poses
+dress/wave <- dress/base : Raise only the hand on the left side of the image beside her head, waving, with a small smile; her other arm stays at her side. ${KEEP}
+# Shown while typing: she faces into the screen, away from the viewer, as if
+# reading over the user's shoulder. A profile read as posing, not watching.
+dress/watch <- dress/base : Turn her completely around into a back view: we see her from behind, the back of her head with her white bob, crossed by her black hairband and the small black cloth knot of her black blindfold (black, not white, no bow), her back, the back of her dress and the backs of her legs, as if she is facing a screen and watching it; arms relaxed at her sides. Same character, black gloves, puffy black juliet sleeves, black dress with its skirt, black thighhigh boots, black hairband, chibi proportions, art style and white background.
+dress/point <- dress/base : She points with the hand on the left side of the image toward the viewer, with a teasing smirk; her other hand rests on her hip. ${KEEP}
+dress/yawn <- dress/base : A big yawn, mouth wide open, one gloved hand covering her mouth. ${KEEP}
+dress/lying <- dress/base : Make her lie on her side on the ground, asleep, her head resting on her folded arms. ${KEEP}
+dress/listen <- dress/base : She cups the hand on the left side of the image behind her ear, listening attentively. ${KEEP}
+dress/think <- dress/base : One gloved hand at her chin, thinking, head tilted slightly. ${KEEP}
+# The monologue's frame: the pose of her official character art, chibi.
+# refs/2b-official-white is that art flattened on white, used only for the
+# pose. A Virtuous Treaty beside her was tried and dropped: klein draws a
+# generic longsword, nothing like the game's, and would not move it.
+dress/muse <- dress/base + refs/2b-official-white : Redraw the chibi character from the first image, keeping her exact chibi proportions, face, outfit and art style, in the standing pose of the woman in the second image: body turned three-quarters, one arm hanging out low to the side with the hand open, the other hand raised near her shoulder, weight on one leg, head slightly lowered, calm and solemn. No weapon, nothing in her hands. ${KEEP}
+# She stays frontal: a three-quarter turn, from the reference or as its own
+# edit, only bent her knees.
+dress/stretch <- dress/base : She stretches both arms up above her head, waking up. ${KEEP}
+# A balloon shoved her out of the corner. Standing versions read as walking
+# in four seeds, so this one is airborne, edited from the attempt whose hands
+# and face were right: refs/shoved-7.png, made from dress/base with
+# --seed 7 by the earlier standing prompt (see generated.log), kept in refs/.
+dress/shoved <- refs/shoved-7 : Keep her face, her scared expression, her uncovered eyes and both raised hands exactly as they are. Change her body and legs: she is in the air, lifted completely off the ground, knocked flying toward the left side of the image by a strong blast from the right; both feet off the ground, legs trailing behind her toward the right side, her body tilted, her hair and skirt blown toward the left. No ground, no blast effect drawn. ${KEEP_BARE}
+
+# --- the leotard set: the same frames, after the self-destruct
+leotard/base <- dress/base : ${LEOTARD}
+leotard/smile <- dress/smile : ${LEOTARD}
+# The generic line let the new eyes go soft: mad read sad, and yawn, whose
+# dress frame has no eyes to copy, read as laughing. Name the mood they carry.
+leotard/mad <- dress/mad : ${LEOTARD} Her expression stays angry and annoyed: furrowed brows, a sharp glaring stare, a frown and a puffed cheek; not sad, not tearful.
+leotard/smirk <- dress/smirk : ${LEOTARD}
+leotard/blush1 <- dress/blush1 : ${LEOTARD}
+leotard/blush2 <- dress/blush2 : ${LEOTARD}
+leotard/blush3 <- dress/blush3 : ${LEOTARD}
+leotard/sleepy <- dress/sleepy : ${LEOTARD}
+leotard/wave <- dress/wave : ${LEOTARD}
+# From dress/watch, klein kept the blindfold's band at the back of her head
+# through three prompts, by name and by position; from leotard/base, which
+# has no blindfold, the back view is the usual pose edit.
+leotard/watch <- leotard/base : Turn her completely around into a back view: we see her from behind, the back of her head with her white bob crossed only by her black hairband over the top, her back, the back of her black bodice, the white leotard and the backs of her legs, as if she is facing a screen and watching it; arms relaxed at her sides. No blindfold. Same character, black gloves, puffy black juliet sleeves, black thighhigh boots, chibi proportions, art style and white background.
+leotard/point <- dress/point : ${LEOTARD}
+leotard/yawn <- dress/yawn : ${LEOTARD} It is a big sleepy yawn, not a laugh: eyes squeezed shut and a little teary, brows relaxed and drowsy, her gloved hand over her wide open mouth.
+# Lying face down and the back view have no eyes to uncover, so the generic
+# line left the blindfold on them: say it is gone, band and knot.
+# Asked to remove it from dress/lying, klein kept the band and only drew an
+# eye under it; from leotard/base, which has none, it is the usual pose edit.
+leotard/lying <- leotard/base : Make her lie on her side on the ground, asleep, her head resting on her folded arms, eyes closed. She keeps her plain white leotard, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips and the small mole under her mouth, and no blindfold. Same character, chibi proportions, art style and white background.
+leotard/listen <- dress/listen : ${LEOTARD}
+leotard/think <- dress/think : ${LEOTARD}
+leotard/muse <- dress/muse : ${LEOTARD}
+leotard/stretch <- dress/stretch : ${LEOTARD}
+leotard/shoved <- dress/shoved : ${LEOTARD}
+leotard/scorched <- leotard/base : Right after an explosion: her clothes lightly scorched and smudged with soot, a few wisps of smoke rising from her, hair slightly messy, a dazed flat expression; standing, arms at her sides. Her eyes stay uncovered, no blindfold. Her top stays black: the black bodice with its chest cutout and the puffy black juliet sleeves, exactly as in the image; the plain white leotard shows only below her waist, where the skirt was. She keeps her black gloves, black thighhigh boots, black hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
diff --git a/desktop/companion/gen-frames b/desktop/companion/gen-frames
new file mode 100755
index 0000000..df78cb9
--- /dev/null
+++ b/desktop/companion/gen-frames
@@ -0,0 +1,150 @@
+#!/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.
+#
+# gen-frames: make the companion's frames listed in frames.txt.
+#
+# gen-frames [--seed N] [--list] [OUTPUT...]
+#
+# With no OUTPUT, makes every frame whose image does not exist yet. Naming
+# outputs (dress/mad, leotard/mad) remakes those, existing or not; a dress
+# frame remade leaves its leotard frame stale, so name both. --seed changes
+# the seed (default 42) to reroll a result; --list prints what would be made.
+# Then runs prep-frames.
+#
+# It drives a running sd-server rather than sd-cli, which would reload the
+# model for every frame: start sd-webui and pick FLUX.2 klein first. sd-webui
+# also unloads llama-server's model, which otherwise leaves too little VRAM.
+# The model is checked from sd-server's command line, since its API does not
+# name it.
+
+set -euo pipefail
+
+here=$(cd "$(dirname "$0")" && pwd)
+art=${ART_DIR:-$HOME/.local/share/keystone/companion/2b}
+url=${SD_URL:-http://127.0.0.1:7860}
+seed=42
+list=0
+want=()
+while (($#)); do
+ case $1 in
+ --seed) seed=$2; shift 2 ;;
+ --list) list=1; shift ;;
+ -h|--help) sed -n '14,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ *) want+=("$1"); shift ;;
+ esac
+done
+
+# frames.txt to "output<TAB>references<TAB>prompt" lines, variables expanded.
+# References are joined with + and no spaces: the first is the image edited,
+# any after it are extra images the prompt can point at ("the second image").
+parse() {
+ local line name out ref prompt
+ local frame='^[[:space:]]*([a-z0-9/_-]+)[[:space:]]*<-[[:space:]]*([a-z0-9/_-]+([[:space:]]*\+[[:space:]]*[a-z0-9/_-]+)*)[[:space:]]*:[[:space:]]*(.*)$'
+ declare -A vars=()
+ while IFS= read -r line || [[ -n $line ]]; do
+ [[ $line =~ ^[[:space:]]*(#|$) ]] && continue
+ if [[ $line =~ ^([A-Z_]+)[[:space:]]*=[[:space:]]*(.*)$ ]]; then
+ vars[${BASH_REMATCH[1]}]=${BASH_REMATCH[2]}
+ continue
+ fi
+ if [[ ! $line =~ $frame ]]; then
+ echo "gen-frames: frames.txt: cannot read: $line" >&2
+ exit 1
+ fi
+ out=${BASH_REMATCH[1]} ref=${BASH_REMATCH[2]// /} prompt=${BASH_REMATCH[4]}
+ for name in "${!vars[@]}"; do prompt=${prompt//\$\{$name\}/${vars[$name]}}; done
+ if [[ $prompt == *'${'* ]]; then
+ echo "gen-frames: frames.txt: $out: undefined variable in: $prompt" >&2
+ exit 1
+ fi
+ printf '%s\t%s\t%s\n' "$out" "$ref" "$prompt"
+ done <"$here/frames.txt"
+}
+
+# What to make: the named outputs, else every missing one, in file order.
+# (No grep -q in a pipe: under pipefail its early exit fails the printf.)
+todo=()
+rows=$(parse) || exit 1
+mapfile -t rows <<<"$rows"
+declare -A known=() wanted=()
+for row in "${rows[@]}"; do known[${row%%$'\t'*}]=1; done
+for name in "${want[@]}"; do
+ [[ -n ${known[$name]:-} ]] || { echo "gen-frames: $name is not in frames.txt" >&2; exit 1; }
+ wanted[$name]=1
+done
+for row in "${rows[@]}"; do
+ out=${row%%$'\t'*}
+ if ((${#want[@]})); then
+ [[ -n ${wanted[$out]:-} ]] && todo+=("$row")
+ elif [[ ! -e $art/$out.png ]]; then
+ todo+=("$row")
+ fi
+done
+if ((list)); then
+ for row in "${todo[@]}"; do printf '%s <- %s\n' "${row%%$'\t'*}" "$(cut -f2 <<<"$row")"; done
+ exit 0
+fi
+if ((${#todo[@]} == 0)); then
+ echo "gen-frames: nothing to make"
+ exec "$here/prep-frames" "$art"
+fi
+
+pgrep -ax sd-server | grep -q klein ||
+ { echo "gen-frames: sd-server is not running FLUX.2 klein; start sd-webui and pick it" >&2; exit 1; }
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+made=0
+for row in "${todo[@]}"; do
+ IFS=$'\t' read -r out ref prompt <<<"$row"
+ IFS=+ read -ra refs <<<"$ref"
+ missing=""
+ for r in "${refs[@]}"; do [[ -e $art/$r.png ]] || missing=$r; done
+ if [[ -n $missing ]]; then
+ echo "gen-frames: $out: its reference $missing.png does not exist yet, skipped" >&2
+ continue
+ fi
+ echo "gen-frames: $out <- ${ref//+/ + }"
+ # One JSON string per reference, as data URLs, slurped into the array.
+ for r in "${refs[@]}"; do
+ { printf 'data:image/png;base64,'; base64 -w0 "$art/$r.png"; } | jq -Rs .
+ done >"$tmp/refs.json"
+ # The settings the frames were tuned with in sd-webui: recipe.txt.
+ jq -n --arg prompt "$prompt" --slurpfile refs "$tmp/refs.json" --argjson seed "$seed" '{
+ prompt: $prompt, negative_prompt: "", width: 1024, height: 1024, seed: $seed,
+ batch_count: 1, ref_images: $refs, auto_resize_ref_image: true,
+ increase_ref_index: false, lora: [], output_format: "png", output_compression: 100,
+ sample_params: { sample_steps: 4, sample_method: "euler", scheduler: "default",
+ guidance: { txt_cfg: 1.0 } }
+ }' >"$tmp/body"
+ id=$(curl -sf -m 30 -X POST "$url/sdcpp/v1/img_gen" -H 'Content-Type: application/json' \
+ --data-binary @"$tmp/body" | jq -r '.id // empty') || id=""
+ [[ -n $id ]] || { echo "gen-frames: $out: sd-server refused the job" >&2; exit 1; }
+ while :; do
+ curl -sf -m 30 "$url/sdcpp/v1/jobs/$id" >"$tmp/job" || { echo "gen-frames: $out: lost the job" >&2; exit 1; }
+ status=$(jq -r '.status' "$tmp/job")
+ case $status in
+ queued|generating) sleep 2 ;;
+ completed) break ;;
+ *) echo "gen-frames: $out: $status: $(jq -r '.error.message // "no message"' "$tmp/job")" >&2; exit 1 ;;
+ esac
+ done
+ mkdir -p "$art/$(dirname "$out")"
+ jq -r '.result.images[0].b64_json' "$tmp/job" | base64 -d >"$tmp/out.png"
+ mv "$tmp/out.png" "$art/$out.png"
+ printf '%s seed %s %s <- %s\n' "$(date +%F)" "$seed" "$out" "$ref" >>"$art/generated.log"
+ made=$((made + 1))
+done
+echo "gen-frames: made $made"
+exec "$here/prep-frames" "$art"
diff --git a/desktop/companion/prep-frames b/desktop/companion/prep-frames
new file mode 100755
index 0000000..462657e
--- /dev/null
+++ b/desktop/companion/prep-frames
@@ -0,0 +1,45 @@
+#!/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.
+#
+# prep-frames: the companion's generated images to the frames she shows.
+#
+# prep-frames [ART_DIR] default: ~/.local/share/keystone/companion/2b
+#
+# Reads ART_DIR/dress/*.png and ART_DIR/leotard/*.png, 1024x1024 on white,
+# and writes ART_DIR/frames/<outfit>/<name>.png: background removed, trimmed
+# to her, scaled by a quarter. One scale for every frame keeps her the same
+# size whatever the pose, which scaling each to a fixed height would not:
+# lying down would come out huge. Sprite.qml draws them at half size and
+# stands each on its bottom edge.
+#
+# The background goes by flood fill from the four corners: the dark line art
+# stops the fill, so the white hair and the white leotard inside it stay.
+
+set -euo pipefail
+
+art=${1:-$HOME/.local/share/keystone/companion/2b}
+n=0
+for outfit in dress leotard; do
+ [[ -d $art/$outfit ]] || continue
+ mkdir -p "$art/frames/$outfit"
+ for src in "$art/$outfit"/*.png; do
+ [[ -e $src ]] || continue
+ read -r w h < <(magick identify -format '%w %h\n' "$src")
+ magick "$src" -alpha set -fuzz 6% -fill none \
+ -draw "color 0,0 floodfill" -draw "color $((w - 1)),0 floodfill" \
+ -draw "color 0,$((h - 1)) floodfill" -draw "color $((w - 1)),$((h - 1)) floodfill" \
+ -trim +repage -resize 25% "$art/frames/$outfit/$(basename "$src")"
+ n=$((n + 1))
+ done
+done
+echo "prep-frames: $n frames in $art/frames"
diff --git a/desktop/modules/ai/AiModule.qml b/desktop/modules/ai/AiModule.qml
index a96f484..6df3d6b 100644
--- a/desktop/modules/ai/AiModule.qml
+++ b/desktop/modules/ai/AiModule.qml
@@ -49,10 +49,16 @@ Module {
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().
+ // story file, and its stop needs the pid, see stop(). llamachat has no
+ // restart, and --quit answers before the app is gone, so a --daemon
+ // straight after would find it still running and do nothing: wait for
+ // --ping to fail first, up to 5s.
readonly property var controls: ({
- assistant: { start: [assistantSh, "start"], stop: [assistantSh, "stop"] },
- chat: { start: ["llamachat", "--daemon"], stop: ["llamachat", "--quit"] },
+ assistant: { start: [assistantSh, "start"], stop: [assistantSh, "stop"],
+ restart: [assistantSh, "restart"] },
+ chat: { start: ["llamachat", "--daemon"], stop: ["llamachat", "--quit"],
+ restart: ["sh", "-c", "llamachat --quit; for _ in $(seq 50); do "
+ + "llamachat --ping || break; sleep 0.1; done; exec llamachat --daemon"] },
imggen: { start: ["imggen", "start"], stop: ["imggen", "stop"] },
})
@@ -62,6 +68,12 @@ Module {
if (canStart(app)) exec(controls[app.name].start);
}
+ function canRestart(app) { return controls[app.name]?.restart !== undefined; }
+
+ function restart(app) {
+ if (canRestart(app)) exec(controls[app.name].restart);
+ }
+
function stop(app) {
if (app.name !== "fanfic") {
exec(controls[app.name].stop);
diff --git a/desktop/modules/ai/AiPage.qml b/desktop/modules/ai/AiPage.qml
index c8e39e9..e89132a 100644
--- a/desktop/modules/ai/AiPage.qml
+++ b/desktop/modules/ai/AiPage.qml
@@ -69,6 +69,8 @@ Column {
}
action: modelData.running ? "Stop" : page.ai.canStart(modelData) ? "Start" : ""
danger: modelData.running
+ restartable: modelData.running && page.ai.canRestart(modelData)
+ onRestart: page.ai.restart(modelData)
onTriggered: modelData.running ? page.ai.stop(modelData) : page.ai.start(modelData)
}
}
diff --git a/desktop/modules/ai/AiRow.qml b/desktop/modules/ai/AiRow.qml
index ac4a180..2950759 100644
--- a/desktop/modules/ai/AiRow.qml
+++ b/desktop/modules/ai/AiRow.qml
@@ -12,8 +12,9 @@
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.
+// One engine or app: a state dot, a name, a detail line, at most one
+// button, and a Restart before it when restartable. An empty action means no
+// button, which is every engine row.
Rectangle {
id: row
@@ -23,7 +24,9 @@ Rectangle {
property bool busy: false
property string action: ""
property bool danger: false
+ property bool restartable: false
signal triggered
+ signal restart
implicitHeight: Math.max(texts.implicitHeight, btn.visible ? btn.implicitHeight : 0) + 16
radius: 8
@@ -40,7 +43,8 @@ Rectangle {
id: texts
anchors {
left: dot.right; leftMargin: 10
- right: btn.visible ? btn.left : parent.right; rightMargin: 10
+ right: restartBtn.visible ? restartBtn.left : btn.visible ? btn.left : parent.right
+ rightMargin: 10
verticalCenter: parent.verticalCenter
}
spacing: 2
@@ -63,6 +67,14 @@ Rectangle {
}
Button {
+ id: restartBtn
+ anchors { right: btn.left; rightMargin: 6; verticalCenter: parent.verticalCenter }
+ visible: row.restartable
+ text: "Restart"
+ onClicked: row.restart()
+ }
+
+ Button {
id: btn
anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
visible: row.action !== ""
diff --git a/desktop/modules/ai/README.md b/desktop/modules/ai/README.md
index 5e204ca..5aec615 100644
--- a/desktop/modules/ai/README.md
+++ b/desktop/modules/ai/README.md
@@ -27,12 +27,16 @@ 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 |
+| App | Status | Start | Stop | Restart |
+| --- | --- | --- | --- | --- |
+| desktop-assistant | `assistant.sh status` | `assistant.sh start` | `assistant.sh stop` | `assistant.sh restart` |
+| local chat | `llamachat --ping` | `llamachat --daemon` | `llamachat --quit` | quit, wait for `--ping` to fail (5s at most), `--daemon` |
+| imggen | `imggen status` | `imggen start` (default model) | `imggen stop` | none |
+| fanfictioner | `pgrep` on `python3 .../fanfictioner` | none | TERM its children, then it, by pid | none |
+
+Restart shows only while an app is running. llamachat's `--quit` answers
+before the app has exited, so a `--daemon` sent straight after finds it still
+running and does nothing; hence the wait.
Each runs detached, so no exit code comes back; the next poll shows whether it
worked. `assistant.sh status` fails when the daemon is down.