aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/assistant/Message.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/assistant/Message.qml')
-rw-r--r--desktop/assistant/Message.qml263
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
+ }
}
}