aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-23 17:09:21 +0200
committerDanilo M. <danix@danix.xyz>2026-10-23 17:09:21 +0200
commitb62a40a8fd7442e0fbb3b44d722fdf8f69aa108a (patch)
tree6dfa032e36a059820a1ec9f481a7f75680bb5e8e /desktop/modules
parent3cba919cb6e641dc873ee02c5c4c4a999bec7f55 (diff)
downloadquickshell-b62a40a8fd7442e0fbb3b44d722fdf8f69aa108a.tar.gz
quickshell-b62a40a8fd7442e0fbb3b44d722fdf8f69aa108a.zip
assistant settings: read and write the daemon's [[cloud]] tables
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules')
-rw-r--r--desktop/modules/assistant/Config.js87
-rw-r--r--desktop/modules/assistant/test_config.js41
2 files changed, 126 insertions, 2 deletions
diff --git a/desktop/modules/assistant/Config.js b/desktop/modules/assistant/Config.js
index c30ea8b..4c71b14 100644
--- a/desktop/modules/assistant/Config.js
+++ b/desktop/modules/assistant/Config.js
@@ -17,7 +17,8 @@
// 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
+// and `KEY = "value"` lines in [env]. [[cloud]] tables are read when they
+// hold only what setClouds writes, and rewritten whole. 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,
@@ -140,3 +141,87 @@ function setEnv(text, key, val) {
lines.splice(last + 1, 0, key + " = " + quote(val));
return lines.join("\n");
}
+
+// [[cloud]] tables, the daemon's providers: name, url and key_cmd strings
+// and a one-line models array, as setClouds writes them.
+const CLOUD = /^\s*\[\[cloud\]\]\s*(#.*)?$/;
+
+// A one-line array of strings, ["a", "b"]; null for anything else.
+function strings(rest) {
+ let r = rest.trim();
+ if (!r.startsWith("[")) return null;
+ const out = [];
+ r = r.slice(1).trim();
+ while (!r.startsWith("]")) {
+ if (!r.startsWith('"')) return null;
+ const v = value(r);
+ out.push(v.v);
+ r = r.slice(v.end).trim();
+ if (r.startsWith(",")) r = r.slice(1).trim();
+ else if (!r.startsWith("]")) return null;
+ }
+ const tail = r.slice(1).trim();
+ return tail === "" || tail.startsWith("#") ? out : null;
+}
+
+// [{start, end}] of each [[cloud]] table, its header to the next header.
+function cloudBlocks(lines) {
+ const out = [];
+ lines.forEach((l, i) => {
+ if (!CLOUD.test(l)) return;
+ const e = lines.findIndex((x, j) => j > i && HEADER.test(x));
+ out.push({ start: i, end: e < 0 ? lines.length : e });
+ });
+ return out;
+}
+
+// [{name, url, key_cmd, models}] in file order, or null when a table holds
+// a line this cannot read (a multi-line array, another key): the page then
+// leaves them to be edited by hand rather than rewrite what it did not read.
+function clouds(text) {
+ const lines = text.split("\n");
+ const out = [];
+ for (const b of cloudBlocks(lines)) {
+ const c = { name: "", url: "", key_cmd: "", models: [] };
+ for (let i = b.start + 1; i < b.end; i++) {
+ if (/^\s*(#.*)?$/.test(lines[i])) continue;
+ const m = KEY.exec(lines[i]);
+ if (!m) return null;
+ if (m[3] === "models") {
+ const a = strings(m[4]);
+ if (a === null) return null;
+ c.models = a;
+ } else if (["name", "url", "key_cmd"].includes(m[3]) && m[4].startsWith('"')) {
+ c[m[3]] = value(m[4]).v;
+ } else return null;
+ }
+ out.push(c);
+ }
+ return out;
+}
+
+// Replaces every [[cloud]] table with list's, at the end of the file. The
+// lines inside the old tables go with them; comments above them stay.
+function setClouds(text, list) {
+ const lines = text.split("\n");
+ for (const b of cloudBlocks(lines).reverse()) lines.splice(b.start, b.end - b.start);
+ while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
+ for (const c of list)
+ lines.push("", "[[cloud]]", "name = " + quote(c.name), "url = " + quote(c.url),
+ "key_cmd = " + quote(c.key_cmd),
+ "models = [" + c.models.map(quote).join(", ") + "]");
+ return lines.join("\n") + "\n";
+}
+
+// Why list cannot be saved, "" when it can: the daemon refuses to start on
+// any of these (assistant.py, check_cloud).
+function cloudError(list) {
+ for (const c of list) {
+ if (!c.name.trim() || !c.url.trim() || !c.key_cmd.trim())
+ return "A cloud provider needs a name, a URL and a key command.";
+ if (c.name.includes(":")) return `Cloud name ${c.name} has a colon.`;
+ }
+ const names = list.map(c => c.name);
+ const dup = names.find((n, i) => names.indexOf(n) !== i);
+ return dup !== undefined ? `Cloud name ${dup} is used twice.` : "";
+}
diff --git a/desktop/modules/assistant/test_config.js b/desktop/modules/assistant/test_config.js
index cc610a8..f79f4e1 100644
--- a/desktop/modules/assistant/test_config.js
+++ b/desktop/modules/assistant/test_config.js
@@ -18,7 +18,7 @@ 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 C = new Function(src + "\nreturn { scalars, setScalar, env, setEnv, quote, clouds, setClouds, cloudError };")();
const KEYS = ["llm", "system", "tool_rounds", "temperature", "thinking"];
const file = [
@@ -86,4 +86,43 @@ 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'), []);
+// [[cloud]] tables.
+const withCloud = file + [
+ "# Cloud models.",
+ "[[cloud]]",
+ 'name = "hetzner"',
+ 'url = "https://llm.example.org/v1" # a comment',
+ 'key_cmd = "pass llm/x"',
+ 'models = ["a/b", "c:d"]',
+ "",
+ "[[cloud]]",
+ 'name = "two"',
+ 'url = "u"',
+ 'key_cmd = "k"',
+ "models = []",
+ "",
+].join("\n");
+assert.deepStrictEqual(C.clouds(withCloud), [
+ { name: "hetzner", url: "https://llm.example.org/v1", key_cmd: "pass llm/x", models: ["a/b", "c:d"] },
+ { name: "two", url: "u", key_cmd: "k", models: [] }]);
+assert.deepStrictEqual(C.clouds(file), []);
+assert.deepStrictEqual(C.env(withCloud), C.env(file)); // [env] ends at the table
+// A table this cannot read in full is not offered for editing.
+assert.strictEqual(C.clouds(withCloud.replace('models = ["a/b", "c:d"]', 'models = [\n "a/b",\n]')), null);
+assert.strictEqual(C.clouds(withCloud.replace('name = "two"', 'name = "two"\nextra = 1')), null);
+// Rewritten at the end of the file; everything else stays.
+const one = C.setClouds(withCloud, [{ name: "n", url: "u", key_cmd: 'pass "q"', models: ["m"] }]);
+assert.deepStrictEqual(C.clouds(one), [{ name: "n", url: "u", key_cmd: 'pass "q"', models: ["m"] }]);
+assert.deepStrictEqual(C.env(one), C.env(file));
+assert.deepStrictEqual(C.scalars(one, KEYS), C.scalars(file, KEYS));
+assert.ok(one.includes("# Cloud models.") && one.endsWith('models = ["m"]\n'), one);
+assert.deepStrictEqual(C.clouds(C.setClouds(one, [])), []);
+// What the daemon refuses to start with.
+const ok = { name: "a", url: "u", key_cmd: "k", models: [] };
+assert.strictEqual(C.cloudError([ok]), "");
+assert.strictEqual(C.cloudError([]), "");
+for (const bad of [{ name: "" }, { name: "a:b" }, { url: "" }, { key_cmd: " " }])
+ assert.notStrictEqual(C.cloudError([Object.assign({}, ok, bad)]), "", JSON.stringify(bad));
+assert.notStrictEqual(C.cloudError([ok, ok]), "");
+
console.log("test_config: ok");