diff --git a/.qmlformat.ini b/.qmlformat.ini index 405a8d1..3956296 100644 --- a/.qmlformat.ini +++ b/.qmlformat.ini @@ -3,8 +3,9 @@ FunctionsSpacing=true IndentWidth=4 MaxColumnWidth=-1 NewlineType=native -NormalizeOrder=true +GroupAttributesTogether=true ObjectsSpacing=true SemicolonRule=always +SingleLineEmptyObjects=true SortImports=false UseTabs=true diff --git a/Components/CustomFlickable.qml b/Components/CustomFlickable.qml index 249f093..796fa77 100644 --- a/Components/CustomFlickable.qml +++ b/Components/CustomFlickable.qml @@ -28,6 +28,9 @@ Flickable { interval: 10 running: root.doneFakeFlick - onTriggered: root.doneFakeFlick = false + onTriggered: { + root.doneFakeFlick = false; + root.returnToBounds(); + } } } diff --git a/Components/CustomListView.qml b/Components/CustomListView.qml index 0b8d618..84c6aba 100644 --- a/Components/CustomListView.qml +++ b/Components/CustomListView.qml @@ -10,24 +10,27 @@ ListView { maximumFlickVelocity: 3000 rebound: Transition { - onRunningChanged: { - if (!running && !root.doneFakeFlick) { - root.doneFakeFlick = true; - root.flick(1, 1); - root.flick(-1, -1); - Qt.callLater(() => root.cancelFlick()); - } - } + // onRunningChanged: { + // if (!running && !root.doneFakeFlick) { + // root.doneFakeFlick = true; + // root.flick(1, 1); + // root.flick(-1, -1); + // Qt.callLater(() => root.cancelFlick()); + // } + // } Anim { properties: "x,y" + type: Anim.DefaultEffects } } - Timer { - interval: 10 - running: root.doneFakeFlick - - onTriggered: root.doneFakeFlick = false - } + // Timer { + // interval: 10 + // running: root.doneFakeFlick + // + // onTriggered: { + // root.doneFakeFlick = false; + // } + // } } diff --git a/Components/TextEditBase.qml b/Components/TextEditBase.qml new file mode 100644 index 0000000..1dd7776 --- /dev/null +++ b/Components/TextEditBase.qml @@ -0,0 +1,99 @@ +import QtQuick +import QtQuick.Templates +import ZShell.Config +import qs.Services + +TextEdit { + id: root + + property bool animateCursor: true + property alias cursor: cursor + + color: Colors.palette.m3onSurface + cursorVisible: !readOnly + font.pointSize: Tokens.font.size.small + renderType: TextField.NativeRendering + selectedTextColor: color + selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4) + verticalAlignment: TextInput.AlignVCenter + + Behavior on color { + CAnim { + } + } + cursorDelegate: Item { + } + Behavior on selectionColor { + CAnim { + } + } + + CustomRect { + id: cursor + + property bool disableBlink + + color: Colors.palette.m3primary + implicitHeight: root.cursorRectangle.height + implicitWidth: 1.5 + radius: Tokens.rounding.largeIncreased + x: root.cursorRectangle.x + y: root.cursorRectangle.y + + Behavior on opacity { + Anim { + type: Anim.StandardSmall + } + } + Behavior on x { + enabled: root.animateCursor + + Anim { + duration: Tokens.anim.durations.expressiveFastEffects + easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1] + } + } + Behavior on y { + enabled: root.animateCursor + + Anim { + duration: Tokens.anim.durations.expressiveFastEffects + easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1] + } + } + + Connections { + function onCursorPositionChanged(): void { + if (root.activeFocus && root.cursorVisible) { + cursor.opacity = 1; + cursor.disableBlink = true; + enableBlink.restart(); + } + } + + target: root + } + + Timer { + id: enableBlink + + interval: 500 + + onTriggered: cursor.disableBlink = false + } + + Timer { + interval: 500 + repeat: true + running: root.activeFocus && root.cursorVisible && !cursor.disableBlink + triggeredOnStart: true + + onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1 + } + + Binding { + cursor.opacity: 0 + when: !root.activeFocus || !root.cursorVisible + } + } +} diff --git a/Components/VerticalFadeListView.qml b/Components/VerticalFadeListView.qml index c563a0b..2430660 100644 --- a/Components/VerticalFadeListView.qml +++ b/Components/VerticalFadeListView.qml @@ -8,6 +8,7 @@ CustomListView { property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 property real fadeAmount: 0.1 + property real fadeThreshold: 0.0 readonly property bool horizontal: orientation === ListView.Horizontal property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 @@ -28,11 +29,11 @@ CustomListView { } function marginEnd(): real { - return horizontal ? rightMargin : bottomMargin; + return horizontal ? rightMargin - fadeThreshold : bottomMargin - fadeThreshold; } function marginStart(): real { - return horizontal ? leftMargin : topMargin; + return horizontal ? leftMargin - fadeThreshold : topMargin - fadeThreshold; } function overshootStart(): real { diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 96ca9f8..8dd15c3 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -140,7 +140,6 @@ Item { anchors.bottom: parent.bottom anchors.right: parent.right - popouts: popouts sidebar: sidebar visibilities: root.visibilities } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index bac1fbc..779f623 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -63,12 +63,10 @@ CustomWindow { name: "Bar" Behavior on fsTransitionProg { - Anim { - } + Anim {} } Behavior on surfaceColor { - CAnim { - } + CAnim {} } contentItem.Keys.onEscapePressed: { @@ -293,8 +291,7 @@ CustomWindow { y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0) Behavior on extraExtent { - Anim { - } + Anim {} } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 637e273..4a1220d 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -1,10 +1,8 @@ -pragma ComponentBehavior: Bound - import ZShell.Config import ZShell.Llm -import Quickshell import QtQuick import QtQuick.Layouts +import qs.Modules.Notifications.Sidebar.Chat.Content import qs.Components import qs.Services @@ -13,18 +11,23 @@ Item { property ChatSession chatData property bool following: true - readonly property int messageCount: chatData.messages.length signal requestClose - function send(): void { - if (Chat.busy || input.text.trim() === "") + function scrollToBottom(): void { + Qt.callLater(list.positionViewAtBeginning); + } + + function send(text: string): void { + if (text.trim() === "") return; following = true; - Chat.send(chatData.id, input.text); + chatData.sendMessage(text); input.text = ""; } + Component.onCompleted: input.focus = true + RowLayout { id: header @@ -54,194 +57,58 @@ Item { VerticalFadeListView { id: list - readonly property real bottomThreshold: Tokens.spacing.extraSmall - property bool stickToBottom: true + property bool userScrolledUp: false - function scrollToEnd(animated: bool): void { - const target = Math.max(0, list.contentHeight - list.height); - if (!animated) { - scrollAnim.stop(); - list.contentY = target; - return; - } - scrollAnim.to = target; - scrollAnim.restart(); - } - - anchors.bottom: inputRow.top + anchors.bottom: input.top anchors.bottomMargin: Tokens.spacing.medium anchors.left: parent.left anchors.right: parent.right anchors.top: header.bottom + anchors.topMargin: Tokens.spacing.medium cacheBuffer: height * 20 clip: true - currentIndex: messages.values.length - 1 + fadeAmount: 0.05 + fadeThreshold: Tokens.padding.medium + model: root.chatData.messagesModel spacing: Tokens.spacing.medium + verticalLayoutDirection: VerticalFadeListView.BottomToTop - CustomScrollBar.vertical: CustomScrollBar { - flickable: list - } - delegate: ColumnLayout { - id: messageRow - - readonly property bool isUser: modelData.role === ChatMessage.Role.User - required property var modelData - - anchors.left: parent.left - anchors.leftMargin: messageRow.isUser ? 0 : Tokens.spacing.extraSmall - anchors.right: parent.right - anchors.rightMargin: messageRow.isUser ? Tokens.spacing.extraSmall : 0 - - Loader { - Layout.fillWidth: true - Layout.rightMargin: Tokens.spacing.extraLarge - active: !messageRow.isUser && messageRow.modelData.reasoning !== "" - - sourceComponent: CustomRect { - id: reasoning - - property bool expanded: false - - color: Colors.palette.m3surfaceContainer - implicitHeight: expanded ? expandedText.implicitHeight : collapsedText.implicitHeight + Tokens.padding.medium * 2 - radius: Tokens.rounding.medium - - Behavior on implicitHeight { - Anim { - } - } - - Loader { - id: spinnerReasoning - - active: opacity > 0 - anchors.left: parent.left - anchors.margins: Tokens.padding.medium - anchors.verticalCenter: parent.verticalCenter - opacity: messageRow.modelData.reasoningActive ? 1 : 0 - - Behavior on opacity { - Anim { - } - } - sourceComponent: LoadingIndicator { - implicitSize: collapsedText.implicitHeight - } - } - - MaterialIcon { - id: reasoningDone - - anchors.left: parent.left - anchors.margins: Tokens.padding.medium - anchors.verticalCenter: parent.verticalCenter - font.pointSize: Tokens.font.size.large - opacity: messageRow.modelData.reasoningActive || reasoning.expanded ? 0 : 1 - text: "check" - - Behavior on opacity { - Anim { - } - } - } - - CustomText { - id: collapsedText - - anchors.left: messageRow.modelData.reasoningActive ? spinnerReasoning.right : reasoningDone.right - anchors.margins: Tokens.padding.medium - anchors.right: parent.right - anchors.top: parent.top - opacity: reasoning.expanded ? 0 : 1 - text: messageRow.modelData.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((messageRow.modelData.reasoningElapsedMs / 1000).toFixed(1)) - visible: opacity > 0 - - Behavior on opacity { - Anim { - } - } - } - - CustomText { - id: expandedText - - anchors.left: parent.left - anchors.margins: Tokens.padding.medium - anchors.right: parent.right - anchors.top: parent.top - color: Colors.palette.m3onSurface - opacity: reasoning.expanded ? 1 : 0 - text: messageRow.modelData.reasoning - visible: opacity > 0 - wrapMode: Text.WordWrap - - Behavior on opacity { - Anim { - } - } - } - - StateLayer { - onClicked: reasoning.expanded = !reasoning.expanded - } - } - } - - CustomRect { - Layout.alignment: messageRow.isUser ? Qt.AlignRight : Qt.AlignLeft - color: messageRow.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer - implicitHeight: Math.max(msgText.implicitHeight + Tokens.padding.medium * 2, spinnerLoader.implicitHeight) - implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, messageRow.ListView.view.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge) - radius: Tokens.rounding.medium - visible: !messageRow.modelData.reasoningActive - - CustomText { - id: msgText - - anchors.left: parent.left - anchors.margins: Tokens.padding.medium - anchors.right: parent.right - anchors.top: parent.top - color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - text: messageRow.modelData.content - textFormat: CustomText.MarkdownText - wrapMode: Text.WordWrap - } - - Loader { - id: spinnerLoader - - active: messageRow.modelData.streaming && msgText.text.length === 0 - anchors.left: parent.left - anchors.leftMargin: 8 - anchors.verticalCenter: parent.verticalCenter - - sourceComponent: Item { - implicitHeight: 18 - implicitWidth: 18 - - LoadingIndicator { - anchors.fill: parent - } - } - } + add: Transition { + Anim { + from: list.width + property: "x" + to: 0 } } - model: ScriptModel { - id: messages - - values: root.chatData.messages + delegate: MessageDelegate {} + displaced: Transition { + Anim { + property: "y" + } + } + move: Transition { + Anim { + property: "y" + } } - Component.onCompleted: scrollToEnd(false) + Component.onCompleted: positionViewAtBeginning() + onAtYEndChanged: { + if (atYEnd) + userScrolledUp = false; + } onContentHeightChanged: { - if (list.stickToBottom) - list.scrollToEnd(false); + if (!userScrolledUp && atYEnd) + root.scrollToBottom(); } - onMovementEnded: { - list.stickToBottom = (list.contentY >= list.contentHeight - list.height - list.bottomThreshold); + onCountChanged: { + if (!userScrolledUp) + root.scrollToBottom(); + } + onMovingChanged: { + if (moving) + userScrolledUp = !atYEnd; } - onMovementStarted: list.stickToBottom = false Anim { id: scrollAnim @@ -252,88 +119,53 @@ Item { } } - ColumnLayout { + EmptyBackground { id: emptyState anchors.fill: parent spacing: Tokens.spacing.small - visible: (root.messageCount === 0 && !Chat.busy) || !root.chatData - - Item { - Layout.fillHeight: true - } - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - color: Colors.tPalette.m3outlineVariant - font.pointSize: 36 - text: "chat" - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.tPalette.m3onSurfaceVariant - text: !root.chatData ? qsTr("Open a previous chat or start a new one") : qsTr("No messages yet") - } - - Item { - Layout.fillHeight: true - } + visible: !root.chatData } IconButton { id: scrollToBottom - anchors.bottom: inputRow.top + anchors.bottom: input.top anchors.bottomMargin: Tokens.spacing.extraLarge anchors.horizontalCenter: parent.horizontalCenter font.pointSize: Tokens.font.size.large icon: "arrow_downward" isRound: true padding: Tokens.padding.extraSmall - scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 ? 1 : 0 + scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0 type: IconButton.Filled visible: scale > 0 Behavior on scale { - Anim { - } + Anim {} } onClicked: { - list.stickToBottom = true; - list.scrollToEnd(true); + list.positionViewAtBeginning(); } } - RowLayout { - id: inputRow + ChatInput { + id: input anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right - spacing: Tokens.spacing.extraSmall + bg.border.color: Colors.palette.m3outlineVariant + bg.color: Colors.tPalette.m3surfaceContainerLowest + font.pointSize: Tokens.font.size.normal + implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) + placeholderText: qsTr("Send a message") + sendIcon.font.pointSize: Tokens.font.size.large + sendIcon.icon: "arrow_upward" + sendIcon.padding: Tokens.padding.extraSmall - CustomTextField { - id: input - - Layout.fillWidth: true - placeholderText: qsTr("Send a message...") - type: CustomTextField.Filled - - onAccepted: root.send() - } - - IconButton { - id: actionBtn - - enabled: Chat.busy || input.text.trim() !== "" - font.pointSize: Tokens.font.size.normal * 2 - icon: Chat.busy ? "stop" : "send" - isRound: true - type: IconButton.Filled - - onClicked: Chat.busy ? Chat.stop() : root.send() - } + onAccepted: root.send(text) + onSendPressed: root.send(text) } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml index b56685f..4784f31 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -1,91 +1,311 @@ import QtQuick -import QtQuick.Layouts +import ZShell.Components import ZShell.Config import ZShell.Llm import qs.Components import qs.Services -CustomRect { +CustomClippingRect { id: root - required property Item chatItem - property alias layout: layout + property bool expanded: false required property ChatSession modelData + signal clicked(content: ChatSession) + signal remove(content: ChatSession) + color: Colors.tPalette.m3surfaceContainer - implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin + implicitHeight: { + let h = 0; + + h += infoContainer.implicitHeight; + + if (expanded) + h += body.implicitHeight + body.topMargin + actionRow.implicitHeight + actionRow.anchors.topMargin; + else + h += preview.implicitHeight + preview.topMargin; + + h += Tokens.padding.small; + + const icon = chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2; + if (h < icon) + return icon; + return h; + } + radius: Tokens.rounding.large + + Behavior on implicitHeight { + enabled: titleText.readOnly + + Anim {} + } MaterialIcon { id: chatIcon anchors.left: parent.left anchors.leftMargin: Tokens.padding.large - anchors.verticalCenter: parent.verticalCenter - color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + anchors.top: parent.top + anchors.topMargin: Tokens.padding.large font.pointSize: Tokens.font.size.extraLarge text: root.modelData.icon || "check" } - ColumnLayout { - id: layout + Item { + id: infoContainer - anchors.bottom: parent.bottom - anchors.bottomMargin: Tokens.padding.small anchors.left: chatIcon.right - anchors.leftMargin: Tokens.padding.medium - anchors.right: parent.right - anchors.rightMargin: Tokens.padding.medium + anchors.leftMargin: Tokens.spacing.medium anchors.top: parent.top - anchors.topMargin: Tokens.padding.small - spacing: Tokens.spacing.extraSmall + anchors.right: expandBtn.left + anchors.rightMargin: Tokens.spacing.medium + implicitHeight: { + let h = 0; + h += title.implicitHeight + title.anchors.topMargin; + h += timestamp.implicitHeight + timestamp.anchors.topMargin; + if (root.expanded) + h += created.implicitHeight + created.anchors.topMargin; + return h; + } CustomRect { - id: titleContainer + id: title - readonly property bool enoughSpace: width > title.implicitWidth + timeSep.implicitWidth + timeSep.anchors.leftMargin + timestamp.implicitWidth + timestamp.anchors.leftMargin + anchors.left: parent.left + anchors.topMargin: Tokens.padding.extraSmall + anchors.top: parent.top + color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 1) + implicitHeight: titleText.implicitHeight + Tokens.padding.extraSmall * 2 + radius: Tokens.rounding.full + implicitWidth: titleText.implicitWidth + Tokens.padding.large * 2 + opacity: titleText.readOnly ? 0 : 1 - Layout.fillWidth: true - implicitHeight: enoughSpace ? title.implicitHeight : title.implicitHeight + timestamp.implicitHeight + timestamp.anchors.topMargin + Behavior on opacity { + Anim {} + } + } - CustomText { - id: title + TextEditBase { + id: titleText - color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - text: root.modelData.title + anchors.left: parent.left + anchors.leftMargin: readOnly ? 0 : Tokens.padding.large + anchors.top: parent.top + anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2 + font.pointSize: Tokens.font.size.normal + text: root.modelData.title + readOnly: true + + Behavior on anchors.leftMargin { + Anim {} + } + Behavior on anchors.topMargin { + Anim {} } - CustomText { - id: timeSep - - anchors.left: title.right - anchors.leftMargin: Tokens.spacing.small - anchors.verticalCenter: title.verticalCenter - color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant - text: "•" - visible: titleContainer.enoughSpace - } - - CustomText { - id: timestamp - - anchors.left: titleContainer.enoughSpace ? timeSep.right : parent.left - anchors.leftMargin: titleContainer.enoughSpace ? Tokens.spacing.small : 0 - anchors.top: titleContainer.enoughSpace ? undefined : title.bottom - anchors.topMargin: Tokens.spacing.extraSmall - anchors.verticalCenter: titleContainer.enoughSpace ? title.verticalCenter : undefined - color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small - text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP") + onEditingFinished: root.modelData.title = text + onReadOnlyChanged: { + if (!readOnly) { + this.forceActiveFocus(); + cursorPosition = text.length; + } else { + root.forceActiveFocus(); + } } } CustomText { - Layout.fillWidth: true - color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant - elide: Text.ElideRight + id: created + + anchors.top: title.bottom + anchors.topMargin: Tokens.spacing.extraSmall + anchors.left: parent.left + anchors.right: parent.right font.pointSize: Tokens.font.size.small - maximumLineCount: 1 - text: root.modelData.messages[root.modelData.messages.length - 1]?.content.replace(/\s+/g, " ").trim() ?? "" + color: Colors.palette.m3onSurfaceVariant + text: qsTr("Created on: %1").arg(root.modelData.createdAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")) + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + CustomText { + id: updated + + anchors.top: created.bottom + anchors.topMargin: Tokens.spacing.small + anchors.left: parent.left + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurfaceVariant + text: qsTr("Updated on: ") + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + CustomText { + id: timestamp + + anchors.left: parent.left + anchors.top: titleText.bottom + anchors.topMargin: Tokens.spacing.extraSmall + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.small + text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP") + + states: State { + name: "expanded" + when: root.expanded + + AnchorChanges { + target: timestamp + anchors.left: updated.right + anchors.top: undefined + anchors.verticalCenter: updated.verticalCenter + } + } + transitions: Transition { + AnchorAnim {} + } + } + } + + CustomText { + id: preview + + readonly property int topMargin: Tokens.spacing.extraSmall + + anchors.left: chatIcon.right + anchors.margins: Tokens.spacing.medium + anchors.right: parent.right + y: infoContainer.implicitHeight + topMargin + elide: Text.ElideRight + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.small + maximumLineCount: 1 + opacity: !root.expanded ? 1 : 0 + text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? "" + + Behavior on y { + Anim {} + } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + CustomText { + id: body + + readonly property int topMargin: root.expanded ? Tokens.spacing.medium : Tokens.spacing.extraSmall + + anchors.left: chatIcon.right + anchors.margins: Tokens.spacing.medium + anchors.right: parent.right + y: infoContainer.implicitHeight + topMargin + animate: true + color: Colors.palette.m3onSurfaceVariant + maximumLineCount: 5 + opacity: root.expanded ? 1 : 0 + text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? "" + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + Behavior on y { + Anim {} + } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + StateLayer { + id: sLayer + + property int startY + + onClicked: { + root.clicked(root.modelData); + } + } + + ButtonRow { + id: actionRow + + anchors.top: body.bottom + anchors.left: chatIcon.right + anchors.right: parent.right + anchors.topMargin: Tokens.spacing.small + anchors.leftMargin: Tokens.spacing.medium + anchors.rightMargin: Tokens.padding.medium + spacing: Tokens.spacing.medium + + IconButton { + icon: "close" + fillWidth: true + shapeMorph: true + isRound: true + font.pointSize: Tokens.font.size.large + inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) + inactiveOnColor: Colors.palette.m3onSurfaceVariant + + onClicked: root.remove(root.modelData) + } + + IconButton { + icon: titleText.readOnly ? "edit" : "check" + label.animate: true + isToggle: true + fillWidth: true + shapeMorph: true + font.pointSize: Tokens.font.size.large + isRound: true + inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) + inactiveOnColor: Colors.palette.m3onSurfaceVariant + activeColor: Colors.palette.m3secondary + activeOnColor: Colors.palette.m3onSecondary + checked: !titleText.readOnly + + onClicked: titleText.readOnly = !titleText.readOnly + } + } + + CustomRect { + id: expandBtn + + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Tokens.padding.small + implicitHeight: expandIcon.implicitHeight + implicitWidth: expandIcon.implicitHeight + radius: Tokens.rounding.full + color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 3) + + StateLayer { + onClicked: root.expanded = !root.expanded + } + + MaterialIcon { + id: expandIcon + + anchors.centerIn: parent + anchors.verticalCenterOffset: root.expanded ? -1 : 1 + rotation: root.expanded ? 180 : 0 + text: "expand_more" + + Behavior on anchors.verticalCenterOffset { + Anim {} + } + Behavior on rotation { + Anim {} + } } } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatInput.qml b/Modules/Notifications/Sidebar/Chat/ChatInput.qml new file mode 100644 index 0000000..290d0cd --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatInput.qml @@ -0,0 +1,87 @@ +import QtQuick +import ZShell.Config +import qs.Components +import qs.Services + +TextFieldBase { + id: root + + readonly property alias bg: bg + readonly property alias sendIcon: sendIcon + + signal sendPressed + + bottomPadding: Tokens.padding.large + leftPadding: Tokens.padding.extraLarge + rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small + topPadding: Tokens.padding.large + wrapMode: TextFieldBase.Wrap + + background: CustomRect { + id: bg + + anchors.fill: parent + color: Colors.tPalette.m3surfaceContainer + radius: Tokens.rounding.extraLarge + + StateLayer { + id: stateLayer + + cursorShape: Qt.IBeamCursor + enabled: !root.activeFocus + manualPressOverride: tapHandler.pressed + + onClicked: root.focus = true + } + } + + onPressed: { + if (stateLayer.enabled) + stateLayer.press(stateLayer.mouseX, stateLayer.mouseY); + } + + CustomText { + id: placeholder + + anchors.left: parent.left + anchors.leftMargin: root.leftPadding + anchors.verticalCenter: parent.verticalCenter + color: root.placeholderTextColor + font: root.font + opacity: root.text ? 0 : 1 + text: root.placeholderText + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + IconButton { + id: sendIcon + + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.small + anchors.verticalCenter: parent.verticalCenter + enabled: root.text + icon: "upward_arrow" + opacity: root.text ? 1 : 0 + radius: Tokens.rounding.full + radiusMorph: false + stateLayer.hoverEnabled: enabled + type: IconButton.Text + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: root.sendPressed() + } + + TapHandler { + id: tapHandler + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index 160936b..9ca55d9 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -15,7 +15,7 @@ Item { property alias model: list.model signal deleteChatRequest(content: ChatSession) - signal loadChatRequest(content: ChatSession, index: int) + signal loadChatRequest(content: ChatSession) signal newChatRequest CustomText { @@ -44,140 +44,13 @@ Item { clip: true spacing: Tokens.spacing.medium - delegate: Component { - Item { - id: chatItem + delegate: ChatDelegate { + id: chat - property alias chat: chat - property bool closing: false - property bool editVisible: false - required property int index - required property var modelData - property real startY: 0.0 + implicitWidth: ListView.view.width - anchors.fill: undefined - implicitHeight: chat.layout.implicitHeight + chat.layout.anchors.topMargin + chat.layout.anchors.bottomMargin - implicitWidth: ListView.view.width - - onEditVisibleChanged: if (!editVisible) - chat.x = 0 - - ParallelAnimation { - id: removeAnim - - onFinished: root.deleteChatRequest(chatItem.modelData) - onStarted: chatItem.closing = true - - Anim { - property: "implicitHeight" - target: chatItem - to: 0 - } - - Anim { - property: "x" - target: chat - to: -chat.implicitWidth - } - - Anim { - property: "opacity" - target: chatItem - to: 0 - } - } - - ChatDelegate { - id: chat - - chatItem: chatItem - implicitWidth: chatItem.implicitWidth - modelData: chatItem.modelData - radius: Tokens.rounding.medium - - Behavior on x { - Anim { - } - } - Behavior on y { - Anim { - } - } - - StateLayer { - cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.PointingHandCursor - drag.axis: Drag.XAxis - drag.target: chat - hoverEnabled: true - preventStealing: true - - onClicked: { - if (chatItem.editVisible) - chatItem.editVisible = false; - else - root.loadChatRequest(chatItem.modelData, chatItem.index); - } - onPressed: event => { - chatItem.startY = event.y; - } - onReleased: event => { - if (chat.x > -(editTools.implicitWidth + Tokens.padding.extraSmall)) { - chat.x = 0; - } else { - chatItem.editVisible = true; - chat.x = -(editTools.implicitWidth + Tokens.spacing.medium + Tokens.padding.extraSmall); - } - } - } - } - - CustomClippingRect { - anchors.left: chat.right - anchors.right: parent.right - anchors.rightMargin: Tokens.padding.extraSmall - anchors.verticalCenter: chat.verticalCenter - implicitHeight: editTools.implicitHeight - - RowLayout { - id: editTools - - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - - IconButton { - id: deleteChatBtn - - font.pointSize: Tokens.font.size.large - icon: "close" - radius: Tokens.rounding.full - - transform: [ - Scale { - property bool shouldBeActive: (chat.x < -deleteChatBtn.implicitWidth) && !chatItem.closing - - origin.x: deleteChatBtn.implicitWidth / 2 - origin.y: deleteChatBtn.implicitWidth / 2 - xScale: shouldBeActive ? 1 : 0 - yScale: shouldBeActive ? 1 : 0 - - Behavior on xScale { - Anim { - } - } - Behavior on yScale { - Anim { - } - } - } - ] - - onClicked: { - removeAnim.start(); - } - } - } - } - } + onClicked: content => root.loadChatRequest(content) + onRemove: content => root.deleteChatRequest(content) } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml index 606e65e..2c9c44c 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -43,20 +43,19 @@ Item { active: ChatState.isWindow && root.width > (ChatState.screen.width / 4) anchors.fill: parent - sourceComponent: SidebarView { - } + sourceComponent: SidebarView {} } - IconButton { - anchors.right: parent.right - icon: "close" - visible: !ChatState.isWindow - - onClicked: { - Detach.create(); - Visibilities.getForActive().sidebar = false; - } - } + // IconButton { + // anchors.right: parent.right + // icon: "close" + // visible: !ChatState.isWindow + // + // onClicked: { + // Detach.create(); + // Visibilities.getForActive().sidebar = false; + // } + // } Component { id: chatList diff --git a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml new file mode 100644 index 0000000..2b0bade --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml @@ -0,0 +1,114 @@ +import QtQuick +import Quickshell +import ZShell.Config +import ZShell.Components +import ZShell.Llm +import qs.Components +import qs.Services + +Item { + id: root + + required property ChatGeneration current + required property TextEdit edit + required property bool hovered + readonly property bool isUser: message.role === ChatMessage.Role.User + required property ChatMessage message + + implicitHeight: root.current.content !== "" ? editButton.implicitHeight : 0 + implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small + + ButtonRow { + id: generationTools + + enabled: visible + visible: !root.isUser + + IconButton { + enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== 0 + icon: "chevron_left" + type: IconButton.Tonal + + onClicked: { + if (!root.edit.readOnly) + root.edit.readOnly = true; + root.message.setActiveGeneration(root.message.activeGenerationIndex - 1); + } + } + + IconButton { + enabled: false + icon: `${root.message.activeGenerationIndex + 1}` + label.font.family: Config.appearance.font.family.sans + label.font.pointSize: Tokens.font.size.small + type: IconButton.Text + } + + IconButton { + enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== root.message.generationCount - 1 + icon: "chevron_right" + type: IconButton.Tonal + + onClicked: { + if (!root.edit.readOnly) + root.edit.readOnly = true; + root.message.setActiveGeneration(root.message.activeGenerationIndex + 1); + } + } + } + + ButtonRow { + id: editTools + + anchors.right: parent.right + opacity: !root.current.streaming && root.hovered ? 1 : 0 + spacing: Tokens.spacing.small + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + + IconButton { + icon: "refresh" + inactiveColor: Colors.palette.m3secondary + inactiveOnColor: Colors.palette.m3onSecondary + scale: root.edit.readOnly ? 1 : 0 + visible: !root.isUser + + Behavior on scale { + Anim { + } + } + + onClicked: { + root.message.retry(); + } + } + + IconButton { + icon: "content_copy" + inactiveColor: Colors.palette.m3secondary + inactiveOnColor: Colors.palette.m3onSecondary + scale: root.edit.readOnly ? 1 : 0 + + Behavior on scale { + Anim { + } + } + + onClicked: Quickshell.clipboardText = root.current.content + } + + IconButton { + id: editButton + + icon: root.edit.readOnly ? "edit" : "check" + inactiveColor: Colors.palette.m3tertiary + inactiveOnColor: Colors.palette.m3onTertiary + + onClicked: root.edit.readOnly = !root.edit.readOnly + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml new file mode 100644 index 0000000..ee21512 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml @@ -0,0 +1,32 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services + +ColumnLayout { + id: root + + spacing: Tokens.spacing.small + + Item { + Layout.fillHeight: true + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3outlineVariant + font.pointSize: 36 + text: "chat" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3onSurfaceVariant + text: qsTr("No messages yet") + } + + Item { + Layout.fillHeight: true + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml new file mode 100644 index 0000000..73e758d --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -0,0 +1,164 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +MouseArea { + id: root + + readonly property ChatGeneration current: modelData.activeGeneration + required property int index + readonly property bool isUser: modelData.role === ChatMessage.Role.User + required property ChatMessage modelData + property bool reasoningExpanded: false + property real savedContentY: -1 + + function handleReasoningToggle(expanded: bool): void { + const view = root.ListView.view; + if (!view) + return; + + if (expanded) { + root.savedContentY = view.contentY; + root.reasoningExpanded = true; + } else { + root.reasoningExpanded = false; + if (root.savedContentY !== -1) { + restoreAnim.to = root.savedContentY; + restoreAnim.restart(); + root.savedContentY = -1; + } + } + } + + anchors.left: parent.left + anchors.leftMargin: root.isUser ? 0 : Tokens.spacing.extraSmall + anchors.right: parent.right + anchors.rightMargin: root.isUser ? Tokens.spacing.extraSmall : 0 + hoverEnabled: true + // Explicit, synchronous height — sum of children, no Layout polish involved. + implicitHeight: reasoningLoader.implicitHeight + (reasoningLoader.active ? Tokens.spacing.medium : 0) + bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin + preventStealing: true + + Binding { + property: "contentY" + restoreMode: Binding.RestoreNone + target: root.ListView.view + value: root.y - Tokens.padding.large * 2 + when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY) + } + + Anim { + id: restoreAnim + + property: "contentY" + target: root.ListView.view + type: Anim.DefaultEffects + } + + Loader { + id: reasoningLoader + + readonly property bool shouldBeActive: root.current.reasoningActive || root.current.content === "" + + active: !root.isUser + anchors.left: parent.left + anchors.right: parent.right + anchors.rightMargin: Tokens.spacing.extraLarge + anchors.top: parent.top + + sourceComponent: Reasoning { + current: root.current + + onExpandedChanged: { + root.handleReasoningToggle(expanded); + } + } + } + + CustomRect { + id: bubble + + anchors.left: root.isUser ? undefined : parent.left + anchors.right: root.isUser ? parent.right : undefined + anchors.top: reasoningLoader.active ? reasoningLoader.bottom : parent.top + anchors.topMargin: reasoningLoader.active ? Tokens.spacing.medium : 0 + color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer + implicitHeight: root.current.content !== "" ? msgText.contentHeight + Tokens.padding.medium * 2 : 0 + implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge) + radius: Tokens.rounding.medium + visible: !root.current.reasoningActive + + TextEditBase { + id: msgText + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + animateCursor: false + color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary + font.pointSize: Tokens.font.size.smaller + readOnly: true + selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4) + text: root.current.content + textFormat: CustomText.MarkdownText + wrapMode: Text.WordWrap + + Keys.onPressed: event => { + if (event.key == Qt.Key_Return) { + if (!(event.modifiers & Qt.ShiftModifier)) { + readOnly = true; + event.accepted = true; + } + } else if (event.key == Qt.Key_Escape) { + text = root.current.content; + readOnly = true; + event.accepted = true; + } + } + onEditingFinished: { + const old = root.current.content; + if (old !== text) { + root.modelData.edit(text); + + if (root.isUser) + root.modelData.generate(); + } + + readOnly = true; + } + onReadOnlyChanged: { + if (readOnly) { + animateCursor = false; + root.forceActiveFocus(); + textFormat = CustomText.MarkdownText; + } else { + var raw = root.current.content; + textFormat = CustomText.PlainText; + text = raw; + forceActiveFocus(); + cursorPosition = text.length; + animateCursor = true; + } + } + } + } + + Actions { + id: actionsRow + + anchors.left: parent.left + anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right + anchors.top: bubble.bottom + anchors.topMargin: Tokens.spacing.medium + current: root.current + edit: msgText + hovered: root.containsMouse + message: root.modelData + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml b/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml new file mode 100644 index 0000000..74bad30 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml @@ -0,0 +1,107 @@ +import QtQuick +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +Item { + id: root + + required property ChatGeneration current + property bool expanded: false + readonly property bool isReasoning: root.current.reasoningActive || root.current.content === "" + + implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin + + LoadingIndicator { + id: spinnerReasoning + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.verticalCenter: parent.verticalCenter + implicitSize: collapsedText.implicitHeight + opacity: root.isReasoning ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + } + + IconButton { + id: expandBtn + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.verticalCenter: collapsedText.verticalCenter + font.pointSize: Tokens.font.size.large + icon: "keyboard_arrow_down" + inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline + opacity: root.isReasoning ? 0 : 1 + rotation: root.expanded ? 180 : 0 + type: IconButton.Text + + Behavior on rotation { + Anim { + } + } + + onClicked: root.expanded = !root.expanded + } + + CustomText { + id: collapsedText + + anchors.left: root.current.reasoningActive ? spinnerReasoning.right : expandBtn.right + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 0 + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + text: root.current.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((root.current.reasoningElapsedMs / 1000).toFixed(1)) + + Behavior on opacity { + Anim { + } + } + } + + CustomClippingRect { + id: expandedRect + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: collapsedText.bottom + anchors.topMargin: Tokens.spacing.medium + color: Colors.palette.m3surfaceContainerLow + implicitHeight: root.expanded ? expandedText.implicitHeight + expandedText.anchors.margins * 2 : -anchors.topMargin + implicitWidth: expandedText.implicitWidth + expandedText.anchors.margins * 2 + opacity: root.expanded ? 1 : 0 + radius: Tokens.rounding.medium + visible: opacity > 0 + + Behavior on implicitHeight { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on opacity { + Anim { + } + } + + CustomText { + id: expandedText + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + color: Colors.palette.m3onSurface + font.pointSize: Tokens.font.size.small + text: root.current.reasoning + textFormat: CustomText.MarkdownText + wrapMode: Text.WordWrap + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml index fc9c5b4..9b83259 100644 --- a/Modules/Notifications/Sidebar/Chat/SidebarView.qml +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -8,26 +8,49 @@ import ZShell.Llm import qs.Components import qs.Services -RowLayout { +Item { id: root - ChatList { - id: chatList + property int breakpoint: 700 + property alias conversationModel: sidebar.model + property var currentConversation: null // whatever model item is "open" - Layout.fillHeight: true - Layout.fillWidth: true - Layout.maximumWidth: Config.sidebar.sizes.width - Layout.minimumWidth: Config.sidebar.sizes.width / 2 + readonly property bool isWide: root.width >= root.breakpoint + property bool narrowShowsSidebar: true + + function closeConversation() { + narrowShowsSidebar = true; + } + + function openConversation(conv) { + currentConversation = conv; + narrowShowsSidebar = false; // in narrow mode, jump to the chat + } + + ChatList { + id: sidebar + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: root.isWide ? undefined : parent.right + anchors.top: parent.top + implicitWidth: 20 list.currentIndex: -1 list.highlightFollowsCurrentItem: false + Behavior on anchors.right { + AnchorAnim {} + } + Behavior on implicitWidth { + Anim {} + } list.highlight: CustomRect { color: Colors.palette.m3primary - implicitHeight: chatList.list.currentItem?.implicitHeight ?? 0 - implicitWidth: chatList.list.width + implicitHeight: sidebar.list.currentItem?.implicitHeight ?? 0 + implicitWidth: sidebar.list.width radius: Tokens.rounding.medium - x: chatList.list.currentItem?.chat.x ?? 0 - y: chatList.list.currentItem?.y ?? 0 + x: sidebar.list.currentItem?.chat.x ?? 0 + y: sidebar.list.currentItem?.y ?? 0 Behavior on y { Anim { @@ -40,18 +63,29 @@ RowLayout { values: Chat.chats.values } + anchors.onRightChanged: { + if (anchors.right === undefined) + implicitWidth = 20; + } onLoadChatRequest: (chat, index) => { - chatList.list.currentIndex = index; - chatContent.chatData = chat; + sidebar.list.currentIndex = index; + root.openConversation(chat); } } - ChatContent { - id: chatContent + CustomClippingWrapperRect { + anchors.bottom: parent.bottom + anchors.left: sidebar.right + anchors.right: parent.right + anchors.top: parent.top + child: conversationView + } - Layout.fillHeight: true - Layout.fillWidth: true - Layout.minimumWidth: Config.sidebar.sizes.width - Layout.preferredWidth: Math.round(Config.sidebar.sizes.width * 1.5) + ChatContent { + id: conversationView + + anchors.fill: root + anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0 + chatData: root.currentConversation } } diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 290a3d0..4b76e12 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -45,14 +45,21 @@ Item { Item { id: pages - anchors.fill: parent + anchors.bottom: parent.bottom + anchors.top: parent.top + implicitWidth: parent.width opacity: root.props.currentTab === 0 ? 1 : 0 - visible: opacity > 0.01 + visible: opacity > 0 + x: root.props.currentTab === 0 ? 0 : -root.width Behavior on opacity { Anim { } } + Behavior on x { + Anim { + } + } CustomRect { anchors.fill: parent @@ -69,15 +76,22 @@ Item { Item { id: chatPage - anchors.fill: parent - opacity: visible ? 1 : 0 - visible: root.props.currentTab === 1 + anchors.bottom: parent.bottom + anchors.top: parent.top + implicitWidth: parent.width + opacity: root.props.currentTab === 1 ? 1 : 0 + visible: opacity > 0 + x: root.props.currentTab === 0 ? root.width : 0 z: 1 Behavior on opacity { Anim { } } + Behavior on x { + Anim { + } + } CustomRect { anchors.fill: parent diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml index f32e6b1..4fbf307 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml @@ -12,13 +12,11 @@ import qs.Services import qs.Helpers import qs.Daemons import qs.Modules.Settings -import qs.Modules.Bar.Popouts as BarPopouts CustomRect { id: root readonly property bool needExtraRow: quickToggles.length > 6 - required property BarPopouts.Wrapper popouts readonly property var quickToggles: { const seenIds = new Set(); diff --git a/Modules/Notifications/Sidebar/Utils/Content.qml b/Modules/Notifications/Sidebar/Utils/Content.qml index 922eff9..1bdf03d 100644 --- a/Modules/Notifications/Sidebar/Utils/Content.qml +++ b/Modules/Notifications/Sidebar/Utils/Content.qml @@ -1,14 +1,12 @@ import Quickshell import QtQuick import QtQuick.Layouts -import qs.Modules.Bar.Popouts as BarPopouts import qs.Modules.Notifications.Sidebar.Utils.Cards import ZShell.Config Item { id: root - required property BarPopouts.Wrapper popouts required property PersistentProperties props required property var visibilities @@ -21,8 +19,7 @@ Item { anchors.fill: parent spacing: 8 - IdleInhibit { - } + IdleInhibit {} Record { props: root.props @@ -32,7 +29,6 @@ Item { Toggles { Layout.fillWidth: true - popouts: root.popouts visibilities: root.visibilities } } diff --git a/Modules/Notifications/Sidebar/Utils/Wrapper.qml b/Modules/Notifications/Sidebar/Utils/Wrapper.qml index 00936ed..fa10251 100644 --- a/Modules/Notifications/Sidebar/Utils/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Utils/Wrapper.qml @@ -4,13 +4,11 @@ import QtQuick import Quickshell import ZShell.Config import qs.Components -import qs.Modules.Bar.Popouts as BarPopouts Item { id: root property real offsetScale: shouldBeActive ? 0 : 1 - required property BarPopouts.Wrapper popouts readonly property PersistentProperties props: PersistentProperties { property string recordingConfirmDelete property bool recordingListExpanded: false @@ -18,17 +16,14 @@ Item { reloadableId: "utilities" } - readonly property bool shouldBeActive: visibilities.sidebar + readonly property bool shouldBeActive: visibilities.sidebar && !sidebar.chatActive required property Item sidebar required property var visibilities anchors.bottomMargin: (-implicitHeight - 5) * offsetScale - clip: chatActive - implicitHeight: chatActive ? 21 : content.implicitHeight + 8 * 2 + implicitHeight: content.implicitHeight + Tokens.padding.small * 2 implicitWidth: sidebar.width * (1 - sidebar.offsetScale) - opacity: (1 - offsetScale) * chatFade - readonly property bool chatActive: sidebar.chatActive - property real chatFade: chatActive ? 0 : 1 + opacity: 1 - offsetScale visible: offsetScale < 1 Behavior on offsetScale { @@ -38,20 +33,6 @@ Item { } } - Behavior on chatFade { - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - } - } - - Behavior on implicitHeight { - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - } - } - Loader { id: content @@ -62,7 +43,6 @@ Item { sourceComponent: Content { implicitWidth: root.implicitWidth - 8 * 2 - popouts: root.popouts props: root.props visibilities: root.visibilities } diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index be25202..05aca8c 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -2,9 +2,12 @@ qml_module(ZShell-llm URI ZShell.Llm SOURCES chat.hpp chat.cpp - session.hpp session.cpp - message.hpp message.cpp chatstore.hpp chatstore.cpp + generation.hpp generation.cpp + llmclient.hpp llmclient.cpp + message.hpp message.cpp + messagemodel.hpp messagemodel.cpp + session.hpp session.cpp LIBRARIES Qt::Network Qt::Sql diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index 98e429e..08ae399 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -2,111 +2,119 @@ #include "config.hpp" #include "llm.hpp" +#include "llmclient.hpp" -#include #include -#include -#include -#include -#include -#include -#include -#include -namespace ZShell { +namespace ZShell::llm { -QString Chat::titleFrom(const QString& content) { - const QString flat = content.simplified(); - if (flat.isEmpty()) - return QString(); - if (flat.size() <= 48) - return flat; - return flat.left(47) + QStringLiteral("…"); -} - -QString Chat::completionsPath(const QString& endpoint, const QString& subpath) { - QString base = endpoint.trimmed(); - while (base.endsWith('/')) - base.chop(1); - if (!base.endsWith("/v1")) - base += "/v1"; - return base + subpath; -} - -QString Chat::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(); - } - } - } - return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed") - : fallback; -} - -void Chat::setBusy(Chat* chat, bool value) { - if (chat->m_busy == value) - return; - chat->m_busy = value; - Q_EMIT chat->busyChanged(); -} - -void Chat::setStreamingChatId(Chat* chat, const QString& id) { - if (chat->m_streamingChatId == id) - return; - chat->m_streamingChatId = id; - Q_EMIT chat->streamingChatIdChanged(); -} - -Chat::Chat(QObject* parent) : QObject(parent), m_store(new ChatStore(this)) { +Chat::Chat(QObject* parent) +: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) { if (!config::Config::instance()) new config::Config(); + m_store->setLlmClient(m_client); + const auto* llm = config::Config::instance()->llm(); - m_endpoint = llm->endpoint(); - m_temperature = llm->temperature(); - m_model = llm->model(); + m_client->setEndpoint(llm->endpoint()); + m_client->setModel(llm->model()); + m_client->setTemperature(llm->temperature()); connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { - if (m_endpoint != llm->endpoint()) { - m_endpoint = llm->endpoint(); - Q_EMIT endpointChanged(); - probeContextSize(); - } - if (m_model.isEmpty()) - refreshModels(); + m_client->setEndpoint(llm->endpoint()); }); connect(llm, &config::Llm::modelChanged, this, [this, llm]() { - if (m_model == llm->model()) - return; - m_model = llm->model(); - Q_EMIT modelChanged(); - if (m_model.isEmpty()) - refreshModels(); + m_client->setModel(llm->model()); + }); + connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() { + m_client->setTemperature(llm->temperature()); + }); + + connect(m_client, &LlmClient::busyChanged, this, [this]() { + // A fresh run supersedes the previous error. + if (m_client->busy() && !m_lastError.isEmpty()) { + m_lastError.clear(); + Q_EMIT lastErrorChanged(); + } + Q_EMIT busyChanged(); }); connect( - llm, - &config::Llm::temperatureChanged, + m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); + connect( + m_client, &LlmClient::modelChanged, this, &Chat::modelChanged); + connect( + m_client, + &LlmClient::availableModelsChanged, this, - [this, llm]() { m_temperature = llm->temperature(); }); - - if (m_model.isEmpty()) - refreshModels(); - probeContextSize(); + &Chat::availableModelsChanged); + connect( + m_client, + &LlmClient::contextSizeChanged, + this, + &Chat::contextSizeChanged); + connect( + m_client, + &LlmClient::streamingChatIdChanged, + this, + &Chat::streamingChatIdChanged); + connect( + m_client, + &LlmClient::errorOccurred, + this, + [this](const QString& message) { + m_lastError = message; + Q_EMIT lastErrorChanged(); + Q_EMIT errorOccurred(message); + }); + connect( + m_store, + &ChatStore::sessionRemoved, + this, + [this](ChatSession* session) { m_client->sessionRemoved(session); }); + connect( + m_client, + &LlmClient::titleSuggested, + this, + [this](ChatSession* session, const QString& title) { + qInfo() << "Chat: applying generated title" << session->id() + << title << "(was" << session->title() << ")"; + session->setTitle(title); + m_store->saveMeta(session); + }); + connect( + m_client, + &LlmClient::iconSuggested, + this, + [this](ChatSession* session, const QString& icon) { + qInfo() << "Chat: applying generated icon" << session->id() + << icon << "(was" << session->icon() << ")"; + session->setIcon(icon); + m_store->saveMeta(session); + }); } -Chat::~Chat() { - if (m_reply) - m_reply->abort(); +bool Chat::busy() const { + return m_client->busy(); +} + +QString Chat::endpoint() const { + return m_client->endpoint(); +} + +QString Chat::model() const { + return m_client->model(); +} + +QStringList Chat::availableModels() const { + return m_client->availableModels(); +} + +int Chat::contextSize() const { + return m_client->contextSize(); +} + +QString Chat::streamingChatId() const { + return m_client->streamingChatId(); } Chat* Chat::s_instance = nullptr; @@ -117,283 +125,8 @@ Chat* Chat::create(QQmlEngine*, QJSEngine*) { return s_instance; } -void Chat::send(const QString& chatId, const QString& content) { - const QString text = content.trimmed(); - if (text.isEmpty() || m_busy) - return; - - ChatSession* session = m_store->sessionById(chatId); - if (!session) { - qWarning() << "Chat: unknown chat id" << chatId; - return; - } - - if (!m_lastError.isEmpty()) { - m_lastError.clear(); - Q_EMIT lastErrorChanged(); - } - - session->ensureLoaded(); - session->appendMessage( - ChatMessage::Role::User, - text, - QDateTime::currentMSecsSinceEpoch()); - while (session->messageCount() > 200) - session->removeMessage(session->messages().first()); - if (session->title().isEmpty()) { - session->setTitle(titleFrom(text)); - qInfo() << "Chat: new session" << chatId - << "fallback title" << session->title() - << "- requesting generated title and icon, model" << m_model - << "endpoint" << m_endpoint; - requestTitle(chatId, text); - requestIcon(chatId, text); - } - if (m_contextSize > 0 && m_lastTokenCount > m_contextSize * 4 / 5) - trimHistory(session, m_contextSize); - - m_active = session; - beginAssistant(); - m_store->persist(session); -} - -void Chat::beginAssistant() { - m_streaming = m_active->appendMessage( - ChatMessage::Role::Assistant, - QString(), - QDateTime::currentMSecsSinceEpoch()); - m_streaming->setStreaming(true); - setBusy(this, true); - setStreamingChatId(this, m_active->id()); - - const QUrl url = - QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); - if (!url.isValid() || url.host().isEmpty()) { - fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); - return; - } - - QNetworkRequest request(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Accept", "text/event-stream"); - - QJsonArray messages; - for (const auto* message : m_active->messages()) { - if (message == m_streaming) - continue; - QJsonObject messageObj; - messageObj[QStringLiteral("role")] = - message->role() == ChatMessage::Role::User - ? QStringLiteral("user") - : QStringLiteral("assistant"); - messageObj[QStringLiteral("content")] = message->content(); - if (!message->reasoning().isEmpty()) - messageObj[QStringLiteral("reasoning_content")] = message->reasoning(); - messages.append(messageObj); - } - - QJsonObject body; - QJsonObject streamOptions; - streamOptions[QStringLiteral("include_usage")] = true; - body[QStringLiteral("stream_options")] = streamOptions; - body[QStringLiteral("messages")] = messages; - body[QStringLiteral("stream")] = true; - body[QStringLiteral("temperature")] = m_temperature; - if (!m_model.isEmpty()) - body[QStringLiteral("model")] = m_model; - - m_buffer.clear(); - m_reply = m_manager.post(request, QJsonDocument(body).toJson()); - - connect(m_reply, &QNetworkReply::readyRead, this, [this]() { - if (m_reply) - m_buffer.append(m_reply->readAll()); - drainBuffer(); - }); - connect(m_reply, &QNetworkReply::finished, this, [this]() { - QNetworkReply* reply = m_reply; - if (!reply) - return; - m_reply = nullptr; - - const QNetworkReply::NetworkError error = reply->error(); - const QString errorString = reply->errorString(); - const QByteArray responseBody = reply->readAll(); - reply->deleteLater(); - - drainBuffer(); - if (!m_streaming) - return; - - if (error == QNetworkReply::NoError || - error == QNetworkReply::OperationCanceledError) - finalize(); - else - fail(serverErrorMessage(responseBody, errorString)); - }); -} - -void Chat::shortRequest( - const QString& tag, - const QString& systemPrompt, - const QString& userText, - std::function onResult) { - const QUrl url = - QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); - if (!url.isValid() || url.host().isEmpty()) - return; - - qInfo() << "Chat:" << tag << "request POST" << url.toString() - << "model=" << m_model; - - QNetworkRequest request(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Accept", "application/json"); - - QJsonArray messages; - QJsonObject system; - system[QStringLiteral("role")] = QStringLiteral("system"); - system[QStringLiteral("content")] = systemPrompt; - messages.append(system); - QJsonObject user; - user[QStringLiteral("role")] = QStringLiteral("user"); - user[QStringLiteral("content")] = userText.simplified().mid(0, 512); - messages.append(user); - - QJsonObject body; - if (!m_model.isEmpty()) - body[QStringLiteral("model")] = m_model; - body[QStringLiteral("stream")] = false; - body[QStringLiteral("temperature")] = 0.3; - body[QStringLiteral("max_tokens")] = 128; - QJsonObject templateKwargs; - templateKwargs[QStringLiteral("enable_thinking")] = false; - body[QStringLiteral("chat_template_kwargs")] = templateKwargs; - body[QStringLiteral("messages")] = messages; - - auto* reply = m_manager.post(request, QJsonDocument(body).toJson()); - connect( - reply, - &QNetworkReply::finished, - this, - [this, reply, tag, onResult = std::move(onResult)]() { - const QByteArray data = reply->readAll(); - reply->deleteLater(); - - qInfo() << "Chat:" << tag << "request finished" - << "error=" << reply->error() << reply->errorString() - << "http=" << reply->attribute( - QNetworkRequest::HttpStatusCodeAttribute).toInt() - << "response=" - << QString::fromUtf8(data.left(400)).simplified(); - - if (reply->error() != QNetworkReply::NoError) - return; - - const QJsonDocument doc = QJsonDocument::fromJson(data); - const QJsonArray choices = - doc.object()[QStringLiteral("choices")].toArray(); - if (choices.isEmpty()) - return; - const QString result = - choices.at(0).toObject()[QStringLiteral("message")].toObject() - [QStringLiteral("content")].toString() - .trimmed(); - qInfo() << "Chat:" << tag << "raw result" << result; - onResult(result); - }); -} - -void Chat::requestTitle(const QString& chatId, const QString& userText) { - shortRequest( - QStringLiteral("title"), - QStringLiteral( - "Write a short, concise title for a chat conversation starting " - "with the user's message below. At most six words, no quotation " - "marks, no trailing punctuation. Reply with the title only."), - userText, - [this, chatId](QString title) { - ChatSession* session = m_store->sessionById(chatId); - if (!session) { - qWarning() << "Chat: title request: session gone" << chatId; - return; - } - const auto isQuote = [](QChar c) { - return c == QLatin1Char('"') || c == QLatin1Char('\'') || - c == QChar(u'\u201C') || c == QChar(u'\u201D') || - c == QChar(u'\u2018') || c == QChar(u'\u2019'); - }; - while (title.size() >= 2 && isQuote(title.at(0)) && - isQuote(title.at(title.size() - 1))) - title = title.mid(1, title.size() - 2).simplified(); - while (!title.isEmpty() && - (title.endsWith(QLatin1Char('.')) || - title.endsWith(QLatin1Char('!')) || - title.endsWith(QLatin1Char('?')))) - title.chop(1); - if (title.size() < 2) { - qWarning() << "Chat: title rejected (too short)" << chatId - << title; - return; - } - if (title.size() > 48) - title = title.left(47) + QStringLiteral("…"); - qInfo() << "Chat: applying generated title" << chatId << title - << "(was" << session->title() << ")"; - session->setTitle(title); - m_store->saveMeta(session); - }); -} - -void Chat::requestIcon(const QString& chatId, const QString& userText) { - const QStringList icons = { - "chat", "lightbulb", "code", - "description", "article", "school", - "work", "build", "science", - "palette", "music_note", "sports_esports", - "takeout_dining", "flight", "photo_camera", - "psychology_alt", "favorite", "savings", - "gamepad", "auto_awesome", - }; - const QString prompt = - QStringLiteral( - "Pick the single icon name from this list that best matches the " - "topic of the user's message below: %1. Reply with only the icon " - "name, exactly as written in the list, and nothing else.") - .arg(icons.join(QStringLiteral(", "))); - shortRequest( - QStringLiteral("icon"), - prompt, - userText, - [this, chatId, icons](QString name) { - ChatSession* session = m_store->sessionById(chatId); - if (!session) { - qWarning() << "Chat: icon request: session gone" << chatId; - return; - } - name = name.simplified().toLower(); - while (name.size() >= 2 && - (name.at(0) == QLatin1Char('"') || - name.at(0) == QLatin1Char('\''))) - name = name.mid(1).left(name.size() - 2).simplified(); - name.replace(QLatin1Char(' '), QLatin1Char('_')); - if (!icons.contains(name)) { - qWarning() << "Chat: icon not in list, using default" << chatId - << name; - name = QStringLiteral("chat"); - } - qInfo() << "Chat: applying generated icon" << chatId << name - << "(was" << session->icon() << ")"; - session->setIcon(name); - m_store->saveMeta(session); - }); -} - void Chat::stop() { - if (!m_busy) - return; - if (m_reply) - m_reply->abort(); + m_client->stop(); } void Chat::dismissError() { @@ -403,256 +136,16 @@ void Chat::dismissError() { Q_EMIT lastErrorChanged(); } -void Chat::clearConversation(const QString& chatId) { - ChatSession* session = m_store->sessionById(chatId); - if (!session) - return; - if (m_busy && m_active == session) { - m_pendingClearChatId = chatId; - stop(); - return; - } - session->ensureLoaded(); - session->clearMessages(); - m_store->persist(session); -} - -void Chat::finalize() { - if (!m_streaming) - return; - - ChatSession* session = m_active; - endStream(); - if (session && m_pendingClearChatId == session->id()) { - session->clearMessages(); - m_pendingClearChatId.clear(); - } - if (session) - m_store->persist(session); -} - -void Chat::endStream() { - if (!m_streaming) - return; - m_streaming->setStreaming(false); - if (m_streaming->content().isEmpty() && m_streaming->reasoning().isEmpty() && m_active) - m_active->removeMessage(m_streaming); - m_streaming = nullptr; - m_active = nullptr; - setBusy(this, false); - setStreamingChatId(this, QString()); -} - -void Chat::fail(const QString& message) { - qWarning() << "Chat:" << message; - - const bool overflow = message.contains("overflow") || - message.contains("exceed") || - m_lastTokenCount > m_contextSize; - const QString shown = overflow - ? QStringLiteral( - "%1 (using ~%2 of %3 tokens; the oldest messages were auto-removed " - "so the next one will fit)") - .arg(message, QString::number(m_lastTokenCount), - QString::number(m_contextSize)) - : message; - m_lastError = shown; - Q_EMIT lastErrorChanged(); - - if (m_streaming) { - ChatSession* session = m_active; - endStream(); - if (overflow) - trimHistory(session, m_contextSize); - if (session && m_pendingClearChatId == session->id()) { - session->clearMessages(); - m_pendingClearChatId.clear(); - } - if (session) - m_store->persist(session); - } - Q_EMIT errorOccurred(shown); -} - -void Chat::drainBuffer() { - while (true) { - const qsizetype newline = m_buffer.indexOf('\n'); - if (newline < 0) - break; - const QByteArray line = m_buffer.left(newline).trimmed(); - m_buffer.remove(0, newline + 1); - handleLine(line); - } -} - -void Chat::handleLine(const QByteArray& line) { - if (line.isEmpty() || line.startsWith('#') || !line.startsWith("data:")) - return; - - const QByteArray data = line.mid(5).trimmed(); - if (data == "[DONE]") { - finalize(); - return; - } - - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isObject()) - return; - const QJsonObject obj = doc.object(); - updateTokenUsage(obj); - - if (obj.contains("error")) { - const QJsonObject error = obj["error"].toObject(); - const QString message = error["message"].toString(); - fail(message.isEmpty() ? QStringLiteral("LLM server returned an error") : message); - return; - } - - for (const QJsonValue& choiceValue : obj["choices"].toArray()) { - const QJsonObject delta = choiceValue.toObject()["delta"].toObject(); - if (!m_streaming) - continue; - m_streaming->appendContent(delta["content"].toString()); - QString reasoning = delta["reasoning_content"].toString(); - if (reasoning.isEmpty()) - reasoning = delta["reasoning"].toString(); - m_streaming->appendReasoning(reasoning); - } -} - void Chat::refreshModels() { - const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); - if (!url.isValid() || url.host().isEmpty()) - return; - - auto* reply = m_manager.get(QNetworkRequest(url)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QNetworkReply::NetworkError error = reply->error(); - const QByteArray data = reply->readAll(); - reply->deleteLater(); - - if (error != QNetworkReply::NoError) { - qWarning() << "Chat: failed to fetch models:" << error; - return; - } - const QJsonArray dataArr = - QJsonDocument::fromJson(data).object()["data"].toArray(); - - QStringList models; - QSet seen; - for (const QJsonValue& value : dataArr) { - const QString id = value.toObject()["id"].toString(); - if (!id.isEmpty() && !seen.contains(id)) { - seen.insert(id); - models.append(id); - } - } - if (models.isEmpty()) - return; - - m_availableModels = models; - Q_EMIT availableModelsChanged(); - - if (m_model.isEmpty()) { - m_model = models.first(); - Q_EMIT modelChanged(); - } - }); + m_client->refreshModels(); } void Chat::selectModel(const QString& id) { if (id.isEmpty()) return; - m_model = id; - Q_EMIT modelChanged(); - config::Config::instance()->llm()->set_model(id); + m_client->setModel(id); + if (auto* config = config::Config::instance()) + config->llm()->set_model(id); } -void Chat::setContextSize(int size) { - if (size <= 0 || m_contextSize == size) - return; - m_contextSize = size; - Q_EMIT contextSizeChanged(); -} - -void Chat::probeContextSize() { - QString base = m_endpoint.trimmed(); - while (base.endsWith('/')) - base.chop(1); - const QUrl url = QUrl::fromUserInput(base + "/props"); - if (!url.isValid() || url.host().isEmpty()) - return; - - auto* reply = m_manager.get(QNetworkRequest(url)); - connect( - reply, - &QNetworkReply::finished, - this, - [this, reply]() { - const QNetworkReply::NetworkError error = reply->error(); - const QByteArray data = reply->readAll(); - reply->deleteLater(); - - int size = 0; - if (error == QNetworkReply::NoError) { - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (doc.isArray()) { - for (const auto& value : doc.array()) { - const QJsonObject slot = value.toObject(); - if (slot.contains("n_ctx")) { - size = slot["n_ctx"].toInt(0); - if (size > 0) - break; - } - } - } else if (doc.isObject()) { - const QJsonObject obj = doc.object(); - size = obj["n_ctx"].toInt(0); - if (size <= 0) - size = obj["default_generation_settings"].toObject()["n_ctx"].toInt(0); - } - } - setContextSize(size > 0 ? size : 4096); - }); -} - -void Chat::updateTokenUsage(const QJsonObject& data) { - if (m_contextSize <= 0) - return; - const QJsonObject usage = data["usage"].toObject(); - if (usage.isEmpty()) - return; - const double used = usage.value("prompt_tokens").toDouble() + - usage.value("completion_tokens").toDouble(); - if (used > 0) - m_lastTokenCount = qMin(static_cast(used), m_contextSize * 2); -} - -void Chat::trimHistory(ChatSession* session, int contextSize) { - if (!session || contextSize <= 0) - return; - const int budget = contextSize * 4 / 5; - auto estimate = [&](const ChatMessage* message) -> qsizetype { - return (message->content().size() + - message->reasoning().size()) / - 4; - }; - qsizetype total = 0; - for (const auto* message : session->messages()) - total += estimate(message); - while (total > budget && session->messageCount() >= 2) { - ChatMessage* first = session->messages().first(); - const qsizetype used = estimate(first); - session->removeMessage(first); - total -= used; - if (session->messageCount() >= 2 && - session->messages().first()->role() == ChatMessage::Role::Assistant) { - ChatMessage* second = session->messages().first(); - const qsizetype usedSecond = estimate(second); - session->removeMessage(second); - total -= usedSecond; - } - } -} - -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index 3270668..c9e98ac 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -1,23 +1,22 @@ #pragma once -#include -#include -#include #include #include #include #include -#include - #include "chatstore.hpp" class QQmlEngine; class QJSEngine; -class QNetworkReply; -namespace ZShell { +namespace ZShell::llm { +class LlmClient; + +// QML-facing facade. Persistence lives in ChatStore, network streaming in +// LlmClient; this class only wires them together and exposes the +// application-wide state. class Chat : public QObject { Q_OBJECT QML_ELEMENT @@ -29,27 +28,22 @@ class Chat : public QObject { Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) - Q_PROPERTY(ChatStore* chats READ chats CONSTANT) + Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged) public: explicit Chat(QObject* parent = nullptr); - ~Chat(); - [[nodiscard]] bool busy() const { return m_busy; } - [[nodiscard]] QString endpoint() const { return m_endpoint; } - [[nodiscard]] QString model() const { return m_model; } - [[nodiscard]] QStringList availableModels() const { return m_availableModels; } - [[nodiscard]] int contextSize() const { return m_contextSize; } - [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } + [[nodiscard]] bool busy() const; + [[nodiscard]] QString endpoint() const; + [[nodiscard]] QString model() const; + [[nodiscard]] QStringList availableModels() const; + [[nodiscard]] int contextSize() const; [[nodiscard]] QString lastError() const { return m_lastError; } [[nodiscard]] ChatStore* chats() const { return m_store; } - [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } - [[nodiscard]] ChatSession* streamingSession() const { return m_active; } + [[nodiscard]] QString streamingChatId() const; - Q_INVOKABLE void send(const QString& chatId, const QString& content); Q_INVOKABLE void stop(); - Q_INVOKABLE void clearConversation(const QString& chatId); Q_INVOKABLE void dismissError(); Q_INVOKABLE void refreshModels(); Q_INVOKABLE void selectModel(const QString& id); @@ -67,51 +61,11 @@ class Chat : public QObject { void streamingChatIdChanged(); private: - void beginAssistant(); - void endStream(); - void finalize(); - void fail(const QString& message); - void handleLine(const QByteArray& line); - void drainBuffer(); - void probeContextSize(); - void setContextSize(int size); - void updateTokenUsage(const QJsonObject& data); - static void trimHistory(ChatSession* session, int contextSize); - - void shortRequest( - const QString& tag, - const QString& systemPrompt, - const QString& userText, - std::function onResult); - void requestTitle(const QString& chatId, const QString& userText); - void requestIcon(const QString& chatId, const QString& userText); - static QString titleFrom(const QString& content); - static QString completionsPath(const QString& endpoint, const QString& subpath); - static QString serverErrorMessage( - const QByteArray& body, const QString& fallback); - static void setBusy(Chat* chat, bool value); - static void setStreamingChatId(Chat* chat, const QString& id); - - QNetworkAccessManager m_manager; ChatStore* m_store = nullptr; - QNetworkReply* m_reply = nullptr; - QByteArray m_buffer; - ChatSession* m_active = nullptr; - ChatMessage* m_streaming = nullptr; - QString m_pendingClearChatId; - bool m_busy = false; - QString m_endpoint; - QString m_model; - QStringList m_availableModels; + LlmClient* m_client = nullptr; QString m_lastError; - QString m_streamingChatId; - double m_temperature = 0.7; - int m_contextSize = 0; - int m_lastTokenCount = 0; static Chat* s_instance; - - friend class ChatStore; }; -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 7990323..3d4b286 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -1,6 +1,6 @@ #include "chatstore.hpp" -#include "chat.hpp" +#include "llmclient.hpp" #include "message.hpp" #include @@ -15,7 +15,7 @@ #include -namespace ZShell { +namespace ZShell::llm { ChatStore::ChatStore(QObject* parent) : QObject(parent), m_connectionName(QUuid::createUuid().toString()) { @@ -67,38 +67,42 @@ void ChatStore::openDb() { ")")); } { - QSqlQuery info(db); - bool hasIcon = false; - if (info.exec(QStringLiteral("PRAGMA table_info(sessions)"))) - while (info.next()) - if (info.value(1).toString() == QLatin1String("icon")) { - hasIcon = true; - break; - } - if (!hasIcon) { - QSqlQuery alter(db); - if (!alter.exec(QStringLiteral( - "ALTER TABLE sessions ADD COLUMN icon TEXT NOT NULL " - "DEFAULT ''"))) - qWarning() << "ChatStore: failed to add icon column:" - << alter.lastError().text(); - } + QSqlQuery query(db); + query.exec( + QStringLiteral( + "CREATE TABLE IF NOT EXISTS messages (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " session_id TEXT NOT NULL REFERENCES sessions (id) " + "ON DELETE CASCADE,\n" + " role TEXT NOT NULL,\n" + " timestamp INTEGER NOT NULL\n" + ")")); } { QSqlQuery query(db); query.exec( QStringLiteral( - "CREATE TABLE IF NOT EXISTS messages (\n" - " session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE " - "CASCADE,\n" - " role TEXT NOT NULL,\n" - " content TEXT,\n" - " reasoning TEXT,\n" + "CREATE TABLE IF NOT EXISTS generations (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " message_id INTEGER NOT NULL REFERENCES messages " + "(id) ON DELETE CASCADE,\n" + " content TEXT,\n" + " reasoning TEXT,\n" " timestamp INTEGER NOT NULL,\n" " reasoning_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" - " content_elapsed_ms INTEGER NOT NULL DEFAULT 0\n" + " content_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" + " is_active INTEGER NOT NULL DEFAULT 1\n" ")")); } + { + QSqlQuery query(db); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_messages_session " + "ON messages (session_id)")); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_generations_message " + "ON generations (message_id)")); + } } int ChatStore::count() const { @@ -121,7 +125,7 @@ ChatSession* ChatStore::at(int index) const { ChatSession* ChatStore::insert(int index) { const qint64 now = QDateTime::currentMSecsSinceEpoch(); - const QString id = QString::number(now); + const QString id = QUuid::createUuid().toString(); { QSqlQuery query(db()); query.prepare( @@ -136,8 +140,7 @@ ChatSession* ChatStore::insert(int index) { } auto* session = new ChatSession(id, this); session->setMeta(QString(), now, now, 0); - const int pos = - index >= 0 && index <= m_sessions.size() ? index : 0; + const int pos = index >= 0 && index <= m_sessions.size() ? index : 0; m_sessions.insert(pos, session); Q_EMIT countChanged(); Q_EMIT valuesChanged(); @@ -155,12 +158,8 @@ void ChatStore::remove(ChatSession* chat) { void ChatStore::removeSession(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; - if (auto* chat = qobject_cast(parent())) - if (chat->m_active == session) { - chat->stop(); - chat->endStream(); - } const QList before = m_sessions; + Q_EMIT sessionRemoved(session); { QSqlQuery query(db()); query.prepare("DELETE FROM sessions WHERE id = :id"); @@ -174,7 +173,7 @@ void ChatStore::removeSession(ChatSession* session) { void ChatStore::move(int from, int to) { if (from < 0 || from >= m_sessions.size() || to < 0 || - to >= m_sessions.size() || from == to) + to >= m_sessions.size() || from == to) return; m_sessions.move(from, to); Q_EMIT valuesChanged(); @@ -193,6 +192,10 @@ ChatSession* ChatStore::sessionById(const QString& id) { return nullptr; } +void ChatStore::setLlmClient(LlmClient* client) { + m_llmClient = client; +} + void ChatStore::persist(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; @@ -243,30 +246,58 @@ bool ChatStore::saveSession(ChatSession* session) { ok = query.exec(); } if (ok) { - QSqlQuery insert(handle); - ok = insert.prepare( - "INSERT INTO messages (session_id, role, content, reasoning, " - "timestamp, reasoning_elapsed_ms, content_elapsed_ms) " - "VALUES (:id, :role, :content, :reasoning, :timestamp, " - ":reasoning_elapsed_ms, :content_elapsed_ms)"); - for (const auto* message : session->messages()) { - insert.bindValue(":id", session->id()); - insert.bindValue( + QSqlQuery messageInsert(handle); + ok = messageInsert.prepare( + "INSERT INTO messages (session_id, role, timestamp) " + "VALUES (:id, :role, :timestamp)"); + QSqlQuery generationInsert(handle); + ok = ok && generationInsert.prepare( + "INSERT INTO generations (message_id, content, reasoning, " + "timestamp, reasoning_elapsed_ms, content_elapsed_ms, " + "is_active) VALUES (:mid, :content, :reasoning, :timestamp, " + ":reasoning_elapsed_ms, :content_elapsed_ms, :is_active)"); + // The model holds messages most recent first; the database keeps + // natural rowid order, so iterate from the oldest row up. + const auto* model = session->messagesModel(); + for (int row = model->rowCount() - 1; ok && row >= 0; --row) { + const auto* message = model->at(row); + messageInsert.bindValue(":id", session->id()); + messageInsert.bindValue( ":role", message->role() == ChatMessage::Role::User ? QStringLiteral("user") : QStringLiteral("assistant")); - insert.bindValue(":content", message->content()); - insert.bindValue(":reasoning", message->reasoning()); - insert.bindValue(":timestamp", message->timestamp()); - insert.bindValue(":reasoning_elapsed_ms", message->reasoningElapsedMs()); - insert.bindValue(":content_elapsed_ms", message->contentElapsedMs()); - if (!insert.exec()) { + messageInsert.bindValue(":timestamp", message->timestamp()); + if (!messageInsert.exec()) { ok = false; - qWarning() << "ChatStore: saveSession" << id << "insert failed:" - << insert.lastError().text(); + qWarning() << "ChatStore: saveSession" << id + << "message insert failed:" + << messageInsert.lastError().text(); break; } + const int messageId = messageInsert.lastInsertId().toInt(); + for (int i = 0; ok && i < message->generationCount(); ++i) { + const auto* generation = message->generation(i); + generationInsert.bindValue(":mid", messageId); + generationInsert.bindValue(":content", generation->content()); + generationInsert.bindValue( + ":reasoning", generation->reasoning()); + generationInsert.bindValue(":timestamp", generation->timestamp()); + generationInsert.bindValue( + ":reasoning_elapsed_ms", generation->reasoningElapsedMs()); + generationInsert.bindValue( + ":content_elapsed_ms", generation->contentElapsedMs()); + generationInsert.bindValue( + ":is_active", + i == message->activeGenerationIndex() ? 1 : 0); + if (!generationInsert.exec()) { + ok = false; + qWarning() << "ChatStore: saveSession" << id + << "generation insert failed:" + << generationInsert.lastError().text(); + break; + } + } } } if (!ok || !handle.commit()) { @@ -280,28 +311,52 @@ bool ChatStore::saveSession(ChatSession* session) { } void ChatStore::loadMessagesInto(ChatSession* session) { + // Newest first so the model receives rows in display order. QSqlQuery query(db()); query.prepare( - "SELECT role, content, reasoning, timestamp, reasoning_elapsed_ms, " - "content_elapsed_ms FROM messages WHERE session_id = :id ORDER BY rowid"); + "SELECT id, role, timestamp FROM messages WHERE session_id = :id " + "ORDER BY rowid DESC"); query.bindValue(":id", session->id()); if (!query.exec()) { qWarning() << "ChatStore: failed to load messages for" << session->id() << ":" << query.lastError().text(); return; } + auto* model = session->messagesModel(); QList messages; while (query.next()) { - auto* message = new ChatMessage( - query.value(0).toString() == QLatin1String("user") + const int messageId = query.value(0).toInt(); + auto* message = model->createMessage( + query.value(1).toString() == QLatin1String("user") ? ChatMessage::Role::User : ChatMessage::Role::Assistant, - query.value(1).toString(), - query.value(3).toLongLong(), - session); - message->setReasoning(query.value(2).toString()); - message->setElapsedMs( - query.value(4).toLongLong(), query.value(5).toLongLong()); + query.value(2).toLongLong()); + QSqlQuery generationQuery(db()); + generationQuery.prepare( + "SELECT content, reasoning, timestamp, reasoning_elapsed_ms, " + "content_elapsed_ms, is_active FROM generations " + "WHERE message_id = :mid ORDER BY rowid"); + generationQuery.bindValue(":mid", messageId); + int activeIndex = 0; + if (generationQuery.exec()) { + int index = 0; + while (generationQuery.next()) { + message->addGeneration( + generationQuery.value(2).toLongLong(), + generationQuery.value(0).toString(), + generationQuery.value(1).toString(), + generationQuery.value(3).toLongLong(), + generationQuery.value(4).toLongLong()); + if (generationQuery.value(5).toInt() != 0) + activeIndex = index; + ++index; + } + } else { + qWarning() << "ChatStore: failed to load generations for message" + << messageId << ":" + << generationQuery.lastError().text(); + } + message->setActiveGeneration(activeIndex); messages.append(message); } session->adoptMessages(messages); @@ -351,4 +406,4 @@ void ChatStore::notify(const QList& before) { Q_EMIT valuesChanged(); } -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp index 8526216..472f4a4 100644 --- a/Plugins/ZShell/Llm/chatstore.hpp +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -7,9 +7,9 @@ #include #include -namespace ZShell { +namespace ZShell::llm { -class Chat; +class LlmClient; class ChatStore : public QObject { Q_OBJECT @@ -20,19 +20,22 @@ class ChatStore : public QObject { public: explicit ChatStore(QObject* parent = nullptr); - ~ChatStore(); + ~ChatStore() override; [[nodiscard]] int count() const; [[nodiscard]] QVariantList values() const; [[nodiscard]] ChatSession* at(int index) const; - Q_INVOKABLE ChatSession* insert(int index = -1); + Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1); Q_INVOKABLE void remove(int index); - Q_INVOKABLE void remove(ChatSession* chat); + Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat); Q_INVOKABLE void move(int from, int to); Q_INVOKABLE void clear(); [[nodiscard]] ChatSession* sessionById(const QString& id); + [[nodiscard]] LlmClient* llmClient() const { return m_llmClient; } + void setLlmClient(LlmClient* client); + void persist(ChatSession* session); void saveMeta(ChatSession* session); void loadMessagesInto(ChatSession* session); @@ -40,6 +43,7 @@ class ChatStore : public QObject { Q_SIGNALS: void countChanged(); void valuesChanged(); + void sessionRemoved(ZShell::llm::ChatSession* session); private: void openDb(); @@ -50,11 +54,10 @@ class ChatStore : public QObject { void notify(const QList& before); QList m_sessions; + LlmClient* m_llmClient = nullptr; QString m_connectionName; [[nodiscard]] QSqlDatabase db() const; - - friend class Chat; }; -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp new file mode 100644 index 0000000..08203d9 --- /dev/null +++ b/Plugins/ZShell/Llm/generation.cpp @@ -0,0 +1,126 @@ +#include "generation.hpp" + +#include + +namespace ZShell::llm { + +ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) +: QObject(parent), m_timestamp(timestamp) { + m_timer.setParent(this); + m_timer.setInterval(500); + m_timer.setTimerType(Qt::CoarseTimer); + connect(&m_timer, &QTimer::timeout, this, [this]() { + if (!reasoningInFlight() && !contentInFlight()) { + m_timer.stop(); + return; + } + Q_EMIT elapsedMsChanged(); + }); +} + +qint64 ChatGeneration::reasoningElapsedMs() const { + if (m_reasoningStartedAt <= 0) + return 0; + const qint64 end = m_reasoningEndedAt > 0 + ? m_reasoningEndedAt + : QDateTime::currentMSecsSinceEpoch(); + return end - m_reasoningStartedAt; +} + +qint64 ChatGeneration::contentElapsedMs() const { + if (m_contentStartedAt <= 0) + return 0; + const qint64 end = m_contentEndedAt > 0 + ? m_contentEndedAt + : QDateTime::currentMSecsSinceEpoch(); + return end - m_contentStartedAt; +} + +void ChatGeneration::updateReasoningActive() { + const bool active = m_streaming && m_content.isEmpty(); + if (m_reasoningActive == active) + return; + m_reasoningActive = active; + Q_EMIT reasoningActiveChanged(); +} + +void ChatGeneration::setContent(const QString& value) { + if (m_content == value) + return; + m_content = value; + Q_EMIT contentChanged(); +} + +void ChatGeneration::setReasoning(const QString& value) { + if (m_reasoning == value) + return; + m_reasoning = value; + Q_EMIT reasoningChanged(); +} + +void ChatGeneration::appendContent(const QString& piece) { + if (piece.isEmpty()) + return; + if (m_content.isEmpty()) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (reasoningInFlight()) + m_reasoningEndedAt = now; + m_contentStartedAt = now; + if (!m_timer.isActive()) + m_timer.start(); + } + m_content += piece; + Q_EMIT contentChanged(); + updateReasoningActive(); + Q_EMIT elapsedMsChanged(); +} + +void ChatGeneration::appendReasoning(const QString& piece) { + if (piece.isEmpty()) + return; + if (m_reasoning.isEmpty()) { + if (m_reasoningStartedAt <= 0) + m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); + if (!m_timer.isActive()) + m_timer.start(); + } + m_reasoning += piece; + Q_EMIT reasoningChanged(); + updateReasoningActive(); + Q_EMIT elapsedMsChanged(); +} + +void ChatGeneration::setElapsedMs(qint64 reasoningMs, qint64 contentMs) { + if (reasoningMs > 0) { + m_reasoningStartedAt = m_timestamp; + m_reasoningEndedAt = m_timestamp + reasoningMs; + } + if (contentMs > 0) { + m_contentStartedAt = m_timestamp; + m_contentEndedAt = m_timestamp + contentMs; + } +} + +void ChatGeneration::setStreaming(bool value) { + if (m_streaming == value) + return; + m_streaming = value; + Q_EMIT streamingChanged(); + if (value) { + if (m_reasoningStartedAt <= 0) + m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); + if (!m_timer.isActive()) + m_timer.start(); + } else { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (reasoningInFlight()) + m_reasoningEndedAt = now; + if (contentInFlight()) + m_contentEndedAt = now; + m_timer.stop(); + Q_EMIT elapsedMsChanged(); + } + updateReasoningActive(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp new file mode 100644 index 0000000..0ec1a83 --- /dev/null +++ b/Plugins/ZShell/Llm/generation.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatGeneration : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat generations are managed by ChatMessage") + + Q_PROPERTY(QString content READ content WRITE setContent NOTIFY contentChanged) + Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) + Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) + Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) + Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + + public: + explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); + + [[nodiscard]] QString content() const { return m_content; } + [[nodiscard]] QString reasoning() const { return m_reasoning; } + [[nodiscard]] bool reasoningActive() const { return m_reasoningActive; } + [[nodiscard]] qint64 reasoningElapsedMs() const; + [[nodiscard]] qint64 contentElapsedMs() const; + [[nodiscard]] bool streaming() const { return m_streaming; } + [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + + void setContent(const QString& value); + void setReasoning(const QString& value); + void appendContent(const QString& piece); + void appendReasoning(const QString& piece); + void setElapsedMs(qint64 reasoningMs, qint64 contentMs); + void setStreaming(bool value); + + Q_SIGNALS: + void contentChanged(); + void reasoningChanged(); + void reasoningActiveChanged(); + void elapsedMsChanged(); + void streamingChanged(); + + private: + void updateReasoningActive(); + + [[nodiscard]] bool reasoningInFlight() const { + return m_reasoningStartedAt > 0 && m_reasoningEndedAt <= 0; + } + [[nodiscard]] bool contentInFlight() const { + return m_contentStartedAt > 0 && m_contentEndedAt <= 0; + } + + QTimer m_timer; + QString m_content; + QString m_reasoning; + bool m_reasoningActive = false; + bool m_streaming = false; + qint64 m_timestamp; + qint64 m_reasoningStartedAt = 0; + qint64 m_reasoningEndedAt = 0; + qint64 m_contentStartedAt = 0; + qint64 m_contentEndedAt = 0; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp new file mode 100644 index 0000000..5f4e1a5 --- /dev/null +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -0,0 +1,568 @@ +#include "llmclient.hpp" + +#include "message.hpp" +#include "messagemodel.hpp" +#include "session.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +QString LlmClient::completionsPath( + const QString& endpoint, const QString& subpath) { + QString base = endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + if (!base.endsWith("/v1")) + base += "/v1"; + return base + subpath; +} + +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(); + } + } + } + return fallback.isEmpty() + ? QStringLiteral("Request to LLM server failed") + : fallback; +} + +void LlmClient::setBusy(bool value) { + if (m_busy == value) + return; + m_busy = value; + Q_EMIT busyChanged(); +} + +void LlmClient::setStreamingChatId(const QString& id) { + if (m_streamingChatId == id) + return; + m_streamingChatId = id; + Q_EMIT streamingChatIdChanged(); +} + +LlmClient::LlmClient(QObject* parent) : QObject(parent) { + probeContextSize(); +} + +LlmClient::~LlmClient() { + if (m_reply) + m_reply->abort(); + endStream(); +} + +void LlmClient::setEndpoint(const QString& value) { + if (m_endpoint == value) + return; + m_endpoint = value; + Q_EMIT endpointChanged(); + probeContextSize(); + if (m_model.isEmpty()) + refreshModels(); +} + +void LlmClient::setModel(const QString& value) { + if (m_model == value) + return; + m_model = value; + Q_EMIT modelChanged(); + if (m_model.isEmpty()) + refreshModels(); +} + +void LlmClient::setTemperature(double value) { + m_temperature = value; +} + +void LlmClient::startGeneration( + ChatSession* session, ChatGeneration* target) { + if (m_busy || !session || !target) + return; + m_active = session; + m_streaming = target; + m_streaming->setStreaming(true); + setBusy(true); + setStreamingChatId(session->id()); + + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) { + fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); + return; + } + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "text/event-stream"); + + const auto* model = session->messagesModel(); + const int targetRow = + model->rowOf(qobject_cast(target->parent())); + if (targetRow < 0) { + fail(QStringLiteral("Internal error: generation target is not in the " + "session")); + return; + } + + // Context: every message older than the target, oldest first. + QJsonArray messages; + for (int row = model->rowCount() - 1; row > targetRow; --row) { + const auto* message = model->at(row); + const auto* generation = message->activeGeneration(); + if (!generation) + continue; + QJsonObject messageObj; + messageObj[QStringLiteral("role")] = + message->role() == ChatMessage::Role::User + ? QStringLiteral("user") + : QStringLiteral("assistant"); + messageObj[QStringLiteral("content")] = generation->content(); + if (!generation->reasoning().isEmpty()) + messageObj[QStringLiteral("reasoning_content")] = + generation->reasoning(); + messages.append(messageObj); + } + + QJsonObject body; + QJsonObject streamOptions; + streamOptions[QStringLiteral("include_usage")] = true; + body[QStringLiteral("stream_options")] = streamOptions; + body[QStringLiteral("messages")] = messages; + body[QStringLiteral("stream")] = true; + body[QStringLiteral("temperature")] = m_temperature; + if (!m_model.isEmpty()) + body[QStringLiteral("model")] = m_model; + + m_buffer.clear(); + m_reply = m_manager.post(request, QJsonDocument(body).toJson()); + + connect(m_reply, &QNetworkReply::readyRead, this, [this]() { + if (m_reply) + m_buffer.append(m_reply->readAll()); + drainBuffer(); + }); + connect(m_reply, &QNetworkReply::finished, this, [this]() { + QNetworkReply* reply = m_reply; + if (!reply) + return; + m_reply = nullptr; + + const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); + // Data not already consumed by readyRead is only reachable here. + const QByteArray responseBody = reply->readAll(); + m_buffer.append(responseBody); + reply->deleteLater(); + + drainBuffer(); + if (!m_streaming) + return; + + if (error == QNetworkReply::NoError || + error == QNetworkReply::OperationCanceledError) + finalize(); + else + fail(serverErrorMessage(responseBody, errorString)); + }); +} + +void LlmClient::shortRequest( + const QString& tag, + const QString& systemPrompt, + const QString& userText, + std::function onResult) { + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) + return; + + qInfo() << "LlmClient:" << tag << "request POST" << url.toString() + << "model=" << m_model; + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + + QJsonArray messages; + QJsonObject system; + system[QStringLiteral("role")] = QStringLiteral("system"); + system[QStringLiteral("content")] = systemPrompt; + messages.append(system); + QJsonObject user; + user[QStringLiteral("role")] = QStringLiteral("user"); + user[QStringLiteral("content")] = userText.simplified().mid(0, 512); + messages.append(user); + + QJsonObject body; + if (!m_model.isEmpty()) + body[QStringLiteral("model")] = m_model; + body[QStringLiteral("stream")] = false; + body[QStringLiteral("temperature")] = 0.3; + body[QStringLiteral("max_tokens")] = 128; + QJsonObject templateKwargs; + templateKwargs[QStringLiteral("enable_thinking")] = false; + body[QStringLiteral("chat_template_kwargs")] = templateKwargs; + body[QStringLiteral("messages")] = messages; + + auto* reply = m_manager.post(request, QJsonDocument(body).toJson()); + connect( + reply, + &QNetworkReply::finished, + this, + [this, reply, tag, onResult = std::move(onResult)]() { + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + qInfo() << "LlmClient:" << tag << "request finished" + << "error=" << reply->error() << reply->errorString() + << "http=" << reply->attribute( + QNetworkRequest::HttpStatusCodeAttribute) + .toInt() + << "response=" + << QString::fromUtf8(data.left(400)).simplified(); + + if (reply->error() != QNetworkReply::NoError) + return; + + const QJsonDocument doc = QJsonDocument::fromJson(data); + const QJsonArray choices = + doc.object()[QStringLiteral("choices")].toArray(); + if (choices.isEmpty()) + return; + const QString result = + choices.at(0).toObject()[QStringLiteral("message")].toObject() + [QStringLiteral("content")].toString() + .trimmed(); + qInfo() << "LlmClient:" << tag << "raw result" << result; + onResult(result); + }); +} + +void LlmClient::requestTitle(ChatSession* session, const QString& userText) { + shortRequest( + QStringLiteral("title"), + QStringLiteral( + "Write a short, concise title for a chat conversation starting " + "with the user's message below. At most six words, no quotation " + "marks, no trailing punctuation. Reply with the title only."), + userText, + [this, session = QPointer(session)](QString title) { + if (!session) { + qWarning() << "LlmClient: title request: session gone"; + return; + } + const auto isQuote = [](QChar c) { + return c == QLatin1Char('"') || c == QLatin1Char('\'') || + c == QChar(u'\u201C') || c == QChar(u'\u201D') || + c == QChar(u'\u2018') || c == QChar(u'\u2019'); + }; + while (title.size() >= 2 && isQuote(title.at(0)) && + isQuote(title.at(title.size() - 1))) + title = title.mid(1, title.size() - 2).simplified(); + while (!title.isEmpty() && + (title.endsWith(QLatin1Char('.')) || + title.endsWith(QLatin1Char('!')) || + title.endsWith(QLatin1Char('?')))) + title.chop(1); + if (title.size() < 2) { + qWarning() << "LlmClient: title rejected (too short)" + << title; + return; + } + if (title.size() > 48) + title = title.left(47) + QStringLiteral("…"); + qInfo() << "LlmClient: suggesting title" << title; + Q_EMIT titleSuggested(session, title); + }); +} + +void LlmClient::requestIcon(ChatSession* session, const QString& userText) { + const QStringList icons = { + "chat", "lightbulb", "code", + "description", "article", "school", + "work", "build", "science", + "palette", "music_note", "sports_esports", + "takeout_dining", "flight", "photo_camera", + "psychology_alt", "favorite", "savings", + "gamepad", "auto_awesome", + }; + const QString prompt = + QStringLiteral( + "Pick the single icon name from this list that best matches the " + "topic of the user's message below: %1. Reply with only the icon " + "name, exactly as written in the list, and nothing else.") + .arg(icons.join(QStringLiteral(", "))); + shortRequest( + QStringLiteral("icon"), + prompt, + userText, + [this, session = QPointer(session), icons]( + QString name) { + if (!session) { + qWarning() << "LlmClient: icon request: session gone"; + return; + } + name = name.simplified().toLower(); + const auto isQuote = [](QChar c) { + return c == QLatin1Char('"') || c == QLatin1Char('\''); + }; + while (name.size() >= 2 && isQuote(name.at(0)) && + isQuote(name.at(name.size() - 1))) + name = name.mid(1, name.size() - 2).simplified(); + name.replace(QLatin1Char(' '), QLatin1Char('_')); + if (!icons.contains(name)) { + qWarning() << "LlmClient: icon not in list, using default" + << name; + name = QStringLiteral("chat"); + } + qInfo() << "LlmClient: suggesting icon" << name; + Q_EMIT iconSuggested(session, name); + }); +} + +void LlmClient::stop() { + if (!m_busy) + return; + if (m_reply) + m_reply->abort(); +} + +void LlmClient::endStream() { + if (!m_streaming) + return; + auto* generation = m_streaming; + auto* session = m_active; + m_streaming = nullptr; + m_active = nullptr; + generation->setStreaming(false); + if (generation->content().isEmpty() && + generation->reasoning().isEmpty()) { + if (auto* message = qobject_cast(generation->parent())) { + if (message->generationCount() <= 1) { + if (session) + session->removeMessage(message); + } else { + message->removeGeneration(generation); + } + } + } + setBusy(false); + setStreamingChatId(QString()); +} + +void LlmClient::finalize() { + if (!m_streaming) + return; + ChatSession* session = m_active; + endStream(); + if (session && m_pendingClear == session) { + session->clearMessages(); + m_pendingClear.clear(); + } + if (session) + session->persist(); +} + +void LlmClient::clearOnFinish(ChatSession* session) { + m_pendingClear = session; +} + +void LlmClient::sessionRemoved(ChatSession* session) { + if (m_pendingClear == session) + m_pendingClear.clear(); + if (m_active == session) { + stop(); + endStream(); + } +} + +void LlmClient::fail(const QString& message) { + qWarning() << "LlmClient:" << message; + + if (m_streaming) { + ChatSession* session = m_active; + endStream(); + if (session && m_pendingClear == session) { + session->clearMessages(); + m_pendingClear.clear(); + } + if (session) + session->persist(); + } + Q_EMIT errorOccurred(message); +} + +void LlmClient::drainBuffer() { + while (true) { + const qsizetype newline = m_buffer.indexOf('\n'); + if (newline < 0) + break; + const QByteArray line = m_buffer.left(newline).trimmed(); + m_buffer.remove(0, newline + 1); + handleLine(line); + } +} + +void LlmClient::handleLine(const QByteArray& line) { + if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) + return; + + const QByteArray data = line.mid(5).trimmed(); + if (data == "[DONE]") { + finalize(); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (!doc.isObject()) + return; + const QJsonObject obj = doc.object(); + updateTokenUsage(obj); + + if (obj.contains("error")) { + const QJsonObject error = obj["error"].toObject(); + const QString message = error["message"].toString(); + fail(message.isEmpty() + ? QStringLiteral("LLM server returned an error") + : message); + return; + } + + for (const QJsonValue& choiceValue : obj["choices"].toArray()) { + if (!m_streaming) + continue; + const QJsonObject delta = + choiceValue.toObject()["delta"].toObject(); + m_streaming->appendContent(delta["content"].toString()); + QString reasoning = delta["reasoning_content"].toString(); + if (reasoning.isEmpty()) + reasoning = delta["reasoning"].toString(); + m_streaming->appendReasoning(reasoning); + } +} + +void LlmClient::refreshModels() { + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (error != QNetworkReply::NoError) { + qWarning() << "LlmClient: failed to fetch models:" << error; + return; + } + const QJsonArray dataArr = + QJsonDocument::fromJson(data).object()["data"].toArray(); + + QStringList models; + QSet seen; + for (const QJsonValue& value : dataArr) { + const QString id = value.toObject()["id"].toString(); + if (!id.isEmpty() && !seen.contains(id)) { + seen.insert(id); + models.append(id); + } + } + if (models.isEmpty()) + return; + + m_availableModels = models; + Q_EMIT availableModelsChanged(); + + if (m_model.isEmpty()) { + m_model = models.first(); + Q_EMIT modelChanged(); + } + }); +} + +void LlmClient::setContextSize(int size) { + if (size <= 0 || m_contextSize == size) + return; + m_contextSize = size; + Q_EMIT contextSizeChanged(); +} + +void LlmClient::probeContextSize() { + // llama.cpp-specific endpoint; other servers fall back to 4096. + QString base = m_endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + const QUrl url = QUrl::fromUserInput(base + "/props"); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect( + reply, + &QNetworkReply::finished, + this, + [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + int size = 0; + if (error == QNetworkReply::NoError) { + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isArray()) { + for (const auto& value : doc.array()) { + const QJsonObject slot = value.toObject(); + if (slot.contains("n_ctx")) { + size = slot["n_ctx"].toInt(0); + if (size > 0) + break; + } + } + } else if (doc.isObject()) { + const QJsonObject obj = doc.object(); + size = obj["n_ctx"].toInt(0); + if (size <= 0) + size = obj["default_generation_settings"].toObject() + ["n_ctx"].toInt(0); + } + } + setContextSize(size > 0 ? size : 4096); + }); +} + +void LlmClient::updateTokenUsage(const QJsonObject& data) { + if (!m_active || m_contextSize <= 0) + return; + const QJsonObject usage = data["usage"].toObject(); + if (usage.isEmpty()) + return; + const double used = + usage.value("prompt_tokens").toDouble() + + usage.value("completion_tokens").toDouble(); + if (used > 0) + m_active->setLastTokenCount(static_cast(used)); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp new file mode 100644 index 0000000..4f3f339 --- /dev/null +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -0,0 +1,103 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QJsonObject; +class QNetworkReply; + +namespace ZShell::llm { + +class ChatGeneration; +class ChatSession; + +// The only component that talks to the LLM server: owns the network +// manager, the in-flight streaming state and the SSE parsing. +class LlmClient : public QObject { + Q_OBJECT + + public: + explicit LlmClient(QObject* parent = nullptr); + ~LlmClient() override; + + [[nodiscard]] QString endpoint() const { return m_endpoint; } + [[nodiscard]] QString model() const { return m_model; } + [[nodiscard]] double temperature() const { return m_temperature; } + [[nodiscard]] QStringList availableModels() const { + return m_availableModels; + } + [[nodiscard]] int contextSize() const { return m_contextSize; } + void setEndpoint(const QString& value); + void setModel(const QString& value); + void setTemperature(double value); + void setContextSize(int size); + void probeContextSize(); + + [[nodiscard]] bool busy() const { return m_busy; } + [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } + [[nodiscard]] ChatSession* streamingSession() const { return m_active; } + + // Streams a new assistant reply into `target` (the active generation of + // a message of `session`). The context sent to the model is every + // message of the session that is older than the target message. + void startGeneration(ChatSession* session, ChatGeneration* target); + void stop(); + void endStream(); + // Clears the session's conversation once the current stream ends. + void clearOnFinish(ChatSession* session); + // A session is about to be destroyed; drop any state pointing at it. + void sessionRemoved(ChatSession* session); + + void refreshModels(); + void requestTitle(ChatSession* session, const QString& userText); + void requestIcon(ChatSession* session, const QString& userText); + + Q_SIGNALS: + void busyChanged(); + void endpointChanged(); + void modelChanged(); + void availableModelsChanged(); + void contextSizeChanged(); + void streamingChatIdChanged(); + void errorOccurred(const QString& message); + void titleSuggested(ZShell::llm::ChatSession* session, const QString& title); + void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon); + + private: + void finalize(); + void fail(const QString& message); + void handleLine(const QByteArray& line); + void drainBuffer(); + void updateTokenUsage(const QJsonObject& data); + void setBusy(bool value); + void setStreamingChatId(const QString& id); + void shortRequest( + const QString& tag, + const QString& systemPrompt, + const QString& userText, + std::function onResult); + static QString completionsPath(const QString& endpoint, const QString& subpath); + static QString serverErrorMessage( + const QByteArray& body, const QString& fallback); + + QNetworkAccessManager m_manager; + QNetworkReply* m_reply = nullptr; + QByteArray m_buffer; + ChatSession* m_active = nullptr; + ChatGeneration* m_streaming = nullptr; + QPointer m_pendingClear; + bool m_busy = false; + QString m_streamingChatId; + QString m_endpoint; + QString m_model; + QStringList m_availableModels; + double m_temperature = 0.7; + int m_contextSize = 0; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index 4f41aee..b0cde7c 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -1,123 +1,90 @@ #include "message.hpp" -#include +#include "messagemodel.hpp" +#include "session.hpp" -namespace ZShell { +namespace ZShell::llm { -ChatMessage::ChatMessage( - Role role, - const QString& content, +namespace { + +ChatSession* sessionOf(const ChatMessage* message) { + if (auto* model = qobject_cast(message->parent())) + return model->session(); + return nullptr; +} + +} // namespace + +ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) +: QObject(parent), m_role(role), m_timestamp(timestamp) {} + +ChatGeneration* ChatMessage::addGeneration( qint64 timestamp, - QObject* parent) -: QObject(parent), m_role(role), m_content(content), m_timestamp(timestamp) { - m_timer.setParent(this); - m_timer.setInterval(500); - m_timer.setTimerType(Qt::CoarseTimer); - connect(&m_timer, &QTimer::timeout, this, [this]() { - if (!reasoningInFlight() && !contentInFlight()) { - m_timer.stop(); - return; - } - Q_EMIT elapsedMsChanged(); - }); + const QString& content, + const QString& reasoning, + qint64 reasoningElapsedMs, + qint64 contentElapsedMs) { + auto* generation = new ChatGeneration(timestamp, this); + generation->setContent(content); + generation->setReasoning(reasoning); + generation->setElapsedMs(reasoningElapsedMs, contentElapsedMs); + m_generations.append(generation); + if (m_active < 0) + m_active = static_cast(m_generations.size() - 1); + Q_EMIT generationsChanged(); + return generation; } -qint64 ChatMessage::reasoningElapsedMs() const { - if (m_reasoningStartedAt <= 0) - return 0; - const qint64 end = m_reasoningEndedAt > 0 - ? m_reasoningEndedAt - : QDateTime::currentMSecsSinceEpoch(); - return end - m_reasoningStartedAt; +ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) { + auto* generation = + addGeneration(timestamp, QString(), QString(), 0, 0); + setActiveInternal(static_cast(m_generations.size() - 1)); + return generation; } -qint64 ChatMessage::contentElapsedMs() const { - if (m_contentStartedAt <= 0) - return 0; - const qint64 end = m_contentEndedAt > 0 - ? m_contentEndedAt - : QDateTime::currentMSecsSinceEpoch(); - return end - m_contentStartedAt; -} - -void ChatMessage::updateReasoningActive() { - const bool active = m_streaming && m_content.isEmpty(); - if (m_reasoningActive == active) +void ChatMessage::removeGeneration(ChatGeneration* generation) { + const int index = static_cast(m_generations.indexOf(generation)); + if (index < 0) return; - m_reasoningActive = active; - Q_EMIT reasoningActiveChanged(); + const bool wasActive = index == m_active; + m_generations.removeAt(index); + delete generation; + if (m_generations.isEmpty()) { + m_active = -1; + } else if (m_active >= m_generations.size()) { + m_active = static_cast(m_generations.size() - 1); + } + Q_EMIT generationsChanged(); + if (wasActive) + Q_EMIT activeGenerationChanged(); } -void ChatMessage::appendContent(const QString& piece) { - if (piece.isEmpty()) +void ChatMessage::setActiveInternal(int index) { + if (index < 0 || index >= m_generations.size() || index == m_active) return; - if (m_content.isEmpty()) { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (reasoningInFlight()) - m_reasoningEndedAt = now; - m_contentStartedAt = now; - if (!m_timer.isActive()) - m_timer.start(); - } - m_content += piece; - Q_EMIT contentChanged(); - updateReasoningActive(); - Q_EMIT elapsedMsChanged(); + m_active = index; + Q_EMIT activeGenerationChanged(); } -void ChatMessage::appendReasoning(const QString& piece) { - if (piece.isEmpty()) - return; - if (m_reasoning.isEmpty()) { - if (m_reasoningStartedAt <= 0) - m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); - if (!m_timer.isActive()) - m_timer.start(); - } - m_reasoning += piece; - Q_EMIT reasoningChanged(); - updateReasoningActive(); - Q_EMIT elapsedMsChanged(); +void ChatMessage::setActiveGeneration(int index) { + setActiveInternal(index); } -void ChatMessage::setReasoning(const QString& value) { - if (m_reasoning == value) - return; - m_reasoning = value; - Q_EMIT reasoningChanged(); +void ChatMessage::edit(const QString& newContent) { + if (auto* generation = activeGeneration()) + generation->setContent(newContent); + if (auto* session = sessionOf(this)) + session->persist(); } -void ChatMessage::setElapsedMs(qint64 reasoningMs, qint64 contentMs) { - if (reasoningMs > 0) { - m_reasoningStartedAt = m_timestamp; - m_reasoningEndedAt = m_timestamp + reasoningMs; - } - if (contentMs > 0) { - m_contentStartedAt = m_timestamp; - m_contentEndedAt = m_timestamp + contentMs; - } +void ChatMessage::retry() { + if (auto* session = sessionOf(this)) + session->retry(this); } -void ChatMessage::setStreaming(bool value) { - if (m_streaming == value) - return; - m_streaming = value; - Q_EMIT streamingChanged(); - if (value) { - if (m_reasoningStartedAt <= 0) - m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); - if (!m_timer.isActive()) - m_timer.start(); - } else { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (reasoningInFlight()) - m_reasoningEndedAt = now; - if (contentInFlight()) - m_contentEndedAt = now; - m_timer.stop(); - Q_EMIT elapsedMsChanged(); - } - updateReasoningActive(); +void ChatMessage::generate() { + if (auto* session = sessionOf(this)) + session->continueFrom(this); } -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp index 66202d4..19abc4a 100644 --- a/Plugins/ZShell/Llm/message.hpp +++ b/Plugins/ZShell/Llm/message.hpp @@ -1,27 +1,29 @@ #pragma once +#include "generation.hpp" + +#include #include #include -#include #include -namespace ZShell { - -class Chat; +namespace ZShell::llm { class ChatMessage : public QObject { Q_OBJECT QML_ELEMENT QML_UNCREATABLE("Chat messages are created by the Chat singleton") - Q_PROPERTY(Role role READ role NOTIFY roleChanged) - Q_PROPERTY(QString content READ content NOTIFY contentChanged) - Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) - Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) - Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) - Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) - Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) + Q_PROPERTY(Role role READ role CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY(int generationCount READ generationCount NOTIFY generationsChanged) + Q_PROPERTY( + QList generations READ generations + NOTIFY generationsChanged) + Q_PROPERTY( + ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration + NOTIFY activeGenerationChanged) + Q_PROPERTY(int activeGenerationIndex READ activeGenerationIndex NOTIFY activeGenerationChanged) public: enum class Role : int { @@ -31,57 +33,51 @@ class ChatMessage : public QObject { Q_ENUM(Role) explicit ChatMessage( - Role role, - const QString& content, - qint64 timestamp, - QObject* parent = nullptr); + Role role, qint64 timestamp, QObject* parent = nullptr); [[nodiscard]] Role role() const { return m_role; } - [[nodiscard]] QString content() const { return m_content; } - [[nodiscard]] QString reasoning() const { return m_reasoning; } - [[nodiscard]] bool reasoningActive() const { return m_reasoningActive; } - [[nodiscard]] qint64 reasoningElapsedMs() const; - [[nodiscard]] qint64 contentElapsedMs() const; - [[nodiscard]] bool streaming() const { return m_streaming; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] int generationCount() const { + return static_cast(m_generations.size()); + } + [[nodiscard]] QList generations() const { + return m_generations; + } + [[nodiscard]] ChatGeneration* activeGeneration() const { + return generation(m_active); + } + [[nodiscard]] int activeGenerationIndex() const { return m_active; } + [[nodiscard]] ChatGeneration* generation(int index) const { + if (index < 0 || index >= m_generations.size()) + return nullptr; + return m_generations.at(index); + } - void appendContent(const QString& piece); - void appendReasoning(const QString& piece); - void setReasoning(const QString& value); - void setElapsedMs(qint64 reasoningMs, qint64 contentMs); - void setStreaming(bool value); + Q_INVOKABLE void setActiveGeneration(int index); + Q_INVOKABLE void edit(const QString& newContent); + Q_INVOKABLE void retry(); + Q_INVOKABLE void generate(); + + ChatGeneration* addGeneration( + qint64 timestamp, + const QString& content, + const QString& reasoning, + qint64 reasoningElapsedMs, + qint64 contentElapsedMs); + ChatGeneration* appendGeneration(qint64 timestamp); + void removeGeneration(ChatGeneration* generation); Q_SIGNALS: - void roleChanged(); - void contentChanged(); - void reasoningChanged(); - void reasoningActiveChanged(); - void elapsedMsChanged(); - void streamingChanged(); + void generationsChanged(); + void activeGenerationChanged(); private: - void updateReasoningActive(); + void setActiveInternal(int index); - [[nodiscard]] bool reasoningInFlight() const { - return m_reasoningStartedAt > 0 && m_reasoningEndedAt <= 0; - } - [[nodiscard]] bool contentInFlight() const { - return m_contentStartedAt > 0 && m_contentEndedAt <= 0; - } - - QTimer m_timer; Role m_role; - QString m_content; - QString m_reasoning; - bool m_reasoningActive = false; - bool m_streaming = false; qint64 m_timestamp; - qint64 m_reasoningStartedAt = 0; - qint64 m_reasoningEndedAt = 0; - qint64 m_contentStartedAt = 0; - qint64 m_contentEndedAt = 0; - - friend class Chat; + QList m_generations; + int m_active = -1; }; -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/messagemodel.cpp b/Plugins/ZShell/Llm/messagemodel.cpp new file mode 100644 index 0000000..03f40b8 --- /dev/null +++ b/Plugins/ZShell/Llm/messagemodel.cpp @@ -0,0 +1,107 @@ +#include "messagemodel.hpp" + +#include "session.hpp" + +namespace ZShell::llm { + +ChatMessageModel::ChatMessageModel(ChatSession* session, QObject* parent) + : QAbstractListModel(parent), m_session(session) {} + +ChatMessageModel::~ChatMessageModel() = default; + +int ChatMessageModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; + return static_cast(m_messages.size()); +} + +QVariant ChatMessageModel::data(const QModelIndex& index, int role) const { + if (role != Qt::UserRole || !index.isValid() || index.row() < 0 || + index.row() >= m_messages.size()) + return {}; + return QVariant::fromValue(m_messages.at(index.row())); +} + +QHash ChatMessageModel::roleNames() const { + return {{Qt::UserRole, "modelData"}}; +} + +ChatMessage* ChatMessageModel::at(int row) const { + if (row < 0 || row >= m_messages.size()) return nullptr; + return m_messages.at(row); +} + +int ChatMessageModel::rowOf(const ChatMessage* message) const { + for (int i = 0; i < m_messages.size(); ++i) + if (m_messages.at(i) == message) return i; + return -1; +} + +ChatMessage* ChatMessageModel::createMessage( + ChatMessage::Role role, qint64 timestamp) { + return new ChatMessage(role, timestamp, this); +} + +ChatMessage* ChatMessageModel::appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp) { + auto* message = new ChatMessage(role, timestamp, this); + message->addGeneration(timestamp, content, QString(), 0, 0); + + beginInsertRows(QModelIndex(), 0, 0); + m_messages.prepend(message); + endInsertRows(); + + emit lastMessageChanged(); + + return message; +} + +void ChatMessageModel::removeMessage(ChatMessage* message) { + const int row = rowOf(message); + if (row < 0) return; + + const bool wasLastMessage = row == 0; + + beginRemoveRows(QModelIndex(), row, row); + m_messages.removeAt(row); + endRemoveRows(); + + delete message; + + if (wasLastMessage) emit lastMessageChanged(); +} + +void ChatMessageModel::removeRange(int firstRow, int lastRow) { + if (firstRow < 0 || firstRow > lastRow || lastRow >= m_messages.size()) + return; + + const bool changesLastMessage = firstRow == 0; + + beginRemoveRows(QModelIndex(), firstRow, lastRow); + for (int row = lastRow; row >= firstRow; --row) + delete m_messages.takeAt(row); + endRemoveRows(); + + if (changesLastMessage) emit lastMessageChanged(); +} + +void ChatMessageModel::clear() { + if (m_messages.isEmpty()) return; + + beginRemoveRows(QModelIndex(), 0, static_cast(m_messages.size() - 1)); + qDeleteAll(m_messages); + m_messages.clear(); + endRemoveRows(); + + emit lastMessageChanged(); +} + +void ChatMessageModel::loadMessages(QList messages) { + beginResetModel(); + qDeleteAll(m_messages); + m_messages = std::move(messages); + endResetModel(); + + emit lastMessageChanged(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/messagemodel.hpp b/Plugins/ZShell/Llm/messagemodel.hpp new file mode 100644 index 0000000..3c7f44b --- /dev/null +++ b/Plugins/ZShell/Llm/messagemodel.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include "message.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatSession; + +// Owns a session's messages, most recent first: row 0 is always the newest +// message. Items are exposed through a role named "modelData" (like +// FileSystemModel), so delegates receive each ChatMessage as `modelData`. +class ChatMessageModel : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat message models are owned by ChatSession") + + Q_PROPERTY( + ZShell::llm::ChatMessage* lastMessage READ lastMessage + NOTIFY lastMessageChanged) + + public: + explicit ChatMessageModel(ChatSession* session, QObject* parent = nullptr); + ~ChatMessageModel() override; + + [[nodiscard]] int rowCount( + const QModelIndex& parent = QModelIndex()) const override; + [[nodiscard]] QVariant data( + const QModelIndex& index, int role = Qt::DisplayRole) const override; + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] ChatSession* session() const { return m_session; } + // Most recent message first. + [[nodiscard]] QList messages() const { return m_messages; } + [[nodiscard]] ChatMessage* at(int row) const; + [[nodiscard]] int rowOf(const ChatMessage* message) const; + + [[nodiscard]] ChatMessage* lastMessage() const { + return m_messages.isEmpty() ? nullptr : m_messages.first(); + } + + // Creates a message owned by this model without inserting it. + ChatMessage* createMessage(ChatMessage::Role role, qint64 timestamp); + // Appends a new message as the newest one (row 0) with a single + // generation holding `content`. + ChatMessage* appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp); + void removeMessage(ChatMessage* message); + void removeRange(int firstRow, int lastRow); + void clear(); + // Replaces every row; takes ownership of the given messages, most recent + // first. + void loadMessages(QList messages); + + signals: + void lastMessageChanged(); + + private: + ChatSession* m_session; + QList m_messages; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp index 7986108..de12a0a 100644 --- a/Plugins/ZShell/Llm/session.cpp +++ b/Plugins/ZShell/Llm/session.cpp @@ -1,102 +1,216 @@ #include "session.hpp" #include "chatstore.hpp" +#include "llmclient.hpp" #include +#include #include -namespace ZShell { +namespace ZShell::llm { + +namespace { + +QString titleFrom(const QString& content) { + const QString flat = content.simplified(); + if (flat.isEmpty()) return QString(); + if (flat.size() <= 48) return flat; + return flat.left(47) + QStringLiteral("…"); +} + +} // namespace ChatSession::ChatSession(const QString& id, QObject* parent) -: QObject(parent), m_id(id) {} + : QObject(parent), m_id(id) { + m_model = new ChatMessageModel(this, this); + connect( + m_model, + &QAbstractListModel::rowsInserted, + this, + &ChatSession::onModelRowsChanged); + connect( + m_model, + &QAbstractListModel::rowsRemoved, + this, + &ChatSession::onModelRowsChanged); + connect( + m_model, + &QAbstractListModel::modelReset, + this, + &ChatSession::onModelRowsChanged); +} + +void ChatSession::onModelRowsChanged() { + setCount(m_model->rowCount()); +} void ChatSession::setTitle(const QString& value) { - if (m_title == value) - return; + if (m_title == value) return; m_title = value; Q_EMIT titleChanged(); + persist(); } void ChatSession::setIcon(const QString& value) { - if (m_icon == value) - return; + if (m_icon == value) return; m_icon = value; Q_EMIT iconChanged(); } void ChatSession::setUpdatedAt(qint64 value) { - if (m_updatedAt == value) - return; + if (m_updatedAt == value) return; m_updatedAt = value; Q_EMIT updatedAtChanged(); } void ChatSession::setPinned(bool value) { - if (m_pinned == value) - return; + if (m_pinned == value) return; m_pinned = value; Q_EMIT pinnedChanged(); } void ChatSession::setCount(int value) { - if (m_messageCount == value) - return; + if (m_messageCount == value) return; m_messageCount = value; Q_EMIT messageCountChanged(); } void ChatSession::setMeta( - const QString& title, - qint64 createdAt, - qint64 updatedAt, - int messageCount) { + const QString& title, qint64 createdAt, qint64 updatedAt, int messageCount) { m_title = title; m_createdAt = createdAt; m_updatedAt = updatedAt; m_messageCount = messageCount; } +void ChatSession::setLastTokenCount(int value) { + m_lastTokenCount = value; +} + +LlmClient* ChatSession::client() const { + if (auto* store = qobject_cast(parent())) + return store->llmClient(); + return nullptr; +} + void ChatSession::ensureLoaded() { - if (m_loaded) - return; + if (m_loaded) return; m_loaded = true; if (auto* store = qobject_cast(parent())) store->loadMessagesInto(this); } -void ChatSession::adoptMessages(QList messages) { - qDeleteAll(m_messages); - m_messages = messages; - setCount(m_messages.size()); - Q_EMIT messagesChanged(); +ChatMessageModel* ChatSession::messagesModel() { + ensureLoaded(); + return m_model; } -ChatMessage* ChatSession::appendMessage( +void ChatSession::adoptMessages(QList messages) { + m_model->loadMessages(std::move(messages)); +} + +void ChatSession::persist() { + if (auto* store = qobject_cast(parent())) store->persist(this); +} + +ChatMessage* ChatSession::appendNewest( ChatMessage::Role role, const QString& content, qint64 timestamp) { - ensureLoaded(); - auto* message = new ChatMessage(role, content, timestamp, this); - m_messages.append(message); - setCount(m_messages.size()); - Q_EMIT messagesChanged(); - return message; + return m_model->appendNewest(role, content, timestamp); } void ChatSession::removeMessage(ChatMessage* message) { - if (!message || !m_messages.removeOne(message)) - return; - delete message; - setCount(m_messages.size()); - Q_EMIT messagesChanged(); + m_model->removeMessage(message); } void ChatSession::clearMessages() { - ensureLoaded(); - if (m_messages.isEmpty()) - return; - qDeleteAll(m_messages); - m_messages.clear(); - setCount(0); - Q_EMIT messagesChanged(); + m_model->clear(); } -} // namespace ZShell +void ChatSession::startGeneration(ChatMessage* target) { + if (auto* generation = target->activeGeneration()) { + if (auto* clientObject = client()) + clientObject->startGeneration(this, generation); + } + persist(); +} + +void ChatSession::sendMessage(const QString& text) { + const QString trimmed = text.trimmed(); + if (trimmed.isEmpty()) return; + if (auto* clientObject = client()) { + if (clientObject->busy()) return; + } + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + appendNewest(ChatMessage::Role::User, trimmed, now); + while (m_model->rowCount() > 200) + m_model->removeMessage(m_model->at(m_model->rowCount() - 1)); + if (m_title.isEmpty()) { + m_title = titleFrom(trimmed); + 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); + } + } + auto* assistant = + appendNewest(ChatMessage::Role::Assistant, QString(), now); + startGeneration(assistant); +} + +void ChatSession::retry(ChatMessage* target) { + if (!target || target->role() != ChatMessage::Role::Assistant) return; + if (auto* clientObject = client()) { + if (clientObject->busy()) return; + } + const int row = m_model->rowOf(target); + if (row < 0) return; + + // Drop everything newer than the target, then regenerate from the + // context ending at the user message before it. + m_model->removeRange(0, row - 1); + if (m_model->rowCount() < 2) return; + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + target->appendGeneration(now); + startGeneration(target); +} + +void ChatSession::continueFrom(ChatMessage* message) { + if (!message) return; + if (auto* clientObject = client()) { + if (clientObject->busy()) return; + } + const int row = m_model->rowOf(message); + if (row < 0) return; + + m_model->removeRange(0, row - 1); + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (message->role() == ChatMessage::Role::Assistant) { + if (m_model->rowCount() < 2) return; + message->appendGeneration(now); + startGeneration(message); + } else { + auto* assistant = + appendNewest(ChatMessage::Role::Assistant, QString(), now); + startGeneration(assistant); + } +} + +void ChatSession::clear() { + if (auto* clientObject = client()) { + if (clientObject->busy() && clientObject->streamingSession() == this) { + clientObject->clearOnFinish(this); + clientObject->stop(); + return; + } + } + m_model->clear(); + persist(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index 1180374..d15d5e1 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -1,14 +1,16 @@ #pragma once +#include "messagemodel.hpp" #include "message.hpp" #include -#include #include #include #include -namespace ZShell { +namespace ZShell::llm { + +class LlmClient; class ChatSession : public QObject { Q_OBJECT @@ -16,13 +18,14 @@ class ChatSession : public QObject { QML_UNCREATABLE("Chat sessions are managed by Chat.chats") Q_PROPERTY(QString id READ id CONSTANT) - Q_PROPERTY(QString title READ title NOTIFY titleChanged) + Q_PROPERTY(QString title READ title WRITE setTitle NOTIFY titleChanged) Q_PROPERTY(QString icon READ icon NOTIFY iconChanged) Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged) Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) - Q_PROPERTY(QList messages READ messages NOTIFY messagesChanged) + Q_PROPERTY( + ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT) public: explicit ChatSession(const QString& id, QObject* parent = nullptr); @@ -40,10 +43,14 @@ class ChatSession : public QObject { [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } [[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] int messageCount() const { return m_messageCount; } - [[nodiscard]] QList messages() { - ensureLoaded(); - return m_messages; - } + // Loads the messages from the store on first access. + [[nodiscard]] ChatMessageModel* messagesModel(); + void ensureLoaded(); + [[nodiscard]] bool isLoaded() const { return m_loaded; } + + [[nodiscard]] LlmClient* client() const; + [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } + void setLastTokenCount(int value); void setTitle(const QString& value); void setIcon(const QString& value); @@ -56,23 +63,29 @@ class ChatSession : public QObject { qint64 updatedAt, int messageCount); - void ensureLoaded(); - [[nodiscard]] bool isLoaded() const { return m_loaded; } + // Replaces the model's rows with `messages` (most recent first). void adoptMessages(QList messages); - ChatMessage* appendMessage( - ChatMessage::Role role, const QString& content, qint64 timestamp); + void persist(); void removeMessage(ChatMessage* message); void clearMessages(); + Q_INVOKABLE void sendMessage(const QString& text); + Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target); + Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message); + Q_INVOKABLE void clear(); + Q_SIGNALS: void titleChanged(); void iconChanged(); void updatedAtChanged(); void pinnedChanged(); void messageCountChanged(); - void messagesChanged(); private: + void onModelRowsChanged(); + ChatMessage* appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp); + void startGeneration(ChatMessage* target); void setCount(int value); QString m_id; @@ -82,8 +95,9 @@ class ChatSession : public QObject { qint64 m_updatedAt = 0; bool m_pinned = false; int m_messageCount = 0; - QList m_messages; + ChatMessageModel* m_model = nullptr; bool m_loaded = false; + int m_lastTokenCount = 0; }; -} // namespace ZShell +} // namespace ZShell::llm