aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/ai/AiModule.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-05 13:19:08 +0200
committerDanilo M. <danix@danix.xyz>2026-10-05 13:19:08 +0200
commit9e40b6bb88179f0315bd2ef193cb253a05fdc5b3 (patch)
tree79e18d4b91ed6e468b2623759df93d23410f78b0 /desktop/modules/ai/AiModule.qml
parentf0c92d6b9938bd6bb2d6e24aeb833fed175a9222 (diff)
downloadquickshell-9e40b6bb88179f0315bd2ef193cb253a05fdc5b3.tar.gz
quickshell-9e40b6bb88179f0315bd2ef193cb253a05fdc5b3.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/ai/AiModule.qml')
-rw-r--r--desktop/modules/ai/AiModule.qml142
1 files changed, 142 insertions, 0 deletions
diff --git a/desktop/modules/ai/AiModule.qml b/desktop/modules/ai/AiModule.qml
new file mode 100644
index 0000000..a96f484
--- /dev/null
+++ b/desktop/modules/ai/AiModule.qml
@@ -0,0 +1,142 @@
+// 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.
+
+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 }
+ }
+ }
+}