diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-23 17:09:21 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-23 17:09:21 +0200 |
| commit | b62a40a8fd7442e0fbb3b44d722fdf8f69aa108a (patch) | |
| tree | 6dfa032e36a059820a1ec9f481a7f75680bb5e8e /desktop/modules | |
| parent | 3cba919cb6e641dc873ee02c5c4c4a999bec7f55 (diff) | |
| download | quickshell-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.js | 87 | ||||
| -rw-r--r-- | desktop/modules/assistant/test_config.js | 41 |
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"); |
