diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-05 13:19:08 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-05 13:19:08 +0200 |
| commit | 9e40b6bb88179f0315bd2ef193cb253a05fdc5b3 (patch) | |
| tree | 79e18d4b91ed6e468b2623759df93d23410f78b0 | |
| parent | f0c92d6b9938bd6bb2d6e24aeb833fed175a9222 (diff) | |
| download | quickshell-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>
| -rw-r--r-- | desktop/modules/ai/AiModule.qml | 142 | ||||
| -rw-r--r-- | desktop/modules/ai/AiPage.qml | 75 | ||||
| -rw-r--r-- | desktop/modules/ai/AiRow.qml | 73 | ||||
| -rw-r--r-- | desktop/modules/ai/AiTile.qml | 44 | ||||
| -rw-r--r-- | desktop/shell.qml | 2 |
5 files changed, 336 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"; + } + } +} diff --git a/desktop/shell.qml b/desktop/shell.qml index 1149124..c559385 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -13,6 +13,7 @@ import Quickshell import Quickshell.Io import Quickshell.Wayland import QtQuick +import "modules/ai" import "modules/appearance" import "modules/bluetooth" import "modules/kdeconnect" @@ -54,6 +55,7 @@ ShellRoot { AppearanceModule {}, StatusModule {}, VmModule {}, + AiModule {}, ] } |
