aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules')
-rw-r--r--desktop/modules/appearance/WallpaperTab.qml21
-rw-r--r--desktop/modules/assistant/AssistantModule.qml125
-rw-r--r--desktop/modules/assistant/AssistantPage.qml205
-rw-r--r--desktop/modules/assistant/Config.js87
-rw-r--r--desktop/modules/assistant/README.md24
-rw-r--r--desktop/modules/assistant/test_config.js41
-rw-r--r--desktop/modules/companion/CompanionModule.qml117
-rw-r--r--desktop/modules/companion/PhrasesModule.qml64
-rw-r--r--desktop/modules/companion/PhrasesPage.qml188
9 files changed, 821 insertions, 51 deletions
diff --git a/desktop/modules/appearance/WallpaperTab.qml b/desktop/modules/appearance/WallpaperTab.qml
index a3b3c55..d9876ed 100644
--- a/desktop/modules/appearance/WallpaperTab.qml
+++ b/desktop/modules/appearance/WallpaperTab.qml
@@ -12,6 +12,7 @@
import Quickshell
import QtQuick
import "../.."
+import "../../companion"
// Wallpapers by folder, staged per screen and set together with Apply, beside
// a mock of the two screens above them. Sized by its host: the thumbnail list scrolls
@@ -338,6 +339,26 @@ Column {
color: Theme.subtext
}
}
+
+ // The companion blushes at the hearted ones
+ // (companion/Admired.qml). After the pick
+ // MouseArea, so it takes the click. Outlined,
+ // so it reads on a light image too.
+ Text {
+ readonly property bool on: Admired.listed(modelData)
+ anchors { top: parent.top; right: parent.right; margins: 10 }
+ visible: on || thumbHover.hovered
+ text: on ? "\u{F02D1}" : "\u{F02D5}"
+ color: on ? Theme.red : Theme.text
+ style: Text.Outline
+ styleColor: Qt.alpha(Theme.base, 0.6)
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize + 8 }
+ MouseArea {
+ anchors { fill: parent; margins: -4 }
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Admired.toggle(modelData)
+ }
+ }
}
}
}
diff --git a/desktop/modules/assistant/AssistantModule.qml b/desktop/modules/assistant/AssistantModule.qml
index 1e3c1e0..a05556d 100644
--- a/desktop/modules/assistant/AssistantModule.qml
+++ b/desktop/modules/assistant/AssistantModule.qml
@@ -27,7 +27,7 @@ Module {
settings: true
group: "Assistant"
keywords: ["llm", "model", "llama", "server", "prompt", "temperature", "thinking",
- "rounds", "tools", "environment", "daemon", "restart"]
+ "rounds", "tools", "environment", "daemon", "restart", "cloud", "provider"]
readonly property string home: Quickshell.env("HOME")
readonly property string daemonDir: home + "/Programming/GIT/desktop-assistant"
@@ -57,14 +57,34 @@ Module {
onDraftChanged: {
const k = C.env(am.draft).map(e => e.key);
if (JSON.stringify(k) !== JSON.stringify(am.envKeys)) am.envKeys = k;
+ const n = (C.clouds(am.draft) ?? []).length;
+ if (n !== am.cloudCount) am.cloudCount = n;
}
function envValue(key) { return (am.envList.find(e => e.key === key) ?? { value: "" }).value; }
- // Tool switches flipped but not saved: name -> wanted on.
+ // The [[cloud]] tables in the draft, null when one cannot be read (the
+ // page then leaves them alone), and the saved ones, which the canvas and
+ // Prefer offer. cloudCount follows the draft's count only, so rows are
+ // not rebuilt on each keystroke (as envKeys).
+ readonly property var clouds: C.clouds(am.draft)
+ readonly property var savedClouds: C.clouds(am.text) ?? []
+ // Their models as the canvas names them, "provider:model". reduce, not
+ // flatMap: the shell's JS engine has no flatMap.
+ readonly property var cloudIds: am.savedClouds.reduce(
+ (a, c) => a.concat(c.models.map(m => c.name + ":" + m)), [])
+ property int cloudCount: 0
+ readonly property string cloudError: am.clouds ? C.cloudError(am.clouds) : ""
+ // name -> the provider's ids, or why they could not be fetched.
+ property var fetched: ({})
+ property string fetching: ""
+ // Tool switches flipped but not saved: name -> wanted on; and the same
+ // for each tool's cloud = true, which offers it to cloud chats.
property var toolsWanted: ({})
+ property var cloudWanted: ({})
// Fields holding a value that cannot be written; Save waits for none.
property var invalid: ({})
- readonly property bool valid: Object.keys(am.invalid).length === 0
+ readonly property bool valid: Object.keys(am.invalid).length === 0 && am.cloudError === ""
dirty: am.draft !== am.base || Object.keys(am.toolsWanted).length > 0
+ || Object.keys(am.cloudWanted).length > 0
// The daemon's built-in values, from assistant.py --defaults.
property var defaults: ({})
// [{name, on, description}]
@@ -163,14 +183,21 @@ Module {
// The config first, then the tool renames, each only when there is
// something to do; any failure stops there and says why.
- function save() {
+ function save(done) {
if (am.text !== am.base) {
am.error = "config.toml changed on disk since these edits began. Discard to load it, then redo them.";
return;
}
am.error = "";
const t = am.draft;
- const tools = () => am.applyTools(Object.keys(am.toolsWanted), () => { am.pending = true; am.listTools(); });
+ const changed = t !== am.text || Object.keys(am.toolsWanted).length > 0
+ || Object.keys(am.cloudWanted).length > 0;
+ const tools = () => am.applyTools(Object.keys(am.toolsWanted), () =>
+ am.applyCloud(Object.keys(am.cloudWanted), () => {
+ if (changed) am.pending = true;
+ am.listTools();
+ if (done) done();
+ }));
if (t === am.text) { tools(); return; }
am.run([am.python, "-c", am.writer, am.cfgPath], t, (code, out, err) => {
if (code !== 0) { am.error = err.trim() || "config.toml could not be written"; return; }
@@ -183,6 +210,7 @@ Module {
am.error = "";
am.invalid = ({});
am.toolsWanted = ({});
+ am.cloudWanted = ({});
am.base = am.text;
am.draft = am.text;
}
@@ -197,15 +225,41 @@ Module {
function setEnv(key, value) { am.draft = C.setEnv(am.draft, key, value); }
function quote(s) { return C.quote(s); }
+ // patch is {name}, {url}, {key_cmd} or {models}; i === cloudCount adds one.
+ function editCloud(i, patch) {
+ const list = (am.clouds ?? []).map(c => Object.assign({}, c));
+ if (i === list.length) list.push({ name: "", url: "", key_cmd: "", models: [] });
+ Object.assign(list[i], patch);
+ am.draft = C.setClouds(am.draft, list);
+ }
+ function removeCloud(i) { am.draft = C.setClouds(am.draft, am.clouds.filter((c, j) => j !== i)); }
+
+ // The daemon reads the saved config, so Fetch saves the draft first.
+ // The key command runs in that process; the key never reaches here.
+ function fetchModels(name) {
+ am.save(() => {
+ am.fetching = name;
+ am.run([am.python, am.daemonDir + "/assistant.py", "--cloud-models", name], null,
+ (code, out, err) => {
+ am.fetching = "";
+ let got;
+ try { got = code === 0 ? JSON.parse(out) : (err.trim().split("\n").pop() || `exit ${code}`); }
+ catch (e) { got = "the assistant sent an unreadable list"; }
+ am.fetched = Object.assign({}, am.fetched, { [name]: got });
+ });
+ });
+ }
+
// 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"
+ + " try:\n t = tomllib.load(open(p, 'rb'))\n"
+ + " d, cloud = t.get('description', ''), t.get('cloud') is True\n"
+ + " except Exception as e:\n d, cloud = f'unreadable: {e}', False\n"
+ + " out.append({'name': name, 'on': on, 'cloud': cloud, 'description': d})\n"
+ "print(json.dumps(sorted(out, key=lambda t: t['name'])))\n"
function listTools() {
@@ -221,6 +275,45 @@ Module {
if (!am.toolOn(t) === t.on) delete next[t.name]; else next[t.name] = !am.toolOn(t);
am.toolsWanted = next;
}
+ // Whether a tool goes to cloud chats in the draft, as toolOn.
+ function toolCloud(t) { return am.cloudWanted[t.name] ?? t.cloud; }
+ function toggleCloud(t) {
+ const next = Object.assign({}, am.cloudWanted);
+ if (!am.toolCloud(t) === t.cloud) delete next[t.name]; else next[t.name] = !am.toolCloud(t);
+ am.cloudWanted = next;
+ }
+
+ // Sets or drops a tool file's top-level cloud line, validated with
+ // tomllib and replaced atomically, like config.toml. The file is
+ // name.toml or, switched off, name.toml.off. Added as the first line:
+ // a top-level key is valid there whatever the file holds below.
+ readonly property string clouder: "import os, re, sys, tomllib\n"
+ + "p, on = sys.argv[1], sys.argv[2] == '1'\n"
+ + "if not os.path.exists(p):\n p += '.off'\n"
+ + "lines = open(p).read().split('\\n')\n"
+ + "head = next((i for i, l in enumerate(lines) if l.lstrip().startswith('[')), len(lines))\n"
+ + "lines = [l for i, l in enumerate(lines) if i >= head or not re.match(r'\\s*cloud\\s*=', l)]\n"
+ + "if on:\n lines.insert(0, 'cloud = true')\n"
+ + "t = '\\n'.join(lines)\n"
+ + "try:\n tomllib.loads(t)\n"
+ + "except tomllib.TOMLDecodeError as e:\n sys.exit(f'{os.path.basename(p)}: not valid TOML: {e}')\n"
+ + "with open(p + '.tmp', 'w') as f:\n f.write(t)\n"
+ + "os.replace(p + '.tmp', p)\n"
+
+ // One file at a time, after the renames, then done().
+ function applyCloud(names, done) {
+ if (names.length === 0) { done(); return; }
+ const name = names[0];
+ am.run([am.python, "-c", am.clouder, am.toolsDir + "/" + name + ".toml",
+ am.cloudWanted[name] ? "1" : "0"], null, (code, out, err) => {
+ if (code !== 0) { am.error = err.trim() || "could not change " + name; am.listTools(); return; }
+ const next = Object.assign({}, am.cloudWanted);
+ delete next[name];
+ am.cloudWanted = next;
+ am.applyCloud(names.slice(1), done);
+ });
+ }
+
// One rename at a time, then done(). -n never overwrites a file that
// already holds the other name.
function applyTools(names, done) {
@@ -269,17 +362,17 @@ Module {
else { poll.left = 300; poll.start(); }
});
}
- // Called when the assistant canvas opens: the preferred model, if one is
- // set, still listed, and neither loaded nor loading, is loaded. One model
- // and one client at a time is how this desktop runs, so opening the
- // assistant is the moment to bring its model back. The list is read on
- // every open, preferred model or not, so hears is current.
- function ensurePreferred() {
+ // Called when the canvas opens and when its chat's model changes, with
+ // the chat's model, else the preferred one: loaded if it is a router
+ // model neither loaded nor loading. A cloud id is not in the router's
+ // list, so nothing is swapped for it. One model and one client at a time
+ // is how this desktop runs, so this is the moment to bring the model
+ // back. The list is read every time, so hears is current.
+ function ensureModel(id) {
if (am.loading !== "") return;
am.listModels(() => {
- const id = Prefs.assistantModel;
const m = am.models.find(x => x.id === id);
- if (id !== "" && m && m.status !== "loaded" && m.status !== "loading") am.loadModel(id);
+ if (m && m.status !== "loaded" && m.status !== "loading") am.loadModel(id);
});
}
diff --git a/desktop/modules/assistant/AssistantPage.qml b/desktop/modules/assistant/AssistantPage.qml
index e088241..bf35763 100644
--- a/desktop/modules/assistant/AssistantPage.qml
+++ b/desktop/modules/assistant/AssistantPage.qml
@@ -166,9 +166,9 @@ Column {
Small {
width: page.width
wrapMode: Text.Wrap
- text: "The assistant answers with whichever model llama-server has loaded. Opening it loads "
- + "the preferred model if another one is in. Load swaps a model in now, for every client, "
- + "without changing the preference."
+ text: "Each chat uses the model picked in the canvas header, else the preferred one. "
+ + "Opening the canvas loads that model if it is a local one and another is in. "
+ + "Load swaps a model in now, for every client, without changing the preference."
}
Small {
@@ -231,6 +231,50 @@ Column {
}
}
+ // The saved cloud models: Prefer only, there is nothing to load.
+ Repeater {
+ model: page.asst.cloudIds
+
+ delegate: Rectangle {
+ id: cloudRow
+ required property string modelData
+ readonly property bool preferred: Prefs.assistantModel === modelData
+ objectName: "model:" + modelData
+ width: page.width
+ height: 40
+ radius: 8
+ color: Qt.alpha(Theme.surface, 0.35)
+
+ // nf-fa-cloud, present in Inconsolata Nerd Font.
+ Text {
+ id: cloudIcon
+ anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: ""
+ color: Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Text {
+ id: cloudName
+ anchors { left: cloudIcon.right; leftMargin: 8; verticalCenter: parent.verticalCenter }
+ text: cloudRow.modelData
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+ Small {
+ anchors { left: cloudName.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ visible: cloudRow.preferred
+ text: "preferred"
+ color: Theme.accent
+ }
+ Button {
+ anchors { right: parent.right; rightMargin: 6; verticalCenter: parent.verticalCenter }
+ text: cloudRow.preferred ? "Preferred" : "Prefer"
+ enabled: !cloudRow.preferred
+ onClicked: Prefs.setAssistantModel(cloudRow.modelData)
+ }
+ }
+ }
+
Small {
width: page.width
visible: page.asst.modelError !== ""
@@ -368,6 +412,141 @@ Column {
// --- env ---
+ // --- cloud ---
+
+ Heading { text: "Cloud" }
+
+ Small {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: "Providers with an OpenAI-compatible API, offered in the canvas's model menu. A cloud "
+ + "chat is sent its history, attachments and the results of the tools marked "
+ + "cloud = true; voice is still transcribed here. The key command prints the API key: "
+ + "the assistant runs it at startup and on Fetch, which saves this page first."
+ }
+
+ Small {
+ width: page.width
+ visible: page.asst.clouds === null
+ wrapMode: Text.Wrap
+ text: "config.toml has a [[cloud]] table this page cannot read; edit it by hand."
+ color: Theme.red
+ }
+
+ Repeater {
+ model: page.asst.clouds === null ? 0 : page.asst.cloudCount
+
+ delegate: Rectangle {
+ id: prov
+ required property int index
+ readonly property var c: page.asst.clouds?.[index] ?? { name: "", url: "", key_cmd: "", models: [] }
+ readonly property var got: page.asst.fetched[prov.c.name]
+ objectName: "cloud:" + index
+ width: page.width
+ height: provCol.implicitHeight + 20
+ radius: 8
+ color: Qt.alpha(Theme.surface, 0.35)
+
+ Column {
+ id: provCol
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 }
+ spacing: 6
+
+ Labelled {
+ width: provCol.width
+ label: "Name"
+ Field {
+ anchors.fill: parent
+ value: prov.c.name
+ placeholder: "openrouter"
+ onEdited: t => page.asst.editCloud(prov.index, { name: t.trim() })
+ }
+ }
+ Labelled {
+ width: provCol.width
+ label: "URL"
+ Field {
+ anchors.fill: parent
+ value: prov.c.url
+ placeholder: "https://openrouter.ai/api/v1"
+ onEdited: t => page.asst.editCloud(prov.index, { url: t.trim() })
+ }
+ }
+ Labelled {
+ width: provCol.width
+ label: "Key command"
+ Field {
+ anchors.fill: parent
+ value: prov.c.key_cmd
+ placeholder: "pass show llm/openrouter"
+ onEdited: t => page.asst.editCloud(prov.index, { key_cmd: t.trim() })
+ }
+ }
+ Row {
+ spacing: 6
+ Button {
+ text: page.asst.fetching === prov.c.name ? "Fetching…" : "Fetch models"
+ enabled: page.asst.fetching === "" && page.asst.valid && prov.c.name !== ""
+ onClicked: page.asst.fetchModels(prov.c.name)
+ }
+ Button {
+ text: "Remove"
+ danger: true
+ onClicked: page.asst.removeCloud(prov.index)
+ }
+ }
+ Small {
+ width: provCol.width
+ visible: typeof prov.got === "string"
+ wrapMode: Text.Wrap
+ text: typeof prov.got === "string" ? prov.got : ""
+ color: Theme.red
+ }
+ // The fetched ids and any kept from before, each switched
+ // into models or out of it.
+ Repeater {
+ model: Array.isArray(prov.got)
+ ? [...new Set(prov.got.concat(prov.c.models))].sort() : prov.c.models
+
+ delegate: Item {
+ id: idRow
+ required property string modelData
+ width: provCol.width
+ height: 28
+ Switch {
+ id: idOn
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ checked: prov.c.models.includes(idRow.modelData)
+ onToggled: on => page.asst.editCloud(prov.index, { models: on
+ ? prov.c.models.concat([idRow.modelData])
+ : prov.c.models.filter(m => m !== idRow.modelData) })
+ }
+ Text {
+ anchors { left: idOn.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: idRow.modelData
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Small {
+ width: page.width
+ visible: page.asst.cloudError !== ""
+ wrapMode: Text.Wrap
+ text: page.asst.cloudError
+ color: Theme.red
+ }
+
+ Button {
+ text: "Add provider"
+ enabled: page.asst.clouds !== null
+ onClicked: page.asst.editCloud(page.asst.cloudCount, {})
+ }
+
Heading { text: "Tool environment" }
Small {
width: page.width
@@ -461,7 +640,8 @@ Column {
Small {
width: page.width
wrapMode: Text.Wrap
- text: "What the model may call. Off renames the file to .toml.off, which the daemon skips."
+ text: "What the model may call. Off renames the file to .toml.off, which the daemon skips. "
+ + "Cloud also offers it to cloud chats, which then receive its results."
}
Repeater {
@@ -480,9 +660,24 @@ Column {
checked: page.asst.toolOn(toolRow.modelData)
onToggled: page.asst.toggleTool(toolRow.modelData)
}
+ // nf-fa-cloud, then the tool's cloud = true.
+ Switch {
+ id: cloudSw
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ checked: page.asst.toolCloud(toolRow.modelData)
+ onToggled: page.asst.toggleCloud(toolRow.modelData)
+ }
+ Text {
+ id: cloudMark
+ anchors { right: cloudSw.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ text: "\uf0c2"
+ color: page.asst.toolCloud(toolRow.modelData) ? Theme.text : Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
Column {
id: toolText
- anchors { left: toolSw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter }
+ anchors { left: toolSw.right; leftMargin: 10; right: cloudMark.left; rightMargin: 10
+ verticalCenter: parent.verticalCenter }
Text {
text: toolRow.modelData.name
color: page.asst.toolOn(toolRow.modelData) ? Theme.text : Theme.subtext
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/README.md b/desktop/modules/assistant/README.md
index 3c35dc6..a1ce840 100644
--- a/desktop/modules/assistant/README.md
+++ b/desktop/modules/assistant/README.md
@@ -13,7 +13,17 @@ are never copied here. A refused number is outlined and holds Save back.
The system prompt also has "Use the default". The tool environment
(`[env]`) is one row per key, with an add row below. A tool's switch, once
saved, renames `tools/<name>.toml`
-to `<name>.toml.off`, which the daemon's `*.toml` glob skips.
+to `<name>.toml.off`, which the daemon's `*.toml` glob skips. Its cloud switch sets or drops
+`cloud = true` at the top of the file, which offers the tool to cloud
+chats; the file is checked with `tomllib` and replaced atomically.
+
+**Cloud** edits the daemon's `[[cloud]]` tables: name, URL and key
+command per provider. Fetch saves the page, then runs `assistant.py
+--cloud-models <name>`, which runs the key command and lists the
+provider's ids; the switches choose which the canvas offers. The key
+never reaches this page. A table holding more than the page writes (a
+multi-line array, another key) is left alone, with a note to edit it by
+hand. The saved cloud models also get Prefer in the Model list.
The page also lists llama-server's models with their state. The daemon
always answers with whichever model is loaded and never asks for one, so
@@ -24,12 +34,12 @@ Prefer is the other, separate choice: the model the assistant should use,
saved in `Prefs`. A first version made Load also set the preference, so a
model loaded for another client became the assistant's too.
-Opening the assistant canvas (SUPER+Home) loads the preferred model when
-another one is resident, and the canvas header reads "loading <model>…"
-until it is in. This desktop runs one model and one client at a time, so
-opening the assistant is when its model should come back; the daemon still
-never asks for a model itself. With no preferred model set, nothing is
-swapped.
+Each chat uses the model picked in the canvas's model menu, else the
+preferred one, else whatever is loaded. Opening the canvas, or picking a
+local model for its chat, loads that model when another one is resident,
+and the canvas header reads "loading <model>…" until it is in. This
+desktop runs one model and one client at a time, so that is when the
+model should come back. A cloud model loads nothing.
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
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");
diff --git a/desktop/modules/companion/CompanionModule.qml b/desktop/modules/companion/CompanionModule.qml
index ea6b13d..8d6a68d 100644
--- a/desktop/modules/companion/CompanionModule.qml
+++ b/desktop/modules/companion/CompanionModule.qml
@@ -11,42 +11,117 @@
import QtQuick
import "../.."
+import "../appearance"
+import "../../companion"
-// The desktop companion's one setting: whether she is shown. The same
-// preference SUPER+\ toggles, so the switch follows the key.
+// The desktop companion's settings: whether she is shown, the same
+// preference SUPER+\ toggles, so the switch follows the key; and where her
+// wallpaper list is, marked with the heart in the wallpaper picker.
Module {
name: "companion"
label: "Companion"
// nf-md-account_heart, present in Inconsolata Nerd Font.
icon: "\u{F0899}"
settings: true
- group: "Desktop"
- keywords: ["companion", "2b", "mascot", "character", "show", "hide"]
+ group: "Companion"
+ keywords: ["companion", "2b", "mascot", "character", "show", "hide", "wallpaper", "blush", "leotard", "timeout"]
page: Component {
Page {
title: "Companion"
- Item {
+ Column {
width: parent.width
- height: 34
- Text {
- id: lbl
- anchors { left: parent.left; verticalCenter: parent.verticalCenter }
- width: 160
- text: "Shown"
- color: Theme.subtext
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ spacing: 6
+
+ Item {
+ width: parent.width
+ height: 34
+ Text {
+ id: lbl
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 160
+ text: "Shown"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Switch {
+ id: sw
+ anchors { left: lbl.right; leftMargin: 8; verticalCenter: parent.verticalCenter }
+ checked: Prefs.companion
+ onToggled: on => Prefs.setCompanion(on)
+ }
+ Text {
+ anchors { left: sw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter }
+ elide: Text.ElideRight
+ text: "Also SUPER+\\. Kept across restarts."
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ }
+ }
+ // After the self-destruct. Off keeps the leotard until she is
+ // hidden and shown again; turning it back on starts from the
+ // seconds in the field.
+ Item {
+ width: parent.width
+ height: 34
+ Text {
+ id: lblSk
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 160
+ text: "Leotard timeout"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Switch {
+ id: swSk
+ anchors { left: lblSk.right; leftMargin: 8; verticalCenter: parent.verticalCenter }
+ checked: Prefs.companionSkirtless > 0
+ onToggled: on => Prefs.setCompanionSkirtless(on ? (parseInt(fSk.value) || 60) : 0)
+ }
+ Field {
+ id: fSk
+ anchors { left: swSk.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ width: 60
+ enabled: swSk.checked
+ opacity: enabled ? 1 : 0.4
+ value: String(Prefs.companionSkirtless || 60)
+ placeholder: "60"
+ onEdited: {
+ const n = parseInt(value);
+ if (n >= 1 && String(n) === value.trim()) Prefs.setCompanionSkirtless(n);
+ else value = String(Prefs.companionSkirtless || 60);
+ }
+ }
+ Text {
+ anchors { left: fSk.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter }
+ elide: Text.ElideRight
+ text: swSk.checked ? "seconds" : "until she is hidden and shown again"
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ }
}
- Switch {
- id: sw
- anchors { left: lbl.right; leftMargin: 8; verticalCenter: parent.verticalCenter }
- checked: Prefs.companion
- onToggled: on => Prefs.setCompanion(on)
+ Item {
+ width: parent.width
+ height: 34
+ Text {
+ id: lbl2
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 160
+ text: "Wallpaper list"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Field {
+ anchors { left: lbl2.right; leftMargin: 8; right: parent.right; verticalCenter: parent.verticalCenter }
+ value: Prefs.companionBlushFile
+ placeholder: Admired.defaultPath.replace(Admired.home, "~")
+ onEdited: Prefs.setCompanionBlushFile(value)
+ }
}
Text {
- anchors { left: sw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter }
- elide: Text.ElideRight
- text: "Also SUPER+\\. Kept across restarts."
+ width: parent.width
+ wrapMode: Text.WordWrap
+ text: "One path per line. She blushes at these on an empty workspace; the heart in Wallpaper adds and removes them."
color: Theme.overlay
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
}
diff --git a/desktop/modules/companion/PhrasesModule.qml b/desktop/modules/companion/PhrasesModule.qml
new file mode 100644
index 0000000..7e4895a
--- /dev/null
+++ b/desktop/modules/companion/PhrasesModule.qml
@@ -0,0 +1,64 @@
+// 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 "../../companion/Lines.js" as Lines
+
+// The lines the companion says when focus moves to a program she
+// recognizes, by its lowercased window class. They live in Prefs and she
+// reads them live, so a save needs no restart. The page edits a draft here;
+// Save hands it to Prefs, Reset drops the saved set so Lines.js' defaults
+// show again.
+Module {
+ id: pm
+ name: "phrases"
+ label: "Companion phrases"
+ // nf-fa-comment, present in Inconsolata Nerd Font.
+ icon: "\uf075"
+ settings: true
+ group: "Companion"
+ keywords: ["companion", "phrase", "line", "say", "speak", "app", "program",
+ "window", "class", "firefox", "2b"]
+ searchItems: pm.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i }))
+
+ // null while there are no edits: the page then shows the saved set, or
+ // Lines.js' defaults when nothing is saved.
+ property var draft: null
+ // Reset was pressed: Save drops the saved set rather than saving a copy
+ // of the defaults, so later default changes still arrive.
+ property bool resetting: false
+ readonly property var shown: pm.draft ?? Lines.appList(Prefs.companionApps)
+ // Every app needs a class and at least one non-empty phrase; Save waits.
+ readonly property bool valid: pm.shown.every(a => String(a.cls).trim() !== ""
+ && (a.lines || []).some(l => String(l).trim() !== ""))
+ dirty: pm.draft !== null
+
+ function edit(list) { pm.draft = list; pm.resetting = false; }
+ function reset() { pm.draft = Lines.appList(null); pm.resetting = true; }
+ function save() {
+ if (pm.resetting) Prefs.resetCompanionApps();
+ else Prefs.setCompanionApps(Lines.toMap(pm.draft));
+ pm.discard();
+ }
+ function discard() { pm.draft = null; pm.resetting = false; }
+
+ page: Component {
+ Page {
+ title: "Companion phrases"
+ actions: Component {
+ SaveBar { dirty: pm.dirty; valid: pm.valid; note: "every app needs a class and a phrase"
+ onSave: pm.save(); onDiscard: pm.discard() }
+ }
+ PhrasesPage { width: parent.width; owner: pm }
+ }
+ }
+}
diff --git a/desktop/modules/companion/PhrasesPage.qml b/desktop/modules/companion/PhrasesPage.qml
new file mode 100644
index 0000000..67c1e03
--- /dev/null
+++ b/desktop/modules/companion/PhrasesPage.qml
@@ -0,0 +1,188 @@
+// 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 "../.."
+
+// One card per recognized program: its window class and the lines she says
+// when focus moves to it. Every change edits the module's draft; the
+// header's Save hands the set to Prefs. Reset fills the draft with the
+// built-in set from Lines.js.
+Column {
+ id: page
+ spacing: 10
+
+ // The module, holding the draft. Not named pm, its id (it would bind to
+ // itself, AGENTS.md).
+ required property QtObject owner
+ readonly property var list: owner.shown
+ // The app card awaiting a second click on Remove.
+ property int confirming: -1
+
+ // Saves a copy of the list with app i replaced by fn(app), or removed
+ // when fn returns null.
+ function edit(i, fn) {
+ const out = [];
+ for (let j = 0; j < page.list.length; j++) {
+ const a = page.list[j];
+ const c = { cls: a.cls, lines: a.lines.slice() };
+ if (j !== i) { out.push(c); continue; }
+ const r = fn(c);
+ if (r) out.push(r);
+ }
+ page.confirming = -1;
+ page.owner.edit(out);
+ }
+
+ function addApp() { page.owner.edit(page.list.concat([{ cls: "", lines: [""] }])); }
+ function removeApp(i) { page.edit(i, () => null); }
+ function setCls(i, t) { page.edit(i, a => Object.assign(a, { cls: t })); }
+ function addLine(i) { page.edit(i, a => Object.assign(a, { lines: a.lines.concat([""]) })); }
+ function setLine(i, j, t) {
+ page.edit(i, a => {
+ const ls = a.lines.slice();
+ ls[j] = t;
+ return Object.assign(a, { lines: ls });
+ });
+ }
+ function removeLine(i, j) { page.edit(i, a => Object.assign(a, { lines: a.lines.filter((_, k) => k !== j) })); }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ // Reports every keystroke into the draft. Not bound to value, which
+ // would reset the cursor; it follows the value only when the draft moves
+ // under it (Discard, Reset). Empty is outlined, since Save refuses it.
+ component Field: Rectangle {
+ id: field
+ property string value: ""
+ property string placeholder: ""
+ signal edited(string text)
+ height: 32
+ radius: 8
+ color: Qt.alpha(Theme.base, 0.6)
+ border.width: ti.text.trim() === "" ? 1 : 0
+ border.color: Theme.red
+ onValueChanged: if (ti.text !== field.value) ti.text = field.value
+ TextInput {
+ activeFocusOnTab: true
+ 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 - 3 }
+ Component.onCompleted: text = field.value
+ onTextEdited: field.edited(text)
+ 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: "When focus moves to a program she recognizes, she says one of its lines at random, a third "
+ + "of the time and under her 30 s line cooldown. The class is Hyprland's window class, "
+ + "lowercased (hyprctl activewindow shows it). Save applies at once."
+ }
+
+ Repeater {
+ // A count, not the list: a new list on every keystroke would rebuild
+ // every card and drop the focus of the field being typed in.
+ model: page.list.length
+
+ delegate: Rectangle {
+ id: card
+ required property int index
+ readonly property var modelData: page.list[index] ?? { cls: "", lines: [] }
+ objectName: "app:" + 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
+ Field {
+ anchors { left: parent.left; right: del.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
+ value: card.modelData.cls
+ placeholder: "window class, e.g. firefox"
+ onEdited: t => page.setCls(card.index, t)
+ }
+ 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.removeApp(card.index);
+ else page.confirming = card.index;
+ }
+ }
+ }
+
+ Repeater {
+ model: card.modelData.lines.length
+ delegate: Row {
+ id: phraseRow
+ required property int index
+ width: body.width
+ spacing: 6
+ Field {
+ width: phraseRow.width - removeBtn.width - phraseRow.spacing
+ value: card.modelData.lines[phraseRow.index] ?? ""
+ placeholder: "a line she says"
+ onEdited: t => page.setLine(card.index, phraseRow.index, t)
+ }
+ Button {
+ id: removeBtn
+ // nf-fa-xmark, present in Inconsolata Nerd Font.
+ icon: "\uf00d"
+ danger: true
+ onClicked: page.removeLine(card.index, phraseRow.index)
+ }
+ }
+ }
+
+ Button {
+ text: "Add phrase"
+ onClicked: page.addLine(card.index)
+ }
+ }
+ }
+ }
+
+ Row {
+ spacing: 8
+ Button { text: "Add app"; onClicked: page.addApp() }
+ Button {
+ text: "Reset to defaults"
+ danger: true
+ enabled: !page.owner.resetting && (Prefs.companionApps !== null || page.owner.dirty)
+ onClicked: page.owner.reset()
+ }
+ }
+}