From 9b8b64da15bd6f96f228736f8604aef003304e72 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 23 Oct 2026 17:10:00 +0200 Subject: assistant: pick the chat's model, local or cloud, from the header The canvas keeps the chat's model from the daemon (hello.chat_model, conversation.model), falls back to the preferred one (set from shell.qml later), and sends it as model with every ask, typed or spoken. The To .md write stays without one: the daemon always writes with the local model. Models.qml lists the daemon's models event, router models then cloud ones, in a panel like History. It opens from the header label in chat, from a Model button next to History outside chat, and with Ctrl+M. The models are asked for on hello too, so the cloud marker is right before the panel was ever opened. Moving a chat with turns to a cloud model says once that its history goes to that provider. Co-Authored-By: Claude Opus 5.5 --- desktop/assistant/Assistant.qml | 46 ++++++++++++++++++++-- desktop/assistant/Card.qml | 66 ++++++++++++++++++++++++++----- desktop/assistant/Models.qml | 87 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 desktop/assistant/Models.qml (limited to 'desktop/assistant') diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml index 2641a5e..0b7026d 100644 --- a/desktop/assistant/Assistant.qml +++ b/desktop/assistant/Assistant.qml @@ -66,6 +66,18 @@ Scope { // The preferred model being loaded on open, shown in the header until // the router reports it loaded. Set from shell.qml. property string swapping: "" + // 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 property int ctxUsed: 0 property int ctxMax: 0 property var convId: null @@ -113,11 +125,12 @@ Scope { function close() { root.endRecording(false); - root.open = false; root.historyOpen = false; root.pickerOpen = false; + root.open = false; root.historyOpen = false; root.pickerOpen = false; root.modelsOpen = false; } // Escape: a recording, the picker, then the history panel, then the card. function back() { if (root.recording) root.endRecording(false); + else if (root.modelsOpen) root.modelsOpen = false; else if (root.pickerOpen) root.pickerOpen = false; else if (root.historyOpen) root.historyOpen = false; else root.close(); @@ -258,8 +271,8 @@ Scope { root.notice = "recording failed"; } else if (rec.send && root.connected) { root.chatting = true; - root.send({ type: "ask", text: "", audio: root.micPath, - files: root.staged.map(f => f.path) }); + root.send(root.withModel({ type: "ask", text: "", audio: root.micPath, + files: root.staged.map(f => f.path) })); } rec.destroy(); } @@ -325,16 +338,37 @@ Scope { || (text.trim() === "" && !root.staged.length)) return false; root.notice = ""; root.chatting = true; - root.send({ type: "ask", text: text, files: root.staged.map(f => f.path) }); + root.send(root.withModel({ type: "ask", text: text, files: root.staged.map(f => f.path) })); return true; } function showHistory() { root.pickerOpen = false; + root.modelsOpen = false; root.historyOpen = true; root.send({ type: "list" }); } + 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; + } + function row(t, live) { return { user: t.user ?? "", reply: t.reply ?? "", think: t.think ?? "", tools: (t.tools ?? []).join(","), error: "", live: live, @@ -371,15 +405,18 @@ Scope { root.state = e.state; root.runningTool = e.state === "tool" ? e.tool : ""; root.model = e.model ?? ""; + root.chatModel = e.chat_model ?? ""; root.ctxUsed = e.ctx_used; root.ctxMax = e.ctx_max; root.load(e.id, e.turns, e.pending); // A restart or reload counts as active: the conversation it // reconnects to is not stale just because this process is new. if (e.pending || e.turns.length) root.lastActive = Date.now(); + root.send({ type: "models" }); break; case "conversation": root.load(e.id, e.turns, null); + root.chatModel = e.model ?? ""; // Opened from History on purpose: it counts as active now. if (e.turns.length) root.lastActive = Date.now(); // Emptied (New, or the current one deleted) with nothing staged: @@ -445,6 +482,7 @@ Scope { case "ctx": root.ctxUsed = e.used; root.ctxMax = e.max; if (e.model) root.model = e.model; break; case "conversations": root.convs = e.items; break; case "results": root.results = e.items; break; + case "models": root.models = e.items; break; } } diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml index a2f8e28..b9fe4e5 100644 --- a/desktop/assistant/Card.qml +++ b/desktop/assistant/Card.qml @@ -28,7 +28,7 @@ Rectangle { property real fullHeight: 600 // the chat state's height, set by the window readonly property string mode: card.canvas.mode - readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen + readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen || card.canvas.modelsOpen readonly property bool tall: card.mode === "chat" || card.panel // Bottom-up: the input, then the actions (file state), then the chips. @@ -49,13 +49,15 @@ Rectangle { // Keys reach a focused item, never the window (AGENTS.md); the card is // the ancestor of every text field, so their Escape bubbles here. Keys.onEscapePressed: card.canvas.back() - // Ctrl+N and Ctrl+H do what the New and History buttons do. + // Ctrl+N, Ctrl+H and Ctrl+M do what New, History and the model menu do. Keys.onPressed: event => { if (!(event.modifiers & Qt.ControlModifier)) return; if (event.key === Qt.Key_N && card.canvas.connected) { card.canvas.fresh(); card.canvas.historyOpen = false; input.forceActiveFocus(); } else if (event.key === Qt.Key_H) { card.canvas.historyOpen ? card.canvas.back() : card.canvas.showHistory(); + } else if (event.key === Qt.Key_M && card.canvas.connected) { + card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels(); } else return; event.accepted = true; } @@ -90,13 +92,40 @@ Rectangle { anchors { top: parent.top; left: parent.left; right: parent.right; margins: 14 } height: 32 - Text { + // 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 } - text: !card.canvas.connected ? "assistant not running" - : card.canvas.swapping ? `loading ${card.canvas.swapping}…` - : card.canvas.model || "assistant" - color: card.canvas.connected ? Theme.text : Theme.red - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + 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() } Row { @@ -466,7 +495,7 @@ Rectangle { id: box anchors { bottom: parent.bottom; left: mic.right; leftMargin: 8 - right: history.left; rightMargin: 8 + right: modelBtn.left; rightMargin: 8 } // Grows with the text up to six lines, then scrolls. height: Math.min(input.contentHeight, 6 * input.font.pixelSize * 1.4) + 16 @@ -524,6 +553,20 @@ Rectangle { } } + // 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() + } + // History from every state; in chat the header has it too. Button { id: history @@ -571,5 +614,10 @@ Rectangle { visible: card.canvas.pickerOpen canvas: card.canvas } + Models { + anchors.fill: parent + visible: card.canvas.modelsOpen + canvas: card.canvas + } } } diff --git a/desktop/assistant/Models.qml b/desktop/assistant/Models.qml new file mode 100644 index 0000000..b1b2a38 --- /dev/null +++ b/desktop/assistant/Models.qml @@ -0,0 +1,87 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 ".." + +// 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 } + } + } +} -- cgit v1.2.3