aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/assistant
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/assistant')
-rw-r--r--desktop/assistant/Assistant.qml34
-rw-r--r--desktop/assistant/Card.qml79
-rw-r--r--desktop/assistant/Message.qml263
-rw-r--r--desktop/assistant/Segments.qml3
4 files changed, 303 insertions, 76 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
index a4fda72..d6863a0 100644
--- a/desktop/assistant/Assistant.qml
+++ b/desktop/assistant/Assistant.qml
@@ -293,6 +293,40 @@ Scope {
}
}
+ // deck-it's Assistant tab look, from its config's [assistant] keys: the
+ // backdrop picture, the user's avatar, and where the assistant's face is
+ // in the backdrop ([centre x, centre y, size] in its pixels). Empty: no
+ // backdrop, plain circles.
+ // ponytail: read once at startup; a qs restart picks up a config edit.
+ property string backdrop: ""
+ property string avatar: ""
+ property var face: []
+ function fileUrl(p) { return p ? "file://" + p.split("/").map(encodeURIComponent).join("/") : ""; }
+
+ Process {
+ running: true
+ command: ["/usr/bin/python3", "-c", `
+import json, os, tomllib
+p = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "deck-it", "config.toml")
+try:
+ a = tomllib.load(open(p, "rb")).get("assistant", {})
+except (OSError, tomllib.TOMLDecodeError):
+ a = {}
+f = a.get("face")
+ok = isinstance(f, list) and len(f) == 3 and all(isinstance(v, (int, float)) for v in f)
+print(json.dumps({"bg": os.path.expanduser(a.get("background") or ""),
+ "avatar": os.path.expanduser(a.get("user_avatar") or ""),
+ "face": f if ok else []}))`]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const c = JSON.parse(text);
+ root.backdrop = c.bg; root.avatar = c.avatar; root.face = c.face;
+ } catch (e) {}
+ }
+ }
+ }
+
// A file whose name says nothing (a script with no suffix) is "other"
// until file(1) has read it. One run for all of them; the daemon makes
// its own call on the content when the question is sent.
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
index 437f0ad..2f9af69 100644
--- a/desktop/assistant/Card.qml
+++ b/desktop/assistant/Card.qml
@@ -11,6 +11,8 @@
import Quickshell
import QtQuick
+import QtQuick.Effects
+import QtQuick.Shapes
import ".."
import "Files.js" as Files
import "Presets.js" as Presets
@@ -45,6 +47,82 @@ Rectangle {
border.width: 1
border.color: Qt.alpha(Theme.text, 0.12)
+ // deck-it's Assistant tab backdrop (Assistant.qml, backdrop): the
+ // picture's subject in the right part of the tall card, desaturated and
+ // faded out toward the transcript through an elliptic mask; stronger
+ // while the chat is empty. First child, so everything draws over it.
+ Item {
+ id: backdrop
+ anchors.fill: parent
+ visible: card.tall && card.canvas.backdrop !== "" && art.status === Image.Ready
+ opacity: list.count === 0 ? 0.24 : 0.07
+ Behavior on opacity { NumberAnimation { duration: 600 } }
+
+ // 108% of the card's height, top-aligned; the face (or, without
+ // one, the picture's centre) sits 74% across, under the mask.
+ // ponytail: decoded at full size while the card is open; the face
+ // avatars share this decode through Qt's pixmap cache.
+ Image {
+ id: art
+ readonly property real k: art.implicitHeight > 0 ? card.height * 1.08 / art.implicitHeight : 0
+ readonly property real fx: card.canvas.face.length ? card.canvas.face[0] : art.implicitWidth / 2
+ visible: false
+ source: card.canvas.fileUrl(card.canvas.backdrop)
+ height: card.height * 1.08
+ width: art.implicitWidth * art.k
+ x: card.width * 0.74 - art.fx * art.k
+ }
+ Item {
+ id: mask
+ anchors.fill: parent
+ visible: false
+ layer.enabled: true
+ Shape {
+ // A circle scaled into deck-it's ellipse (27% x 56% at 74%, 40%).
+ width: parent.width
+ height: parent.height
+ transform: Scale {
+ origin { x: mask.width * 0.74; y: mask.height * 0.40 }
+ yScale: mask.width > 0 ? mask.height * 0.56 / (mask.width * 0.27) : 1
+ }
+ ShapePath {
+ strokeWidth: -1
+ fillGradient: RadialGradient {
+ centerX: mask.width * 0.74; centerY: mask.height * 0.40
+ focalX: centerX; focalY: centerY
+ centerRadius: mask.width * 0.27
+ GradientStop { position: 0.3; color: "black" }
+ GradientStop { position: 1.0; color: "transparent" }
+ }
+ PathRectangle { x: 0; y: -mask.height; width: mask.width; height: mask.height * 3 }
+ }
+ }
+ }
+ MultiEffect {
+ x: art.x
+ width: art.width
+ height: art.height
+ source: art
+ autoPaddingEnabled: false
+ saturation: -0.25
+ brightness: -0.1
+ maskEnabled: true
+ maskSource: maskView
+ // The gradient's alpha as a soft edge; the default is a hard cut.
+ maskThresholdMin: 0.0
+ maskSpreadAtMin: 1.0
+ }
+ // The mask in the picture's own coordinates, since the effect is.
+ ShaderEffectSource {
+ id: maskView
+ visible: false
+ width: art.width
+ height: art.height
+ sourceItem: mask
+ sourceRect: Qt.rect(art.x, 0, art.width, art.height)
+ }
+ }
+
// 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()
@@ -178,6 +256,7 @@ Rectangle {
delegate: Message {
width: list.width
+ canvas: card.canvas
running: card.canvas.runningTool
}
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
+ }
}
}
diff --git a/desktop/assistant/Segments.qml b/desktop/assistant/Segments.qml
index cd216df..3b6384c 100644
--- a/desktop/assistant/Segments.qml
+++ b/desktop/assistant/Segments.qml
@@ -21,6 +21,7 @@ import "Md.js" as Md
Column {
id: seg
property string text
+ property color color: Theme.text // prose; the question bubble sets its own
spacing: 8
Repeater {
@@ -42,7 +43,7 @@ Column {
textFormat: TextEdit.MarkdownText
text: visible ? Notify.sanitize(part.modelData.text) : ""
wrapMode: TextEdit.Wrap
- color: Theme.text
+ color: seg.color
selectionColor: Qt.alpha(Theme.accent, 0.4)
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
// Web and mail links only: a model-written file: or custom