aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules')
-rw-r--r--desktop/modules/assistant/AssistantModule.qml175
-rw-r--r--desktop/modules/assistant/AssistantPage.qml420
-rw-r--r--desktop/modules/assistant/Config.js142
-rw-r--r--desktop/modules/assistant/PresetsModule.qml34
-rw-r--r--desktop/modules/assistant/PresetsPage.qml215
-rw-r--r--desktop/modules/assistant/README.md34
-rw-r--r--desktop/modules/assistant/test_config.js89
7 files changed, 1109 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 }
+ }
+ }
+}
diff --git a/desktop/modules/assistant/AssistantPage.qml b/desktop/modules/assistant/AssistantPage.qml
new file mode 100644
index 0000000..e5d76c8
--- /dev/null
+++ b/desktop/modules/assistant/AssistantPage.qml
@@ -0,0 +1,420 @@
+// 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 QtQuick.Controls
+import "../.."
+
+// The view over AssistantModule: the model settings, the tools' environment,
+// the tools themselves, and the restart that applies them. A field saves on
+// Enter; emptied, it falls back to the daemon's default, which it shows as
+// its placeholder.
+Column {
+ id: page
+ spacing: 8
+
+ // The module. Not named `am`, its id (it would bind to itself).
+ required property QtObject asst
+
+ readonly property var v: asst.values
+ readonly property var d: asst.defaults
+
+ // What the daemon would run with: the file's value, else the default.
+ function shown(key) {
+ const x = page.v[key] !== undefined ? page.v[key] : page.d[key];
+ return x === undefined || x === null ? "" : String(x);
+ }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ component Heading: Text {
+ topPadding: 14
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+
+ // A one-line input that saves on Enter and undoes on Escape; edited
+ // reports every keystroke, for the add row.
+ component Field: Rectangle {
+ id: field
+ property string value: ""
+ property string placeholder: ""
+ signal commit(string text)
+ signal edited(string text)
+ // Commit on Enter even when unchanged: the add row's fields track
+ // their value as they are typed, so they never differ from it.
+ property bool live: false
+ height: 34
+ radius: 8
+ color: Theme.surface
+ TextInput {
+ id: ti
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ text: field.value
+ onTextEdited: field.edited(text)
+ onAccepted: if (field.live || text !== field.value) field.commit(text)
+ Keys.onEscapePressed: event => {
+ if (text !== field.value) text = field.value;
+ else event.accepted = false;
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width
+ elide: Text.ElideRight
+ visible: parent.text === ""
+ text: field.placeholder
+ color: Theme.overlay
+ font: parent.font
+ }
+ }
+ }
+
+ // A labelled row: label on the left, the control filling the rest.
+ component Labelled: Item {
+ property alias label: lbl.text
+ default property alias control: slot.data
+ width: page.width
+ height: 34
+ Text {
+ id: lbl
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 120
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Item {
+ id: slot
+ anchors { left: lbl.right; leftMargin: 8; right: parent.right; top: parent.top; bottom: parent.bottom }
+ }
+ }
+
+ // --- restart ---
+
+ Rectangle {
+ width: page.width
+ height: bar.implicitHeight + 20
+ radius: 10
+ color: page.asst.pending ? Qt.alpha(Theme.accent, 0.16) : Qt.alpha(Theme.surface, 0.35)
+ border.width: 1
+ border.color: page.asst.pending ? Qt.alpha(Theme.accent, 0.5) : Qt.alpha(Theme.text, 0.08)
+
+ Column {
+ id: bar
+ anchors { left: parent.left; right: parent.right; verticalCenter: parent.verticalCenter; margins: 12 }
+ spacing: 6
+
+ Item {
+ width: parent.width
+ height: 30
+ Text {
+ anchors { left: parent.left; right: restartBtn.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ wrapMode: Text.Wrap
+ text: page.asst.pending ? "Saved. The assistant reads these at startup: restart it to apply."
+ : "The assistant reads these at startup."
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Button {
+ id: restartBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: page.asst.restarting ? "Restarting…" : "Restart assistant"
+ enabled: !page.asst.restarting
+ onClicked: page.asst.restart()
+ }
+ }
+
+ Small {
+ width: parent.width
+ visible: page.asst.status !== ""
+ wrapMode: Text.Wrap
+ text: page.asst.status
+ color: page.asst.status === "Restarted." ? Theme.green : Theme.red
+ }
+ }
+ }
+
+ Text {
+ width: page.width
+ visible: page.asst.error !== ""
+ wrapMode: Text.Wrap
+ text: page.asst.error
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ // --- model ---
+
+ Heading { text: "Model" }
+
+ Labelled {
+ objectName: "key:llm"
+ label: "Server"
+ Field {
+ anchors.fill: parent
+ value: page.v.llm ?? ""
+ placeholder: page.d.llm ?? ""
+ onCommit: t => page.asst.setKey("llm", t.trim() ? page.asst.quote(t.trim()) : null)
+ }
+ }
+
+ Labelled {
+ objectName: "key:temperature"
+ label: "Temperature"
+ Field {
+ id: temp
+ anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
+ width: 120
+ value: page.v.temperature !== undefined ? String(page.v.temperature) : ""
+ placeholder: "the model's preset"
+ onCommit: t => {
+ const x = Number(t.trim());
+ if (t.trim() === "") page.asst.setKey("temperature", null);
+ else if (!isNaN(x) && x >= 0 && x <= 2) page.asst.setKey("temperature", String(x));
+ else page.asst.error = "Temperature is a number from 0 to 2.";
+ }
+ }
+ Small {
+ anchors { left: temp.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: "0 to 2; empty uses the llama-server preset's"
+ }
+ }
+
+ Labelled {
+ objectName: "key:tool_rounds"
+ label: "Tool rounds"
+ Field {
+ id: rounds
+ anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
+ width: 120
+ value: page.v.tool_rounds !== undefined ? String(page.v.tool_rounds) : ""
+ placeholder: page.d.tool_rounds !== undefined ? String(page.d.tool_rounds) : ""
+ onCommit: t => {
+ const n = Number(t.trim());
+ if (t.trim() === "") page.asst.setKey("tool_rounds", null);
+ else if (Number.isInteger(n) && n >= 0 && n <= 20) page.asst.setKey("tool_rounds", String(n));
+ else page.asst.error = "Tool rounds is a whole number from 0 to 20.";
+ }
+ }
+ Small {
+ anchors { left: rounds.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: "tool round trips per question"
+ }
+ }
+
+ Labelled {
+ objectName: "key:thinking"
+ label: "Thinking"
+ Switch {
+ id: think
+ anchors.verticalCenter: parent.verticalCenter
+ checked: page.shown("thinking") !== "false"
+ onToggled: page.asst.setKey("thinking", checked ? "true" : "false")
+ }
+ Small {
+ anchors { left: think.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: "off skips the model's reasoning: a faster first word"
+ }
+ }
+
+ // The prompt is long and multi-line, so it gets its own box and an
+ // explicit Save rather than Enter.
+ Column {
+ objectName: "key:system"
+ width: page.width
+ spacing: 6
+
+ Text {
+ text: "System prompt"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 130
+ radius: 8
+ color: Theme.surface
+
+ ScrollView {
+ anchors { fill: parent; margins: 8 }
+ TextArea {
+ id: prompt
+ wrapMode: TextEdit.Wrap
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ background: null
+ padding: 0
+ text: page.shown("system")
+ }
+ }
+ }
+
+ Row {
+ spacing: 8
+ Button {
+ text: "Save prompt"
+ enabled: prompt.text.trim() !== "" && prompt.text !== page.shown("system")
+ onClicked: page.asst.setKey("system", prompt.text === page.d.system ? null
+ : page.asst.quote(prompt.text))
+ }
+ Button {
+ text: "Use the default"
+ enabled: page.v.system !== undefined
+ onClicked: page.asst.setKey("system", null)
+ }
+ Small {
+ anchors.verticalCenter: parent.verticalCenter
+ text: page.v.system !== undefined ? "custom" : "default"
+ }
+ }
+ }
+
+ // --- env ---
+
+ Heading { text: "Tool environment" }
+ Small {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: "Added to every tool's environment: instance URLs, key file paths."
+ }
+
+ property string confirming: ""
+
+ Repeater {
+ model: page.asst.envList
+
+ delegate: Item {
+ id: envRow
+ required property var modelData
+ objectName: "env:" + modelData.key
+ width: page.width
+ height: 34
+
+ Text {
+ id: envKey
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 120
+ elide: Text.ElideRight
+ text: envRow.modelData.key
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Field {
+ anchors { left: envKey.right; leftMargin: 8; right: envDel.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
+ value: envRow.modelData.value
+ onCommit: t => page.asst.setEnv(envRow.modelData.key, t)
+ }
+ Button {
+ id: envDel
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ danger: true
+ text: page.confirming === envRow.modelData.key ? "Confirm" : "Remove"
+ onClicked: {
+ if (page.confirming === envRow.modelData.key) {
+ page.confirming = "";
+ page.asst.setEnv(envRow.modelData.key, null);
+ } else page.confirming = envRow.modelData.key;
+ }
+ }
+ }
+ }
+
+ // The add row: a key, a value, Add.
+ property string newKey: ""
+ property string newValue: ""
+ readonly property bool keyOk: /^[A-Za-z_][A-Za-z0-9_]*$/.test(page.newKey)
+ && !page.asst.envList.some(e => e.key === page.newKey)
+ function addEnv() {
+ if (!page.keyOk) return;
+ page.asst.setEnv(page.newKey, page.newValue);
+ page.newKey = "";
+ page.newValue = "";
+ }
+
+ Item {
+ width: page.width
+ height: 34
+ Field {
+ id: keyField
+ anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
+ width: 120
+ value: page.newKey
+ placeholder: "NEW_KEY"
+ onEdited: t => page.newKey = t
+ }
+ Field {
+ anchors { left: keyField.right; leftMargin: 8; right: addBtn.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
+ value: page.newValue
+ placeholder: "value"
+ live: true
+ onEdited: t => page.newValue = t
+ onCommit: page.addEnv()
+ }
+ Button {
+ id: addBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Add"
+ enabled: page.keyOk
+ onClicked: page.addEnv()
+ }
+ }
+
+ // --- tools ---
+
+ Heading { text: "Tools" }
+ Small {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: "What the model may call. Off renames the file to .toml.off, which the daemon skips."
+ }
+
+ Repeater {
+ model: page.asst.tools
+
+ delegate: Item {
+ id: toolRow
+ required property var modelData
+ objectName: "tool:" + modelData.name
+ width: page.width
+ height: Math.max(40, toolText.implicitHeight + 10)
+
+ Switch {
+ id: toolSw
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ checked: toolRow.modelData.on
+ onToggled: page.asst.toggleTool(toolRow.modelData)
+ }
+ Column {
+ id: toolText
+ anchors { left: toolSw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter }
+ Text {
+ text: toolRow.modelData.name
+ color: toolRow.modelData.on ? Theme.text : Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ }
+ Small {
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: toolRow.modelData.description
+ }
+ }
+ }
+ }
+}
diff --git a/desktop/modules/assistant/Config.js b/desktop/modules/assistant/Config.js
new file mode 100644
index 0000000..c30ea8b
--- /dev/null
+++ b/desktop/modules/assistant/Config.js
@@ -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.
+
+.pragma library
+
+// Pure helpers for desktop-assistant's config.toml, tested by
+// test_config.js under node.
+//
+// The file is edited line by line so its comments survive: the example it
+// is copied from documents every key in place. Only the shapes this page
+// writes are understood: top-level `key = value` before the first table,
+// and `KEY = "value"` lines in [env]. A top-level key that is unset sits
+// commented out (`# key = value`), which is how the example ships, so
+// setting one activates its line and unsetting comments it back out.
+// ponytail: no multi-line strings or arrays; the page never writes one,
+// and Save validates the whole file with tomllib before writing.
+
+const KEY = /^(\s*)(#\s*)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/;
+const HEADER = /^\s*\[/;
+
+function quote(s) {
+ return '"' + String(s).replace(/[\\"]/g, c => "\\" + c)
+ .replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r") + '"';
+}
+
+// The value at the start of rest and where it ends, so what follows (an
+// inline comment) can be kept.
+function value(rest) {
+ if (rest.startsWith('"')) {
+ let out = "";
+ for (let i = 1; i < rest.length; i++) {
+ const c = rest[i];
+ if (c === '"') return { v: out, end: i + 1 };
+ if (c === "\\" && i + 1 < rest.length) {
+ const n = rest[++i];
+ out += n === "n" ? "\n" : n === "t" ? "\t" : n === "r" ? "\r" : n;
+ } else out += c;
+ }
+ return { v: out, end: rest.length };
+ }
+ const m = /^[^\s#]*/.exec(rest)[0];
+ const v = m === "true" ? true : m === "false" ? false
+ : m !== "" && !isNaN(Number(m)) ? Number(m) : m;
+ return { v: v, end: m.length };
+}
+
+function firstHeader(lines) {
+ const i = lines.findIndex(l => HEADER.test(l) && !/^\s*#/.test(l));
+ return i < 0 ? lines.length : i;
+}
+
+// {key: value} for the active top-level keys among keys.
+function scalars(text, keys) {
+ const lines = text.split("\n");
+ const out = {};
+ for (let i = 0; i < firstHeader(lines); i++) {
+ const m = KEY.exec(lines[i]);
+ if (m && !m[2] && keys.includes(m[3])) out[m[3]] = value(m[4]).v;
+ }
+ return out;
+}
+
+// toml is the value already written as TOML (quote() for a string), or null
+// to unset.
+function setScalar(text, key, toml) {
+ const lines = text.split("\n");
+ const end = firstHeader(lines);
+ let commented = -1;
+ for (let i = 0; i < end; i++) {
+ const m = KEY.exec(lines[i]);
+ if (!m || m[3] !== key) continue;
+ if (!m[2]) {
+ const tail = m[4].slice(value(m[4]).end);
+ lines[i] = toml === null ? m[1] + "# " + key + " = " + m[4]
+ : m[1] + key + " = " + toml + tail;
+ return lines.join("\n");
+ }
+ if (commented < 0) commented = i;
+ }
+ if (toml === null) return text;
+ if (commented >= 0) {
+ const m = KEY.exec(lines[commented]);
+ lines[commented] = m[1] + key + " = " + toml + m[4].slice(value(m[4]).end);
+ } else {
+ // Before the blank line that separates the first table, if any.
+ const at = end > 0 && lines[end - 1].trim() === "" ? end - 1 : end;
+ lines.splice(at, 0, key + " = " + toml);
+ }
+ return lines.join("\n");
+}
+
+function envRange(lines) {
+ const h = lines.findIndex(l => /^\s*\[env\]\s*(#.*)?$/.test(l));
+ if (h < 0) return null;
+ let e = lines.findIndex((l, i) => i > h && HEADER.test(l));
+ return { h: h, end: e < 0 ? lines.length : e };
+}
+
+// [{key, value}] for the active lines of [env], in file order.
+function env(text) {
+ const lines = text.split("\n");
+ const r = envRange(lines);
+ if (!r) return [];
+ const out = [];
+ for (let i = r.h + 1; i < r.end; i++) {
+ const m = KEY.exec(lines[i]);
+ if (m && !m[2]) out.push({ key: m[3], value: String(value(m[4]).v) });
+ }
+ return out;
+}
+
+// value is a plain string, quoted here; null removes the key.
+function setEnv(text, key, val) {
+ const lines = text.split("\n");
+ const r = envRange(lines);
+ if (!r) {
+ if (val === null) return text;
+ return text.replace(/\n*$/, "") + "\n\n[env]\n" + key + " = " + quote(val) + "\n";
+ }
+ let last = r.h;
+ for (let i = r.h + 1; i < r.end; i++) {
+ const m = KEY.exec(lines[i]);
+ if (!m || m[2]) continue;
+ if (m[3] === key) {
+ if (val === null) lines.splice(i, 1);
+ else lines[i] = m[1] + key + " = " + quote(val) + m[4].slice(value(m[4]).end);
+ return lines.join("\n");
+ }
+ last = i;
+ }
+ if (val === null) return text;
+ lines.splice(last + 1, 0, key + " = " + quote(val));
+ return lines.join("\n");
+}
diff --git a/desktop/modules/assistant/PresetsModule.qml b/desktop/modules/assistant/PresetsModule.qml
new file mode 100644
index 0000000..9490bf7
--- /dev/null
+++ b/desktop/modules/assistant/PresetsModule.qml
@@ -0,0 +1,34 @@
+// 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 "../.."
+import "../../assistant/Presets.js" as Presets
+
+// The assistant canvas's Ask row: the canned questions offered for the
+// staged files. They live in Prefs and the canvas reads them live, so
+// unlike the daemon's settings these need no restart.
+Module {
+ name: "presets"
+ label: "Ask presets"
+ // nf-fa-lightbulb_o, present in Inconsolata Nerd Font.
+ icon: ""
+ settings: true
+ keywords: ["assistant", "canvas", "ask", "question", "prompt", "summarize", "files"]
+ searchItems: Presets.list(Prefs.presets).map((p, i) => ({ text: p.label + ": " + p.prompt, key: "preset:" + i }))
+
+ page: Component {
+ Page {
+ title: "Ask presets"
+ PresetsPage { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/assistant/PresetsPage.qml b/desktop/modules/assistant/PresetsPage.qml
new file mode 100644
index 0000000..ec103cf
--- /dev/null
+++ b/desktop/modules/assistant/PresetsPage.qml
@@ -0,0 +1,215 @@
+// 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 "../.."
+import "../../assistant/Presets.js" as Presets
+
+// One card per preset: its label and question, saved on Enter, and the file
+// kinds it is offered for. Every change saves the whole list to Prefs; Reset
+// drops the saved list, which brings the built-in one back.
+Column {
+ id: page
+ spacing: 10
+
+ readonly property var list: Presets.list(Prefs.presets)
+ // The kinds a preset can name: the four the canvas chats about, and
+ // "many", offered whenever two or more files are staged.
+ readonly property var kinds: ["pdf", "text", "code", "image", "many"]
+ property string error: ""
+ property int confirming: -1
+
+ // Saves a copy of the list with entry i replaced by fn(entry), or
+ // removed when fn returns null.
+ function edit(i, fn) {
+ const out = [];
+ page.list.forEach((p, j) => {
+ const c = { label: p.label, icon: p.icon, kinds: p.kinds.slice(), prompt: p.prompt };
+ if (j !== i) { out.push(c); return; }
+ const r = fn(c);
+ if (r) out.push(r);
+ });
+ page.error = "";
+ page.confirming = -1;
+ Prefs.setPresets(out);
+ }
+
+ function add() {
+ Prefs.setPresets(page.list.concat([{ label: "New preset", icon: Presets.FALLBACK,
+ kinds: ["text"], prompt: "Summarize this." }]));
+ }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ component Field: Rectangle {
+ id: field
+ property string value: ""
+ property string placeholder: ""
+ signal commit(string text)
+ height: 32
+ radius: 8
+ color: Qt.alpha(Theme.base, 0.6)
+ TextInput {
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ text: field.value
+ onAccepted: if (text !== field.value) field.commit(text)
+ Keys.onEscapePressed: event => {
+ if (text !== field.value) text = field.value;
+ else event.accepted = false;
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: field.placeholder
+ color: Theme.overlay
+ font: parent.font
+ }
+ }
+ }
+
+ Small {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: "Offered on the assistant canvas for the staged files: a preset shows when every staged file is "
+ + "one of its kinds, or, with \"many\", when two or more are staged. Enter saves a field; "
+ + "changes apply at once."
+ }
+
+ Text {
+ width: page.width
+ visible: page.error !== ""
+ wrapMode: Text.Wrap
+ text: page.error
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ Repeater {
+ model: page.list
+
+ delegate: Rectangle {
+ id: card
+ required property var modelData
+ required property int index
+ objectName: "preset:" + index
+ width: page.width
+ height: body.implicitHeight + 20
+ radius: 10
+ color: Qt.alpha(Theme.surface, 0.45)
+
+ Column {
+ id: body
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 }
+ spacing: 8
+
+ Item {
+ width: parent.width
+ height: 32
+ Text {
+ id: glyph
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 24
+ text: card.modelData.icon ?? Presets.FALLBACK
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize }
+ color: Theme.text
+ }
+ Field {
+ anchors { left: glyph.right; leftMargin: 6; right: del.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
+ value: card.modelData.label
+ placeholder: "label"
+ onCommit: t => {
+ if (t.trim() === "") { page.error = "A preset needs a label."; return; }
+ page.edit(card.index, c => Object.assign(c, { label: t.trim() }));
+ }
+ }
+ Button {
+ id: del
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ danger: true
+ text: page.confirming === card.index ? "Confirm" : "Remove"
+ onClicked: {
+ if (page.confirming === card.index) page.edit(card.index, c => null);
+ else page.confirming = card.index;
+ }
+ }
+ }
+
+ Field {
+ width: parent.width
+ value: card.modelData.prompt
+ placeholder: "the question asked"
+ onCommit: t => {
+ if (t.trim() === "") { page.error = "A preset needs a question."; return; }
+ page.edit(card.index, c => Object.assign(c, { prompt: t.trim() }));
+ }
+ }
+
+ // A kind toggles on click; the last one cannot be cleared,
+ // since a preset with no kinds would never be offered.
+ Flow {
+ width: parent.width
+ spacing: 6
+ Repeater {
+ model: page.kinds
+ Rectangle {
+ id: chip
+ required property string modelData
+ readonly property bool on: card.modelData.kinds.includes(modelData)
+ implicitWidth: chipText.implicitWidth + 20
+ implicitHeight: 26
+ radius: 13
+ color: on ? Qt.alpha(Theme.accent, 0.85)
+ : chipArea.containsMouse ? Qt.alpha(Theme.accent, 0.22) : Qt.alpha(Theme.base, 0.6)
+ Text {
+ id: chipText
+ anchors.centerIn: parent
+ text: chip.modelData
+ color: chip.on ? Theme.base : Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ MouseArea {
+ id: chipArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ const ks = card.modelData.kinds;
+ if (chip.on && ks.length === 1) { page.error = "A preset needs at least one kind."; return; }
+ page.edit(card.index, c => Object.assign(c, {
+ kinds: chip.on ? ks.filter(k => k !== chip.modelData) : ks.concat([chip.modelData]) }));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Row {
+ spacing: 8
+ Button { text: "Add preset"; onClicked: page.add() }
+ Button {
+ text: "Reset to defaults"
+ danger: true
+ enabled: Prefs.presets !== null
+ onClicked: Prefs.resetPresets()
+ }
+ }
+}
diff --git a/desktop/modules/assistant/README.md b/desktop/modules/assistant/README.md
new file mode 100644
index 0000000..347f9ea
--- /dev/null
+++ b/desktop/modules/assistant/README.md
@@ -0,0 +1,34 @@
+# assistant
+
+Two settings pages for desktop-assistant, the daemon behind the assistant
+canvas (`../../assistant/`).
+
+**Assistant** edits the daemon's `~/.config/desktop-assistant/config.toml`
+and switches its tools. The model fields (server, temperature, tool rounds,
+thinking) save on Enter, and an emptied field falls back to the daemon's
+default, shown as its placeholder; the defaults come from `assistant.py
+--defaults`, so they are never copied here. The system prompt has its own
+box, Save and "Use the default". The tool environment (`[env]`) is one row
+per key, with an add row below. A tool's switch renames `tools/<name>.toml`
+to `<name>.toml.off`, which the daemon's `*.toml` glob skips.
+
+The daemon reads all of this only at startup, so after a change the page
+says so and offers Restart, which runs `assistant.sh restart`; a daemon that
+does not come back shows the script's status, whose log tail says why.
+
+The file is edited line by line (`Config.js`) so the comments the example
+ships with survive: an unset key sits commented out, setting it activates
+its line, and unsetting comments it back out. Every write first parses the
+whole new file with `tomllib`, then replaces the old one atomically, so an
+edit can never leave a config the daemon refuses to start with.
+
+**Ask presets** edits the canvas's canned questions for staged files:
+label, question, and the file kinds each is offered for. They live in
+`Prefs` and the canvas reads them live, so changes apply at once; "Reset to
+defaults" drops the saved list and brings back the built-in one in
+`../../assistant/Presets.js`. A new preset gets the fallback icon.
+
+Search finds the model fields, every `[env]` key, every tool and every
+preset by name.
+
+Check: `node desktop/modules/assistant/test_config.js`.
diff --git a/desktop/modules/assistant/test_config.js b/desktop/modules/assistant/test_config.js
new file mode 100644
index 0000000..cc610a8
--- /dev/null
+++ b/desktop/modules/assistant/test_config.js
@@ -0,0 +1,89 @@
+// 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.
+
+// Run: node desktop/modules/assistant/test_config.js
+// Config.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Config.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const C = new Function(src + "\nreturn { scalars, setScalar, env, setEnv, quote };")();
+
+const KEYS = ["llm", "system", "tool_rounds", "temperature", "thinking"];
+const file = [
+ "# desktop-assistant configuration.",
+ "# Style name = weight; prose with an equals sign",
+ "",
+ '# llm = "http://localhost:8181/v1" # llama-server, OpenAI API',
+ '# system = "You are a voice assistant. ..."',
+ "temperature = 0.6 # per request; unset = the preset's",
+ "",
+ "# [whisper]",
+ "# port = 8182",
+ "",
+ "[env]",
+ 'SEARXNG_URL = "http://search.example.org" # a # inside a comment',
+ 'MD_EDITOR = "typora"',
+ '# WEATHER_ENV = "/home/you/x"',
+ "",
+].join("\n");
+
+// Only active keys have values; prose and commented keys do not.
+assert.deepStrictEqual(C.scalars(file, KEYS), { temperature: 0.6 });
+assert.deepStrictEqual(C.env(file), [
+ { key: "SEARXNG_URL", value: "http://search.example.org" },
+ { key: "MD_EDITOR", value: "typora" },
+]);
+
+// Setting an active key keeps its inline comment.
+let t = C.setScalar(file, "temperature", "0.2");
+assert.strictEqual(t.split("\n")[5], "temperature = 0.2 # per request; unset = the preset's");
+// Unsetting comments it out in place, and it reads as unset.
+t = C.setScalar(file, "temperature", null);
+assert.strictEqual(t.split("\n")[5], "# temperature = 0.6 # per request; unset = the preset's");
+assert.deepStrictEqual(C.scalars(t, KEYS), {});
+// Setting a commented key activates its line rather than adding one.
+t = C.setScalar(file, "llm", C.quote("http://box:8080/v1"));
+assert.strictEqual(t.split("\n")[3], 'llm = "http://box:8080/v1" # llama-server, OpenAI API');
+assert.strictEqual(t.split("\n").length, file.split("\n").length);
+// A key nowhere in the file lands before the first table, not inside it.
+t = C.setScalar(file, "thinking", "false");
+const lines = t.split("\n");
+assert.ok(lines.indexOf("thinking = false") < lines.indexOf("[env]"));
+assert.strictEqual(C.scalars(t, KEYS).thinking, false);
+// The prose line with "=" is never treated as a key.
+assert.strictEqual(C.setScalar(file, "name", null), file);
+
+// Strings round-trip with quotes, backslashes and newlines.
+const tricky = 'Say "hi"\\now\nline two\ttab';
+t = C.setScalar(file, "system", C.quote(tricky));
+assert.strictEqual(C.scalars(t, KEYS).system, tricky);
+assert.strictEqual(t.split("\n").length, file.split("\n").length);
+
+// env: replace, remove, add after the last active entry.
+t = C.setEnv(file, "MD_EDITOR", "xdg-open");
+assert.deepStrictEqual(C.env(t)[1], { key: "MD_EDITOR", value: "xdg-open" });
+t = C.setEnv(file, "SEARXNG_URL", null);
+assert.deepStrictEqual(C.env(t).map(e => e.key), ["MD_EDITOR"]);
+t = C.setEnv(file, "CALENDAR_DIR", "/home/you/cal");
+assert.deepStrictEqual(C.env(t).map(e => e.key), ["SEARXNG_URL", "MD_EDITOR", "CALENDAR_DIR"]);
+assert.strictEqual(t.split("\n")[13], 'CALENDAR_DIR = "/home/you/cal"');
+// No [env] table yet: one is appended.
+t = C.setEnv("temperature = 0.6\n", "X", "1");
+assert.deepStrictEqual(C.env(t), [{ key: "X", value: "1" }]);
+assert.ok(/\n\[env\]\nX = "1"\n$/.test(t), JSON.stringify(t));
+// An env key in another table is not the env one.
+assert.deepStrictEqual(C.env('[voice]\nlang = "it"\n'), []);
+
+console.log("test_config: ok");