aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
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
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')
-rw-r--r--desktop/modules/ai/AiModule.qml142
-rw-r--r--desktop/modules/ai/AiPage.qml75
-rw-r--r--desktop/modules/ai/AiRow.qml73
-rw-r--r--desktop/modules/ai/AiTile.qml44
4 files changed, 334 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 }
+ }
+ }
+}
diff --git a/desktop/modules/ai/AiPage.qml b/desktop/modules/ai/AiPage.qml
new file mode 100644
index 0000000..c8e39e9
--- /dev/null
+++ b/desktop/modules/ai/AiPage.qml
@@ -0,0 +1,75 @@
+// 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 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)
+ }
+ }
+}
diff --git a/desktop/modules/ai/AiRow.qml b/desktop/modules/ai/AiRow.qml
new file mode 100644
index 0000000..ac4a180
--- /dev/null
+++ b/desktop/modules/ai/AiRow.qml
@@ -0,0 +1,73 @@
+// 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 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()
+ }
+}
diff --git a/desktop/modules/ai/AiTile.qml b/desktop/modules/ai/AiTile.qml
new file mode 100644
index 0000000..185d866
--- /dev/null
+++ b/desktop/modules/ai/AiTile.qml
@@ -0,0 +1,44 @@
+// 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 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";
+ }
+ }
+}