diff options
Diffstat (limited to 'desktop/assistant/Message.qml')
| -rw-r--r-- | desktop/assistant/Message.qml | 263 |
1 files changed, 188 insertions, 75 deletions
diff --git a/desktop/assistant/Message.qml b/desktop/assistant/Message.qml index 0aa686a..c2e1b13 100644 --- a/desktop/assistant/Message.qml +++ b/desktop/assistant/Message.qml @@ -9,12 +9,16 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +import Quickshell.Widgets import QtQuick import ".." -// One turn: the question, then the reply with its thinking line, the tool -// chips, and an error or stopped mark. Both texts go through Segments, so -// the question renders as markdown too. +// One turn as chat bubbles, deck-it's Assistant tab look: the question on +// the right with the user's avatar, the reply on the left with the +// assistant's (its face cropped from the backdrop, Assistant.qml). The reply +// bubble holds the thinking line, the tool chips, the reply and an error or +// stopped mark. Both texts go through Segments, so the question renders as +// markdown too. Column { id: msg required property int index @@ -28,26 +32,48 @@ Column { required property bool stopped required property bool expanded required property var model // the row, for setting expanded + property var canvas property string running: "" // the tool running now, if any - spacing: 6 - - Rectangle { - width: msg.width - height: question.height + 16 - radius: 8 - color: Qt.alpha(Theme.surface, 0.7) - Segments { - id: question - anchors { left: parent.left; right: parent.right; top: parent.top; margins: 8 } - text: msg.user + readonly property int av: 32 + // ponytail: bubbles fit the raw text's widest line, measured unformatted; + // a reply with a code block or a table, or open reasoning, takes the + // full width rather than be measured. + readonly property real meMax: msg.width * 0.85 - msg.av - 8 + readonly property real botMax: msg.width * 0.90 - msg.av - 8 + + spacing: 10 + + Row { + anchors.right: parent.right + spacing: 8 + layoutDirection: Qt.RightToLeft + visible: msg.user !== "" + + Avatar { source: msg.canvas ? msg.canvas.fileUrl(msg.canvas.avatar) : "" } + + Rectangle { + width: Math.min(msg.meMax, qProbe.implicitWidth + 32) + height: question.height + 20 + radius: 16 + bottomRightRadius: 4 + color: Qt.rgba(Theme.accent.r * 0.85 + Theme.text.r * 0.15, + Theme.accent.g * 0.85 + Theme.text.g * 0.15, + Theme.accent.b * 0.85 + Theme.text.b * 0.15, 1) + Segments { + id: question + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: 14; rightMargin: 14; topMargin: 10 } + text: msg.user + color: Theme.crust + } } } // The files that went with the question, as the daemon read them. Flow { - width: msg.width + width: msg.width - msg.av - 8 spacing: 6 + layoutDirection: Qt.RightToLeft visible: msg.files !== "[]" Repeater { model: JSON.parse(msg.files) @@ -70,75 +96,162 @@ Column { } } - // Collapsed by default; a click shows the reasoning as plain text. - Text { - visible: msg.think !== "" - text: (msg.expanded ? "▾ " : "▸ ") + (msg.live && msg.reply === "" ? "thinking…" : "thinking") - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: msg.model.expanded = !msg.expanded + Row { + spacing: 8 + visible: msg.live || msg.think !== "" || msg.tools !== "" || msg.reply !== "" + || msg.error !== "" || msg.stopped + + Avatar { + id: face + readonly property var f: msg.canvas ? msg.canvas.face : [] + source: f.length ? msg.canvas.fileUrl(msg.canvas.backdrop) : "" + crop: f } - } - Text { - width: msg.width - visible: msg.expanded && msg.think !== "" - text: msg.think - textFormat: Text.PlainText - wrapMode: Text.Wrap - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - Flow { - width: msg.width - spacing: 6 - visible: msg.tools !== "" - Repeater { - model: msg.tools ? msg.tools.split(",") : [] - Rectangle { - required property string modelData - readonly property bool lit: msg.live && msg.running === modelData - property int elapsed: 0 - width: chip.implicitWidth + 14 - height: chip.implicitHeight + 6 - radius: height / 2 - color: Qt.alpha(Theme.surface, 0.9) - opacity: lit ? 1 : 0.55 - // A lit chip alone looks frozen on a clone that takes a - // minute, so the running tool counts seconds. - onLitChanged: if (!lit) elapsed = 0 - Timer { - running: parent.lit - interval: 1000 - repeat: true - onTriggered: parent.elapsed++ + Rectangle { + id: bubble + readonly property bool wide: msg.expanded || /```|^\|/m.test(msg.reply) + width: bubble.wide ? msg.botMax + : Math.min(msg.botMax, Math.max(rProbe.implicitWidth, 120) + 30) + height: inner.height + 20 + radius: 16 + bottomLeftRadius: 4 + color: Qt.alpha(Theme.surface, 0.88) + border.width: 1 + border.color: Theme.surfaceAlt + + Column { + id: inner + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: 14; rightMargin: 14; topMargin: 10 } + spacing: 6 + + // Three dots bouncing in turn while the reply has no words yet. + Row { + id: dots + visible: msg.live && msg.think === "" && msg.tools === "" && msg.reply === "" + height: 16 + spacing: 5 + Repeater { + model: 3 + Rectangle { + required property int index + width: 7 + height: 7 + radius: 3.5 + color: Theme.subtext + y: 8 + opacity: 1 - (y - 2) / 12 + SequentialAnimation on y { + running: dots.visible + loops: Animation.Infinite + PauseAnimation { duration: index * 150 } + NumberAnimation { to: 2; duration: 180; easing.type: Easing.InOutSine } + NumberAnimation { to: 8; duration: 180; easing.type: Easing.InOutSine } + PauseAnimation { duration: 840 - index * 150 } + } + } + } } + + // Collapsed by default; a click shows the reasoning as plain text. Text { - id: chip - anchors.centerIn: parent - text: parent.modelData + (parent.lit ? " · " + parent.elapsed + "s" : "") - color: parent.lit ? Theme.text : Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + visible: msg.think !== "" + text: (msg.expanded ? "▾ " : "▸ ") + (msg.live && msg.reply === "" ? "thinking…" : "thinking") + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: msg.model.expanded = !msg.expanded + } + } + Text { + width: inner.width + visible: msg.expanded && msg.think !== "" + text: msg.think + textFormat: Text.PlainText + wrapMode: Text.Wrap + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Flow { + width: inner.width + spacing: 6 + visible: msg.tools !== "" + Repeater { + model: msg.tools ? msg.tools.split(",") : [] + Rectangle { + required property string modelData + readonly property bool lit: msg.live && msg.running === modelData + property int elapsed: 0 + width: chip.implicitWidth + 14 + height: chip.implicitHeight + 6 + radius: height / 2 + color: Qt.alpha(Theme.surface, 0.9) + opacity: lit ? 1 : 0.55 + // A lit chip alone looks frozen on a clone that takes a + // minute, so the running tool counts seconds. + onLitChanged: if (!lit) elapsed = 0 + Timer { + running: parent.lit + interval: 1000 + repeat: true + onTriggered: parent.elapsed++ + } + Text { + id: chip + anchors.centerIn: parent + text: parent.modelData + (parent.lit ? " · " + parent.elapsed + "s" : "") + color: parent.lit ? Theme.text : Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + } + } + } + + Segments { + width: inner.width + text: msg.reply + visible: msg.reply !== "" + } + + Text { + visible: msg.error !== "" || msg.stopped + width: inner.width + text: msg.error !== "" ? msg.error : "stopped" + color: msg.error !== "" ? Theme.red : Theme.overlay + wrapMode: Text.Wrap + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } } } } - Segments { - width: msg.width - text: msg.reply - visible: msg.reply !== "" - } + // Unformatted twins of the texts, for the bubbles' widths. + Text { id: qProbe; visible: false; text: msg.user; textFormat: Text.PlainText; font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } + Text { id: rProbe; visible: false; text: msg.reply; textFormat: Text.PlainText; font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } - Text { - visible: msg.error !== "" || msg.stopped - width: msg.width - text: msg.error !== "" ? msg.error : "stopped" - color: msg.error !== "" ? Theme.red : Theme.overlay - wrapMode: Text.Wrap - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + // A round avatar: a picture filling it, or with crop ([centre x, centre + // y, size] in the picture's pixels) that square of it. No picture: a + // plain circle. + component Avatar: ClippingRectangle { + id: a + property string source: "" + property var crop: [] + width: msg.av + height: msg.av + radius: msg.av / 2 + color: Theme.surfaceAlt + Image { + readonly property real k: a.crop.length ? a.width / a.crop[2] : 0 + source: a.source + visible: status === Image.Ready + mipmap: true + fillMode: a.crop.length ? Image.Stretch : Image.PreserveAspectCrop + width: a.crop.length ? implicitWidth * k : a.width + height: a.crop.length ? implicitHeight * k : a.height + x: a.crop.length ? -(a.crop[0] - a.crop[2] / 2) * k : 0 + y: a.crop.length ? -(a.crop[1] - a.crop[2] / 2) * k : 0 + } } } |
