diff options
Diffstat (limited to 'desktop/modules/assistant/AssistantModule.qml')
| -rw-r--r-- | desktop/modules/assistant/AssistantModule.qml | 175 |
1 files changed, 175 insertions, 0 deletions
diff --git a/desktop/modules/assistant/AssistantModule.qml b/desktop/modules/assistant/AssistantModule.qml new file mode 100644 index 0000000..bd3e270 --- /dev/null +++ b/desktop/modules/assistant/AssistantModule.qml @@ -0,0 +1,175 @@ +// 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 "../.." +import "Config.js" as C + +// desktop-assistant's daemon settings: its config.toml, the tools it loads, +// and a restart, since it reads both only at startup. The module holds the +// data and the writes; AssistantPage is the view. See README.md. +Module { + id: am + name: "assistant" + label: "Assistant" + // nf-fa-comments, present in Inconsolata Nerd Font. + icon: "\uf086" + settings: true + keywords: ["llm", "model", "llama", "server", "prompt", "temperature", "thinking", + "rounds", "tools", "environment", "daemon", "restart"] + + readonly property string home: Quickshell.env("HOME") + readonly property string daemonDir: home + "/Programming/GIT/desktop-assistant" + readonly property string cfgDir: (Quickshell.env("XDG_CONFIG_HOME") || home + "/.config") + + "/desktop-assistant" + readonly property string cfgPath: cfgDir + "/config.toml" + readonly property string toolsDir: cfgDir + "/tools" + // The system interpreter, as for calctl: python3 on PATH is a pyenv shim. + readonly property string python: "/usr/bin/python3" + + readonly property var keys: ["llm", "system", "tool_rounds", "temperature", "thinking"] + readonly property var labels: ({ llm: "Server", system: "System prompt", tool_rounds: "Tool rounds", + temperature: "Temperature", thinking: "Thinking" }) + + // config.toml as last read or written; "" when there is none. + property string text: "" + readonly property var values: C.scalars(am.text, am.keys) + readonly property var envList: C.env(am.text) + // The daemon's built-in values, from assistant.py --defaults. + property var defaults: ({}) + // [{name, on, description}] + property var tools: [] + // A write since the last restart, which the daemon has not read yet. + property bool pending: false + property bool restarting: false + property string status: "" + property string error: "" + + searchItems: am.keys.map(k => ({ text: am.labels[k], key: "key:" + k })) + .concat(am.envList.map(e => ({ text: e.key + " = " + e.value, key: "env:" + e.key }))) + .concat(am.tools.map(t => ({ text: "tool: " + t.name, key: "tool:" + t.name }))) + + function refresh() { + fv.reload(); + am.listTools(); + if (Object.keys(am.defaults).length === 0) + am.run([am.python, am.daemonDir + "/assistant.py", "--defaults"], null, (code, out) => { + try { if (code === 0) am.defaults = JSON.parse(out); } catch (e) {} + }); + } + + property FileView fv: FileView { + path: am.cfgPath + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: am.text = text() + onLoadFailed: am.text = "" + } + + // One Process per call, so calls never share one (a running Process + // ignores a second start, AGENTS.md). input, when not null, is written + // to stdin and stdin closed. cb(code, stdout, stderr) runs once the exit + // and both streams are in, which arrive in no fixed order. + function run(cmd, input, cb) { + op.createObject(am, { command: cmd, input: input, cb: cb, stdinEnabled: input !== null }).running = true; + } + property Component op: Component { + Process { + id: proc + property var input: null + property var cb: null + property int left: 3 + property int code: -1 + property string out: "" + property string err: "" + function step() { + if (--left > 0) return; + try { if (proc.cb) proc.cb(proc.code, proc.out, proc.err); } + finally { proc.destroy(); } + } + onStarted: if (proc.input !== null) { proc.write(proc.input); proc.stdinEnabled = false; } + onExited: code => { proc.code = code; proc.step(); } + stdout: StdioCollector { onStreamFinished: { proc.out = text; proc.step(); } } + stderr: StdioCollector { onStreamFinished: { proc.err = text; proc.step(); } } + } + } + + // Validates the whole file with tomllib before it replaces the old one, + // atomically, so a bad edit can never leave a config the daemon refuses + // to start with. + readonly property string writer: "import os, sys, tomllib\n" + + "t = sys.stdin.read()\n" + + "try:\n tomllib.loads(t)\n" + + "except tomllib.TOMLDecodeError as e:\n sys.exit(f'not valid TOML: {e}')\n" + + "p = sys.argv[1]\nos.makedirs(os.path.dirname(p), exist_ok=True)\n" + + "with open(p + '.tmp', 'w') as f:\n f.write(t)\n" + + "os.replace(p + '.tmp', p)\n" + + function save(t) { + am.run([am.python, "-c", am.writer, am.cfgPath], t, (code, out, err) => { + if (code === 0) { am.text = t; am.pending = true; am.error = ""; } + else am.error = err.trim() || "config.toml could not be written"; + }); + } + // toml is the value written as TOML, or null to fall back to the default. + function setKey(key, toml) { am.save(C.setScalar(am.text, key, toml)); } + function setEnv(key, value) { am.save(C.setEnv(am.text, key, value)); } + function quote(s) { return C.quote(s); } + + // Every tools/*.toml is loaded; *.toml.off is the same file switched off. + readonly property string lister: "import glob, json, os, sys, tomllib\n" + + "out = []\n" + + "for p in glob.glob(sys.argv[1] + '/*.toml') + glob.glob(sys.argv[1] + '/*.toml.off'):\n" + + " on = p.endswith('.toml')\n" + + " name = os.path.basename(p).removesuffix('.off').removesuffix('.toml')\n" + + " try:\n d = tomllib.load(open(p, 'rb')).get('description', '')\n" + + " except Exception as e:\n d = f'unreadable: {e}'\n" + + " out.append({'name': name, 'on': on, 'description': d})\n" + + "print(json.dumps(sorted(out, key=lambda t: t['name'])))\n" + + function listTools() { + am.run([am.python, "-c", am.lister, am.toolsDir], null, (code, out) => { + try { if (code === 0) am.tools = JSON.parse(out); } catch (e) {} + }); + } + + function toggleTool(t) { + const on = am.toolsDir + "/" + t.name + ".toml"; + const off = on + ".off"; + // -n: never overwrite a file that already holds the other name. + am.run(["mv", "-n", t.on ? on : off, t.on ? off : on], null, (code, out, err) => { + if (code === 0) { am.pending = true; am.error = ""; } + else am.error = err.trim() || "could not switch " + t.name; + am.listTools(); + }); + } + + function restart() { + am.restarting = true; + am.run([am.daemonDir + "/assistant.sh", "restart"], null, (code, out) => { + am.restarting = false; + // assistant.sh ends on its status: the process, the socket, and + // the log's tail, which is where a refused config says why. + am.status = code === 0 ? "Restarted." : out.trim(); + if (code === 0) am.pending = false; + }); + } + + page: Component { + Page { + title: "Assistant" + AssistantPage { width: parent.width; asst: am } + } + } +} |
