aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/assistant/AssistantModule.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/assistant/AssistantModule.qml')
-rw-r--r--desktop/modules/assistant/AssistantModule.qml175
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 }
+ }
+ }
+}