diff options
| -rw-r--r-- | docs/superpowers/plans/2026-10-23-assistant-cloud-models.md | 909 |
1 files changed, 909 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-23-assistant-cloud-models.md b/docs/superpowers/plans/2026-10-23-assistant-cloud-models.md new file mode 100644 index 0000000..bec65eb --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-assistant-cloud-models.md @@ -0,0 +1,909 @@ +# Assistant cloud models (quickshell side) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Configure cloud providers in the settings window's Assistant page, and pick the model for each chat from the assistant canvas. + +**Architecture:** The daemon (desktop-assistant, already done) owns providers, keys and routing. This side only edits `[[cloud]]` tables in its `config.toml` (`Config.js`, line based, as for the other keys), runs `assistant.py --cloud-models <name>` to fetch a provider's ids, and sends `model` with each `ask`. The canvas keeps the chat's model from the daemon's `hello.chat_model` and `conversation.model`, and lists models from its `models` event in a panel like History. + +**Tech Stack:** Quickshell 0.3.1 QML, `.pragma library` JS tested with node. Check: `node desktop/modules/assistant/test_config.js` (prints nothing, exits 0). QML is checked live by the user (a detached `qs` does not survive an agent's tool call, AGENTS.md). + +Spec: desktop-assistant `docs/superpowers/specs/2026-10-23-cloud-models-design.md`, sections "Settings page" and "Canvas header". Daemon protocol now in place: `ask` takes `model`; `hello` has `chat_model`; `conversation` has `model`; `{"type": "models"}` answers `{"type": "models", "items": [{id, status, cloud: false} | {id: "provider:model", provider, cloud: true}]}`; `assistant.py --cloud-models NAME` prints a JSON array of ids, or exits non-zero with the reason on stderr. + +--- + +## File map + +- Modify `desktop/modules/assistant/Config.js`: `clouds`, `setClouds`, `cloudError`. +- Modify `desktop/modules/assistant/test_config.js`: their checks. +- Modify `desktop/modules/assistant/AssistantModule.qml`: cloud state, edits, fetch, `save(done)`, `ensureModel` replacing `ensurePreferred`. +- Modify `desktop/modules/assistant/AssistantPage.qml`: Cloud section, cloud rows with Prefer, Model text. +- Modify `desktop/assistant/Assistant.qml`: `chatModel`, `preferred`, `wanted`, `cloud`, `models`, `modelsOpen`, `pickModel`, `model` on every ask. +- Create `desktop/assistant/Models.qml`: the model panel. +- Modify `desktop/assistant/Card.qml`: header label opens the panel, a Model button outside chat, Ctrl+M, panel slot. +- Modify `desktop/shell.qml`: `preferred` and `ensureModel` wiring. +- Modify `desktop/modules/assistant/README.md`, `desktop/assistant/README.md`. + +Every new source file carries the repo's GPLv2 header (copy it from `desktop/assistant/History.qml`). No home paths in committed files. No em dashes. Commits GPG-signed (automatic), never `--no-verify`; never stage HANDOFF.md; trailer `Co-Authored-By: <the model you are> <noreply@anthropic.com>`. + +--- + +### Task 1: `[[cloud]]` tables in Config.js + +**Files:** +- Modify: `desktop/modules/assistant/Config.js` (append) +- Test: `desktop/modules/assistant/test_config.js` + +- [ ] **Step 1: Write the failing tests** + +In `test_config.js`, the line building `C` exports the new functions: + +```js +const C = new Function(src + "\nreturn { scalars, setScalar, env, setEnv, quote, clouds, setClouds, cloudError };")(); +``` + +Append at the end of the file: + +```js +// [[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]), ""); +``` + +- [ ] **Step 2: Run, expect failure** + +`node desktop/modules/assistant/test_config.js`. Expected: `ReferenceError: clouds is not defined`. + +- [ ] **Step 3: Implement** + +Append to `Config.js`: + +```js +// [[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.` : ""; +} +``` + +Also extend the file's header comment (the paragraph starting `// The file is edited line by line`): after `and \`KEY = "value"\` lines in [env].` add the sentence `[[cloud]] tables are read when they hold only what setClouds writes, and rewritten whole.` + +- [ ] **Step 4: Run, expect success** + +`node desktop/modules/assistant/test_config.js`: no output, exit 0 (`echo $?` prints 0). + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/assistant/Config.js desktop/modules/assistant/test_config.js +git commit -m "assistant settings: read and write the daemon's [[cloud]] tables" +``` + +(with the trailer, see File map) + +--- + +### Task 2: AssistantModule, cloud state and fetch + +**Files:** +- Modify: `desktop/modules/assistant/AssistantModule.qml` + +No node test: QML. Verified in Task 6. + +- [ ] **Step 1: Keywords** + +`keywords:` gains `"cloud", "provider"`. + +- [ ] **Step 2: Cloud state** + +After the `function envValue(key) ...` line add: + +```qml + // 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) ?? [] + 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: "" +``` + +In the existing `onDraftChanged` handler, add after its two lines: + +```qml + const n = (C.clouds(am.draft) ?? []).length; + if (n !== am.cloudCount) am.cloudCount = n; +``` + +`valid` becomes: + +```qml + readonly property bool valid: Object.keys(am.invalid).length === 0 && am.cloudError === "" +``` + +- [ ] **Step 3: Edits** + +After `function quote(s) { return C.quote(s); }` add: + +```qml + // 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 }); + }); + }); + } +``` + +- [ ] **Step 4: `save(done)`** + +`save()` becomes `save(done)`, calling `done` once both writes are through; `pending` is set only when something was written: + +```qml + 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 changed = t !== am.text || Object.keys(am.toolsWanted).length > 0; + const tools = () => am.applyTools(Object.keys(am.toolsWanted), () => { + 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; } + am.text = t; + am.base = t; + tools(); + }); + } +``` + +The SaveBar's `onSave: am.save()` stays as it is. + +- [ ] **Step 5: `ensureModel` replaces `ensurePreferred`** + +Replace the whole `ensurePreferred` function and its comment with: + +```qml + // 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 m = am.models.find(x => x.id === id); + if (m && m.status !== "loaded" && m.status !== "loading") am.loadModel(id); + }); + } +``` + +Then `grep -rn ensurePreferred desktop/` must list only `desktop/shell.qml` (fixed in Task 5). + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/assistant/AssistantModule.qml +git commit -m "assistant settings: cloud providers, fetch their models, load the chat's model" +``` + +--- + +### Task 3: AssistantPage, Cloud section and cloud Prefer + +**Files:** +- Modify: `desktop/modules/assistant/AssistantPage.qml` + +- [ ] **Step 1: Model text** + +The first `Small` under `Heading { text: "Model" }` gets: + +```qml + 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." +``` + +- [ ] **Step 2: Cloud models in the Model list** + +Right after the router models' `Repeater { model: page.asst.models ... }` (before the `modelError` Small), add: + +```qml + // The saved cloud models: Prefer only, there is nothing to load. + Repeater { + model: page.asst.savedClouds.flatMap(c => c.models.map(m => c.name + ":" + m)) + + 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) + } + } + } +``` + +- [ ] **Step 3: Cloud section** + +Insert right before the `Heading { text: "Tool environment" }` line: + +```qml + // --- 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, {}) + } +``` + +- [ ] **Step 4: Commit** + +```bash +git add desktop/modules/assistant/AssistantPage.qml +git commit -m "assistant settings: a Cloud section, and Prefer for cloud models" +``` + +--- + +### Task 4: Canvas, the chat's model + +**Files:** +- Modify: `desktop/assistant/Assistant.qml` +- Create: `desktop/assistant/Models.qml` +- Modify: `desktop/assistant/Card.qml` + +- [ ] **Step 1: State in Assistant.qml** + +After `property string swapping: ""` add: + +```qml + // The model this chat is asked with: its stored pick (hello.chat_model, + // conversation.model), "" for none, then the preferred one (set from + // shell.qml). "" for both: the daemon uses whatever the router has + // loaded. Spec: desktop-assistant, + // docs/superpowers/specs/2026-10-23-cloud-models-design.md. + property string chatModel: "" + property string preferred: "" + readonly property string wanted: root.chatModel || root.preferred + // The daemon's models event: [{id, status, cloud: false} | {id, provider, cloud: true}]. + property var models: [] + readonly property bool cloud: root.models.some(m => m.cloud && m.id === root.wanted) + property bool modelsOpen: false +``` + +- [ ] **Step 2: Functions** + +`close()` resets the panel too: + +```qml + root.open = false; root.historyOpen = false; root.pickerOpen = false; root.modelsOpen = false; +``` + +`back()`, after the recording branch: + +```qml + else if (root.modelsOpen) root.modelsOpen = false; +``` + +`showHistory()` gains `root.modelsOpen = false;` next to `root.pickerOpen = false;`. + +After `showHistory()` add: + +```qml + function showModels() { + root.historyOpen = root.pickerOpen = false; + root.modelsOpen = true; + root.send({ type: "models" }); + } + // Applies from the next ask; the daemon stores it with the chat then. + // Moving a chat with turns to the cloud sends its history there, so + // that is said once, on the switch. + function pickModel(id) { + const m = root.models.find(x => x.id === id); + if (m && m.cloud && !root.cloud && turns.count) + root.notice = `History in this chat goes to ${m.provider}.`; + root.chatModel = id; + root.modelsOpen = false; + } + function withModel(cmd) { + if (root.wanted) cmd.model = root.wanted; + return cmd; + } +``` + +Both asks carry the model. In `ask(text)`: + +```qml + root.send(root.withModel({ type: "ask", text: text, files: root.staged.map(f => f.path) })); +``` + +In the recorder's `onExited`: + +```qml + root.send(root.withModel({ type: "ask", text: "", audio: root.micPath, + files: root.staged.map(f => f.path) })); +``` + +The `write` send (To .md) stays without a model: the daemon always writes with the local one. + +- [ ] **Step 3: Events** + +In `handle()`: +- `case "hello":` after `root.model = e.model ?? "";` add `root.chatModel = e.chat_model ?? "";` and, at its end before `break`, `root.send({ type: "models" });` (so the cloud marker is right before the panel was ever opened). +- `case "conversation":` after `root.load(e.id, e.turns, null);` add `root.chatModel = e.model ?? "";` +- New case, next to `"results"`: `case "models": root.models = e.items; break;` + +- [ ] **Step 4: `Models.qml`** + +Create `desktop/assistant/Models.qml` (GPLv2 header first, copied from `History.qml`): + +```qml +import QtQuick +import "../.." + +// The model panel, over the transcript: the router's models, then the +// cloud ones, in the daemon's order. A pick applies to this chat from its +// next question. +Rectangle { + id: panel + property var canvas + + color: Qt.alpha(Theme.base, 0.97) + radius: 8 + + MouseArea { anchors.fill: parent } // keep clicks off the transcript below + + ListView { + id: rows + anchors.fill: parent + clip: true + spacing: 2 + model: panel.canvas.models + + delegate: Rectangle { + id: r + required property var modelData + readonly property bool picked: r.modelData.id === panel.canvas.wanted + width: rows.width + height: 34 + radius: 6 + color: hover.hovered ? Qt.alpha(Theme.surface, 0.9) : "transparent" + border.width: r.picked ? 1 : 0 + border.color: Qt.alpha(Theme.accent, 0.5) + + HoverHandler { id: hover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: panel.canvas.pickModel(r.modelData.id) + } + // nf-fa-cloud, present in Inconsolata Nerd Font. + Text { + id: icon + anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter } + width: 16 + text: r.modelData.cloud ? "" : "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + anchors { left: icon.right; leftMargin: 6; right: stat.left; rightMargin: 8 + verticalCenter: parent.verticalCenter } + // A cloud id without its provider: the right column says it. + text: r.modelData.cloud ? r.modelData.id.slice(r.modelData.provider.length + 1) + : r.modelData.id + elide: Text.ElideRight + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: r.picked } + } + Text { + id: stat + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + text: r.modelData.cloud ? r.modelData.provider : r.modelData.status + color: r.modelData.status === "loaded" ? Theme.green : Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + + Text { + anchors.centerIn: parent + visible: rows.count === 0 + text: "No models: is llama-server running?" + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + } +} +``` + +- [ ] **Step 5: Card.qml** + +`panel` includes the new one: + +```qml + readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen || card.canvas.modelsOpen +``` + +Ctrl+M, in `Keys.onPressed`, after the `Qt.Key_H` branch: + +```qml + } else if (event.key === Qt.Key_M && card.canvas.connected) { + card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels(); +``` + +and the comment above it reads `// Ctrl+N, Ctrl+H and Ctrl+M do what New, History and the model menu do.` + +Header: replace the single header `Text { ... }` (the one showing "assistant not running" / "loading …" / the model) with: + +```qml + // The chat's model, a click opens the model panel. nf-fa-cloud for a + // cloud one, nf-fa-angle_down after it. + Row { + id: modelRow + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + spacing: 6 + Text { + anchors.verticalCenter: parent.verticalCenter + visible: card.canvas.connected && !card.canvas.swapping && card.canvas.cloud + text: "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: !card.canvas.connected ? "assistant not running" + : card.canvas.swapping ? `loading ${card.canvas.swapping}…` + : card.canvas.wanted || card.canvas.model || "assistant" + color: card.canvas.connected ? Theme.text : Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: card.canvas.connected + text: "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } + } + MouseArea { + anchors.fill: modelRow + enabled: card.canvas.connected + cursorShape: Qt.PointingHandCursor + onClicked: card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels() + } +``` + +Outside chat, a Model button left of History. Add before `Button { id: history ...`: + +```qml + // The model menu outside chat, where there is no header; nf-fa-cloud + // when the next question goes to a cloud model. + Button { + id: modelBtn + visible: card.mode !== "chat" + width: visible ? implicitWidth : 0 + anchors { bottom: parent.bottom; right: history.left; rightMargin: visible ? 8 : 0 } + height: box.height + icon: card.canvas.cloud ? "" : "" + text: "Model" + enabled: card.canvas.connected + onClicked: card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels() + } +``` + +and the input `box`'s anchors change `right: history.left` to `right: modelBtn.left`. + +In `panelArea`, after `Picker { ... }`: + +```qml + Models { + anchors.fill: parent + visible: card.canvas.modelsOpen + canvas: card.canvas + } +``` + +- [ ] **Step 6: Commit** + +```bash +git add desktop/assistant/Assistant.qml desktop/assistant/Models.qml desktop/assistant/Card.qml +git commit -m "assistant: pick the chat's model, local or cloud, from the header" +``` + +--- + +### Task 5: Wiring and docs + +**Files:** +- Modify: `desktop/shell.qml`, `desktop/modules/assistant/README.md`, `desktop/assistant/README.md` + +- [ ] **Step 1: shell.qml** + +```qml + Assistant { id: assistant; swapping: asstSettings.loading; hears: asstSettings.hears + preferred: Prefs.assistantModel } +``` + +and the Connections block becomes: + +```qml + // Opening the assistant, or picking another model for its chat, loads + // that model if it is a local one and another is in VRAM. See + // modules/assistant/README.md. + Connections { + target: assistant + function onOpenChanged() { if (assistant.open) asstSettings.ensureModel(assistant.wanted); } + function onWantedChanged() { if (assistant.open) asstSettings.ensureModel(assistant.wanted); } + } +``` + +`grep -rn ensurePreferred desktop/` prints nothing. + +- [ ] **Step 2: `desktop/modules/assistant/README.md`** + +Replace the paragraph starting `Opening the assistant canvas (SUPER+Home) loads the preferred model` with: + +``` +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. +``` + +After the paragraph ending `...which the daemon's \`*.toml\` glob skips.`, add: + +``` +**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. +``` + +- [ ] **Step 3: `desktop/assistant/README.md`** + +Add a `Models.qml` row to the file table: + +``` +| `Models.qml` | the model panel: local and cloud models, the chat's pick | +``` + +After the mic paragraph, add: + +``` +The header's model name (the Model button outside chat, or Ctrl+M) opens +the model panel: the router's models, then each cloud provider's. A pick +applies to this chat from its next question and is stored with it by the +daemon; a new chat starts on the preferred model. Moving a chat with turns +to a cloud model says once that its history goes to that provider. Voice is +transcribed by the local model in a cloud chat too. +``` + +and in the Keys line, `Ctrl+H toggles the history panel.` becomes `Ctrl+H toggles the history panel, Ctrl+M the model panel.` + +- [ ] **Step 4: Check and commit** + +`node desktop/modules/assistant/test_config.js` exits 0. Then: + +```bash +git add desktop/shell.qml desktop/modules/assistant/README.md desktop/assistant/README.md +git commit -m "assistant: load the chat's model on open, docs for cloud models" +``` + +--- + +### Task 6: Live check (the user) + +The agent cannot run `qs` itself (AGENTS.md, "A detached qs does not survive"). Ask the user to run `./reload.sh desktop` and check, then fix what they report: + +- [ ] Settings, Assistant page: the hetzner card shows name, URL, key command, and its two models switched on. Fetch lists the four ids (pass may ask for the PIN). Switching one off and Save rewrites the `[[cloud]]` table at the end of `config.toml`; Restart applies it. Add provider with an empty name holds Save back with the cloud error. +- [ ] Model list: the cloud models have Prefer; preferring one marks it. +- [ ] Canvas, entry state: Model button opens the panel; local models with their state, then the hetzner ones with the cloud icon; the preferred model is outlined. +- [ ] Pick `Qwen3.8-27B`, ask about the weather: the header shows the cloud icon and the id; the answer uses `weather`. +- [ ] Pick Gemma in that chat: the notice does not show; next ask answers locally (ctx meter shows `/ 32768` again). Pick the cloud one back: "History in this chat goes to hetzner." shows. +- [ ] History: reopening the chat shows its stored model in the header. +- [ ] The header's model name opens the panel on click, and Ctrl+M toggles it. |
