aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/assistant/Message.qml
blob: c2e1b135497729b7be1287149dc44b1442bcc12f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
// 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 Quickshell.Widgets
import QtQuick
import ".."

// 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
    required property string user
    required property string reply
    required property string think
    required property string tools
    required property string files       // JSON: [{name, kind, pages, truncated}]
    required property string error
    required property bool live
    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

    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 - msg.av - 8
        spacing: 6
        layoutDirection: Qt.RightToLeft
        visible: msg.files !== "[]"
        Repeater {
            model: JSON.parse(msg.files)
            Rectangle {
                required property var modelData
                width: f.implicitWidth + 16
                height: f.implicitHeight + 6
                radius: height / 2
                color: Qt.alpha(Theme.surface, 0.6)
                Text {
                    id: f
                    anchors.centerIn: parent
                    text: parent.modelData.name
                        + (parent.modelData.pages ? ` · ${parent.modelData.pages} pages` : "")
                        + (parent.modelData.truncated ? " · truncated" : "")
                    color: parent.modelData.truncated ? Theme.yellow : Theme.subtext
                    font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
                }
            }
        }
    }

    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
        }

        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 {
                    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 }
                }
            }
        }
    }

    // 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 } }

    // 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
        }
    }
}