diff options
Diffstat (limited to 'desktop/modules')
| -rw-r--r-- | desktop/modules/appearance/WallpaperTab.qml | 21 | ||||
| -rw-r--r-- | desktop/modules/assistant/AssistantModule.qml | 125 | ||||
| -rw-r--r-- | desktop/modules/assistant/AssistantPage.qml | 205 | ||||
| -rw-r--r-- | desktop/modules/assistant/Config.js | 87 | ||||
| -rw-r--r-- | desktop/modules/assistant/README.md | 24 | ||||
| -rw-r--r-- | desktop/modules/assistant/test_config.js | 41 | ||||
| -rw-r--r-- | desktop/modules/companion/CompanionModule.qml | 117 | ||||
| -rw-r--r-- | desktop/modules/companion/PhrasesModule.qml | 64 | ||||
| -rw-r--r-- | desktop/modules/companion/PhrasesPage.qml | 188 |
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() + } + } +} |
