From 9e40b6bb88179f0315bd2ef193cb253a05fdc5b3 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 5 Oct 2026 13:19:08 +0200 Subject: 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 --- desktop/modules/ai/AiModule.qml | 142 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 desktop/modules/ai/AiModule.qml (limited to 'desktop/modules/ai/AiModule.qml') 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. +// +// 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 } + } + } +} -- cgit v1.2.3