// 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 } } } }