diff options
Diffstat (limited to 'desktop')
| -rw-r--r-- | desktop/assistant/Assistant.qml | 34 | ||||
| -rw-r--r-- | desktop/assistant/Card.qml | 79 | ||||
| -rw-r--r-- | desktop/assistant/Message.qml | 263 | ||||
| -rw-r--r-- | desktop/assistant/Segments.qml | 3 |
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 |
