diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index a70e9a8..69339d5 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -9,6 +9,7 @@ ScrollBar { id: root required property Flickable flickable + readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root readonly property real axisSize: isHorizontal ? flickable.width : flickable.height readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight @@ -20,9 +21,11 @@ ScrollBar { readonly property real nonAnimY: root.axisContentPos / root.axisContentSize readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight) readonly property bool reversed: isHorizontal ? (flickable instanceof ListView && flickable.layoutDirection === Qt.RightToLeft) : (flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop) - property bool shouldBeActive readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 + property bool shouldBeActive + property bool inverted + enabled: !Visibilities.getForActive().isDrawing implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 @@ -119,7 +122,7 @@ ScrollBar { } var delta = event.angleDelta.y > 0 ? -0.1 : 0.1; - var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta)); + var newPos = Math.max(0, Math.min(1 - root.size, root.position + (root.inverted ? -delta : delta))); root.position = newPos; } diff --git a/Components/VerticalFadeListView.qml b/Components/VerticalFadeListView.qml index 2430660..6fe91b6 100644 --- a/Components/VerticalFadeListView.qml +++ b/Components/VerticalFadeListView.qml @@ -7,6 +7,7 @@ CustomListView { id: root property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 + property bool fadeEnabled: true property real fadeAmount: 0.1 property real fadeThreshold: 0.0 readonly property bool horizontal: orientation === ListView.Horizontal @@ -17,7 +18,9 @@ CustomListView { } function fadeShouldBeActive(isStart: bool): bool { - // When content is smaller than flickable size, hide fade when rebound starts. + if (!fadeEnabled) + return false; + if (contentSize() + marginStart() + marginEnd() < viewportSize() && rebound.running && ((isStart ? overshootStart() > 0 : overshootStart() < 0))) { return false; } diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 2b8f2d7..88a4c2d 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -1,4 +1,7 @@ +pragma ComponentBehavior: Bound + import QtQuick +import Quickshell import QtQuick.Layouts import ZShell.Components import ZShell.Config @@ -11,7 +14,6 @@ Item { id: root property ChatSession chatData - property bool following: true // qmlformat off readonly property var keys: [ Qt.Key_A, @@ -87,12 +89,33 @@ Item { signal requestClose + readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4 + + property var pendingAttachments: [] + + function attachFiles(): void { + Files.pickFiles().then(files => { + if (!files || files.length === 0) + return; + const added = chatData.addAttachments(files); + root.pendingAttachments = root.pendingAttachments.concat(added); + }); + } + function send(text: string): void { - if (text.trim() === "") + if (text.trim() === "" && root.pendingAttachments.length === 0) return; - following = true; - chatData.sendMessage(text); + root.scrollToLatest(); + chatData.sendMessage(text, root.pendingAttachments); input.text = ""; + root.pendingAttachments = []; + } + + function scrollToLatest(): void { + if (!listLoader.item || root.atNewestEnd) + return; + scrollAnim.stop(); + scrollAnim.start(); } function focusInput(): void { @@ -110,6 +133,18 @@ Item { } } + Anim { + id: scrollAnim + + target: listLoader.item + property: "contentY" + to: 0 + type: Anim.DefaultEffects + + onFinished: if (target) + target.contentY = 0 + } + RowLayout { id: header @@ -151,41 +186,112 @@ Item { anchors.topMargin: Tokens.spacing.medium active: root.chatData && root.chatData.loaded - sourceComponent: VerticalFadeListView { - id: list + sourceComponent: Flickable { + id: flick - property bool userScrolledUp: false - property real lastCHeight: 0.0 + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: list.layoutHeight - function scrollToBottom(): void { - scrollAnim.start(); + transform: Rotation { + origin.y: flick.height / 2 + origin.x: flick.width / 2 + angle: 180 } - cacheBuffer: Math.max(height * 20, 0) - clip: true - fadeAmount: 0.05 - fadeThreshold: Tokens.padding.medium - model: root.chatData.messagesModel - spacing: 0 - rotation: 180 - add: Transition { - Anim { - from: -100 - property: "y" + WheelInverter { + target: flick + } + + Anim { + id: restoreAnim + + target: flick + type: Anim.DefaultEffects + property: "contentY" + } + + ChatListView { + id: list + + width: flick.width + height: flick.height + yAnimDuration: Tokens.anim.durations.expressiveDefaultEffects + + asynchronous: true + cacheBuffer: 50 + estimatedHeight: 100 + readyDelay: 5 + removeDuration: Tokens.anim.durations.normal + spacing: 0 + + model: root.chatData.messagesModel + + useCustomViewport: true + viewport: { + tWatcher.transform; + return Qt.rect(0, flick.contentY, width, flick.height); + } + onViewportAdjustNeeded: delta => { + restoreAnim.stop(); + flick.contentY += delta; + } + onViewportRestoreRequested: target => { + restoreAnim.to = target; + restoreAnim.start(); + } + + delegate: MessageDelegate { + id: message + + rootParent: flick + chatList: list + ChatListView.preferredHeight: implicitHeight + ChatListView.trackViewport: true + ChatListView.visibleHeight: ChatListView.removing ? 0 : implicitHeight + opacity: ChatListView.removing || ChatListView.adding ? 0 : 1 + transform: Rotation { + origin.y: message.height / 2 + origin.x: message.width / 2 + angle: 180 + } + + Behavior on y { + enabled: message.ChatListView.ready + + Anim { + id: yAnim + type: Anim.DefaultEffects + } + } + + Behavior on x { + Anim { + type: Anim.DefaultEffects + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } } } CustomScrollBar.vertical: CustomScrollBar { id: scrollBar - flickable: list - parent: list.parent - anchors.top: list.top - anchors.right: list.right - anchors.bottom: list.bottom + flickable: flick + parent: flick.parent + anchors.top: flick.top + anchors.right: flick.right + anchors.bottom: flick.bottom + inverted: true transform: Rotation { - origin.y: list.height / 2 + origin.y: flick.height / 2 origin.x: scrollBar.width / 2 angle: 180 @@ -196,57 +302,15 @@ Item { } } } - delegate: MessageDelegate { - rotation: 180 - rootParent: list - } - displaced: Transition { - Anim { - property: "y" - } - } - move: Transition { - Anim { - property: "y" - } - } + TransformWatcher { + id: tWatcher - Component.onCompleted: { - positionViewAtBeginning(); - } - onAtYBeginningChanged: { - if (atYBeginning) - userScrolledUp = false; - } - onContentHeightChanged: { - if (userScrolledUp && Chat.busy) { - const delta = contentHeight - lastCHeight; - contentY += delta; - } - - lastCHeight = contentHeight; - } - onMovingChanged: { - if (moving) - userScrolledUp = !atYBeginning; - } - - Anim { - id: scrollAnim - - property: "contentY" - target: list - type: Anim.DefaultEffects - to: list.originY - } - - WheelInverter { - target: list + a: flick.contentItem + b: list } } - // Trigger the load even before we're ready to show the list. Component.onCompleted: if (root.chatData) root.chatData.messagesModel } @@ -265,7 +329,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter implicitHeight: scrollToBottom.implicitHeight implicitWidth: scrollToBottom.implicitWidth - scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0 + scale: root.atNewestEnd || !listLoader.item || listLoader.item.contentHeight <= listLoader.item.height ? 0 : 1 visible: scale > 0 Behavior on scale { @@ -289,7 +353,180 @@ Item { type: IconButton.Tonal onClicked: { - listLoader.item.scrollToBottom(); + root.scrollToLatest(); + } + } + } + + GridView { + id: attachments + + readonly property int columns: ChatState.isWindow ? 5 : 3 + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: input.top + anchors.bottomMargin: Tokens.spacing.medium + + flow: GridView.LeftToRight + verticalLayoutDirection: GridView.BottomToTop + + cellWidth: width / columns + cellHeight: cellWidth + + implicitHeight: Math.ceil(count / columns) * cellHeight + + displaced: Transition { + Anim { + property: "x" + } + Anim { + property: "y" + } + } + + add: Transition { + id: addTrans + Anim { + property: "y" + from: addTrans.ViewTransition.destination.y + attachments.cellHeight / 4 + } + Anim { + property: "opacity" + from: 0 + to: 1 + } + } + remove: Transition { + id: removeTrans + Anim { + property: "y" + to: removeTrans.ViewTransition.destination.y + attachments.cellHeight / 4 + } + Anim { + property: "opacity" + from: 1 + to: 0 + } + } + + model: ScriptModel { + objectProp: "path" + values: root.pendingAttachments + } + + delegate: Item { + id: thumb + + required property var modelData + function fileIcon(mime): string { + if (mime.startsWith("archive/") || mime === "application/zip") + return "folder_zip"; + if (mime === "application/pdf") + return "picture_as_pdf"; + if (mime.startsWith("audio/")) + return "audio_file"; + if (mime.startsWith("video/")) + return "video_file"; + if (mime.startsWith("text/")) + return "description"; + return "insert_drive_file"; + } + + width: attachments.cellWidth + height: width + opacity: modelData ? 1 : 0 + CustomClippingRect { + anchors.fill: parent + anchors.margins: Tokens.spacing.extraSmall + + radius: Tokens.rounding.medium + color: Colors.tPalette.m3surfaceContainer + + FadeImage { + id: attImage + + anchors.fill: parent + fillMode: FadeImage.PreserveAspectCrop + asynchronous: true + source: visible ? thumb.modelData?.path ?? "" : "" + visible: thumb.modelData?.isImage ?? false + } + + MaterialIcon { + anchors.centerIn: parent + font.pointSize: Tokens.font.size.extraLarge + text: thumb.fileIcon(thumb.modelData?.mimeType ?? "") + visible: !thumb.modelData?.isImage ?? false + } + + Loader { + id: attIndicatorLoader + + anchors.centerIn: parent + + opacity: 0 + active: opacity > 0 && (thumb.modelData?.isImage ?? false) + + sourceComponent: CustomRect { + implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: attLoadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: thumb.implicitWidth * 0.4 + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Timer { + id: attLoadDebounceTimer + + interval: 100 + onTriggered: { + if (attImage.status !== Image.Ready) + attIndicatorLoader.opacity = 1; + } + } + + MouseArea { + id: hover + + anchors.fill: parent + hoverEnabled: true + cursorShape: undefined + + IconButton { + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Tokens.padding.medium + + icon: "close" + visible: opacity > 0 + enabled: thumb.opacity > 0 + opacity: hover.containsMouse ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: root.pendingAttachments = root.pendingAttachments.filter(a => a !== thumb.modelData) + } + } } } } @@ -310,7 +547,9 @@ Item { sendIcon.icon: "arrow_upward" sendIcon.padding: Tokens.padding.extraSmall - Component.onCompleted: root.focusInput() + Component.onCompleted: { + root.focusInput(); + } Keys.onPressed: e => { if (e.key == Qt.Key_Return) { if (!(e.modifiers & Qt.ShiftModifier)) { @@ -318,6 +557,14 @@ Item { e.accepted = true; } } + + if (e.key == Qt.Key_V && e.modifiers & Qt.ControlModifier) { + if (root.chatData.clipboardHasAttachment()) { + const att = root.chatData.pasteAttachments(); + root.pendingAttachments = [...root.pendingAttachments, ...att]; + e.accepted = true; + } + } } onSendPressed: root.send(text) } diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml index e264c85..d199797 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml @@ -16,10 +16,15 @@ CustomRect { property bool copied: false property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh property color codeHeaderColor: Colors.palette.m3outline - property var codeSpans: [] + property string html: "" + property bool colored: false + property string requestedCode: "" + property string requestedLanguage: "" property int highlightToken: 0 function refresh() { + requestedCode = root.code; + requestedLanguage = root.language; const token = ++root.highlightToken; CodeHighlighter.highlight(root.code, root.language, root, token); } @@ -27,7 +32,11 @@ CustomRect { function onHighlightSpans(token, spans) { if (token !== root.highlightToken) return; - codeSpans = spans; + if (root.code !== root.requestedCode || + root.language !== root.requestedLanguage) + return; + html = spans.length ? root.highlightedHtml(root.code, spans) : ""; + colored = root.visible && html.length > 0; } function roleColor(kind) { @@ -71,6 +80,21 @@ CustomRect { return text.replace(/&/g, "&").replace(//g, ">"); } + // Mirrors the whitespace rules of the rich text rendering below (leading + // whitespace kept, mid-line runs collapsed, tabs as single spaces) so the + // plain and colored layouts are identical. + function plainForDisplay(code): string { + return code.split("\n") + .map(line => { + const i = line.search(/\S/); + if (i < 0) + return ""; + const lead = line.slice(0, i).replace(/\t/g, " "); + return lead + line.slice(i).replace(/\s+/g, " ").replace(/\s+$/, ""); + }) + .join("\n"); + } + function highlightedHtml(code, spans): string { let out; if (!spans.length) { @@ -101,10 +125,26 @@ CustomRect { radius: Tokens.rounding.medium onLanguageChanged: { - codeSpans = []; - refresh(); + html = ""; + colored = false; + if (root.visible) + refreshTimer.restart(); + } + onCodeChanged: { + colored = false; + if (root.visible) + refreshTimer.restart(); + } + onVisibleChanged: if (root.visible) + refreshTimer.restart(); + + Timer { + id: refreshTimer + + interval: 200 + repeat: false + onTriggered: refresh() } - onCodeChanged: refresh() RowLayout { id: headerRow @@ -229,21 +269,6 @@ CustomRect { implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2 color: CodeColors.active.bg - CustomText { - id: code - anchors.top: parent.top - anchors.bottom: parent.bottom - x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small - anchors.margins: Tokens.padding.small - text: root.highlightedHtml(root.code, root.codeSpans) - textFormat: Text.RichText - color: CodeColors.active.normal - layer.enabled: true - clip: false - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.small - } - Flickable { id: codeFlick @@ -263,12 +288,11 @@ CustomRect { color: CodeColors.active.normal layer.enabled: true - leftInset: Tokens.padding.small clip: false font.family: Config.appearance.font.family.mono font.pointSize: Tokens.font.size.small - textFormat: Text.RichText - text: code.text + textFormat: root.colored ? Text.RichText : Text.PlainText + text: root.colored ? root.html : root.plainForDisplay(root.code) readOnly: true } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml new file mode 100644 index 0000000..503df55 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml @@ -0,0 +1,128 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Llm +import ZShell.Config +import qs.Components +import qs.Services + +Item { + id: root + + required property ChatGeneration current + + signal dismiss + + implicitHeight: 0 + + Component.onCompleted: { + root.implicitHeight = Qt.binding(() => errorInner.implicitHeight + errorInner.anchors.margins * 2); + enterAnim.start(); + } + + Behavior on implicitHeight { + Anim {} + } + + ParallelAnimation { + id: enterAnim + + Anim { + target: container + property: "y" + from: container.implicitHeight + to: 0 + } + + Anim { + target: container + property: "opacity" + from: 0 + to: 1 + } + } + + ParallelAnimation { + id: exitAnim + + Anim { + target: root + property: "implicitHeight" + from: root.implicitHeight + to: 0 + } + + Anim { + target: container + property: "y" + to: container.implicitHeight + from: 0 + } + + Anim { + target: container + property: "opacity" + to: 0 + from: 1 + } + + onFinished: root.dismiss() + } + + CustomRect { + id: container + + anchors.left: parent.left + anchors.right: parent.right + + radius: Tokens.rounding.medium + color: Colors.palette.m3errorContainer + implicitHeight: errorInner.implicitHeight + errorInner.anchors.margins * 2 + + ColumnLayout { + id: errorInner + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + + spacing: Tokens.spacing.small + + CustomText { + id: header + + Layout.fillWidth: true + + font.pointSize: Tokens.font.size.large + elide: CustomText.ElideRight + color: Colors.palette.m3onErrorContainer + + text: qsTr("An error occured") + } + + CustomText { + id: body + + Layout.fillWidth: true + + wrapMode: CustomText.WordWrap + color: Colors.palette.m3onErrorContainer + + text: root.current.errorMessage || Chat.serverError + } + } + + IconButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + + isRound: true + icon: "close" + type: IconButton.Text + font.pointSize: Tokens.font.size.large + + onClicked: exitAnim.start() + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml index 0a8727d..b07d49f 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml @@ -26,8 +26,7 @@ Item { id: equationImage anchors.centerIn: parent - source: math.imageUrl - asynchronous: true + source: math.imageId.length ? "image://llmmath/" + math.imageId : "" visible: math.ok fillMode: Image.PreserveAspectFit smooth: true diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 20d4e32..af362f7 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -3,45 +3,50 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import Quickshell +import ZShell.Components import ZShell.Config import ZShell.Llm +import qs.Modules.Notifications.Sidebar.Chat import qs.Components import qs.Services MouseArea { id: root - property ChatGeneration current: modelData.activeGeneration required property int index - readonly property bool isUser: modelData.role === ChatMessage.Role.User required property Item rootParent required property ChatMessage modelData - property bool reasoningExpanded: false - readonly property var blocks: blockify(current.segments) - property real savedOffset: -1 - property bool stickActive: false + required property ChatListView chatList - function handleReasoningToggle(expanded: bool): void { - const view = root.ListView.view; - if (!view) + readonly property bool isUser: modelData.role === ChatMessage.Role.User + readonly property var blocks: blockify(current.segments, current.error) + + property ChatGeneration current: modelData.activeGeneration + property bool reasoningExpanded: false + property real savedOffset: NaN + property bool anchored: false + + function handleReasoningToggle(expanded: bool, block: Item): void { + if (!root.rootParent) return; if (expanded) { - root.savedOffset = view.contentY - root.y; - root.reasoningExpanded = true; + root.savedOffset = root.rootParent.contentY - root.y; + root.anchored = true; + chatList.anchorItem(block, ChatListView.TopEdge); + settleTimer.start(); } else { - root.reasoningExpanded = false; - root.stickActive = false; settleTimer.stop(); + root.anchored = false; + chatList.clearAnchor(); if (!isNaN(root.savedOffset)) { - restoreAnim.to = root.y + root.savedOffset; - restoreAnim.restart(); + chatList.requestRestore(root.y + root.savedOffset); root.savedOffset = NaN; } } } - function blockify(segs) { + function blockify(segs, errored) { const blocks = []; for (let i = 0; i < segs.length; i++) { const seg = segs[i]; @@ -65,35 +70,32 @@ MouseArea { } } + if (errored) + blocks.push({ + kind: "error", + id: segs.length + }); + return blocks; } - implicitWidth: ListView.view.width hoverEnabled: true preventStealing: false - implicitHeight: layout.implicitHeight + Tokens.padding.medium + implicitHeight: layout.implicitHeight + Tokens.padding.medium + (attachments.visible ? attachments.implicitHeight : 0) + onImplicitHeightChanged: if (root.anchored) + settleTimer.restart() Timer { id: settleTimer - interval: 16 + + interval: Tokens.anim.durations.expressiveDefaultEffects + repeat: false onTriggered: { - root.stickActive = false; - } - } - - onImplicitHeightChanged: { - if (root.reasoningExpanded) { - root.stickActive = true; - settleTimer.restart(); - - const view = root.ListView.view; - if (!view) - return; - const bottomEdge = root.y + root.height + Tokens.padding.large * 2; - const viewportBottom = view.contentY + view.height; - if (bottomEdge > viewportBottom) - view.contentY = bottomEdge - view.height; + if (root.anchored) { + root.anchored = false; + chatList.clearAnchor(); + } } } @@ -147,20 +149,144 @@ MouseArea { } } - Anim { - id: restoreAnim + GridView { + id: attachments - property: "contentY" - target: root.ListView.view - type: Anim.DefaultEffects + readonly property int columns: ChatState.isWindow ? 5 : 3 + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + + flow: GridView.LeftToRight + verticalLayoutDirection: GridView.BottomToTop + + cellWidth: width / columns + cellHeight: cellWidth + + implicitHeight: Math.ceil(count / columns) * cellHeight + + displaced: Transition { + Anim { + property: "x" + } + Anim { + property: "y" + } + } + + model: ScriptModel { + objectProp: "path" + values: { + const list = [...root.modelData.attachments]; + + if (list.length > 0 && list.length < attachments.columns) { + while (list.length < attachments.columns) + list.unshift(null); + } + + return list; + } + } + + delegate: Item { + id: thumb + + required property var modelData + + function fileIcon(mime): string { + if (mime.startsWith("archive/") || mime === "application/zip") + return "folder_zip"; + if (mime === "application/pdf") + return "picture_as_pdf"; + if (mime.startsWith("audio/")) + return "audio_file"; + if (mime.startsWith("video/")) + return "video_file"; + if (mime.startsWith("text/")) + return "description"; + return "insert_drive_file"; + } + + width: attachments.cellWidth + height: width + opacity: modelData ? 1 : 0 + + CustomClippingRect { + anchors.fill: parent + anchors.margins: Tokens.spacing.extraSmall + + radius: Tokens.rounding.medium + color: Colors.tPalette.m3surfaceContainer + + FadeImage { + id: attImage + + anchors.fill: parent + fillMode: FadeImage.PreserveAspectCrop + asynchronous: true + source: visible ? thumb.modelData?.path ?? "" : "" + visible: thumb.modelData?.isImage ?? false + } + + MaterialIcon { + anchors.centerIn: parent + font.pointSize: Tokens.font.size.extraLarge + text: thumb.fileIcon(thumb.modelData?.mimeType ?? "") + visible: !thumb.modelData?.isImage ?? false + } + + Loader { + id: attIndicatorLoader + + anchors.centerIn: parent + + opacity: 0 + active: opacity > 0 && (thumb.modelData?.isImage ?? false) + + sourceComponent: CustomRect { + implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: attLoadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: thumb.implicitWidth * 0.4 + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Timer { + id: attLoadDebounceTimer + + interval: 100 + onTriggered: { + if (attImage.status !== Image.Ready) + attIndicatorLoader.opacity = 1; + } + } + } + } } Column { id: layout - anchors.top: parent.top + anchors.top: attachments.bottom anchors.left: parent.left anchors.right: parent.right + spacing: Tokens.spacing.medium Repeater { @@ -173,6 +299,17 @@ MouseArea { delegate: DelegateChooser { role: "kind" + DelegateChoice { + roleValue: "error" + + delegate: ErrorBubble { + width: root.width + current: root.current + + onDismiss: root.current.dismissError() + } + } + DelegateChoice { roleValue: "process" @@ -182,7 +319,7 @@ MouseArea { current: root.current rootParent: root.rootParent - onExpandedChanged: root.handleReasoningToggle(expanded) + onExpandedChanged: root.handleReasoningToggle(expanded, this) } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index c8a733c..70bff51 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -22,7 +22,6 @@ Item { readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s)) - clip: true implicitHeight: { const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin; const collapsed = layout.implicitHeight; @@ -36,6 +35,8 @@ Item { return collapsed; } + onCurrentChanged: expanded = false + Behavior on implicitHeight { Anim { type: Anim.DefaultEffects @@ -147,19 +148,30 @@ Item { property bool open: false property Item rootParent: root.rootParent - property int openHeight: toolsBg.rootParent.height - Tokens.padding.medium * 2 + property int maxHeight: rootParent.height - Tokens.padding.medium * 2 + property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight) property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2 + property real openHeightProg: openHeight / maxHeight + property int collapsedY: 0 implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2 + z: open || dialogTransition.running ? 2 : 0 Layout.fillWidth: true function reparentWrapper(): void { const newParent = open ? toolsBg.rootParent : toolsBg; - const pos = toolsWrapper.mapToItem(newParent, 0, 0); + const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2); + toolsBg.collapsedY = toolsWrapper.y; toolsWrapper.parent = newParent; - toolsWrapper.x = pos.x; - toolsWrapper.y = pos.y; - console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height); + toolsWrapper.x = c.x - toolsWrapper.width / 2; + toolsWrapper.y = c.y - toolsWrapper.height / 2; + } + + onOpenChanged: { + if (open) + toolsBg.rootParent.interactive = false; + else + toolsBg.rootParent.interactive = true; } BlobGroup { @@ -189,6 +201,8 @@ Item { width: toolsBg.width height: toolBox.implicitHeight + rotation: parent === toolsBg.rootParent ? 180 : 0 + z: 1 states: State { name: "open" @@ -200,11 +214,11 @@ Item { toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased toolsBackground.topRightRadius: Tokens.rounding.largeIncreased toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased - // toolsContent.opacity: 1 + toolsContent.opacity: 1 toolsWrapper.height: toolsBg.openHeight toolsWrapper.width: toolsBg.openWidth toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2 - toolsWrapper.y: (toolsBg.rootParent.height - toolsBg.openHeight) / 2 + toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium elevation.opacity: 1 toolBox.opacity: 0 } @@ -274,7 +288,6 @@ Item { StateLayer { onClicked: { - console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height); toolsBg.open = true; } } @@ -287,7 +300,10 @@ Item { anchors.margins: Tokens.padding.medium Repeater { - model: root.current.pendingToolCalls ?? [] + id: repeater + + model: root.current.pendingToolCalls?.slice(0, 4) ?? [] + delegate: RowLayout { id: toolText @@ -309,10 +325,10 @@ Item { return String(seg.arguments); } - CustomText { + MaterialIcon { color: Colors.palette.m3onPrimaryContainer - text: String(toolText.index + 1) - font.pointSize: Tokens.font.size.large + text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet" + fill: 1 } CustomText { @@ -331,12 +347,134 @@ Item { } } } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length) + visible: repeater.count < root.current.pendingToolCalls.length + } + } + } + + Loader { + id: toolsContent + + active: opacity > 0 + anchors.fill: parent + asynchronous: false + opacity: 0 + + sourceComponent: MouseArea { + onWheel: event => event.accepted = true + preventStealing: true + implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2 + + VerticalFadeListView { + id: innerLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.small + cacheBuffer: height * 10 + fadeAmount: 0.1 + fadeEnabled: interactive + spacing: 0 + clip: true + interactive: contentHeight > toolsBg.maxHeight + + model: root.current.pendingToolCalls ?? [] + + delegate: Item { + id: toolCall + + required property LlmSegment modelData + property bool expanded: false + + implicitWidth: CustomListView.view.width + implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins) + + function toolTarget(seg: LlmSegment): string { + var a = null; + try { + a = JSON.parse(seg.arguments); + } catch (e) {} + if (a && typeof a === "object") { + const v = a.url ?? a.path; + if (v !== undefined && v !== null) + return String(v); + } + return String(seg.arguments); + } + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: collapsedRow + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + + IconButton { + id: toolExpandBtn + + font.pointSize: Tokens.font.size.large + icon: "keyboard_arrow_down" + inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline + rotation: toolCall.expanded ? 180 : 0 + type: IconButton.Text + + Behavior on rotation { + Anim {} + } + + onClicked: toolCall.expanded = !toolCall.expanded + } + + CustomText { + id: preview + + Layout.fillWidth: true + + text: toolCall.modelData.name + } + } + + CustomClippingRect { + id: expandedTool + + anchors.top: collapsedRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.medium + anchors.topMargin: Tokens.spacing.medium + + implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2 + radius: Tokens.rounding.medium + color: Colors.palette.m3surfaceContainerLow + + CustomText { + id: fullToolCall + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + + text: toolCall.toolTarget(toolCall.modelData) + wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere + } + } + } + } } } } } ButtonRow { + id: confirmationRow Layout.alignment: Qt.AlignRight spacing: Tokens.spacing.medium @@ -366,7 +504,7 @@ Item { } } - CustomRect { + CustomClippingRect { id: expandedRect anchors.left: parent.left diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 422b784..5c302ae 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -1,4 +1,4 @@ -find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter) +find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick QuickPrivate Concurrent Sql Network DBus CanvasPainter) find_package(PkgConfig REQUIRED) pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index 5d1cd78..bd4f511 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -1,11 +1,13 @@ qml_module(ZShell-components URI ZShell.Components - SOURCES - lazylistview.hpp lazylistview.cpp + SOURCES + lazylistview.hpp lazylistview.cpp + chatlistview.hpp chatlistview.cpp wavyline.hpp wavyline.cpp buttonrow.hpp buttonrow.cpp carouselview.hpp carouselview.cpp wheelinverter.hpp - LIBRARIES - Qt::Quick + LIBRARIES + Qt::Quick + Qt::QuickPrivate ) diff --git a/Plugins/ZShell/Components/chatlistview.cpp b/Plugins/ZShell/Components/chatlistview.cpp new file mode 100644 index 0000000..56ff397 --- /dev/null +++ b/Plugins/ZShell/Components/chatlistview.cpp @@ -0,0 +1,1239 @@ +#include "chatlistview.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int ASYNC_BATCH_CREATE = 2; +constexpr int ASYNC_BATCH_DESTROY = 4; +constexpr int RESTORE_QUIET_MS = 150; +constexpr int MEASURE_BATCH = 4; +constexpr int MEASURE_SETTLE_MS = 100; +constexpr int MEASURE_DEBOUNCE_MS = 150; + +} // namespace + +namespace ZShell::components { + +ChatListViewAttached::ChatListViewAttached(QObject* parent) : QObject(parent) {} + +qreal ChatListViewAttached::preferredHeight() const { + return m_preferredHeight; +} + +void ChatListViewAttached::setPreferredHeight(qreal height) { + if (qFuzzyCompare(m_preferredHeight + 1.0, height + 1.0)) return; + m_preferredHeight = height; + emit preferredHeightChanged(); +} + +qreal ChatListViewAttached::visibleHeight() const { + return m_visibleHeight; +} + +void ChatListViewAttached::setVisibleHeight(qreal height) { + if (qFuzzyCompare(m_visibleHeight + 1.0, height + 1.0)) return; + m_visibleHeight = height; + emit visibleHeightChanged(); +} + +bool ChatListViewAttached::ready() const { + return m_ready; +} + +void ChatListViewAttached::setReady(bool ready) { + if (m_ready == ready) return; + m_ready = ready; + emit readyChanged(); +} + +bool ChatListViewAttached::adding() const { + return m_adding; +} + +void ChatListViewAttached::setAdding(bool adding) { + if (m_adding == adding) return; + m_adding = adding; + emit addingChanged(); +} + +bool ChatListViewAttached::removing() const { + return m_removing; +} + +void ChatListViewAttached::setRemoving(bool removing) { + if (m_removing == removing) return; + m_removing = removing; + emit removingChanged(); +} + +bool ChatListViewAttached::trackViewport() const { + return m_trackViewport; +} + +void ChatListViewAttached::setTrackViewport(bool track) { + if (m_trackViewport == track) return; + m_trackViewport = track; + emit trackViewportChanged(); +} + +ChatListView::ChatListView(QQuickItem* parent) : QQuickItem(parent) { + m_measureTimer = new QTimer(this); + m_measureTimer->setSingleShot(true); + m_measureTimer->setInterval(MEASURE_DEBOUNCE_MS); + connect(m_measureTimer, &QTimer::timeout, this, [this] { + buildMeasureQueue(); + polish(); + }); +} + +ChatListViewAttached* ChatListView::qmlAttachedProperties(QObject* object) { + return new ChatListViewAttached(object); +} + +ChatListView::~ChatListView() { + for (auto& entry : m_delegates) + destroyDelegate(entry); + for (auto& entry : m_dyingDelegates) + destroyDelegate(entry); +} + +QAbstractItemModel* ChatListView::model() const { + return m_model; +} + +void ChatListView::setModel(QAbstractItemModel* model) { + if (m_model == model) return; + + if (m_model) disconnectModel(); + + m_model = model; + + if (m_model) connectModel(); + + resetContent(); + emit modelChanged(); +} + +QQmlComponent* ChatListView::delegate() const { + return m_delegate; +} + +void ChatListView::setDelegate(QQmlComponent* delegate) { + if (m_delegate == delegate) return; + + m_delegate = delegate; + resetContent(); + emit delegateChanged(); +} + +qreal ChatListView::spacing() const { + return m_spacing; +} + +void ChatListView::setSpacing(qreal spacing) { + if (qFuzzyCompare(m_spacing, spacing)) return; + m_spacing = spacing; + emit spacingChanged(); + polish(); +} + +qreal ChatListView::contentHeight() const { + return m_contentHeight; +} + +qreal ChatListView::layoutHeight() const { + return m_layoutHeight; +} + +qreal ChatListView::contentY() const { + return m_contentY; +} + +void ChatListView::setContentY(qreal contentY) { + if (qFuzzyCompare(m_contentY, contentY)) return; + m_contentY = contentY; + emit contentYChanged(); + polish(); +} + +QRectF ChatListView::viewport() const { + return m_viewport; +} + +void ChatListView::setViewport(const QRectF& viewport) { + if (m_viewport == viewport) return; + m_viewport = viewport; + emit viewportChanged(); + if (m_useCustomViewport) polish(); +} + +bool ChatListView::useCustomViewport() const { + return m_useCustomViewport; +} + +void ChatListView::setUseCustomViewport(bool use) { + if (m_useCustomViewport == use) return; + m_useCustomViewport = use; + emit useCustomViewportChanged(); + polish(); +} + +qreal ChatListView::cacheBuffer() const { + return m_cacheBuffer; +} + +void ChatListView::setCacheBuffer(qreal buffer) { + if (qFuzzyCompare(m_cacheBuffer, buffer)) return; + m_cacheBuffer = buffer; + emit cacheBufferChanged(); + polish(); +} + +qreal ChatListView::estimatedHeight() const { + return m_estimatedHeight; +} + +void ChatListView::setEstimatedHeight(qreal height) { + if (qFuzzyCompare(m_estimatedHeight, height)) return; + m_estimatedHeight = height; + emit estimatedHeightChanged(); + polish(); +} + +bool ChatListView::asynchronous() const { + return m_asynchronous; +} + +void ChatListView::setAsynchronous(bool async) { + if (m_asynchronous == async) return; + m_asynchronous = async; + emit asynchronousChanged(); + polish(); +} + +int ChatListView::removeDuration() const { + return m_removeDuration; +} + +void ChatListView::setRemoveDuration(int duration) { + if (m_removeDuration == duration) return; + m_removeDuration = duration; + emit removeDurationChanged(); +} + +int ChatListView::readyDelay() const { + return m_readyDelay; +} + +void ChatListView::setReadyDelay(int delay) { + if (m_readyDelay == delay) return; + m_readyDelay = delay; + emit readyDelayChanged(); +} + +int ChatListView::yAnimDuration() const { + return m_yAnimDuration; +} + +void ChatListView::setYAnimDuration(int duration) { + if (m_yAnimDuration == duration) return; + m_yAnimDuration = duration; + emit yAnimDurationChanged(); +} + +int ChatListView::count() const { + return m_model ? m_model->rowCount() : 0; +} + +bool ChatListView::event(QEvent* e) { + // qDebug() << "ChatListView::event() runs"; + + return QQuickItem::event(e); +} + +void ChatListView::componentComplete() { + QQuickItem::componentComplete(); + m_componentComplete = true; + resetContent(); +} + +void ChatListView::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + + if (!m_componentComplete) return; + + if (!qFuzzyCompare(newGeometry.width(), oldGeometry.width())) { + for (auto& entry : m_delegates) { + if (entry.item) entry.item->setWidth(newGeometry.width()); + } + m_measureTimer->start(); + } + + polish(); +} + +void ChatListView::updatePolish() { + if (!m_componentComplete || !m_model || !m_delegate) return; + + QList pendingIndices; + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); + ++it) { + if (it->pendingInsert && it->item) pendingIndices.append(it.key()); + } + + for (int pendingIdx : pendingIndices) { + auto& entry = m_delegates[pendingIdx]; + + if (m_readyDelay > 0) { + if (!entry.readyDelayStarted) { + entry.readyDelayStarted = true; + auto* item = entry.item; + QTimer::singleShot(m_readyDelay, this, [this, item] { + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) return; + const int idx = indexIt.value(); + auto it = m_delegates.find(idx); + if (it == m_delegates.end() || it->item != item || + !it->pendingInsert) + return; + + it->pendingInsert = false; + it->readyDelayStarted = false; + + if (idx >= 0 && idx < static_cast(m_layout.size())) { + qreal visualY = 0; + bool hasVisItem = false; + for (int i = 0; i < static_cast(m_layout.size()); + ++i) { + qreal h = NAN; + auto dit = m_delegates.find(i); + if (dit != m_delegates.end() && dit->item) + h = delegateVisibleHeight(dit->item); + else + h = m_layout[i].heightKnown + ? m_layout[i].height + : effectiveEstimatedHeight(); + if (h > 0) { + if (hasVisItem) visualY += m_spacing; + hasVisItem = true; + } + if (i == idx) break; + if (h > 0) visualY += h; + } + item->setY(visualY); + } + + item->setVisible(true); + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (att) { + att->setAdding(false); + att->setReady(true); + } + + m_animateY = true; + polish(); + }); + } + continue; + } + + entry.pendingInsert = false; + entry.item->setVisible(true); + auto* att = qobject_cast( + qmlAttachedPropertiesObject(entry.item, false)); + if (att) { + att->setAdding(false); + att->setReady(true); + } + } + + relayout(); + syncDelegates(); + pumpMeasureQueue(); + + for (auto& record : m_layout) + record.isNew = false; + + const bool animate = m_animateY; + m_animateY = false; + const int layoutSize = static_cast(m_layout.size()); + const qint64 now = m_clock.elapsed(); + bool wrote = false; + for (auto& entry : m_delegates) { + if (!entry.item || entry.pendingRemoval || entry.pendingInsert) + continue; + + const int idx = entry.modelIndex; + if (idx < 0 || idx >= layoutSize) continue; + + if (m_layout[idx].heightKnown && qFuzzyIsNull(m_layout[idx].height)) + continue; + + const qreal target = m_layout[idx].targetY; + const qreal cur = entry.item->y(); + + const bool inFlight = entry.hasPropY && qAbs(cur - entry.propY) > 0.5; + const bool cooling = entry.hasPropY && + (now - entry.lastPropTime) < m_yAnimDuration + 50; + + const bool needWrite = inFlight ? qAbs(target - entry.propY) > 0.25 + : qAbs(cur - target) > 0.25; + if (!needWrite) continue; + + if (animate || inFlight || cooling) { + entry.item->setProperty("y", target); + entry.propY = target; + entry.hasPropY = true; + entry.lastPropTime = now; + } else { + entry.item->setY(target); + entry.hasPropY = false; + } + wrote = true; + } + + applyAnchor(); +} + +void ChatListView::relayout() { + qreal y = 0; + bool hasLayoutItem = false; + for (auto& record : m_layout) { + const qreal layoutH = record.heightKnown ? record.height + : effectiveEstimatedHeight(); + if (layoutH > 0) { + if (hasLayoutItem) y += m_spacing; + hasLayoutItem = true; + record.targetY = y; + y += layoutH; + } else { + record.targetY = y; + } + } + + if (!qFuzzyCompare(m_layoutHeight + 1.0, y + 1.0)) { + m_layoutHeight = y; + emit layoutHeightChanged(); + } + + qreal visY = 0; + bool hasVisItem = false; + for (int i = 0; i < static_cast(m_layout.size()); ++i) { + qreal h = NAN; + auto dit = m_delegates.find(i); + if (dit != m_delegates.end() && dit->item) + h = delegateVisibleHeight(dit->item); + else + h = m_layout[i].heightKnown ? m_layout[i].height + : effectiveEstimatedHeight(); + if (h > 0) { + if (hasVisItem) visY += m_spacing; + hasVisItem = true; + visY += h; + } + } + + for (const auto& dying : std::as_const(m_dyingDelegates)) { + if (!dying.item) continue; + const qreal dyingH = delegateVisibleHeight(dying.item); + if (dyingH > 0) visY = std::max(visY, dying.item->y() + dyingH); + } + + if (!qFuzzyCompare(m_contentHeight + 1.0, visY + 1.0)) { + m_contentHeight = visY; + emit contentHeightChanged(); + } +} + +QRectF ChatListView::effectiveViewport() const { + QRectF vp; + if (m_useCustomViewport) + vp = m_viewport; + else + vp = QRectF(0, m_contentY, width(), height()); + + if (!m_useCustomViewport && m_layoutHeight > 0) { + const qreal top = std::min(vp.y(), m_layoutHeight); + const qreal bottom = std::max(vp.y() + vp.height(), 0.0); + if (bottom > top) vp = QRectF(vp.x(), top, vp.width(), bottom - top); + } + + vp.adjust(0, -m_cacheBuffer, 0, m_cacheBuffer); + + if (m_layoutHeight > 0) { + const qreal top = std::max(vp.y(), 0.0); + const qreal bottom = std::min(vp.y() + vp.height(), m_layoutHeight); + if (top < bottom) + vp = QRectF(vp.x(), top, vp.width(), bottom - top); + else + return {}; + } + + return vp; +} + +std::pair ChatListView::computeVisibleRange() const { + if (m_layout.isEmpty()) return {-1, -1}; + + const auto vp = effectiveViewport(); + if (vp.isEmpty()) return {-1, -1}; + + const qreal vpTop = vp.y(); + const qreal vpBottom = vp.y() + vp.height(); + + int lo = 0; + int hi = static_cast(m_layout.size()) - 1; + int first = static_cast(m_layout.size()); + + while (lo <= hi) { + const int mid = lo + (hi - lo) / 2; + const auto& record = m_layout[mid]; + const qreal itemBottom = + record.targetY + + (record.heightKnown ? record.height : effectiveEstimatedHeight()); + + if (itemBottom >= vpTop) { + first = mid; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + if (first >= static_cast(m_layout.size())) return {-1, -1}; + + int last = first; + for (int i = first; i < static_cast(m_layout.size()); ++i) { + if (m_layout[i].targetY > vpBottom) break; + last = i; + } + + return {first, last}; +} + +void ChatListView::syncDelegates() { + const auto [first, last] = computeVisibleRange(); + + QSet visibleIndices; + if (first >= 0) { + for (int i = first; i <= last; ++i) + visibleIndices.insert(i); + } + + const auto vp = effectiveViewport(); + QList toRemove; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + if (it->measureOnly) continue; + if (visibleIndices.contains(it.key())) continue; + if (!it->item || vp.isEmpty()) { + toRemove.append(it.key()); + continue; + } + const qreal itemTop = it->item->y(); + const qreal itemBottom = itemTop + delegateVisibleHeight(it->item); + if (itemBottom < vp.top() || itemTop > vp.bottom()) + toRemove.append(it.key()); + } + + const int destroyBudget = m_asynchronous + ? ASYNC_BATCH_DESTROY + : static_cast(toRemove.size()); + QVector removedEntries; + removedEntries.reserve( + std::min(destroyBudget, static_cast(toRemove.size()))); + int destroyed = 0; + for (int idx : toRemove) { + if (destroyed >= destroyBudget) break; + auto entry = m_delegates.take(idx); + if (entry.item) m_itemToIndex.remove(entry.item); + removedEntries.append(std::move(entry)); + ++destroyed; + } + for (auto& entry : removedEntries) { + destroyDelegate(entry); + } + + QList toCreate; + if (first >= 0) { + for (int i = first; i <= last; ++i) { + auto existing = m_delegates.find(i); + if (existing != m_delegates.end()) { + if (existing->measureOnly) { + existing->measureOnly = false; + polish(); + } + continue; + } + toCreate.append(i); + } + } + + const int createBudget = m_asynchronous ? ASYNC_BATCH_CREATE + : static_cast(toCreate.size()); + int created = 0; + for (int i : toCreate) { + if (created >= createBudget) break; + + auto entry = createDelegate(i); + if (entry.item) { + entry.pendingInsert = true; + entry.item->setY(m_layout[i].targetY); + m_itemToIndex.insert(entry.item, i); + m_delegates.insert(i, std::move(entry)); + ++created; + } + } + + if (created > 0 || + (m_asynchronous && (destroyed < static_cast(toRemove.size()) || + created < static_cast(toCreate.size())))) + polish(); +} + +bool ChatListView::isStaleRecord(const ItemRecord& record, qreal viewWidth) { + return record.heightKnown && record.measuredWidth >= 0 && + qAbs(record.measuredWidth - viewWidth) > 1.0; +} + +void ChatListView::buildMeasureQueue() { + m_measureQueue.clear(); + if (m_layout.isEmpty()) return; + + qreal center = 0; + const auto vp = effectiveViewport(); + if (!vp.isEmpty()) + center = vp.center().y(); + else if (m_layoutHeight > 0) + center = m_layoutHeight / 2; + + for (int i = 0; i < static_cast(m_layout.size()); ++i) { + if (!isStaleRecord(m_layout[i], width())) continue; + if (m_delegates.contains(i)) continue; + m_measureQueue.append(i); + } + + const qreal c = center; + std::stable_sort( + m_measureQueue.begin(), m_measureQueue.end(), [this, c](int a, int b) { + return qAbs(m_layout[a].targetY - c) < + qAbs(m_layout[b].targetY - c); + }); +} + +void ChatListView::pumpMeasureQueue() { + const qint64 now = m_clock.elapsed(); + + QList done; + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); + ++it) { + if (!it->measureOnly || !it->item) continue; + if (!isDelegateReady(it->item)) continue; + if (now - it->lastHeightChange < MEASURE_SETTLE_MS) continue; + done.append(it.key()); + } + for (int idx : done) { + auto entry = m_delegates.take(idx); + if (entry.item) m_itemToIndex.remove(entry.item); + destroyDelegate(entry); + } + + int live = 0; + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); ++it) + if (it->measureOnly) ++live; + + bool created = false; + while (live < MEASURE_BATCH && !m_measureQueue.isEmpty()) { + const int idx = m_measureQueue.takeFirst(); + if (idx < 0 || idx >= static_cast(m_layout.size())) continue; + if (!isStaleRecord(m_layout[idx], width())) continue; + if (m_delegates.contains(idx)) continue; + + auto entry = createDelegate(idx); + if (!entry.item) continue; + + entry.measureOnly = true; + entry.pendingInsert = true; + entry.lastHeightChange = now; + entry.item->setY(m_layout[idx].targetY); + + m_itemToIndex.insert(entry.item, idx); + m_delegates.insert(idx, std::move(entry)); + ++live; + created = true; + } + + if (created || live > 0 || !m_measureQueue.isEmpty()) polish(); +} + +ChatListView::DelegateEntry ChatListView::createDelegate(int modelIndex) { + DelegateEntry entry; + entry.modelIndex = modelIndex; + + if (!m_delegate || !m_model) return entry; + + const auto roleNames = m_model->roleNames(); + + auto* compContext = m_delegate->creationContext(); + if (!compContext) compContext = qmlContext(this); + if (!compContext) return entry; + + auto* obj = m_delegate->beginCreate(compContext); + entry.item = qobject_cast(obj); + + if (!entry.item) { + if (obj) m_delegate->completeCreate(); + delete obj; + return entry; + } + + const auto index = m_model->index(modelIndex, 0); + QVariantMap initialProps; + bool hasModelData = false; + + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + const auto name = QString::fromUtf8(it.value()); + initialProps.insert(name, m_model->data(index, it.key())); + if (name == QStringLiteral("modelData")) hasModelData = true; + } + initialProps.insert(QStringLiteral("index"), modelIndex); + + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole + : roleNames.constBegin().key(); + initialProps.insert( + QStringLiteral("modelData"), m_model->data(index, role)); + } + + m_delegate->setInitialProperties(entry.item, initialProps); + + entry.item->setParentItem(this); + entry.item->setWidth(width()); + + if (modelIndex < static_cast(m_layout.size()) && + m_layout[modelIndex].isNew) { + auto* addingAttached = qobject_cast( + qmlAttachedPropertiesObject(entry.item, true)); + if (addingAttached) addingAttached->setAdding(true); + } + + m_delegate->completeCreate(); + + entry.item->setVisible(false); + + auto onHeightChanged = [this, item = entry.item] { + if (!isDelegateReady(item)) return; + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) return; + const int idx = indexIt.value(); + auto delegateIt = m_delegates.find(idx); + if (delegateIt == m_delegates.end() || delegateIt->item != item) return; + const qreal h = delegateHeight(item); + if (idx < static_cast(m_layout.size()) && + !qFuzzyCompare(m_layout[idx].height + 1.0, h + 1.0)) { + const qreal oldH = m_layout[idx].height; + const bool wasKnown = m_layout[idx].heightKnown; + m_layout[idx].height = h; + m_layout[idx].heightKnown = true; + m_layout[idx].measuredWidth = width(); + if (wasKnown) untrackHeight(oldH); + trackHeight(h); + if (delegateIt->measureOnly) + delegateIt->lastHeightChange = m_clock.elapsed(); + + if (!wasKnown) { + m_animateY = true; + } + + if (wasKnown) { + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (att && att->trackViewport() && !m_anchorItem && + m_layout[idx].targetY < m_viewport.y()) { + bool suppressed = false; + if (m_restoreActive && idx == m_restoreIndex) { + if (m_clock.elapsed() < m_restoreQuietUntil) { + suppressed = true; + m_restoreQuietUntil = + m_clock.elapsed() + RESTORE_QUIET_MS; + } else { + m_restoreActive = false; + } + } + if (!suppressed) emit viewportAdjustNeeded(h - oldH); + } + } + + if (!m_relayoutPending) { + m_relayoutPending = true; + QTimer::singleShot(0, this, [this] { + m_relayoutPending = false; + relayout(); + polish(); + }); + } + } + }; + + connect( + entry.item, &QQuickItem::implicitHeightChanged, this, onHeightChanged); + + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(entry.item, false)); + if (attached) { + connect( + attached, + &ChatListViewAttached::preferredHeightChanged, + this, + onHeightChanged); + connect( + attached, + &ChatListViewAttached::visibleHeightChanged, + this, + [this] { polish(); }); + connect( + attached, + &ChatListViewAttached::readyChanged, + this, + [this, item = entry.item] { + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) return; + const int idx = indexIt.value(); + if (idx >= static_cast(m_layout.size())) return; + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (!att || !att->ready()) return; + + const qreal h = delegateHeight(item); + const qreal oldLayoutH = m_layout[idx].heightKnown + ? m_layout[idx].height + : effectiveEstimatedHeight(); + const bool wasKnown = m_layout[idx].heightKnown; + if (wasKnown) untrackHeight(m_layout[idx].height); + m_layout[idx].height = h; + m_layout[idx].heightKnown = true; + m_layout[idx].measuredWidth = width(); + trackHeight(h); + auto delegateIt = m_delegates.find(idx); + if (delegateIt != m_delegates.end() && delegateIt->measureOnly) + delegateIt->lastHeightChange = m_clock.elapsed(); + + if (!wasKnown) { + m_animateY = true; + } + + if (att->trackViewport() && !m_anchorItem && + !qFuzzyCompare(h + 1.0, oldLayoutH + 1.0) && + m_layout[idx].targetY < m_viewport.y()) { + emit viewportAdjustNeeded(h - oldLayoutH); + } + + polish(); + }); + } + + return entry; +} + +void ChatListView::destroyDelegate(DelegateEntry& entry) { + if (entry.item) { + entry.item->setParentItem(nullptr); + entry.item->setVisible(false); + entry.item->deleteLater(); + entry.item = nullptr; + } +} + +void ChatListView::updateDelegateData(DelegateEntry& entry) { + if (!m_model || !entry.item) return; + + const auto roleNames = m_model->roleNames(); + const auto index = m_model->index(entry.modelIndex, 0); + bool hasModelData = false; + + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + const auto name = QString::fromUtf8(it.value()); + entry.item->setProperty( + name.toUtf8().constData(), m_model->data(index, it.key())); + if (name == QStringLiteral("modelData")) hasModelData = true; + } + + entry.item->setProperty("index", entry.modelIndex); + + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole + : roleNames.constBegin().key(); + entry.item->setProperty("modelData", m_model->data(index, role)); + } +} + +void ChatListView::connectModel() { + if (!m_model) return; + + m_modelConnections = { + connect( + m_model, + &QAbstractItemModel::rowsInserted, + this, + &ChatListView::onRowsInserted), + connect( + m_model, + &QAbstractItemModel::rowsAboutToBeRemoved, + this, + &ChatListView::onRowsAboutToBeRemoved), + connect( + m_model, + &QAbstractItemModel::rowsRemoved, + this, + &ChatListView::onRowsRemoved), + connect( + m_model, + &QAbstractItemModel::dataChanged, + this, + &ChatListView::onDataChanged), + connect( + m_model, + &QAbstractItemModel::modelReset, + this, + &ChatListView::onModelReset), + connect( + m_model, + &QAbstractItemModel::layoutChanged, + this, + [this] { + for (auto& entry : m_delegates) + updateDelegateData(entry); + polish(); + }), + connect( + m_model, + &QObject::destroyed, + this, + [this] { + m_model = nullptr; + resetContent(); + emit modelChanged(); + }), + }; +} + +void ChatListView::disconnectModel() { + for (auto& conn : m_modelConnections) + disconnect(conn); + m_modelConnections.clear(); +} + +void ChatListView::resetContent() { + for (auto& entry : m_delegates) + destroyDelegate(entry); + m_delegates.clear(); + m_itemToIndex.clear(); + + for (auto& entry : m_dyingDelegates) + destroyDelegate(entry); + m_dyingDelegates.clear(); + + m_knownHeightSum = 0; + m_knownHeightCount = 0; + + m_layout.clear(); + m_measureQueue.clear(); + if (m_model && m_componentComplete) { + const int rows = m_model->rowCount(); + m_layout.resize(rows); + for (int i = 0; i < rows; ++i) { + m_layout[i].height = 0; + m_layout[i].heightKnown = false; + m_layout[i].measuredWidth = -1; + } + emit countChanged(); + } + + polish(); +} + +void ChatListView::onRowsInserted( + const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + + const int insertCount = last - first + 1; + m_layout.insert(first, insertCount, ItemRecord{0, 0, false, true}); + for (int& i : m_measureQueue) + if (i >= first) i += insertCount; + + QHash shifted; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int newIdx = it.key() >= first ? it.key() + insertCount : it.key(); + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + emit countChanged(); + m_animateY = true; + polish(); +} + +void ChatListView::onRowsAboutToBeRemoved( + const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + + for (int i = first; i <= last; ++i) { + if (!m_delegates.contains(i)) continue; + + auto entry = m_delegates.take(i); + if (entry.item) m_itemToIndex.remove(entry.item); + entry.pendingRemoval = true; + + if (entry.pendingInsert) { + destroyDelegate(entry); + continue; + } + + if (m_removeDuration > 0 && entry.item) { + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(entry.item, false)); + if (attached) attached->setRemoving(true); + + auto* item = entry.item; + QTimer::singleShot(m_removeDuration, this, [this, item] { + for (auto it = m_dyingDelegates.begin(); + it != m_dyingDelegates.end(); + ++it) { + if (it->item == item) { + destroyDelegate(*it); + m_dyingDelegates.erase(it); + return; + } + } + }); + m_dyingDelegates.append(std::move(entry)); + } else { + destroyDelegate(entry); + } + } +} + +void ChatListView::onRowsRemoved( + const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + + const int removeCount = last - first + 1; + + for (int i = first; i <= last; ++i) { + if (m_layout[i].heightKnown) untrackHeight(m_layout[i].height); + } + + QVector queue; + queue.reserve(m_measureQueue.size()); + for (int i : m_measureQueue) { + if (i >= first && i <= last) continue; + if (i > last) i -= removeCount; + queue.append(i); + } + m_measureQueue = std::move(queue); + + m_layout.remove(first, removeCount); + + QHash shifted; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int newIdx = it.key() > last ? it.key() - removeCount : it.key(); + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + emit countChanged(); + m_animateY = true; + polish(); +} + +void ChatListView::onDataChanged( + const QModelIndex& topLeft, + const QModelIndex& bottomRight, + const QList& roles) { + Q_UNUSED(roles) + + if (topLeft.parent().isValid()) return; + + for (int i = topLeft.row(); i <= bottomRight.row(); ++i) { + if (m_delegates.contains(i)) updateDelegateData(m_delegates[i]); + } +} + +void ChatListView::onModelReset() { + if (!m_model) { + resetContent(); + return; + } + + const int newRows = m_model->rowCount(); + const int oldRows = static_cast(m_layout.size()); + + if (newRows == oldRows) { + const auto roleNames = m_model->roleNames(); + const auto role = roleNames.isEmpty() ? Qt::DisplayRole + : roleNames.constBegin().key(); + bool changed = false; + + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); + ++it) { + if (!it->item || it.key() >= newRows) { + changed = true; + break; + } + const auto newData = + m_model->data(m_model->index(it.key(), 0), role); + const auto oldData = it->item->property("modelData"); + if (newData != oldData) { + changed = true; + break; + } + } + + if (!changed) { + for (auto& entry : m_delegates) + updateDelegateData(entry); + return; + } + } + + resetContent(); + if (!qFuzzyIsNull(m_viewport.y())) { + emit viewportAdjustNeeded(-m_viewport.y()); + } +} + +qreal ChatListView::effectiveEstimatedHeight() const { + if (m_estimatedHeight >= 0) return m_estimatedHeight; + if (m_knownHeightCount > 0) return m_knownHeightSum / m_knownHeightCount; + return 40; +} + +void ChatListView::trackHeight(qreal height) { + m_knownHeightSum += height; + ++m_knownHeightCount; +} + +void ChatListView::untrackHeight(qreal height) { + m_knownHeightSum -= height; + --m_knownHeightCount; +} + +qreal ChatListView::delegateHeight(QQuickItem* item) { + if (!item) return 0; + + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (attached && attached->preferredHeight() >= 0) + return attached->preferredHeight(); + + return item->implicitHeight(); +} + +qreal ChatListView::delegateVisibleHeight(QQuickItem* item) { + if (!item) return 0; + + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (attached) { + if (attached->visibleHeight() >= 0) return attached->visibleHeight(); + if (attached->preferredHeight() >= 0) + return attached->preferredHeight(); + } + + return item->implicitHeight(); +} + +bool ChatListView::isDelegateReady(QQuickItem* item) { + if (!item) return false; + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + return !att || att->ready(); +} + +void ChatListView::anchorItem(QQuickItem* item, Edge edge) { + if (!item) return; + + if (m_anchorItem == item) { + if (m_anchorEdge != edge) { + m_anchorEdge = edge; + applyAnchor(); + } + return; + } + + m_anchorItem = item; + m_anchorEdge = edge; + connect(item, &QObject::destroyed, this, [this, item] { + if (m_anchorItem == item) clearAnchor(); + }); + applyAnchor(); +} + +void ChatListView::clearAnchor() { + if (!m_anchorItem) return; + for (auto* it = m_anchorItem.data(); it; it = it->parentItem()) { + auto delegateIt = m_itemToIndex.find(it); + if (delegateIt != m_itemToIndex.end()) { + m_restoreIndex = delegateIt.value(); + break; + } + } + m_anchorItem = nullptr; +} + +void ChatListView::requestRestore(qreal targetY) { + m_restoreActive = true; + m_restoreQuietUntil = m_clock.elapsed() + RESTORE_QUIET_MS; + const qreal target = + qBound(0.0, targetY, qMax(0.0, m_layoutHeight - m_viewport.height())); + if (!qFuzzyCompare(target + 1.0, m_viewport.y() + 1.0)) { + emit viewportRestoreRequested(target); + } +} + +void ChatListView::applyAnchor() { + const auto item = m_anchorItem; + if (!item || m_viewport.isNull()) return; + + const qreal a = item->mapToItem(this, 0, 0).y(); + const qreal b = item->mapToItem(this, 0, item->height()).y(); + const qreal near = std::min(a, b); + const qreal far = std::max(a, b); + + const qreal vpTop = m_viewport.y(); + const qreal vpBottom = m_viewport.y() + m_viewport.height(); + + qreal next = -1; + if (m_anchorEdge == TopEdge) { + if (far > vpBottom) next = far - m_viewport.height(); + } else { + if (near < vpTop) next = near; + } + if (next < 0) return; + + next = qBound(0.0, next, qMax(0.0, m_layoutHeight - m_viewport.height())); + if (!qFuzzyCompare(next + 1.0, vpTop + 1.0)) { + emit viewportAdjustNeeded(next - vpTop); + } +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/chatlistview.hpp b/Plugins/ZShell/Components/chatlistview.hpp new file mode 100644 index 0000000..91dc1f2 --- /dev/null +++ b/Plugins/ZShell/Components/chatlistview.hpp @@ -0,0 +1,299 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +class ChatListViewAttached : public QObject { + Q_OBJECT + + Q_PROPERTY( + qreal preferredHeight READ preferredHeight WRITE setPreferredHeight + NOTIFY preferredHeightChanged) + Q_PROPERTY( + qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY + visibleHeightChanged) + Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) + Q_PROPERTY(bool adding READ adding NOTIFY addingChanged) + Q_PROPERTY(bool removing READ removing NOTIFY removingChanged) + Q_PROPERTY( + bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY + trackViewportChanged) + + public: + explicit ChatListViewAttached(QObject* parent = nullptr); + + [[nodiscard]] qreal preferredHeight() const; + void setPreferredHeight(qreal height); + + [[nodiscard]] qreal visibleHeight() const; + void setVisibleHeight(qreal height); + + [[nodiscard]] bool ready() const; + void setReady(bool ready); + + [[nodiscard]] bool adding() const; + void setAdding(bool adding); + + [[nodiscard]] bool removing() const; + void setRemoving(bool removing); + + [[nodiscard]] bool trackViewport() const; + void setTrackViewport(bool track); + + signals: + void preferredHeightChanged(); + void visibleHeightChanged(); + void readyChanged(); + void addingChanged(); + void removingChanged(); + void trackViewportChanged(); + + private: + qreal m_preferredHeight = -1; + qreal m_visibleHeight = -1; + bool m_ready = false; + bool m_adding = false; + bool m_removing = false; + bool m_trackViewport = false; +}; + +class ChatListView : public QQuickItem { + Q_OBJECT + QML_ELEMENT + QML_ATTACHED(ChatListViewAttached) + + public: + Q_PROPERTY( + QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged) + Q_PROPERTY( + QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY + delegateChanged) + + Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) + Q_PROPERTY( + qreal contentHeight READ contentHeight NOTIFY contentHeightChanged) + Q_PROPERTY(qreal layoutHeight READ layoutHeight NOTIFY layoutHeightChanged) + Q_PROPERTY( + qreal contentY READ contentY WRITE setContentY NOTIFY contentYChanged) + + Q_PROPERTY( + QRectF viewport READ viewport WRITE setViewport NOTIFY viewportChanged) + Q_PROPERTY( + bool useCustomViewport READ useCustomViewport WRITE setUseCustomViewport + NOTIFY useCustomViewportChanged) + Q_PROPERTY( + qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY + cacheBufferChanged) + + Q_PROPERTY( + qreal estimatedHeight READ estimatedHeight WRITE setEstimatedHeight + NOTIFY estimatedHeightChanged) + + Q_PROPERTY( + bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY + asynchronousChanged) + + Q_PROPERTY( + int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY + removeDurationChanged) + Q_PROPERTY( + int readyDelay READ readyDelay WRITE setReadyDelay NOTIFY + readyDelayChanged) + Q_PROPERTY( + int yAnimDuration READ yAnimDuration WRITE setYAnimDuration NOTIFY + yAnimDurationChanged) + + Q_PROPERTY(int count READ count NOTIFY countChanged) + + enum Edge { + TopEdge, + BottomEdge, + }; + Q_ENUM(Edge) + + Q_INVOKABLE void anchorItem(QQuickItem* item, Edge edge); + Q_INVOKABLE void clearAnchor(); + Q_INVOKABLE void requestRestore(qreal targetY); + + explicit ChatListView(QQuickItem* parent = nullptr); + ~ChatListView() override; + + static ChatListViewAttached* qmlAttachedProperties(QObject* object); + + [[nodiscard]] QAbstractItemModel* model() const; + void setModel(QAbstractItemModel* model); + + [[nodiscard]] QQmlComponent* delegate() const; + void setDelegate(QQmlComponent* delegate); + + [[nodiscard]] qreal spacing() const; + void setSpacing(qreal spacing); + + [[nodiscard]] qreal contentHeight() const; + [[nodiscard]] qreal layoutHeight() const; + + [[nodiscard]] qreal contentY() const; + void setContentY(qreal contentY); + + [[nodiscard]] QRectF viewport() const; + void setViewport(const QRectF& viewport); + + [[nodiscard]] bool useCustomViewport() const; + void setUseCustomViewport(bool use); + + [[nodiscard]] qreal cacheBuffer() const; + void setCacheBuffer(qreal buffer); + + [[nodiscard]] qreal estimatedHeight() const; + void setEstimatedHeight(qreal height); + + [[nodiscard]] bool asynchronous() const; + void setAsynchronous(bool async); + + [[nodiscard]] int removeDuration() const; + void setRemoveDuration(int duration); + + [[nodiscard]] int readyDelay() const; + void setReadyDelay(int delay); + + [[nodiscard]] int yAnimDuration() const; + void setYAnimDuration(int duration); + + [[nodiscard]] int count() const; + + signals: + void modelChanged(); + void delegateChanged(); + void spacingChanged(); + void contentHeightChanged(); + void layoutHeightChanged(); + void contentYChanged(); + void viewportChanged(); + void useCustomViewportChanged(); + void cacheBufferChanged(); + void estimatedHeightChanged(); + void asynchronousChanged(); + void removeDurationChanged(); + void readyDelayChanged(); + void yAnimDurationChanged(); + void countChanged(); + void viewportAdjustNeeded(qreal delta); + void viewportRestoreRequested(qreal targetY); + + protected: + void componentComplete() override; + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; + void updatePolish() override; + bool event(QEvent* e) override; + + private: + struct ItemRecord { + qreal targetY = 0; + qreal height = 0; + bool heightKnown = false; + bool isNew = false; + qreal measuredWidth = -1; + }; + + struct DelegateEntry { + int modelIndex = -1; + QQuickItem* item = nullptr; + bool pendingRemoval = false; + bool pendingInsert = false; + bool readyDelayStarted = false; + bool measureOnly = false; + qreal propY = 0; + bool hasPropY = false; + qint64 lastPropTime = 0; + qint64 lastHeightChange = 0; + }; + + void relayout(); + [[nodiscard]] std::pair computeVisibleRange() const; + [[nodiscard]] QRectF effectiveViewport() const; + [[nodiscard]] qreal effectiveEstimatedHeight() const; + [[nodiscard]] static bool isStaleRecord(const ItemRecord& record, + qreal viewWidth); + [[nodiscard]] static qreal delegateHeight(QQuickItem* item); + [[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item); + [[nodiscard]] static bool isDelegateReady(QQuickItem* item); + void trackHeight(qreal height); + void untrackHeight(qreal height); + + void syncDelegates(); + void pumpMeasureQueue(); + void buildMeasureQueue(); + DelegateEntry createDelegate(int modelIndex); + void destroyDelegate(DelegateEntry& entry); + void updateDelegateData(DelegateEntry& entry); + + void connectModel(); + void disconnectModel(); + void resetContent(); + void onRowsInserted(const QModelIndex& parent, int first, int last); + void onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last); + void onRowsRemoved(const QModelIndex& parent, int first, int last); + void onDataChanged( + const QModelIndex& topLeft, + const QModelIndex& bottomRight, + const QList& roles); + void onModelReset(); + + void applyAnchor(); + + QAbstractItemModel* m_model = nullptr; + QQmlComponent* m_delegate = nullptr; + + qreal m_spacing = 0; + qreal m_contentHeight = 0; + qreal m_layoutHeight = 0; + qreal m_contentY = 0; + + QRectF m_viewport; + bool m_useCustomViewport = false; + qreal m_cacheBuffer = 0; + + qreal m_estimatedHeight = -1; + qreal m_knownHeightSum = 0; + int m_knownHeightCount = 0; + bool m_asynchronous = false; + + int m_removeDuration = 300; + int m_readyDelay = 0; + int m_yAnimDuration = 300; + QElapsedTimer m_clock; + + QVector m_layout; + QHash m_delegates; + QHash m_itemToIndex; + QVector m_dyingDelegates; + + QVector m_measureQueue; + QTimer* m_measureTimer = nullptr; + + bool m_componentComplete = false; + bool m_relayoutPending = false; + bool m_animateY = false; + + QPointer m_anchorItem; + Edge m_anchorEdge = TopEdge; + int m_restoreIndex = -1; + bool m_restoreActive = false; + qint64 m_restoreQuietUntil = 0; + + QList m_modelConnections; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp index 4b5e68b..b04df7d 100644 --- a/Plugins/ZShell/Config/llm.hpp +++ b/Plugins/ZShell/Config/llm.hpp @@ -24,6 +24,7 @@ class Llm : public ConfigObject { CFG_PROPERTY(QString, model, "") CFG_PROPERTY(double, temperature, 0.7) CFG_PROPERTY(bool, tools, true) + CFG_PROPERTY(bool, sandbox, true) CFG_PROPERTY(bool, enabled, true) CONFIG_SUBOBJECT(LlmAppearance, appearance) @@ -32,4 +33,4 @@ class Llm : public ConfigObject { : ConfigObject(parent), m_appearance(new LlmAppearance(this)) {} }; -} // namespace ZShell::config +} diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index 97697aa..6433ee0 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -285,6 +285,7 @@ file(WRITE "${LM_FONTS_HPP}" "${_lm_header}") qml_module(ZShell-llm URI ZShell.Llm SOURCES + attachment.hpp attachment.cpp chat.hpp chat.cpp chatstore.hpp chatstore.cpp codehighlighter.hpp codehighlighter.cpp @@ -298,6 +299,7 @@ qml_module(ZShell-llm messagemodel.hpp messagemodel.cpp segment.hpp segment.cpp session.hpp session.cpp + shelltool.hpp shelltool.cpp tool.hpp tool.cpp webfetchtool.hpp webfetchtool.cpp LIBRARIES diff --git a/Plugins/ZShell/Llm/attachment.cpp b/Plugins/ZShell/Llm/attachment.cpp new file mode 100644 index 0000000..fca66d9 --- /dev/null +++ b/Plugins/ZShell/Llm/attachment.cpp @@ -0,0 +1,19 @@ +#include "attachment.hpp" + +namespace ZShell::llm { + +Attachment::Attachment( + const QString& name, + const QString& path, + Kind kind, + const QString& mimeType, + qint64 size, + QObject* parent) + : QObject(parent) + , m_name(name) + , m_path(path) + , m_kind(kind) + , m_mimeType(mimeType) + , m_size(size) {} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/attachment.hpp b/Plugins/ZShell/Llm/attachment.hpp new file mode 100644 index 0000000..e3c02cd --- /dev/null +++ b/Plugins/ZShell/Llm/attachment.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::llm { + +class Attachment : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Attachments are created by ChatSession.addAttachments") + + Q_PROPERTY(QString name READ name CONSTANT) + Q_PROPERTY(QString path READ path CONSTANT) + Q_PROPERTY(Kind kind READ kind CONSTANT) + Q_PROPERTY(bool isImage READ isImage CONSTANT) + Q_PROPERTY(QString mimeType READ mimeType CONSTANT) + Q_PROPERTY(qint64 size READ size CONSTANT) + + public: + enum class Kind : int { Image = 0, File = 1 }; + Q_ENUM(Kind) + + Attachment( + const QString& name, + const QString& path, + Kind kind, + const QString& mimeType, + qint64 size, + QObject* parent = nullptr); + + [[nodiscard]] QString name() const { return m_name; } + [[nodiscard]] QString path() const { return m_path; } + [[nodiscard]] Kind kind() const { return m_kind; } + [[nodiscard]] bool isImage() const { return m_kind == Kind::Image; } + [[nodiscard]] QString mimeType() const { return m_mimeType; } + [[nodiscard]] qint64 size() const { return m_size; } + + private: + QString m_name; + QString m_path; + Kind m_kind; + QString m_mimeType; + qint64 m_size = 0; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index a716d21..9e28bed 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -4,6 +4,7 @@ #include "filetool.hpp" #include "llm.hpp" #include "llmclient.hpp" +#include "shelltool.hpp" #include "webfetchtool.hpp" #include @@ -19,12 +20,14 @@ Chat::Chat(QObject* parent) m_store->setLlmClient(m_client); m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); m_client->tools()->registerTool(new FileReadTool(m_client->tools())); + m_client->tools()->registerTool(new ShellTool(m_client, m_client->tools())); const auto* llm = config::Config::instance()->llm(); m_client->setEndpoint(llm->endpoint()); m_client->setModel(llm->model()); m_client->setTemperature(llm->temperature()); m_client->setToolsEnabled(llm->tools()); + m_client->setSandboxEnabled(llm->sandbox()); connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { m_client->setEndpoint(llm->endpoint()); @@ -38,11 +41,16 @@ Chat::Chat(QObject* parent) connect(llm, &config::Llm::toolsChanged, this, [this, llm]() { m_client->setToolsEnabled(llm->tools()); }); + connect(llm, &config::Llm::sandboxChanged, this, [this, llm]() { + m_client->setSandboxEnabled(llm->sandbox()); + Q_EMIT sandboxEnabledChanged(); + }); connect(m_client, &LlmClient::busyChanged, this, [this]() { - if (m_client->busy() && !m_lastError.isEmpty()) { - m_lastError.clear(); - Q_EMIT lastErrorChanged(); + if (m_client->busy() && (m_hasError || !m_errorMessage.isEmpty())) { + m_hasError = false; + m_errorMessage.clear(); + Q_EMIT errorChanged(); } Q_EMIT busyChanged(); }); @@ -73,10 +81,16 @@ Chat::Chat(QObject* parent) &LlmClient::errorOccurred, this, [this](const QString& message) { - m_lastError = message; - Q_EMIT lastErrorChanged(); + m_hasError = !message.isEmpty(); + m_errorMessage = message; + Q_EMIT errorChanged(); Q_EMIT errorOccurred(message); }); + connect( + m_client, + &LlmClient::serverErrorChanged, + this, + &Chat::serverErrorChanged); connect( m_store, &ChatStore::sessionRemoved, @@ -134,11 +148,24 @@ void Chat::setToolsEnabled(bool value) { config->llm()->set_tools(value); } +bool Chat::sandboxEnabled() const { + return m_client->sandboxEnabled(); +} + +void Chat::setSandboxEnabled(bool value) { + m_client->setSandboxEnabled(value); + if (auto* config = config::Config::instance()) + config->llm()->set_sandbox(value); +} + +QString Chat::serverError() const { + return m_client->serverError(); +} + QString Chat::streamingChatId() const { return m_client->streamingChatId(); } - Chat* Chat::s_instance = nullptr; Chat* Chat::create(QQmlEngine*, QJSEngine*) { @@ -151,9 +178,10 @@ void Chat::stop() { } void Chat::dismissError() { - if (m_lastError.isEmpty()) return; - m_lastError.clear(); - Q_EMIT lastErrorChanged(); + if (!m_hasError && m_errorMessage.isEmpty()) return; + m_hasError = false; + m_errorMessage.clear(); + Q_EMIT errorChanged(); } void Chat::refreshModels() { @@ -161,7 +189,7 @@ void Chat::refreshModels() { } void Chat::refreshFromServer() { - m_client->refreshFromServer(); + m_client->refreshAllStates(); } void Chat::selectModel(const QString& id) { diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index 0d4919b..89a37c1 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -29,7 +29,12 @@ class Chat : public QObject { Q_PROPERTY( bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY toolsEnabledChanged) - Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) + Q_PROPERTY( + bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY + sandboxEnabledChanged) + Q_PROPERTY(bool error READ error NOTIFY errorChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged) + Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged) Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) Q_PROPERTY( QString streamingChatId READ streamingChatId NOTIFY @@ -45,7 +50,11 @@ class Chat : public QObject { [[nodiscard]] int contextSize() const; [[nodiscard]] bool toolsEnabled() const; void setToolsEnabled(bool value); - [[nodiscard]] QString lastError() const { return m_lastError; } + [[nodiscard]] bool sandboxEnabled() const; + void setSandboxEnabled(bool value); + [[nodiscard]] bool error() const { return m_hasError; } + [[nodiscard]] QString errorMessage() const { return m_errorMessage; } + [[nodiscard]] QString serverError() const; [[nodiscard]] ChatStore* chats() const { return m_store; } [[nodiscard]] QString streamingChatId() const; @@ -64,14 +73,17 @@ class Chat : public QObject { void availableModelsChanged(); void contextSizeChanged(); void toolsEnabledChanged(); + void sandboxEnabledChanged(); void errorOccurred(const QString& message); - void lastErrorChanged(); + void errorChanged(); + void serverErrorChanged(); void streamingChatIdChanged(); private: ChatStore* m_store = nullptr; LlmClient* m_client = nullptr; - QString m_lastError; + bool m_hasError = false; + QString m_errorMessage; static Chat* s_instance; }; diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 3d9056f..86ef46b 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -65,10 +65,19 @@ struct GenerationRow { QVector segments; }; +struct AttachmentRow { + QString name; + QString path; + int kind = 1; + QString mime; + qint64 size = 0; +}; + struct MessageRow { bool user = false; qint64 timestamp = 0; QVector generations; + QVector attachments; }; } // namespace @@ -160,6 +169,20 @@ void ChatStore::openDb() { " timestamp INTEGER NOT NULL\n" ")")); } + { + QSqlQuery query(db); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS attachments (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " message_id INTEGER NOT NULL REFERENCES messages " + "(id) ON DELETE CASCADE,\n" + " name TEXT NOT NULL,\n" + " path TEXT NOT NULL,\n" + " kind INTEGER NOT NULL DEFAULT 1,\n" + " mime TEXT NOT NULL DEFAULT '',\n" + " size INTEGER NOT NULL DEFAULT 0\n" + ")")); + } { QSqlQuery query(db); query.exec(QStringLiteral( @@ -171,6 +194,9 @@ void ChatStore::openDb() { query.exec(QStringLiteral( "CREATE INDEX IF NOT EXISTS idx_segments_generation " "ON segments (generation_id)")); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_attachments_message " + "ON attachments (message_id)")); } } @@ -233,6 +259,7 @@ void ChatStore::removeSession(ChatSession* session) { query.bindValue(":id", session->id()); query.exec(); } + QDir(ChatSession::sandboxDirFor(session->id())).removeRecursively(); m_sessions.removeOne(session); session->deleteLater(); notify(before); @@ -330,6 +357,11 @@ bool ChatStore::saveSession(ChatSession* session) { "timestamp) VALUES (:gid, :type, :text, :name, " ":tool_call_id, :arguments, :result, :status, :elapsed_ms, " ":timestamp)"); + QSqlQuery attachmentInsert(handle); + ok = ok && attachmentInsert.prepare( + "INSERT INTO attachments (message_id, name, path, kind, " + "mime, size) VALUES (:mid, :name, :path, :kind, :mime, " + ":size)"); const auto* model = session->messagesModel(); for (int row = model->rowCount() - 1; ok && row >= 0; --row) { const auto* message = model->at(row); @@ -390,6 +422,26 @@ bool ChatStore::saveSession(ChatSession* session) { } } } + if (ok) + for (const auto* attachment : message->attachments()) { + attachmentInsert.bindValue(":mid", messageId); + attachmentInsert.bindValue( + ":name", sqlText(attachment->name())); + attachmentInsert.bindValue( + ":path", sqlText(attachment->path())); + attachmentInsert.bindValue( + ":kind", static_cast(attachment->kind())); + attachmentInsert.bindValue( + ":mime", sqlText(attachment->mimeType())); + attachmentInsert.bindValue(":size", attachment->size()); + if (!attachmentInsert.exec()) { + ok = false; + qWarning() << "ChatStore: saveSession" << id + << "attachment insert failed:" + << attachmentInsert.lastError().text(); + break; + } + } } } if (!ok || !handle.commit()) { @@ -497,6 +549,34 @@ void ChatStore::loadMessagesInto(ChatSession* session) { << messageId << ":" << generationQuery.lastError().text(); } + QSqlQuery attachmentQuery(db); + attachmentQuery.prepare( + "SELECT name, path, kind, mime, size FROM " + "attachments WHERE message_id = :mid " + "ORDER BY rowid"); + attachmentQuery.bindValue(":mid", messageId); + if (attachmentQuery.exec()) { + while (attachmentQuery.next()) { + AttachmentRow attachment; + attachment.name = + attachmentQuery.value(0).toString(); + attachment.path = + attachmentQuery.value(1).toString(); + attachment.kind = + attachmentQuery.value(2).toInt(); + attachment.mime = + attachmentQuery.value(3).toString(); + attachment.size = + attachmentQuery.value(4).toLongLong(); + message.attachments.append(attachment); + } + } else { + qWarning() + << "ChatStore: failed to load attachments " + "for message" + << messageId << ":" + << attachmentQuery.lastError().text(); + } rows.append(message); } } @@ -523,6 +603,22 @@ void ChatStore::loadMessagesInto(ChatSession* session) { row.user ? ChatMessage::Role::User : ChatMessage::Role::Assistant, row.timestamp); + if (!row.attachments.isEmpty()) { + QList attachments; + for (const AttachmentRow& attachmentRow : + row.attachments) { + attachments.append(new Attachment( + attachmentRow.name, + attachmentRow.path, + attachmentRow.kind == 0 + ? Attachment::Kind::Image + : Attachment::Kind::File, + attachmentRow.mime, + attachmentRow.size, + message)); + } + message->setAttachments(attachments); + } int activeIndex = 0; for (int i = 0; i < row.generations.size(); ++i) { const GenerationRow& generationRow = diff --git a/Plugins/ZShell/Llm/codehighlighter.cpp b/Plugins/ZShell/Llm/codehighlighter.cpp index 86e2f54..7351920 100644 --- a/Plugins/ZShell/Llm/codehighlighter.cpp +++ b/Plugins/ZShell/Llm/codehighlighter.cpp @@ -198,7 +198,7 @@ const char* CodeHighlighter::roleName(uint8_t role) { QString CodeHighlighter::resolveId(const QString& language) { const QString tag = language.trimmed().toLower(); const QString alias = aliases().value(tag); - return alias.isEmpty() ? tag : alias; // unknown tags = grammar id + return alias.isEmpty() ? tag : alias; } QString CodeHighlighter::cacheKey(const QString& id, const QString& code) { @@ -223,8 +223,8 @@ void CodeHighlighter::storeSpans( const QString& language, const QVariantList& spans) const { if (spans.isEmpty() || code.isEmpty()) return; - static constexpr int kMaxEntries = 32; - static constexpr int kMaxBytes = 1024 * 1024; + static constexpr int kMaxEntries = 64; + static constexpr int kMaxBytes = 2 * 1024 * 1024; const QString key = cacheKey(resolveId(language), code); const int bytes = static_cast(code.toUtf8().size()); QMutexLocker locker(&m_cacheMutex); @@ -369,7 +369,7 @@ QVariantList CodeHighlighter::doHighlight( if (c < 0x80) cu[b + 1] += 1; else if (c < 0xC0) - ; // continuation byte + ; else if (c < 0xF0) cu[b + 1] += 1; else diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp index 2822aa3..407db6b 100644 --- a/Plugins/ZShell/Llm/generation.cpp +++ b/Plugins/ZShell/Llm/generation.cpp @@ -176,6 +176,21 @@ void ChatGeneration::appendReasoning(const QString& piece) { openReasoningSegment()->appendText(piece); } +void ChatGeneration::setError(const QString& value) { + const bool hasError = !value.isEmpty(); + if (m_hasError == hasError && m_errorMessage == value) return; + m_hasError = hasError; + m_errorMessage = value; + Q_EMIT errorChanged(); +} + +void ChatGeneration::dismissError() { + if (!m_hasError && m_errorMessage.isEmpty()) return; + m_hasError = false; + m_errorMessage.clear(); + Q_EMIT errorChanged(); +} + void ChatGeneration::setStreaming(bool value) { if (m_streaming == value) return; m_streaming = value; diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp index e10c595..ac1a85c 100644 --- a/Plugins/ZShell/Llm/generation.hpp +++ b/Plugins/ZShell/Llm/generation.hpp @@ -21,6 +21,8 @@ class ChatGeneration : public QObject { Q_PROPERTY( QString content READ content WRITE setContent NOTIFY contentChanged) Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) + Q_PROPERTY(bool error READ error NOTIFY errorChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged) Q_PROPERTY( qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) @@ -48,6 +50,8 @@ class ChatGeneration : public QObject { [[nodiscard]] QString content() const; [[nodiscard]] QString reasoning() const; + [[nodiscard]] bool error() const { return m_hasError; } + [[nodiscard]] QString errorMessage() const { return m_errorMessage; } [[nodiscard]] bool reasoningActive() const; [[nodiscard]] qint64 reasoningElapsedMs() const; [[nodiscard]] qint64 contentElapsedMs() const; @@ -65,11 +69,13 @@ class ChatGeneration : public QObject { Q_INVOKABLE void approveTools(); Q_INVOKABLE void denyTools(); + Q_INVOKABLE void dismissError(); void setContent(const QString& value); void appendContent(const QString& piece); void appendReasoning(const QString& piece); void setStreaming(bool value); + void setError(const QString& value); [[nodiscard]] LlmSegment* openContentSegment(); [[nodiscard]] LlmSegment* openReasoningSegment(); @@ -81,6 +87,7 @@ class ChatGeneration : public QObject { Q_SIGNALS: void contentChanged(); void reasoningChanged(); + void errorChanged(); void reasoningActiveChanged(); void elapsedMsChanged(); void streamingChanged(); @@ -98,6 +105,8 @@ class ChatGeneration : public QObject { bool m_streaming = false; bool m_approvalPending = false; qint64 m_timestamp; + bool m_hasError = false; + QString m_errorMessage; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index 6650aaf..1dc831a 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -1,5 +1,6 @@ #include "llmclient.hpp" +#include "attachment.hpp" #include "generation.hpp" #include "message.hpp" #include "messagemodel.hpp" @@ -7,6 +8,7 @@ #include "session.hpp" #include +#include #include #include #include @@ -132,26 +134,30 @@ QString LlmClient::completionsPath( return base + subpath; } +QString LlmClient::jsonErrorMessage(const QByteArray& body) { + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (!doc.isObject()) return QString(); + const QJsonValue errorValue = doc.object().value("error"); + if (errorValue.isObject()) + return errorValue.toObject().value("message").toString(); + if (errorValue.isString()) return errorValue.toString(); + return QString(); +} + QString LlmClient::serverErrorMessage( const QByteArray& body, const QString& fallback) { - const QJsonDocument doc = QJsonDocument::fromJson(body); - if (doc.isObject()) { - const QJsonObject obj = doc.object(); - if (obj.contains("error")) { - const QJsonValue errorValue = obj["error"]; - if (errorValue.isObject()) { - const QString message = - errorValue.toObject()["message"].toString(); - if (!message.isEmpty()) return message; - } else if (!errorValue.toString().isEmpty()) { - return errorValue.toString(); - } - } - } + const QString message = jsonErrorMessage(body); + if (!message.isEmpty()) return message; return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed") : fallback; } +void LlmClient::setServerError(const QString& value) { + if (m_serverError == value) return; + m_serverError = value; + Q_EMIT serverErrorChanged(); +} + void LlmClient::setBusy(bool value) { if (m_busy == value) return; m_busy = value; @@ -175,14 +181,17 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) { m_refreshTimer.setParent(this); m_refreshTimer.setInterval(kRefreshIntervalMs); connect( - &m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer); + &m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshAllStates); m_refreshTimer.start(); } LlmClient::~LlmClient() { - if (m_reply) m_reply->abort(); + if (m_reply) { + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->abort(); + } m_tools->cancelAll(); - endStream(); } void LlmClient::setEndpoint(const QString& value) { @@ -190,7 +199,7 @@ void LlmClient::setEndpoint(const QString& value) { m_endpoint = value; Q_EMIT endpointChanged(); m_refreshTimer.start(); - refreshFromServer(); + refreshAllStates(); } void LlmClient::setModel(const QString& value) { @@ -204,9 +213,16 @@ void LlmClient::setTemperature(double value) { m_temperature = value; } +void LlmClient::setSandboxEnabled(bool value) { + m_sandboxEnabled = value; +} + +QString LlmClient::activeWorkspace() const { + return m_active ? m_active->workspacePath() : QString(); +} + void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { if (m_busy || !session || !target) return; - refreshModels(); m_contextRetryUsed = false; m_active = session; m_streaming = target; @@ -242,6 +258,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { probeContextSize([this, session, target]() { if (m_active != session || m_streaming != target) return; + refreshModels(); sendRound(); }); } @@ -253,6 +270,7 @@ void LlmClient::sendRound() { m_callBuilders.clear(); m_callResults.clear(); m_roundDone = false; + m_streamDone = false; m_toolPhase = false; m_pendingCalls = 0; m_buffer.clear(); @@ -308,6 +326,8 @@ void LlmClient::sendRound() { } } + appendSandboxPreamble(messages); + QJsonObject body; QJsonObject streamOptions; streamOptions[QStringLiteral("include_usage")] = true; @@ -323,15 +343,24 @@ void LlmClient::sendRound() { request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setRawHeader("Accept", "text/event-stream"); - m_reply = m_manager.post(request, QJsonDocument(body).toJson()); + QNetworkReply* reply = + m_manager.post(request, QJsonDocument(body).toJson()); + if (!reply) { + fail(QStringLiteral("Failed to start request to LLM server")); + return; + } + m_reply = reply; - connect(m_reply, &QNetworkReply::readyRead, this, [this]() { - if (m_reply) m_buffer.append(m_reply->readAll()); + connect(reply, &QNetworkReply::readyRead, this, [this, reply]() { + if (m_reply != reply) return; + m_buffer.append(reply->readAll()); drainBuffer(); }); - connect(m_reply, &QNetworkReply::finished, this, [this]() { - QNetworkReply* reply = m_reply; - if (!reply) return; + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + if (m_reply != reply) { + reply->deleteLater(); + return; + } m_reply = nullptr; const QNetworkReply::NetworkError error = reply->error(); @@ -343,25 +372,37 @@ void LlmClient::sendRound() { drainBuffer(); if (!m_streaming) return; - if (error == QNetworkReply::NoError) - roundFinished(); - else if (error == QNetworkReply::OperationCanceledError) - finishTurn(); - else { - const QString message = - serverErrorMessage(responseBody, errorString); - const int reported = contextSizeFromErrorMessage(message); - if (reported > 0 && reported != m_contextSize && - !m_contextRetryUsed) { - qWarning() << "LlmClient: prompt overflow, server reports" - << reported << "tokens; retrying round"; - setContextSize(reported); - m_contextRetryUsed = true; - sendRound(); + if (error == QNetworkReply::NoError) { + if (m_streamDone) { + roundFinished(); return; } - fail(message); + const QByteArray tail = m_buffer; + m_buffer.clear(); + const QString serverMessage = jsonErrorMessage(tail.simplified()); + fail( + serverMessage.isEmpty() + ? QStringLiteral( + "LLM server closed the stream before " + "completing the response") + : serverMessage); + return; } + if (error == QNetworkReply::OperationCanceledError) { + finishTurn(); + return; + } + const QString message = serverErrorMessage(responseBody, errorString); + const int reported = contextSizeFromErrorMessage(message); + if (reported > 0 && reported != m_contextSize && !m_contextRetryUsed) { + qWarning() << "LlmClient: prompt overflow, server reports" + << reported << "tokens; retrying round"; + setContextSize(reported); + m_contextRetryUsed = true; + sendRound(); + return; + } + fail(message); }); } @@ -373,12 +414,51 @@ QJsonArray LlmClient::buildContextMessages( const auto* message = model->at(row); const auto* generation = message->activeGeneration(); if (!generation) continue; + const bool hasAnyContent = !generation->content().isEmpty() || + !generation->reasoning().isEmpty() || + generation->toolCallCount() > 0; + if (message->role() != ChatMessage::Role::User && !hasAnyContent) + continue; ContextUnit unit; if (message->role() == ChatMessage::Role::User) { QJsonObject user; user[QStringLiteral("role")] = QStringLiteral("user"); - user[QStringLiteral("content")] = generation->content(); + const QString text = generation->content(); + const QList attachments = message->attachments(); + if (attachments.isEmpty()) { + user[QStringLiteral("content")] = text; + } else { + QJsonArray parts; + if (!text.isEmpty()) { + QJsonObject textPart; + textPart[QStringLiteral("type")] = QStringLiteral("text"); + textPart[QStringLiteral("text")] = text; + parts.append(textPart); + } + for (const auto* attachment : attachments) { + if (attachment->kind() != Attachment::Kind::Image) continue; + QFile file(attachment->path()); + if (!file.open(QIODevice::ReadOnly)) continue; + const QByteArray base64 = file.readAll().toBase64(); + file.close(); + if (base64.isEmpty()) continue; + QJsonObject imageUrl; + imageUrl[QStringLiteral("url")] = + QStringLiteral("data:%1;base64,%2") + .arg(attachment->mimeType()) + .arg(QString::fromLatin1(base64)); + QJsonObject imagePart; + imagePart[QStringLiteral("type")] = + QLatin1String("image_url"); + imagePart[QStringLiteral("image_url")] = imageUrl; + parts.append(imagePart); + } + if (parts.isEmpty()) + user[QStringLiteral("content")] = text; + else + user[QStringLiteral("content")] = parts; + } unit.messages.append(user); } else { QList toolSegments; @@ -455,6 +535,48 @@ QJsonArray LlmClient::buildContextMessages( return messages; } +void LlmClient::appendSandboxPreamble(QJsonArray& messages) { + if (!m_sandboxEnabled) return; + ChatSession* active = m_active; + if (!active) return; + const QString workspace = active->workspacePath(); + if (workspace.isEmpty()) return; + const auto* model = active->messagesModel(); + QStringList files; + QSet seenPaths; + for (int row = 0; row < model->rowCount(); ++row) { + const auto* message = model->at(row); + if (message->role() != ChatMessage::Role::User) continue; + for (const auto* attachment : message->attachments()) { + if (attachment->kind() != Attachment::Kind::File) continue; + const QString relativePath = + attachment->path().mid(workspace.size() + 1); + if (seenPaths.contains(relativePath)) continue; + seenPaths.insert(relativePath); + files.append(QStringLiteral("- %1 (available as %2)") + .arg(attachment->name(), relativePath)); + } + } + if (files.isEmpty()) return; + QJsonObject preamble; + preamble[QStringLiteral("role")] = QStringLiteral("system"); + preamble[QStringLiteral("content")] = + QStringLiteral( + "The user attached the following files to this " + "conversation. They are available in your sandbox " + "directory at %1:\n%2\nruncommand executes shell " + "commands inside a bubblewrap sandbox: the rest of the " + "filesystem is read-only, there is no network access, " + "and only the sandbox directory is writable. Use it to " + "inspect, transform, or extract attached files (for " + "example to extract archives or search across many " + "files). Files you create stay inside the sandbox " + "directory. Use the webfetch tool for anything that " + "needs the network.") + .arg(workspace, files.join(QStringLiteral("\n"))); + messages.prepend(preamble); +} + void LlmClient::applyToolCallDelta(const QJsonObject& call) { ChatGeneration* streaming = m_streaming; if (!streaming) return; @@ -688,10 +810,12 @@ void LlmClient::stop() { finishTurn(); return; } - if (m_reply) - m_reply->abort(); - else - endStream(); + if (m_reply) { + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->abort(); + } + endStream(); } void LlmClient::endStream() { @@ -700,6 +824,11 @@ void LlmClient::endStream() { ChatSession* session = m_active; m_streaming = nullptr; m_active = nullptr; + if (m_reply) { + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->abort(); + } generation->setStreaming(false); m_approvalPending = false; generation->setApprovalPending(false); @@ -708,7 +837,7 @@ void LlmClient::endStream() { segment->status() == LlmSegment::Status::Pending) segment->finishTool(QStringLiteral("Cancelled"), false); if (generation->content().isEmpty() && generation->reasoning().isEmpty() && - generation->toolCallCount() == 0) { + generation->toolCallCount() == 0 && !generation->error()) { if (auto* message = qobject_cast(generation->parent())) { if (message->generationCount() <= 1) { if (session) session->removeMessage(message); @@ -730,7 +859,7 @@ void LlmClient::finishTurn() { m_pendingClear.clear(); } if (session) session->persist(); - refreshFromServer(); + refreshAllStates(); } void LlmClient::clearOnFinish(ChatSession* session) { @@ -747,6 +876,7 @@ void LlmClient::sessionRemoved(ChatSession* session) { void LlmClient::fail(const QString& message) { qWarning() << "LlmClient:" << message; + if (ChatGeneration* streaming = m_streaming) streaming->setError(message); finishTurn(); Q_EMIT errorOccurred(message); } @@ -766,6 +896,7 @@ void LlmClient::handleLine(const QByteArray& line) { const QByteArray data = line.mid(5).trimmed(); if (data == "[DONE]") { + m_streamDone = true; roundFinished(); return; } @@ -776,8 +907,12 @@ void LlmClient::handleLine(const QByteArray& line) { updateTokenUsage(obj); if (obj.contains("error")) { - const QJsonObject error = obj["error"].toObject(); - const QString message = error["message"].toString(); + const QJsonValue errorValue = obj["error"]; + QString message; + if (errorValue.isObject()) + message = errorValue.toObject()["message"].toString(); + else if (errorValue.isString()) + message = errorValue.toString(); fail( message.isEmpty() ? QStringLiteral("LLM server returned an error") : message); @@ -805,25 +940,36 @@ void LlmClient::handleLine(const QByteArray& line) { } } -void LlmClient::refreshFromServer() { - if (m_endpoint.trimmed().isEmpty()) return; - probeContextSize(); - refreshModels(); +void LlmClient::refreshAllStates() { + if (m_endpoint.trimmed().isEmpty()) { + setServerError(QStringLiteral("LLM endpoint is not configured")); + return; + } + probeContextSize([this]() { refreshModels(); }); } void LlmClient::refreshModels() { const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); - if (!url.isValid() || url.host().isEmpty()) return; + if (!url.isValid() || url.host().isEmpty()) { + setServerError(QStringLiteral("LLM endpoint is not configured")); + return; + } auto* reply = m_manager.get(QNetworkRequest(url)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); const QByteArray data = reply->readAll(); reply->deleteLater(); if (error != QNetworkReply::NoError) { - qWarning() << "LlmClient: failed to fetch models:" << error; + const QString serverMessage = jsonErrorMessage(data); + setServerError( + serverMessage.isEmpty() + ? QStringLiteral("Cannot reach LLM server: %1") + .arg(errorString) + : serverMessage); return; } const QJsonArray dataArr = @@ -838,7 +984,12 @@ void LlmClient::refreshModels() { models.append(id); } } - if (models.isEmpty()) return; + if (models.isEmpty()) { + setServerError(QStringLiteral( + "LLM server is running but has no models loaded")); + return; + } + setServerError(QString()); m_availableModels = models; Q_EMIT availableModelsChanged(); @@ -866,6 +1017,8 @@ void LlmClient::probeContextSize(std::function done) { base.chop(1); const QUrl url = QUrl::fromUserInput(base + "/props"); if (!url.isValid() || url.host().isEmpty()) { + setServerError( + QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); if (done) done(); return; } @@ -877,11 +1030,13 @@ void LlmClient::probeContextSize(std::function done) { *settled = true; const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); const QByteArray data = reply->readAll(); reply->deleteLater(); int size = 0; if (error == QNetworkReply::NoError) { + setServerError(QString()); const QJsonDocument doc = QJsonDocument::fromJson(data); if (doc.isArray()) { for (const auto& value : doc.array()) { @@ -899,6 +1054,13 @@ void LlmClient::probeContextSize(std::function done) { .toObject()["n_ctx"] .toInt(0); } + } else { + const QString serverMessage = jsonErrorMessage(data); + setServerError( + serverMessage.isEmpty() + ? QStringLiteral("Cannot reach LLM server: %1") + .arg(errorString) + : serverMessage); } if (size > 0) setContextSize(size); diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index 7c864b1..3a1b955 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -39,16 +39,20 @@ class LlmClient : public QObject { } [[nodiscard]] int contextSize() const { return m_contextSize; } [[nodiscard]] ToolRegistry* tools() const { return m_tools; } + [[nodiscard]] QString serverError() const { return m_serverError; } void setEndpoint(const QString& value); void setModel(const QString& value); void setTemperature(double value); void setContextSize(int size); void probeContextSize(std::function done = {}); - Q_INVOKABLE void refreshFromServer(); + void refreshAllStates(); [[nodiscard]] bool busy() const { return m_busy; } [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } void setToolsEnabled(bool value) { m_tools->setEnabled(value); } + [[nodiscard]] bool sandboxEnabled() const { return m_sandboxEnabled; } + void setSandboxEnabled(bool value); + [[nodiscard]] QString activeWorkspace() const; [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } [[nodiscard]] ChatSession* streamingSession() const { return m_active; } @@ -74,6 +78,7 @@ class LlmClient : public QObject { void toolsEnabledChanged(); void streamingChatIdChanged(); void errorOccurred(const QString& message); + void serverErrorChanged(); void titleSuggested(ZShell::llm::ChatSession* session, const QString& title); void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon); @@ -89,6 +94,7 @@ class LlmClient : public QObject { void sendRound(); QJsonArray buildContextMessages( ChatSession* session, int stopBeforeRow, int extraReserveTokens) const; + void appendSandboxPreamble(QJsonArray& messages); void applyToolCallDelta(const QJsonObject& call); void roundFinished(); void executeAllCalls(); @@ -102,6 +108,7 @@ class LlmClient : public QObject { void updateTokenUsage(const QJsonObject& data); void setBusy(bool value); void setStreamingChatId(const QString& id); + void setServerError(const QString& value); void shortRequest( const QString& tag, const QString& systemPrompt, @@ -109,6 +116,7 @@ class LlmClient : public QObject { std::function onResult); static QString completionsPath( const QString& endpoint, const QString& subpath); + static QString jsonErrorMessage(const QByteArray& body); static QString serverErrorMessage( const QByteArray& body, const QString& fallback); @@ -125,6 +133,7 @@ class LlmClient : public QObject { QString m_endpoint; QString m_model; QStringList m_availableModels; + QString m_serverError; double m_temperature = 0.7; int m_contextSize = 0; @@ -139,7 +148,9 @@ class LlmClient : public QObject { int m_round = 0; qsizetype m_contentMark = 0; qsizetype m_reasoningMark = 0; + bool m_sandboxEnabled = true; bool m_roundDone = false; + bool m_streamDone = false; bool m_toolPhase = false; bool m_approvalPending = false; bool m_contextRetryUsed = false; diff --git a/Plugins/ZShell/Llm/markdownblock.hpp b/Plugins/ZShell/Llm/markdownblock.hpp index 7242ed3..576a289 100644 --- a/Plugins/ZShell/Llm/markdownblock.hpp +++ b/Plugins/ZShell/Llm/markdownblock.hpp @@ -11,12 +11,7 @@ class LlmMarkdown : public QObject { QML_UNCREATABLE("Blocks are produced by LlmSegment") public: - enum class Type : int { - Text = 0, // Paragraph, list, quote, table; "text" is markdown source - Heading, // "level" + "text" (markdown source of the content) - Code, // "language" + "code" - Math // "latex" (display math, without the $$ delimiters) - }; + enum class Type : int { Text = 0, Heading, Code, Math }; Q_ENUM(Type) explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {} diff --git a/Plugins/ZShell/Llm/mathtext.cpp b/Plugins/ZShell/Llm/mathtext.cpp index 8abcede..542f142 100644 --- a/Plugins/ZShell/Llm/mathtext.cpp +++ b/Plugins/ZShell/Llm/mathtext.cpp @@ -10,9 +10,14 @@ #include #include #include +#include +#include +#include #include #include +#include + namespace ZShell::llm { namespace { @@ -71,26 +76,27 @@ const LatinModern& loadLatinModern() { return fonts; } -} // namespace - -LlmMathText::LlmMathText(QObject* parent) - : QObject(parent) - , m_renderer( - std::make_shared(nullptr, /* useFontsForGUI */ true)) { +void configureLatinModern(JKQTMathText& renderer) { const LatinModern& fonts = loadLatinModern(); if (fonts.roman) - m_renderer->setFontRomanAndMath( + renderer.setFontRomanAndMath( QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode); if (fonts.math) { - m_renderer->setFontMathRoman( + renderer.setFontMathRoman( QStringLiteral("Latin Modern Math"), JKQTMathTextFontEncoding::MTFEUnicode); - m_renderer->setFallbackFontSymbols( + renderer.setFallbackFontSymbols( QStringLiteral("Latin Modern Math"), JKQTMathTextFontEncoding::MTFEUnicode); } } +} // namespace + +LlmMathText::LlmMathText(QObject* parent) : QObject(parent) { + loadLatinModern(); +} + void LlmMathText::setLatex(const QString& value) { if (m_latex == value) return; m_latex = value; @@ -120,23 +126,88 @@ namespace { struct MathRender { bool ok = false; QImage image; - QUrl url; + QString id; qreal width = 0; qreal height = 0; }; -QHash& mathCache() { - static QHash cache; - return cache; +struct MathStore { + QMutex mutex; + QHash entries; + QStringList order; + QHash idIndex; + QHash idRefs; + qint64 bytes = 0; + quint64 nextId = 1; +}; + +MathStore& mathStore() { + static MathStore store; + return store; +} + +qint64 imageBytes(const QImage& image) { + return image.isNull() ? 0 : static_cast(image.sizeInBytes()); +} + +void retainMathId(const QString& id) { + if (id.isEmpty()) return; + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + ++store.idRefs[id]; +} + +void releaseMathId(const QString& id) { + if (id.isEmpty()) return; + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + auto it = store.idRefs.find(id); + if (it == store.idRefs.end()) return; + if (--it.value() <= 0) store.idRefs.erase(it); +} + +} // namespace + +namespace { + +constexpr qint64 kMaxCacheBytes = 64 * 1024 * 1024; + +class MathImageProvider : public QQuickImageProvider { + public: + MathImageProvider() : QQuickImageProvider(QQuickImageProvider::Image) {} + + QImage requestImage( + const QString& id, QSize* size, const QSize& requestedSize) override { + Q_UNUSED(requestedSize) + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + const auto keyIt = store.idIndex.constFind(id); + if (keyIt == store.idIndex.constEnd()) return {}; + const QString key = keyIt.value(); + const auto entryIt = store.entries.constFind(key); + if (entryIt == store.entries.constEnd()) return {}; + if (size) *size = entryIt->image.size(); + const qsizetype pos = store.order.indexOf(key); + if (pos >= 0) store.order.move(pos, store.order.size() - 1); + return entryIt->image; + } +}; + +void ensureImageProvider(QObject* owner) { + if (auto* engine = qmlEngine(owner)) + if (!engine->imageProvider(QStringLiteral("llmmath"))) + engine->addImageProvider( + QStringLiteral("llmmath"), new MathImageProvider()); } } // namespace void LlmMathText::reRender() { + ensureImageProvider(this); ++m_requestId; if (m_latex.trimmed().isEmpty()) { - m_image = QImage(); - m_imageUrl = QUrl(); + releaseMathId(m_imageId); + m_imageId.clear(); m_width = 0; m_height = 0; m_ok = false; @@ -147,12 +218,30 @@ void LlmMathText::reRender() { const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() + QLatin1Char(0x1f) + QString::number(m_fontPointSize) + QLatin1Char(0x1f) + QString::number(m_devicePixelRatio); - if (auto it = mathCache().find(key); it != mathCache().end()) { - m_image = it->image; - m_imageUrl = it->url; - m_width = it->width; - m_height = it->height; - m_ok = it->ok; + bool cacheHit = false; + QString hitId; + qreal hitW = 0; + qreal hitH = 0; + bool hitOk = false; + { + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + const auto it = store.entries.constFind(key); + if (it != store.entries.constEnd()) { + cacheHit = true; + hitId = it->id; + hitW = it->width; + hitH = it->height; + hitOk = it->ok; + } + } + if (cacheHit) { + releaseMathId(m_imageId); + retainMathId(hitId); + m_imageId = hitId; + m_width = hitW; + m_height = hitH; + m_ok = hitOk; Q_EMIT changed(); return; } @@ -164,33 +253,46 @@ void LlmMathText::reRender() { const QColor color = m_color; const double pointSize = m_fontPointSize; const qreal dpr = m_devicePixelRatio; - auto renderer = m_renderer; QThreadPool::globalInstance()->start( - [this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() { + [this, id = m_requestId, key, latex, color, pointSize, dpr]() { + JKQTMathText renderer(nullptr, true); + configureLatinModern(renderer); MathRender render; - renderer->setFontPointSize(pointSize); - renderer->setFontColor(color); - if (renderer->parse( + renderer.setFontPointSize(pointSize); + renderer.setFontColor(color); + if (renderer.parse( latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) { - const QImage image = renderer->drawIntoImage( - /* drawBoxes */ false, + { + QImage scratch(1, 1, QImage::Format_ARGB32); + QPainter painter(&scratch); + const QSizeF size = renderer.getSize(painter); + render.width = size.width() + 2 * kRenderMargin; + render.height = size.height() + 2 * kRenderMargin; + } + QPointer sizeGuard(this); + const qreal sizeW = render.width; + const qreal sizeH = render.height; + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [sizeGuard, id, sizeW, sizeH]() { + LlmMathText* self = sizeGuard; + if (!self || id != self->m_requestId) return; + self->m_width = sizeW; + self->m_height = sizeH; + Q_EMIT self->changed(); + }, + Qt::QueuedConnection); + + const QImage image = renderer.drawIntoImage( + false, QColor(Qt::transparent), kRenderMargin, dpr, kResolutionDpi); if (!image.isNull()) { - QByteArray png; - { - QBuffer buffer(&png); - buffer.open(QIODevice::WriteOnly); - image.save(&buffer, "PNG"); - } render.image = image; - render.url = QUrl( - QStringLiteral("data:image/png;base64,") + - QString::fromLatin1(png.toBase64())); render.width = image.width() / dpr; render.height = image.height() / dpr; render.ok = true; @@ -208,12 +310,46 @@ void LlmMathText::reRender() { return; } if (render.ok) { - auto& cache = mathCache(); - if (cache.size() >= kCacheLimit) cache.clear(); - cache.insert(key, render); + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + const bool isNew = !store.entries.contains(key); + if (isNew) + render.id = QString::number(store.nextId++); + else + render.id = store.entries[key].id; + store.idIndex.insert(render.id, key); + if (isNew) { + const qint64 newBytes = imageBytes(render.image); + while (store.order.size() >= kCacheLimit || + store.bytes + newBytes > kMaxCacheBytes) { + bool evicted = false; + for (int i = 0; i < store.order.size(); ++i) { + const QString oldKey = store.order.at(i); + const auto oldIt = + store.entries.constFind(oldKey); + if (oldIt == store.entries.constEnd()) { + store.order.removeAt(i); + continue; + } + if (store.idRefs.value(oldIt->id, 0) > 0) + continue; + store.order.removeAt(i); + store.bytes -= imageBytes(oldIt->image); + store.idIndex.remove(oldIt->id); + store.entries.remove(oldKey); + evicted = true; + break; + } + if (!evicted) break; + } + store.entries.insert(key, render); + store.order.append(key); + store.bytes += imageBytes(render.image); + } } - self->m_image = render.image; - self->m_imageUrl = render.url; + releaseMathId(self->m_imageId); + retainMathId(render.id); + self->m_imageId = render.id; self->m_width = render.width; self->m_height = render.height; self->m_ok = render.ok; @@ -223,4 +359,8 @@ void LlmMathText::reRender() { }); } +LlmMathText::~LlmMathText() { + releaseMathId(m_imageId); +} + } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/mathtext.hpp b/Plugins/ZShell/Llm/mathtext.hpp index 3f97a59..f0210a9 100644 --- a/Plugins/ZShell/Llm/mathtext.hpp +++ b/Plugins/ZShell/Llm/mathtext.hpp @@ -1,16 +1,10 @@ #pragma once #include -#include #include #include -#include #include -#include - -#include - namespace ZShell::llm { class LlmMathText : public QObject { @@ -25,14 +19,14 @@ class LlmMathText : public QObject { Q_PROPERTY( qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio NOTIFY changed) - Q_PROPERTY(QImage image READ image NOTIFY changed) - Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed) + Q_PROPERTY(QString imageId READ imageId NOTIFY changed) Q_PROPERTY(qreal width READ width NOTIFY changed) Q_PROPERTY(qreal height READ height NOTIFY changed) Q_PROPERTY(bool ok READ ok NOTIFY changed) public: explicit LlmMathText(QObject* parent = nullptr); + ~LlmMathText() override; [[nodiscard]] QString latex() const { return m_latex; } void setLatex(const QString& value); @@ -42,8 +36,7 @@ class LlmMathText : public QObject { void setFontPointSize(double value); [[nodiscard]] qreal devicePixelRatio() const { return m_devicePixelRatio; } void setDevicePixelRatio(qreal value); - [[nodiscard]] QImage image() const { return m_image; } - [[nodiscard]] QUrl imageUrl() const { return m_imageUrl; } + [[nodiscard]] QString imageId() const { return m_imageId; } [[nodiscard]] qreal width() const { return m_width; } [[nodiscard]] qreal height() const { return m_height; } [[nodiscard]] bool ok() const { return m_ok; } @@ -54,13 +47,11 @@ class LlmMathText : public QObject { private: void reRender(); - std::shared_ptr m_renderer; QString m_latex; QColor m_color; double m_fontPointSize = 12.0; qreal m_devicePixelRatio = 1.0; - QImage m_image; - QUrl m_imageUrl; + QString m_imageId; qreal m_width = 0; qreal m_height = 0; bool m_ok = false; diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index 070b3cb..0cf1fba 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -57,6 +57,20 @@ void ChatMessage::setActiveGeneration(int index) { setActiveInternal(index); } +void ChatMessage::setAttachments(QList attachments) { + QList stale; + for (Attachment* attachment : m_attachments) + if (!attachments.contains(attachment)) stale.append(attachment); + m_attachments.clear(); + for (Attachment* attachment : attachments) { + attachment->setParent(this); + m_attachments.append(attachment); + } + Q_EMIT attachmentsChanged(); + for (Attachment* attachment : stale) + attachment->deleteLater(); +} + void ChatMessage::edit(const QString& newContent) { if (auto* generation = activeGeneration()) generation->setContent(newContent); diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp index 87310ed..df9ff5d 100644 --- a/Plugins/ZShell/Llm/message.hpp +++ b/Plugins/ZShell/Llm/message.hpp @@ -1,5 +1,6 @@ #pragma once +#include "attachment.hpp" #include "generation.hpp" #include @@ -27,6 +28,9 @@ class ChatMessage : public QObject { Q_PROPERTY( int activeGenerationIndex READ activeGenerationIndex NOTIFY activeGenerationChanged) + Q_PROPERTY( + QList attachments READ attachments NOTIFY + attachmentsChanged) public: enum class Role : int { User = 0, Assistant }; @@ -46,12 +50,16 @@ class ChatMessage : public QObject { return generation(m_active); } [[nodiscard]] int activeGenerationIndex() const { return m_active; } + [[nodiscard]] QList attachments() const { + return m_attachments; + } [[nodiscard]] ChatGeneration* generation(int index) const { if (index < 0 || index >= m_generations.size()) return nullptr; return m_generations.at(index); } Q_INVOKABLE void setActiveGeneration(int index); + void setAttachments(QList attachments); Q_INVOKABLE void edit(const QString& newContent); Q_INVOKABLE void retry(); Q_INVOKABLE void generate(); @@ -63,6 +71,7 @@ class ChatMessage : public QObject { Q_SIGNALS: void generationsChanged(); void activeGenerationChanged(); + void attachmentsChanged(); private: void setActiveInternal(int index); @@ -70,6 +79,7 @@ class ChatMessage : public QObject { Role m_role; qint64 m_timestamp; QList m_generations; + QList m_attachments; int m_active = -1; }; diff --git a/Plugins/ZShell/Llm/segment.cpp b/Plugins/ZShell/Llm/segment.cpp index e75087c..9134f7d 100644 --- a/Plugins/ZShell/Llm/segment.cpp +++ b/Plugins/ZShell/Llm/segment.cpp @@ -11,7 +11,7 @@ namespace ZShell::llm { namespace { constexpr int kMarkdownRefreshMs = 150; -} // namespace +} LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) : QObject(parent), m_type(type), m_timestamp(timestamp) { diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp index 2f083ba..ee7e4df 100644 --- a/Plugins/ZShell/Llm/session.cpp +++ b/Plugins/ZShell/Llm/session.cpp @@ -1,16 +1,31 @@ #include "session.hpp" +#include "attachment.hpp" #include "chatstore.hpp" #include "llmclient.hpp" +#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include namespace ZShell::llm { namespace { +constexpr qint64 kMaxImageBytes = 20 * 1024 * 1024; +constexpr qint64 kMaxFileBytes = 100 * 1024 * 1024; + QString titleFrom(const QString& content) { const QString flat = content.simplified(); if (flat.isEmpty()) return QString(); @@ -21,7 +36,7 @@ QString titleFrom(const QString& content) { } // namespace ChatSession::ChatSession(const QString& id, QObject* parent) - : QObject(parent), m_id(id) { + : QObject(parent), m_id(id), m_workspace(sandboxDirFor(id)) { m_model = new ChatMessageModel(this, this); connect( m_model, @@ -157,26 +172,179 @@ void ChatSession::startGeneration(ChatMessage* target) { persist(); } +QString ChatSession::sandboxDirFor(const QString& sessionId) { + const QString state = + QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation); + return state + QStringLiteral("/zshell/sandboxes/") + sessionId; +} + +void ChatSession::ensureWorkspace() { + QDir().mkpath(m_workspace); +} + +void ChatSession::setAttachmentError(const QString& value) { + if (m_attachmentError == value) return; + m_attachmentError = value; + Q_EMIT attachmentErrorChanged(); +} + +QVariantList ChatSession::addAttachments(const QStringList& paths) { + ensureWorkspace(); + setAttachmentError(QString()); + QVariantList result; + for (const QString& rawPath : paths) { + const QFileInfo info(rawPath); + if (!info.exists() || !info.isFile()) { + qWarning() << "ChatSession: addAttachments skipping" << rawPath + << ": file not found or not a file"; + setAttachmentError( + QStringLiteral("File not found: %1").arg(info.fileName())); + continue; + } + const QString mimeType = QMimeDatabase().mimeTypeForFile(info).name(); + const bool isImage = + mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive); + const qint64 cap = isImage ? kMaxImageBytes : kMaxFileBytes; + if (info.size() > cap) { + qWarning() << "ChatSession: addAttachments skipping" << rawPath + << ": too large (" << info.size() << "bytes, cap" << cap + << ")"; + setAttachmentError( + QStringLiteral("File too large: %1").arg(info.fileName())); + continue; + } + QString destination = m_workspace + QLatin1Char('/') + info.fileName(); + int suffix = 1; + while (QFile::exists(destination)) { + const QString extension = info.suffix().isEmpty() + ? QString() + : QLatin1Char('.') + info.suffix(); + destination = m_workspace + QLatin1Char('/') + + info.completeBaseName() + + QStringLiteral("_%1%2").arg(suffix).arg(extension); + ++suffix; + } + if (!QFile::copy(info.absoluteFilePath(), destination)) { + qWarning() << "ChatSession: addAttachments skipping" << rawPath + << ": copy to" << destination << "failed"; + setAttachmentError( + QStringLiteral("Could not copy file: %1").arg(info.fileName())); + continue; + } + auto* attachment = new Attachment( + info.fileName(), + destination, + isImage ? Attachment::Kind::Image : Attachment::Kind::File, + mimeType, + info.size(), + this); + result.append(QVariant::fromValue(attachment)); + } + return result; +} + +bool ChatSession::clipboardHasAttachment() { + QClipboard* clipboard = QGuiApplication::clipboard(); + if (!clipboard) return false; + const QMimeData* data = clipboard->mimeData(); + if (!data) return false; + if (data->hasImage()) return true; + for (const QUrl& url : data->urls()) + if (url.isLocalFile()) return true; + return false; +} + +QVariantList ChatSession::pasteAttachments() { + QClipboard* clipboard = QGuiApplication::clipboard(); + const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr; + if (!data) { + qWarning() << "ChatSession: pasteAttachments: no clipboard data"; + return {}; + } + + qInfo() << "ChatSession: pasteAttachments: formats =" << data->formats() + << "hasImage =" << data->hasImage() + << "hasUrls =" << data->hasUrls() << "hasText =" << data->hasText(); + + QStringList paths; + QString tempImagePath; + if (data->hasImage()) { + const QImage image = clipboard->image(); + qInfo() << "ChatSession: pasteAttachments: image" + << (image.isNull() ? "null" + : QString::number(image.width()) + "x" + + QString::number(image.height())); + if (image.isNull()) { + setAttachmentError( + QStringLiteral("Clipboard image could not be decoded")); + return {}; + } + tempImagePath = + QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png") + .arg(QDateTime::currentMSecsSinceEpoch()); + if (!image.save(tempImagePath)) { + qWarning() << "ChatSession: pasteAttachments: saving image to" + << tempImagePath << "failed"; + setAttachmentError( + QStringLiteral("Could not save the pasted image")); + return {}; + } + paths.append(tempImagePath); + } else if (data->hasUrls()) { + for (const QUrl& url : data->urls()) { + qInfo() << "ChatSession: pasteAttachments: url =" << url.toString() + << "local =" << url.isLocalFile(); + if (url.isLocalFile()) paths.append(url.toLocalFile()); + } + } + if (paths.isEmpty()) { + if (data->hasImage() || data->hasUrls()) + setAttachmentError(QStringLiteral( + "Nothing usable on the clipboard (expected an image " + "or copied files)")); + return {}; + } + const QVariantList attachments = addAttachments(paths); + if (!tempImagePath.isEmpty()) QFile::remove(tempImagePath); + qInfo() << "ChatSession: pasteAttachments: result =" << attachments.size() + << "attachment(s)"; + return attachments; +} + void ChatSession::sendMessage(const QString& text) { + sendMessage(text, QVariantList()); +} + +void ChatSession::sendMessage( + const QString& text, const QVariantList& attachments) { + QList attachmentList; + for (const QVariant& variant : attachments) + if (auto* attachment = + qobject_cast(variant.value())) + attachmentList.append(attachment); const QString trimmed = text.trimmed(); - if (trimmed.isEmpty()) return; + if (trimmed.isEmpty() && attachmentList.isEmpty()) return; if (auto* clientObject = client()) { if (clientObject->busy()) return; } const qint64 now = QDateTime::currentMSecsSinceEpoch(); - appendNewest(ChatMessage::Role::User, trimmed, now); + auto* userMessage = appendNewest(ChatMessage::Role::User, trimmed, now); + if (!attachmentList.isEmpty()) userMessage->setAttachments(attachmentList); while (m_model->rowCount() > 200) m_model->removeMessage(m_model->at(m_model->rowCount() - 1)); if (m_title.isEmpty()) { - m_title = titleFrom(trimmed); + QString titleSource = trimmed; + if (titleSource.isEmpty() && !attachmentList.isEmpty()) + titleSource = attachmentList.first()->name(); + m_title = titleFrom(titleSource); Q_EMIT titleChanged(); qInfo() << "ChatSession:" << m_id << "new conversation," << "fallback title" << m_title << "- requesting generated title and icon"; if (auto* clientObject = client()) { - clientObject->requestTitle(this, trimmed); - clientObject->requestIcon(this, trimmed); + clientObject->requestTitle(this, titleSource); + clientObject->requestIcon(this, titleSource); } } auto* assistant = diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index a024322..f96e70f 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include namespace ZShell::llm { @@ -27,6 +29,10 @@ class ChatSession : public QObject { Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) Q_PROPERTY( ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT) + Q_PROPERTY(QString workspace READ workspacePath CONSTANT) + Q_PROPERTY( + QString attachmentError READ attachmentError NOTIFY + attachmentErrorChanged) public: explicit ChatSession(const QString& id, QObject* parent = nullptr); @@ -55,6 +61,13 @@ class ChatSession : public QObject { [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } void setLastTokenCount(int value); + [[nodiscard]] QString workspacePath() const { return m_workspace; } + [[nodiscard]] QString attachmentError() const { return m_attachmentError; } + void setAttachmentError(const QString& value); + + static QString sandboxDirFor(const QString& sessionId); + void ensureWorkspace(); + void setTitle(const QString& value); void setIcon(const QString& value); void setUpdatedAt(qint64 value); @@ -71,7 +84,12 @@ class ChatSession : public QObject { void removeMessage(ChatMessage* message); void clearMessages(); + Q_INVOKABLE QVariantList addAttachments(const QStringList& paths); + Q_INVOKABLE bool clipboardHasAttachment(); + Q_INVOKABLE QVariantList pasteAttachments(); Q_INVOKABLE void sendMessage(const QString& text); + Q_INVOKABLE void sendMessage( + const QString& text, const QVariantList& attachments); Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target); Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message); Q_INVOKABLE void clear(); @@ -83,6 +101,7 @@ class ChatSession : public QObject { void pinnedChanged(); void messageCountChanged(); void loaded(); + void attachmentErrorChanged(); private: void onModelRowsChanged(); @@ -103,6 +122,8 @@ class ChatSession : public QObject { bool m_loaded = false; bool m_clearPending = false; int m_lastTokenCount = 0; + QString m_workspace; + QString m_attachmentError; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/shelltool.cpp b/Plugins/ZShell/Llm/shelltool.cpp new file mode 100644 index 0000000..c18ceaa --- /dev/null +++ b/Plugins/ZShell/Llm/shelltool.cpp @@ -0,0 +1,258 @@ +#include "shelltool.hpp" + +#include "llmclient.hpp" +#include "session.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace ZShell::llm { + +namespace { + +QJsonObject makeOutput(const QString& text) { + QJsonObject obj; + obj[QStringLiteral("output")] = text; + return obj; +} + +QJsonObject makeError(const QString& message) { + QJsonObject obj; + obj[QStringLiteral("error")] = message; + return obj; +} + +} // namespace + +ShellTool::ShellTool(LlmClient* client, QObject* parent) + : LlmTool(parent), m_client(client) {} + +ShellTool::~ShellTool() { + for (auto* job : m_jobs) { + if (job->timer) job->timer->stop(); + if (job->process) job->process->kill(); + delete job; + } + m_jobs.clear(); +} + +QString ShellTool::name() const { + return QStringLiteral("runcommand"); +} + +QString ShellTool::description() const { + return QStringLiteral( + "Run a shell command inside a bubblewrap sandbox that contains " + "the files the user attached to the conversation. The rest of " + "the filesystem is mounted read-only, there is no network " + "access, and only the sandbox directory is writable. The " + "command is executed with /bin/sh and the sandbox directory as " + "its working directory, so relative paths refer to the sandbox. " + "Use it to explore, transform, or extract attached files (for " + "example: ls, find, grep, unzip, tar, head, wc). The combined " + "output is limited; the exit code is always reported. The call " + "only completes when every child process has exited, so do not " + "start long-lived background processes."); +} + +QJsonObject ShellTool::parameters() const { + QJsonObject command; + command[QStringLiteral("type")] = QStringLiteral("string"); + command[QStringLiteral("description")] = + QStringLiteral("The shell command to execute"); + + QJsonObject timeout; + timeout[QStringLiteral("type")] = QStringLiteral("integer"); + timeout[QStringLiteral("minimum")] = 1; + timeout[QStringLiteral("maximum")] = MaxTimeoutSeconds; + timeout[QStringLiteral("description")] = + QStringLiteral("Timeout in seconds. Defaults to %1.") + .arg(DefaultTimeoutSeconds); + + QJsonObject properties; + properties[QStringLiteral("command")] = command; + properties[QStringLiteral("timeout")] = timeout; + + QJsonObject schema; + schema[QStringLiteral("type")] = QStringLiteral("object"); + schema[QStringLiteral("properties")] = properties; + QJsonArray required; + required.append(QStringLiteral("command")); + schema[QStringLiteral("required")] = required; + return schema; +} + +QStringList ShellTool::sandboxArguments(const QString& workspace) { + QStringList args; + args << QLatin1String("--unshare-user") << QLatin1String("--uid") + << QString::number(::getuid()) << QLatin1String("--gid") + << QString::number(::getgid()) << QLatin1String("--disable-userns") + << QLatin1String("--unshare-pid") << QLatin1String("--unshare-net") + << QLatin1String("--unshare-ipc") << QLatin1String("--unshare-uts") + << QLatin1String("--hostname") << QLatin1String("zshell-sandbox"); + args << QLatin1String("--ro-bind") << QLatin1String("/") + << QLatin1String("/") << QLatin1String("--tmpfs") + << QLatin1String("/tmp") << QLatin1String("--tmpfs") + << QLatin1String("/run") << QLatin1String("--bind") << workspace + << workspace << QLatin1String("--dev") << QLatin1String("/dev") + << QLatin1String("--proc") << QLatin1String("/proc"); + args << QLatin1String("--chdir") << workspace; + args << QLatin1String("--clearenv") << QLatin1String("--setenv") + << QLatin1String("PATH") + << QLatin1String( + "/usr/local/sbin:/usr/local/bin:/usr/sbin:" + "/usr/bin:/sbin:/bin") + << QLatin1String("--setenv") << QLatin1String("HOME") << workspace + << QLatin1String("--setenv") << QLatin1String("TMPDIR") + << QLatin1String("/tmp") << QLatin1String("--setenv") + << QLatin1String("SHELL") << QLatin1String("/bin/sh"); + const QByteArray user = qgetenv("USER"); + if (!user.isEmpty()) + args << QLatin1String("--setenv") << QLatin1String("USER") + << QString::fromLocal8Bit(user) << QLatin1String("--setenv") + << QLatin1String("LOGNAME") << QString::fromLocal8Bit(user); + const QByteArray lang = qgetenv("LANG"); + if (!lang.isEmpty()) + args << QLatin1String("--setenv") << QLatin1String("LANG") + << QString::fromLocal8Bit(lang); + args << QLatin1String("--new-session") + << QLatin1String("--die-with-parent"); + return args; +} + +void ShellTool::execute( + const QString& toolCallId, + const QJsonObject& args, + int outputBudgetChars, + std::function done) { + Q_UNUSED(toolCallId); + const QString command = args[QStringLiteral("command")].toString(); + if (command.trimmed().isEmpty()) { + done(makeError(QStringLiteral("Missing required argument 'command'"))); + return; + } + LlmClient* client = m_client; + if (!client || !client->sandboxEnabled()) { + done(makeError(QStringLiteral( + "The file sandbox is disabled, so no commands can run"))); + return; + } + const QString workspace = client->activeWorkspace(); + if (workspace.isEmpty()) { + done(makeError( + QStringLiteral("No sandbox available: no chat session is active"))); + return; + } + QDir().mkpath(workspace); + + const int timeoutSeconds = qBound( + 1, + args[QStringLiteral("timeout")].toInt(DefaultTimeoutSeconds), + MaxTimeoutSeconds); + auto* job = new Job; + job->timeoutSeconds = timeoutSeconds; + job->outputBudgetChars = outputBudgetChars; + job->done = std::move(done); + m_jobs.append(job); + + QProcess* process = new QProcess(this); + process->setWorkingDirectory(workspace); + process->setProcessChannelMode(QProcess::MergedChannels); + job->process = process; + + QTimer* timer = new QTimer(this); + timer->setSingleShot(true); + connect(timer, &QTimer::timeout, this, [this, job, process]() { + job->timedOut = true; + process->kill(); + }); + timer->start(timeoutSeconds * 1000); + job->timer = timer; + + connect( + process, + QOverload::of(&QProcess::finished), + this, + [this, job](int, QProcess::ExitStatus) { finishJob(job); }); + connect( + process, + &QProcess::errorOccurred, + this, + [this, job, process](QProcess::ProcessError error) { + if (job->finished) return; + if (error == QProcess::FailedToStart || + process->state() == QProcess::NotRunning) { + job->startError = process->errorString(); + finishJob(job); + } else { + process->kill(); + } + }); + + QStringList commandArgs = sandboxArguments(workspace); + commandArgs << QStringLiteral("sh") << QStringLiteral("-c") << command; + process->start(QStringLiteral("bwrap"), commandArgs); +} + +void ShellTool::cancel() { + for (auto* job : m_jobs) { + if (job->finished || !job->process) continue; + job->cancelled = true; + job->process->kill(); + } +} + +void ShellTool::finishJob(Job* job) { + if (!job || job->finished) return; + job->finished = true; + if (job->timer) job->timer->stop(); + m_jobs.removeAll(job); + + QProcess* process = job->process; + const bool ran = process && process->state() != QProcess::NotRunning; + const int exitCode = ran ? process->exitCode() : -1; + QString output; + if (process) output = QString::fromUtf8(process->readAllStandardOutput()); + + QString note; + if (!job->startError.isEmpty()) + note = + QStringLiteral("Command failed to start: %1\n").arg(job->startError); + else if (job->cancelled) + note = QStringLiteral("Command was cancelled.\n"); + else if (job->timedOut) + note = QStringLiteral( + "Command timed out after %1 seconds and was killed.\n") + .arg(job->timeoutSeconds); + + int budget = inlineBudgetChars(); + if (job->outputBudgetChars > 0) + budget = qMin(budget, job->outputBudgetChars); + if (output.size() > budget) + output = output.left(budget) + QStringLiteral( + "\n[output truncated: %1 characters " + "total]") + .arg(output.size()); + + const QString result = + note + QStringLiteral("exit code: %1\n").arg(exitCode) + + (output.isEmpty() ? QStringLiteral("(no output)") : output); + + QJsonObject resultObject; + if (!job->cancelled && !job->timedOut && exitCode == 0) + resultObject = makeOutput(result); + else + resultObject = makeError(result); + + std::function done = std::move(job->done); + if (process) process->deleteLater(); + delete job; + if (done) done(resultObject); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/shelltool.hpp b/Plugins/ZShell/Llm/shelltool.hpp new file mode 100644 index 0000000..de1e205 --- /dev/null +++ b/Plugins/ZShell/Llm/shelltool.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "tool.hpp" + +#include +#include +#include + +class QProcess; +class QTimer; + +namespace ZShell::llm { + +class LlmClient; + +class ShellTool : public LlmTool { + Q_OBJECT + + public: + explicit ShellTool(LlmClient* client, QObject* parent = nullptr); + ~ShellTool() override; + + QString name() const override; + QString description() const override; + QJsonObject parameters() const override; + void execute( + const QString& toolCallId, + const QJsonObject& args, + int outputBudgetChars, + std::function done) override; + void cancel() override; + + static constexpr int DefaultTimeoutSeconds = 60; + static constexpr int MaxTimeoutSeconds = 300; + + private: + struct Job { + QPointer process; + QPointer timer; + int timeoutSeconds = DefaultTimeoutSeconds; + int outputBudgetChars = 0; + std::function done; + QString startError; + bool timedOut = false; + bool cancelled = false; + bool finished = false; + }; + + static QStringList sandboxArguments(const QString& workspace); + + void finishJob(Job* job); + + QPointer m_client; + QList m_jobs; +}; + +} // namespace ZShell::llm diff --git a/scripts/mock-llama-server.py b/scripts/mock-llama-server.py new file mode 100644 index 0000000..d632cd5 --- /dev/null +++ b/scripts/mock-llama-server.py @@ -0,0 +1,718 @@ +#!/usr/bin/env python3 +"""Mock llama-server for exercising zshell's LLM error handling. + +Implements the OpenAI-compatible subset of llama-server that the C++ +backend talks to, and lets you trigger specific failure modes from the +QML chat UI by typing a keyword as your message. + +Wire format is taken from ggml-org/llama.cpp (tools/server): + - SSE events are "data: \n\n" (note the space after "data:"). + - A clean stream ends with "data: [DONE]\n\n". + - Errors are {"error": {"code", "message", "type"}}. + - A stream that dies mid-way sends the error event, then closes + WITHOUT [DONE]. + - A request that errors before the first token is a plain HTTP 4xx/5xx + with the error JSON body (not a stream). + - /props is a JSON object; n_ctx lives under + default_generation_settings.n_ctx. + +Run: + python3 scripts/mock-llama-server.py [--port 8080] + +Then point zshell's LLM endpoint at http://localhost:8080. + +Trigger words (type as the chat message, case-insensitive): + normal -> healthy stream, ends with [DONE] (also the default) + instant error -> 500 before any token ("server_error") + context overflow -> 400 "exceeds the available context size (2048 ...)"; + backend retries once at 2048, then fails + error during -> streams a few tokens, then an SSE error event, + closes without [DONE] + no done -> streams a few tokens, closes without [DONE] and + without an error (server killed mid-response) + 200 error -> HTTP 200 with a plain JSON error body (not SSE) + tool call -> streams reasoning, then a tool_calls round calling + readfile on /etc/os-release (finish_reason + "tool_calls"); after you approve (or deny) the + permission dialog, the follow-up round that + carries the tool result gets a normal summary + answer. Exercises the tool permission dialog, + tool execution and the multi-round transcript. + multiple tool -> two tool calls in one round (readfile + webfetch + calls of this mock's own / endpoint); approve both at + once + unknown tool -> a tool call for a tool the client does not have; + approving it yields an "unknown tool" error + result + +Control endpoint (for the non-chat routes / global state), via curl: + curl -XPOST localhost:8080/_mock -d '{"loading": true}' + /props, /v1/models, /health all answer 503 "Loading model" + curl -XPOST localhost:8080/_mock -d '{"no_models": true}' + /v1/models returns an empty list + curl -XPOST localhost:8080/_mock -d '{"chat_mode": "error_during"}' + force a chat behaviour regardless of the message text + (values: normal|instant_error|context_overflow|error_during| + no_done|200_error|tool_call|tool_call_multi| + tool_call_unknown, or null to go back to keywords) + curl -XPOST localhost:8080/_mock -d '{"reset": true}' +""" + +import argparse +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +MODEL_ID = "mock-model" +CTX_SIZE = 8192 + +# Set in main(); used by the tool-call behaviours so webfetch can point +# at this server itself. +_host = "127.0.0.1" +_port = 8080 + +# Mutable, shared across handler threads. +_state_lock = threading.Lock() +_state = { + "loading": False, # 503 "Loading model" on /props, /v1/models, /health + "no_models": False, # /v1/models returns an empty list + "chat_mode": None, # force a chat behaviour, overriding keywords +} + + +def _now(): + return int(time.time()) + + +def _chunk(cid, delta, finish_reason=None): + return { + "id": cid, + "object": "chat.completion.chunk", + "created": _now(), + "model": MODEL_ID, + "system_fingerprint": "mock", + "choices": [ + { + "index": 0, + "delta": delta, + "finish_reason": finish_reason, + } + ], + } + + +def _usage_chunk(cid, prompt_tokens, completion_tokens): + return { + "id": cid, + "object": "chat.completion.chunk", + "created": _now(), + "model": MODEL_ID, + "choices": [], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + } + + +def _error(code, message, etype): + return {"error": {"code": code, "message": message, "type": etype}} + + +def _sse(obj): + # Exact llama.cpp format_oai_sse shape: "data: " + compact json + "\n\n". + return ("data: " + json.dumps(obj, separators=(",", ":")) + "\n\n").encode() + + +def _sse_done(): + return b"data: [DONE]\n\n" + + +# Chat behaviours. Each returns ("json", status, body_bytes) for a +# non-streaming answer, or ("sse", 200, None) and then writes to the +# provided stream writer. +def _behaviour_for(text): + t = text.lower() + if "instant error" in t: + return "instant_error" + if "context overflow" in t: + return "context_overflow" + if "error during" in t: + return "error_during" + if "no done" in t or "truncate" in t: + return "no_done" + if "200 error" in t: + return "200_error" + # Order matters: "multiple tool calls" contains "tool call". + if "two tool calls" in t or "two tool call" in t: + return "tool_call_duo" + if "some tool calls" in t: + return "tool_call_some" + if "many tool calls" in t: + return "tool_call_many" + if "unknown tool" in t: + return "tool_call_unknown" + if "tool call" in t: + return "tool_call" + return "normal" + + +_TOOL_MODES = ("tool_call", "tool_call_duo", "tool_call_some", "tool_call_many", "tool_call_unknown") + + +def _tool_call_delta(index, call_id=None, name=None, arguments=None): + call = {"index": index} + if call_id is not None: + call["id"] = call_id + if name is not None or arguments is not None: + function = {} + if name is not None: + function["name"] = name + if arguments is not None: + function["arguments"] = arguments + call["function"] = function + return {"tool_calls": [call]} + + +def _stream_tool_calls(w, cid, reasoning, calls): + # One tool round: optional reasoning, then the calls (llama.cpp + # streams the id/name with the first fragment of each call and the + # arguments as successive fragments), then finish_reason + # "tool_calls". The client concatenates the argument fragments and + # shows the permission dialog on stream end. + w(_sse(_chunk(cid, {"role": "assistant"}))) + if reasoning: + for piece in reasoning: + w(_sse(_chunk(cid, {"reasoning_content": piece}))) + time.sleep(0.05) + for i, (call_id, name, arguments) in enumerate(calls): + w(_sse(_chunk(cid, _tool_call_delta(i, call_id=call_id, name=name)))) + time.sleep(0.05) + for fragment in arguments: + w( + _sse( + _chunk(cid, _tool_call_delta(i, arguments=fragment)) + ) + ) + time.sleep(0.05) + w(_sse(_chunk(cid, {}, finish_reason="tool_calls"))) + w(_sse(_usage_chunk(cid, 24, len(reasoning or []) + len(calls) * 2))) + w(_sse_done()) + + +def _tool_result_snippets(messages, limit=160): + snippets = [] + for msg in messages or []: + if msg.get("role") != "tool": + continue + content = msg.get("content") + if isinstance(content, list): + content = " ".join( + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ) + flat = " ".join(str(content or "").split()) + snippets.append(flat[:limit] + ("..." if len(flat) > limit else "")) + return snippets + + +def _stream_tool_round_done(w, cid, messages): + # Follow-up round: the client sent the tool results back; answer + # with a plain content summary so the turn completes. + w(_sse(_chunk(cid, {"role": "assistant"}))) + parts = ["mock:", "tool", "round", "complete."] + for i, snippet in enumerate(_tool_result_snippets(messages), 1): + parts.append(f"result {i}:") + parts.append(snippet or "(empty)") + for word in parts: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.02) + w(_sse(_chunk(cid, {}, finish_reason="stop"))) + w(_sse(_usage_chunk(cid, 40, len(parts) + 1))) + w(_sse_done()) + + +def _stream_normal(w, cid, words): + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + w(_sse(_chunk(cid, {}, finish_reason="stop"))) + w(_sse(_usage_chunk(cid, 12, len(words) + 1))) + w(_sse_done()) + + +def _stream_error_during(w, cid, words): + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + # Mid-stream failure: error event, then the connection closes without + # [DONE] (llama.cpp terminates the stream on a task error). + w(_sse(_error(500, "Mock: generation failed mid-stream", "server_error"))) + + +def _stream_no_done(w, cid, words): + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + # Clean FIN, no [DONE], no error: server died mid-response. + + +def _last_user_text(messages): + for msg in reversed(messages or []): + if msg.get("role") == "user": + content = msg.get("content") + if isinstance(content, list): + # multimodal shape: pull out the text parts + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(part.get("text", "")) + content = " ".join(parts) + return content or "" + return "" + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "MockLlama/1.0" + + # --- low-level response helpers ----------------------------------- + def _send_json(self, status, obj): + body = json.dumps(obj, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def _send_loading_error(self): + # llama.cpp middleware: server not ready -> 503 on every route. + self._send_json( + 503, _error(503, "Loading model", "unavailable_error") + ) + + def _not_ready(self): + with _state_lock: + return _state["loading"] + + def _stream(self, status, writer): + self.send_response(status) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + try: + writer(self.wfile.write) + except (BrokenPipeError, ConnectionResetError): + pass + finally: + try: + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + pass + + # --- routing ------------------------------------------------------- + def do_GET(self): + path = self.path.split("?", 1)[0].rstrip("/") or "/" + if path in ("/health", "/v1/health"): + if self._not_ready(): + return self._send_loading_error() + return self._send_json(200, {"status": "ok"}) + if path in ("/props", "/v1/props"): + if self._not_ready(): + return self._send_loading_error() + return self._send_json( + 200, + { + "total_slots": 1, + "model_alias": MODEL_ID, + "model_path": "/mock/model.gguf", + "default_generation_settings": { + "params": {"n_predict": 2048}, + "n_ctx": CTX_SIZE, + }, + "is_sleeping": False, + }, + ) + if path in ("/models", "/v1/models"): + if self._not_ready(): + return self._send_loading_error() + with _state_lock: + no_models = _state["no_models"] + data = [] if no_models else [ + { + "id": MODEL_ID, + "object": "model", + "created": _now(), + "owned_by": "mock", + } + ] + return self._send_json(200, {"object": "list", "data": data}) + if path == "/": + return self._send_json(200, {"status": "ok", "mock": True}) + return self._send_json(404, _error(404, "not found", "not_found_error")) + + def do_POST(self): + path = self.path.split("?", 1)[0].rstrip("/") or "/" + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw) if raw else {} + except json.JSONDecodeError: + body = {} + + if path == "/_mock": + return self._mock_control(body) + + if path in ("/chat/completions", "/v1/chat/completions"): + if self._not_ready(): + return self._send_loading_error() + return self._chat(body) + + return self._send_json(404, _error(404, "not found", "not_found_error")) + + # --- /_mock control ------------------------------------------------ + def _mock_control(self, body): + with _state_lock: + if body.get("reset"): + _state["loading"] = False + _state["no_models"] = False + _state["chat_mode"] = None + if "loading" in body: + _state["loading"] = bool(body["loading"]) + if "no_models" in body: + _state["no_models"] = bool(body["no_models"]) + if "chat_mode" in body: + _state["chat_mode"] = body["chat_mode"] + snapshot = dict(_state) + self.log_message("control -> %s", json.dumps(snapshot)) + return self._send_json(200, {"ok": True, "state": snapshot}) + + # --- /chat/completions -------------------------------------------- + def _chat(self, body): + text = _last_user_text(body.get("messages")) + with _state_lock: + forced = _state["chat_mode"] + mode = forced or _behaviour_for(text) + stream = bool(body.get("stream", True)) + cid = "chatcmpl-mock" + words = ["mock", "says", "hello", "from", "the", "mock", "server"] + self.log_message("chat mode=%s stream=%s text=%r", mode, stream, text) + + if mode == "instant_error": + # Error before the first token: plain HTTP error, not a stream. + return self._send_json( + 500, _error(500, "Mock: instant server error", "server_error") + ) + + if mode == "context_overflow": + return self._send_json( + 400, + _error( + 400, + "request (100000 tokens) exceeds the available context " + "size (2048 tokens), try increasing it", + "exceed_context_size_error", + ), + ) + + if mode == "200_error": + # 200 with a plain JSON error body (not SSE) -- the case the + # data: filter would otherwise swallow. + return self._send_json( + 200, _error(400, "Mock: 200-with-json-error", "server_error") + ) + + if mode in _TOOL_MODES: + # Continuation round: the previous round's tool results came + # back through the client (role: "tool" messages). + if any(m.get("role") == "tool" for m in body.get("messages") or []): + return self._stream( + 200, + lambda w: _stream_tool_round_done( + w, cid, body.get("messages") + ), + ) + if mode == "tool_call": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "os-release\"}"], + ) + ] + reasoning = [ + "The user wants a tool call. ", + "I will read /etc/os-release.", + ] + elif mode == "tool_call_duo": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "hostname\"}"], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ] + reasoning = ["Two independent tool calls, ", + "batched together."] + elif mode == "tool_call_some": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "hostname\"}"], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', "os-release\"}"], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', "uptime\"}"], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ] + reasoning = ["Two independent tool calls, ", + "batched together."] + elif mode == "tool_call_many": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "hostname\"}"], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', "os-release\"}"], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', "uptime\"}"], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "hostname\"}"], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', "os-release\"}"], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', "uptime\"}"], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "hostname\"}"], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', "os-release\"}"], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', "uptime\"}"], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ] + reasoning = ["Two independent tool calls, ", + "batched together."] + else: # tool_call_unknown + calls = [ + ( + "call_mock_3", + "nonexistent_tool", + ['{"target": "test"}'], + ) + ] + reasoning = ["Calling a tool the client ", "does not have."] + self.log_message( + "tool mode=%s calls=%s", mode, [c[1] for c in calls] + ) + return self._stream( + 200, lambda w: _stream_tool_calls(w, cid, reasoning, calls) + ) + + if not stream: + # Non-streaming success: single chat.completion object. + content = " ".join(words) + return self._send_json( + 200, + { + "id": cid, + "object": "chat.completion", + "created": _now(), + "model": MODEL_ID, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": len(words), + "total_tokens": 12 + len(words), + }, + }, + ) + + if mode == "error_during": + return self._stream(200, lambda w: _stream_error_during(w, cid, words)) + if mode == "no_done": + return self._stream(200, lambda w: _stream_no_done(w, cid, words)) + return self._stream(200, lambda w: _stream_normal(w, cid, words)) + + def log_message(self, format, *args): + # Compact single-line logging to stdout. + print("%s - %s" % (self.log_date_time_string(), format % args), flush=True) + + +def main(): + ap = argparse.ArgumentParser( + description="Mock llama-server for exercising zshell LLM error handling" + ) + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--host", default="127.0.0.1") + args = ap.parse_args() + + global _host, _port + _host = args.host + _port = args.port + + server = ThreadingHTTPServer((args.host, args.port), Handler) + server.daemon_threads = True + print( + f"mock llama-server on http://{args.host}:{args.port} " + f"(model={MODEL_ID}, ctx={CTX_SIZE})\n" + "type trigger words in the chat UI, or curl /_mock to change state", + flush=True, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/run-qml-tests.sh b/tests/run-qml-tests.sh new file mode 100755 index 0000000..c24a86a --- /dev/null +++ b/tests/run-qml-tests.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DURATION="${1:-${QS_SMOKE_DURATION:-10}}" +QML_IMPORT_PATH="${QML_IMPORT_PATH:-$ROOT/build/qml}" + +if [ ! -d "$QML_IMPORT_PATH/ZShell" ]; then + echo "error: no ZShell plugin at $QML_IMPORT_PATH/ZShell" + echo "build the project first: cmake --build build" + exit 2 +fi + +OUT="$(mktemp /tmp/zshell-smoke.XXXXXX)" +trap 'rm -f "$OUT"' EXIT + +echo "booting shell for ${DURATION}s (a second bar will be visible)" +env QT_QPA_PLATFORM=wayland QML_IMPORT_PATH="$QML_IMPORT_PATH" \ + qs --no-color -p "$ROOT/shell.qml" >"$OUT" 2>&1 & +CLI_PID=$! + +IID="" +for _ in $(seq 1 100); do + IID="$(sed -nE 's|.*by-id/([a-z0-9]+)/log\.qslog.*|\1|p' "$OUT" | head -1)" + [ -n "$IID" ] && break + kill -0 "$CLI_PID" 2>/dev/null || break + sleep 0.2 +done + +if [ -z "$IID" ]; then + echo "FAIL: shell did not start" + cat "$OUT" + exit 2 +fi + +sleep "$DURATION" + +qs kill -i "$IID" >/dev/null 2>&1 +if qs list --all 2>/dev/null | grep -q "Instance $IID"; then + PID="$(qs list --all 2>/dev/null | awk -v id="$IID" ' + /^Instance /{cur = $2} + cur == id && /Process ID:/{print $3}')" + [ -n "$PID" ] && kill "$PID" 2>/dev/null +fi +wait "$CLI_PID" 2>/dev/null + +ERRORS="$(grep -aE '^[[:space:]]*ERROR' "$OUT" || true)" +if [ -n "$ERRORS" ]; then + echo + echo "FAIL: QML errors detected" + echo "$ERRORS" + exit 1 +fi + +WARNINGS="$(grep -aE '^[[:space:]]*WARN' "$OUT" | sed -E 's/^[[:space:]]*WARN[[:space:]]*//' | sort | uniq -c | sort -rn || true)" +echo +if [ -n "$WARNINGS" ]; then + echo "PASS: no errors (${DURATION}s boot). Non-fatal warnings:" + echo "$WARNINGS" +else + echo "PASS: no errors or warnings (${DURATION}s boot)." +fi +exit 0