NexusAI-DMS/src/components/ChatComposer.qml
2026-09-03 20:19:04 +02:00

703 lines
27 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Widgets
import qs.Modals.FileBrowser
import "../lib/Providers.js" as Providers
// Composer: transparent input, send/stop right; input grows to a
// cap then scrolls. Attachments stage above the input (clipboard
// or file picker) and pass Providers.sanitizeImages.
Item {
id: composer
signal submitted(string text, var images)
signal escapePressed()
signal newChatRequested()
property var service: null
property string placeholder: "Ask anything…"
readonly property bool busy: service ? service.isStreaming : false
// Staged image attachments ([{mime, data}]), capped by
// sanitizeImages (4). attachHint surfaces a rejection reason
// briefly, then auto-clears.
readonly property int maxImages: 4
property var attachments: []
property string attachHint: ""
// Input grows with content up to ~10 lines, then scrolls internally.
readonly property int maxInputHeight: 220
implicitHeight: divider.height + 12
+ (thumbStrip.visible ? thumbStrip.height + 8 : 0)
+ Math.max(48, Math.min(input.implicitHeight, maxInputHeight))
function send() {
var text = input.text.trim();
if (busy) return;
if (!text && composer.attachments.length === 0) return;
submitted(text, composer.attachments);
input.clear();
composer.attachments = [];
attachHint = "";
}
function showAttachHint(msg) {
attachHint = String(msg || "");
hintTimer.restart();
}
// Single ingest gate: sanitize (mime whitelist, base64 shape, size,
// cap) then stage. Reports a transient hint when the new image was
// rejected.
function addAttachment(mime, data) {
var before = composer.attachments.length;
var clean = Providers.sanitizeImages(
composer.attachments.concat([{ mime: mime, data: data }]),
composer.maxImages);
composer.attachments = clean;
showAttachHint(clean.length > before
? "" : "Image dropped — unsupported type, over 10 MB, or limit of "
+ composer.maxImages + " reached");
}
function removeAttachment(idx) {
var arr = composer.attachments.slice();
if (idx < 0 || idx >= arr.length) return;
arr.splice(idx, 1);
composer.attachments = arr;
if (arr.length === 0) attachHint = "";
}
// Clipboard ingest: ask wl-paste for the offered types; on an
// image, re-read that type as base64. Without an image the key
// press falls through to a normal text paste.
function pasteFromClipboard() {
clipProc.phase = 1;
clipProc.command = ["wl-paste", "--list-types"];
clipProc.running = true;
}
function pickImageMime(typesText) {
var types = String(typesText).split("\n").map(
function (t) { return t.trim(); });
var prefs = ["image/png", "image/jpeg", "image/webp", "image/gif"];
for (var i = 0; i < prefs.length; i++)
if (types.indexOf(prefs[i]) >= 0) return prefs[i];
return "";
}
// Magic-byte sniffing over the base64 head: PNG \x89PNG, JPEG
// \xFF\xD8\xFF, WEBP RIFF, GIF8. "" for anything else.
function sniffImageMime(b64) {
if (b64.indexOf("iVBOR") === 0) return "image/png";
if (b64.indexOf("/9j/") === 0) return "image/jpeg";
if (b64.indexOf("UklGR") === 0) return "image/webp";
if (b64.indexOf("R0lGOD") === 0) return "image/gif";
return "";
}
// File picker: DMS's native FileBrowser modal — Qt's FileDialog
// (GTK3 helper) aborts the whole shell process here.
function attachFromFile() {
imageBrowserLoader.active = true;
if (imageBrowserLoader.item)
imageBrowserLoader.item.open();
}
function ingestFilePath(path) {
if (!path || path.length === 0) return;
var p = String(path).replace(/'/g, "'\\''");
fileProc.command = ["sh", "-c", "base64 -w0 '" + p + "'"];
fileProc.running = true;
}
// Discard an in-progress edit: leave edit mode and clear the
// draft, returning the composer to normal new-message state.
function discardEdit() {
if (service) service.cancelEditUserMessage();
input.clear();
focusInput();
}
function focusInput() {
input.forceActiveFocus();
input.cursorPosition = input.text.length;
}
// Modifier flags required for Enter to send.
readonly property int sendModifiers: {
var m = service ? service.sendModifier : "none";
if (m === "shift") return Qt.ShiftModifier;
if (m === "ctrl") return Qt.ControlModifier;
if (m === "alt") return Qt.AltModifier;
return Qt.NoModifier;
}
// Enter fires a send only for the configured chord; every other
// chord falls through (accepted = false) and inserts a newline.
// KeypadModifier is masked so numpad Enter matches Key_Enter.
function handleReturnKey(event) {
var mods = event.modifiers & ~Qt.KeypadModifier;
if (mods === composer.sendModifiers) composer.send();
else event.accepted = false;
}
// Load a draft into the input (used by message editing: the text
// of the edited user message lands here; submitting performs the
// edit — see NexusChat's onSubmitted routing).
function setText(t) {
input.text = String(t || "");
input.cursorPosition = input.text.length;
}
Rectangle {
id: divider
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 1
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 0.5; color: Theme.primary }
GradientStop { position: 1.0; color: "transparent" }
}
}
// Staged attachment thumbnails + rejection hint. Zero-height while
// empty so the input row sits right under the divider.
Row {
id: thumbStrip
visible: composer.attachments.length > 0 || composer.attachHint.length > 0
anchors.top: divider.bottom
anchors.topMargin: 8
anchors.left: parent.left
anchors.leftMargin: 4
anchors.right: parent.right
anchors.rightMargin: 4
spacing: 8
Repeater {
model: composer.attachments.length
delegate: Item {
id: thumb
required property int index
width: 92
height: 68
Rectangle {
anchors.fill: parent
radius: 6
color: Theme.withAlpha(Theme.surfaceContainerLowest, 0.6)
clip: true
Image {
anchors.fill: parent
asynchronous: true
fillMode: Image.PreserveAspectCrop
source: composer.attachments[thumb.index]
? "data:" + composer.attachments[thumb.index].mime
+ ";base64," + composer.attachments[thumb.index].data
: ""
}
}
// Remove badge: overlaps the top-right corner.
Item {
anchors.right: parent.right
anchors.top: parent.top
anchors.rightMargin: -6
anchors.topMargin: -6
width: 20
height: 20
Rectangle {
anchors.fill: parent
radius: 10
color: thumbRemoveHover.hovered
? Theme.withAlpha(Theme.primary, 0.25)
: Theme.surfaceContainerHigh
border.width: 1
border.color: Theme.outlineVariant
}
DankIcon {
anchors.centerIn: parent
name: "close"
size: 12
color: thumbRemoveHover.hovered
? Theme.primary : Theme.surfaceText
}
HoverHandler {
id: thumbRemoveHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: composer.removeAttachment(thumb.index)
}
}
}
}
Text {
visible: composer.attachHint.length > 0
text: composer.attachHint
color: Theme.error
font.pixelSize: 11
anchors.verticalCenter: parent.verticalCenter
}
}
RowLayout {
anchors.top: thumbStrip.visible ? thumbStrip.bottom : divider.bottom
anchors.topMargin: 8
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 4
anchors.rightMargin: 4
anchors.bottomMargin: 4
spacing: 8
Flickable {
id: inputScroll
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
Layout.maximumHeight: composer.maxInputHeight
Layout.preferredHeight: Math.min(input.implicitHeight, composer.maxInputHeight)
contentHeight: input.contentHeight
clip: true
interactive: contentHeight > height
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: DankScrollbar { _scrollBarActive: true }
TextEdit {
id: input
width: inputScroll.width
textFormat: TextEdit.PlainText
wrapMode: TextEdit.Wrap
color: Theme.surfaceText
font.pixelSize: 14
// keep the cursor visible while typing past the cap
onCursorRectangleChanged: {
var y = cursorRectangle.y - inputScroll.contentY;
if (y < 0)
inputScroll.contentY += y;
else if (y + cursorRectangle.height > inputScroll.height)
inputScroll.contentY += y + cursorRectangle.height - inputScroll.height;
}
Keys.onEscapePressed: (event) => {
event.accepted = true;
composer.escapePressed();
}
Keys.onPressed: (event) => {
if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_N) {
event.accepted = true;
composer.newChatRequested();
return;
}
// Ctrl+V: an image on the clipboard is staged as an
// attachment; plain text falls through to the
// normal paste inside pasteFromClipboard's flow.
if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_V) {
event.accepted = true;
composer.pasteFromClipboard();
return;
}
// Up in an empty input = edit the last user message
// (Slack/Discord convention). Non-empty inputs keep
// normal cursor navigation.
if (event.key === Qt.Key_Up && input.text.length === 0
&& !busy && service && service.messageCount > 0) {
event.accepted = true;
service.beginEditLastUserMessage();
}
}
Keys.onReturnPressed: (event) => handleReturnKey(event)
Keys.onEnterPressed: (event) => handleReturnKey(event)
}
// Placeholder: sits exactly where typing starts, non-
// interactive so clicks land on the input. Shown only for a
// brand-new chat (no messages yet); re-rolled on clear.
Text {
anchors.left: parent.left
anchors.top: parent.top
width: input.width
text: composer.placeholder
color: Theme.surfaceVariantText
opacity: 0.5
font.pixelSize: 14
visible: input.text.length === 0
&& (!composer.service || composer.service.messageCount === 0)
}
}
// Discard affordance while a message edit is in progress:
// drops the draft and leaves edit mode.
DankButton {
iconName: "close"
text: ""
buttonHeight: 36
Layout.alignment: Qt.AlignVCenter
visible: !busy && service && service.editingMessageId.length > 0
onClicked: composer.discardEdit()
}
// Split send button: left half = send/stop/confirm-edit, right
// half = chord picker. Hover/press brightens only that half.
Rectangle {
id: splitSend
height: 36
radius: Theme.cornerRadius
Layout.alignment: Qt.AlignVCenter
implicitWidth: sendZone.width + arrowZone.width
color: Theme.buttonBg
readonly property bool sendHover: sendArea.containsMouse
readonly property bool sendPressed: sendArea.pressed
readonly property bool arrowHover: arrowArea.containsMouse
readonly property bool arrowPressed: arrowArea.pressed
// Split line as a gradient position (0..1).
readonly property real splitPos:
sendZone.width / Math.max(1, width)
// Rest shading: gradient hard-stops at the split line so
// the tint rounds cleanly into the corners.
Rectangle {
anchors.fill: parent
radius: splitSend.radius
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: "transparent" }
GradientStop {
position: splitSend.splitPos
color: "transparent"
}
GradientStop {
position: Math.min(1, splitSend.splitPos + 0.001)
color: Theme.withAlpha(Theme.buttonText, 0.10)
}
GradientStop {
position: 1
color: Theme.withAlpha(Theme.buttonText, 0.10)
}
}
}
// Disabled veil on the send half only (picker stays lit).
Rectangle {
anchors.fill: parent
radius: splitSend.radius
opacity: sendArea.enabled ? 0 : 0.55
Behavior on opacity { NumberAnimation { duration: 180 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.surfaceContainer }
GradientStop {
position: splitSend.splitPos
color: Theme.surfaceContainer
}
GradientStop {
position: Math.min(1, splitSend.splitPos + 0.001)
color: "transparent"
}
GradientStop { position: 1; color: "transparent" }
}
}
// White hover/press wash (buttonText tints misbehave on
// some buttonBg modes); hard-stops at the split line.
Rectangle {
anchors.fill: parent
radius: splitSend.radius
opacity: splitSend.sendHover || splitSend.sendPressed ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 180 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0
color: splitSend.sendPressed
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
}
GradientStop {
position: splitSend.splitPos
color: splitSend.sendPressed
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
}
GradientStop {
position: Math.min(1, splitSend.splitPos + 0.001)
color: "transparent"
}
GradientStop { position: 1; color: "transparent" }
}
}
Rectangle {
anchors.fill: parent
radius: splitSend.radius
opacity: splitSend.arrowHover || splitSend.arrowPressed ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 180 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: "transparent" }
GradientStop {
position: splitSend.splitPos
color: "transparent"
}
GradientStop {
position: Math.min(1, splitSend.splitPos + 0.001)
color: splitSend.arrowPressed
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
}
GradientStop {
position: 1
color: splitSend.arrowPressed
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
}
}
}
// Left half: send / stop streaming / confirm edit. Dimmed
// when there is nothing to do (empty input, idle).
Item {
id: sendZone
anchors.left: parent.left
width: 64
height: parent.height
DankIcon {
anchors.centerIn: parent
name: busy ? "stop_circle"
: (service && service.editingMessageId.length > 0
? "check" : "send")
size: 18
color: Theme.buttonText
opacity: sendArea.enabled ? 1 : 0.4
Behavior on opacity { NumberAnimation { duration: 180 } }
}
MouseArea {
id: sendArea
anchors.fill: parent
hoverEnabled: true
enabled: busy || input.text.trim().length > 0
|| composer.attachments.length > 0
cursorShape: enabled ? Qt.PointingHandCursor
: Qt.ArrowCursor
onClicked: {
if (busy) service.cancelStream();
else composer.send();
}
}
}
// Right half: send-chord picker (always active).
Item {
id: arrowZone
anchors.right: parent.right
width: 30
height: parent.height
DankIcon {
anchors.centerIn: parent
name: "arrow_drop_down"
size: 18
color: Theme.buttonText
}
MouseArea {
id: arrowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: modPopup.visible ? modPopup.close()
: modPopup.open()
}
}
}
// Send-chord picker: opens above the composer, right-aligned
// to the split button.
Popup {
id: modPopup
parent: arrowZone
x: parent.width - width
y: -height - 8
width: 168
height: modCol.implicitHeight + 12
padding: 6
background: Rectangle {
radius: 8
color: Theme.surfaceContainerHigh
border.width: 1
border.color: Theme.outlineVariant
}
contentItem: ColumnLayout {
id: modCol
spacing: 2
Repeater {
model: [
{ key: "none", label: "Return only", icon: "keyboard_return" },
{ key: "shift", label: "Shift", icon: "shift" },
{ key: "ctrl", label: "Ctrl", icon: "keyboard" },
{ key: "alt", label: "Alt", icon: "buttons_alt" }
]
delegate: Item {
id: modRow
required property var modelData
readonly property bool active: composer.service
&& composer.service.sendModifier === modRow.modelData.key
readonly property bool hovered: modHover.hovered
width: modCol.width
height: 32
Rectangle {
anchors.fill: parent
radius: 6
color: modRow.hovered
? Theme.withAlpha(Theme.primary, 0.14) : "transparent"
Behavior on color { ColorAnimation { duration: 150 } }
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 8
DankIcon {
name: modRow.modelData.icon
size: 16
color: modRow.active || modRow.hovered
? Theme.primary : Theme.surfaceVariantText
Behavior on color { ColorAnimation { duration: 150 } }
}
Text {
Layout.fillWidth: true
text: modRow.modelData.label
color: modRow.active || modRow.hovered
? Theme.primary : Theme.surfaceText
font.pixelSize: 13
elide: Text.ElideRight
}
DankIcon {
visible: modRow.active
name: "check"
size: 16
color: Theme.primary
}
}
HoverHandler {
id: modHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
if (composer.service)
composer.service.setSendModifier(modRow.modelData.key);
modPopup.close();
}
}
}
}
}
}
// ── Attachment ingest (clipboard) ──────────────────────────
Process {
id: clipProc
property int phase: 0 // 0 idle | 1 types | 2 data
property string _mime: ""
command: ["true"]
stdout: StdioCollector {
id: clipOut
onStreamFinished: {
if (clipProc.phase === 1) {
var pick = composer.pickImageMime(clipOut.text);
if (pick.length === 0) {
clipProc.phase = 0;
input.paste();
return;
}
clipProc._mime = pick;
clipProc.phase = 2;
clipProc.command = ["sh", "-c",
"wl-paste -t '" + pick + "' | base64 -w0"];
clipProc.running = true;
} else if (clipProc.phase === 2) {
var b64 = clipOut.text.replace(/\s+/g, "");
clipProc.phase = 0;
if (b64.length > 0)
composer.addAttachment(clipProc._mime, b64);
}
}
}
}
// Picked file bytes → base64; the mime is sniffed from the
// magic bytes rather than trusting the extension.
Process {
id: fileProc
command: ["true"]
stdout: StdioCollector {
id: fileOut
onStreamFinished: {
var b64 = fileOut.text.replace(/\s+/g, "");
var mime = composer.sniffImageMime(b64);
if (mime.length > 0)
composer.addAttachment(mime, b64);
else
composer.showAttachHint("Unsupported image type");
}
}
}
// Native DMS file browser, lazily created on first use and
// dropped again when it closes (same pattern as Notepad).
LazyLoader {
id: imageBrowserLoader
active: false
FileBrowserSurfaceModal {
browserTitle: "Attach image"
browserIcon: "attach_file"
browserType: "nexus_attach"
fileExtensions: ["*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif"]
allowStacking: true
onFileSelected: (path) => {
close();
var p = String(path).replace(/^file:\/\//, "");
try { p = decodeURIComponent(p); }
catch (e) { /* literal % in the name — keep raw */ }
composer.ingestFilePath(p);
}
onDialogClosed: imageBrowserLoader.active = false
}
}
Timer {
id: hintTimer
interval: 4000
onTriggered: composer.attachHint = ""
}
}
}