diff options
Diffstat (limited to 'desktop/modules')
| -rw-r--r-- | desktop/modules/assistant/AssistantModule.qml | 175 | ||||
| -rw-r--r-- | desktop/modules/assistant/AssistantPage.qml | 420 | ||||
| -rw-r--r-- | desktop/modules/assistant/Config.js | 142 | ||||
| -rw-r--r-- | desktop/modules/assistant/PresetsModule.qml | 34 | ||||
| -rw-r--r-- | desktop/modules/assistant/PresetsPage.qml | 215 | ||||
| -rw-r--r-- | desktop/modules/assistant/README.md | 34 | ||||
| -rw-r--r-- | desktop/modules/assistant/test_config.js | 89 |
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"); |
