diff options
Diffstat (limited to 'desktop/assistant')
| -rw-r--r-- | desktop/assistant/Assistant.qml | 54 | ||||
| -rw-r--r-- | desktop/assistant/Card.qml | 68 | ||||
| -rw-r--r-- | desktop/assistant/Models.qml | 87 | ||||
| -rw-r--r-- | desktop/assistant/README.md | 10 |
4 files changed, 203 insertions, 16 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml index 33616ad..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 @@ -74,6 +86,9 @@ Scope { property var convs: [] property var results: [] readonly property bool busy: root.state !== "idle" + // A reply finished (not stopped). The canvas notifies through notifyDone; + // the desktop companion waves. + signal replied() // A daemon gone mid-reply sends no idle; its hello on return redraws. onConnectedChanged: if (!root.connected) { @@ -110,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(); @@ -255,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(); } @@ -322,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, @@ -368,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: @@ -405,7 +445,10 @@ Scope { } if (!e.stopped) root.convId = e.id; root.lastActive = Date.now(); - if (!e.stopped && i >= 0) root.notifyDone(turns.get(i).reply); + if (!e.stopped && i >= 0) { + root.notifyDone(turns.get(i).reply); + root.replied(); + } break; case "progress": if (root.opDaemon) root.opChars = e.chars; break; case "written": @@ -439,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..810920f 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 { @@ -436,7 +465,7 @@ Rectangle { text: "" font.family: Theme.iconFamily onClicked: { - card.canvas.historyOpen = false; + card.canvas.historyOpen = card.canvas.modelsOpen = false; card.canvas.pickerOpen = !card.canvas.pickerOpen; } } @@ -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. <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 ".." + +// 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 } + } + } +} diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md index 58fbb14..4c76a66 100644 --- a/desktop/assistant/README.md +++ b/desktop/assistant/README.md @@ -18,6 +18,7 @@ what is typed. The protocol is specified in that repo, | `Segments.qml` | markdown with a copy button per code block | | `History.qml` | conversation list and full-text search | | `Picker.qml` | the in-card file picker behind the paperclip | +| `Models.qml` | the model panel: local and cloud models, the chat's pick | | `Md.js` | splits a message into prose and fenced code | | `Files.js` | a path's kind, and a dropped URL to a path | | `Presets.js` | the Ask row's canned questions and which kinds they fit | @@ -38,9 +39,16 @@ model transcribe it and answers the words. The mic is greyed out while the model in use reports no `audio` input (`AssistantModule.hears`, from the router's `/models`, read on every open). +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. + Keys: Enter sends, Shift+Enter is a newline, Escape cancels a recording, then closes the picker, the history panel, then the card. Ctrl+N starts a -new chat, Ctrl+H toggles the history panel. Stop replaces +new chat, Ctrl+H toggles the history panel, Ctrl+M the model panel. Stop replaces Send while a reply streams. Notes: |
