better chat list view + anchor to bottom. sqlite db for chats + LIFO-ordered

This commit is contained in:
2026-08-23 16:37:11 +02:00
parent 37c6a9986e
commit aae3757daf
36 changed files with 2653 additions and 1459 deletions
+2 -1
View File
@@ -3,8 +3,9 @@ FunctionsSpacing=true
IndentWidth=4 IndentWidth=4
MaxColumnWidth=-1 MaxColumnWidth=-1
NewlineType=native NewlineType=native
NormalizeOrder=true GroupAttributesTogether=true
ObjectsSpacing=true ObjectsSpacing=true
SemicolonRule=always SemicolonRule=always
SingleLineEmptyObjects=true
SortImports=false SortImports=false
UseTabs=true UseTabs=true
+4 -1
View File
@@ -28,6 +28,9 @@ Flickable {
interval: 10 interval: 10
running: root.doneFakeFlick running: root.doneFakeFlick
onTriggered: root.doneFakeFlick = false onTriggered: {
root.doneFakeFlick = false;
root.returnToBounds();
}
} }
} }
+17 -14
View File
@@ -10,24 +10,27 @@ ListView {
maximumFlickVelocity: 3000 maximumFlickVelocity: 3000
rebound: Transition { rebound: Transition {
onRunningChanged: { // onRunningChanged: {
if (!running && !root.doneFakeFlick) { // if (!running && !root.doneFakeFlick) {
root.doneFakeFlick = true; // root.doneFakeFlick = true;
root.flick(1, 1); // root.flick(1, 1);
root.flick(-1, -1); // root.flick(-1, -1);
Qt.callLater(() => root.cancelFlick()); // Qt.callLater(() => root.cancelFlick());
} // }
} // }
Anim { Anim {
properties: "x,y" properties: "x,y"
type: Anim.DefaultEffects
} }
} }
Timer { // Timer {
interval: 10 // interval: 10
running: root.doneFakeFlick // running: root.doneFakeFlick
//
onTriggered: root.doneFakeFlick = false // onTriggered: {
} // root.doneFakeFlick = false;
// }
// }
} }
+99
View File
@@ -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
}
}
}
+3 -2
View File
@@ -8,6 +8,7 @@ CustomListView {
property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1
property real fadeAmount: 0.1 property real fadeAmount: 0.1
property real fadeThreshold: 0.0
readonly property bool horizontal: orientation === ListView.Horizontal readonly property bool horizontal: orientation === ListView.Horizontal
property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1
@@ -28,11 +29,11 @@ CustomListView {
} }
function marginEnd(): real { function marginEnd(): real {
return horizontal ? rightMargin : bottomMargin; return horizontal ? rightMargin - fadeThreshold : bottomMargin - fadeThreshold;
} }
function marginStart(): real { function marginStart(): real {
return horizontal ? leftMargin : topMargin; return horizontal ? leftMargin - fadeThreshold : topMargin - fadeThreshold;
} }
function overshootStart(): real { function overshootStart(): real {
-1
View File
@@ -140,7 +140,6 @@ Item {
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.right: parent.right anchors.right: parent.right
popouts: popouts
sidebar: sidebar sidebar: sidebar
visibilities: root.visibilities visibilities: root.visibilities
} }
+3 -6
View File
@@ -63,12 +63,10 @@ CustomWindow {
name: "Bar" name: "Bar"
Behavior on fsTransitionProg { Behavior on fsTransitionProg {
Anim { Anim {}
}
} }
Behavior on surfaceColor { Behavior on surfaceColor {
CAnim { CAnim {}
}
} }
contentItem.Keys.onEscapePressed: { 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) y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0)
Behavior on extraExtent { Behavior on extraExtent {
Anim { Anim {}
}
} }
} }
@@ -1,10 +1,8 @@
pragma ComponentBehavior: Bound
import ZShell.Config import ZShell.Config
import ZShell.Llm import ZShell.Llm
import Quickshell
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Components import qs.Components
import qs.Services import qs.Services
@@ -13,18 +11,23 @@ Item {
property ChatSession chatData property ChatSession chatData
property bool following: true property bool following: true
readonly property int messageCount: chatData.messages.length
signal requestClose signal requestClose
function send(): void { function scrollToBottom(): void {
if (Chat.busy || input.text.trim() === "") Qt.callLater(list.positionViewAtBeginning);
}
function send(text: string): void {
if (text.trim() === "")
return; return;
following = true; following = true;
Chat.send(chatData.id, input.text); chatData.sendMessage(text);
input.text = ""; input.text = "";
} }
Component.onCompleted: input.focus = true
RowLayout { RowLayout {
id: header id: header
@@ -54,194 +57,58 @@ Item {
VerticalFadeListView { VerticalFadeListView {
id: list id: list
readonly property real bottomThreshold: Tokens.spacing.extraSmall property bool userScrolledUp: false
property bool stickToBottom: true
function scrollToEnd(animated: bool): void { anchors.bottom: input.top
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.bottomMargin: Tokens.spacing.medium anchors.bottomMargin: Tokens.spacing.medium
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.top: header.bottom anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.medium
cacheBuffer: height * 20 cacheBuffer: height * 20
clip: true clip: true
currentIndex: messages.values.length - 1 fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
verticalLayoutDirection: VerticalFadeListView.BottomToTop
CustomScrollBar.vertical: CustomScrollBar { add: Transition {
flickable: list Anim {
} from: list.width
delegate: ColumnLayout { property: "x"
id: messageRow to: 0
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
}
}
}
} }
} }
model: ScriptModel { delegate: MessageDelegate {}
id: messages displaced: Transition {
Anim {
values: root.chatData.messages property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
} }
Component.onCompleted: scrollToEnd(false) Component.onCompleted: positionViewAtBeginning()
onAtYEndChanged: {
if (atYEnd)
userScrolledUp = false;
}
onContentHeightChanged: { onContentHeightChanged: {
if (list.stickToBottom) if (!userScrolledUp && atYEnd)
list.scrollToEnd(false); root.scrollToBottom();
} }
onMovementEnded: { onCountChanged: {
list.stickToBottom = (list.contentY >= list.contentHeight - list.height - list.bottomThreshold); if (!userScrolledUp)
root.scrollToBottom();
}
onMovingChanged: {
if (moving)
userScrolledUp = !atYEnd;
} }
onMovementStarted: list.stickToBottom = false
Anim { Anim {
id: scrollAnim id: scrollAnim
@@ -252,88 +119,53 @@ Item {
} }
} }
ColumnLayout { EmptyBackground {
id: emptyState id: emptyState
anchors.fill: parent anchors.fill: parent
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
visible: (root.messageCount === 0 && !Chat.busy) || !root.chatData visible: !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
}
} }
IconButton { IconButton {
id: scrollToBottom id: scrollToBottom
anchors.bottom: inputRow.top anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.extraLarge anchors.bottomMargin: Tokens.spacing.extraLarge
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
font.pointSize: Tokens.font.size.large font.pointSize: Tokens.font.size.large
icon: "arrow_downward" icon: "arrow_downward"
isRound: true isRound: true
padding: Tokens.padding.extraSmall 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 type: IconButton.Filled
visible: scale > 0 visible: scale > 0
Behavior on scale { Behavior on scale {
Anim { Anim {}
}
} }
onClicked: { onClicked: {
list.stickToBottom = true; list.positionViewAtBeginning();
list.scrollToEnd(true);
} }
} }
RowLayout { ChatInput {
id: inputRow id: input
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right 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 { onAccepted: root.send(text)
id: input onSendPressed: root.send(text)
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()
}
} }
} }
@@ -1,91 +1,311 @@
import QtQuick import QtQuick
import QtQuick.Layouts import ZShell.Components
import ZShell.Config import ZShell.Config
import ZShell.Llm import ZShell.Llm
import qs.Components import qs.Components
import qs.Services import qs.Services
CustomRect { CustomClippingRect {
id: root id: root
required property Item chatItem property bool expanded: false
property alias layout: layout
required property ChatSession modelData required property ChatSession modelData
signal clicked(content: ChatSession)
signal remove(content: ChatSession)
color: Colors.tPalette.m3surfaceContainer 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 { MaterialIcon {
id: chatIcon id: chatIcon
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Tokens.padding.large anchors.leftMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter anchors.top: parent.top
color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface anchors.topMargin: Tokens.padding.large
font.pointSize: Tokens.font.size.extraLarge font.pointSize: Tokens.font.size.extraLarge
text: root.modelData.icon || "check" text: root.modelData.icon || "check"
} }
ColumnLayout { Item {
id: layout id: infoContainer
anchors.bottom: parent.bottom
anchors.bottomMargin: Tokens.padding.small
anchors.left: chatIcon.right anchors.left: chatIcon.right
anchors.leftMargin: Tokens.padding.medium anchors.leftMargin: Tokens.spacing.medium
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.medium
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: Tokens.padding.small anchors.right: expandBtn.left
spacing: Tokens.spacing.extraSmall 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 { 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 Behavior on opacity {
implicitHeight: enoughSpace ? title.implicitHeight : title.implicitHeight + timestamp.implicitHeight + timestamp.anchors.topMargin Anim {}
}
}
CustomText { TextEditBase {
id: title id: titleText
color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface anchors.left: parent.left
text: root.modelData.title 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 { onEditingFinished: root.modelData.title = text
id: timeSep onReadOnlyChanged: {
if (!readOnly) {
anchors.left: title.right this.forceActiveFocus();
anchors.leftMargin: Tokens.spacing.small cursorPosition = text.length;
anchors.verticalCenter: title.verticalCenter } else {
color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant root.forceActiveFocus();
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")
} }
} }
CustomText { CustomText {
Layout.fillWidth: true id: created
color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant
elide: Text.ElideRight anchors.top: title.bottom
anchors.topMargin: Tokens.spacing.extraSmall
anchors.left: parent.left
anchors.right: parent.right
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
maximumLineCount: 1 color: Colors.palette.m3onSurfaceVariant
text: root.modelData.messages[root.modelData.messages.length - 1]?.content.replace(/\s+/g, " ").trim() ?? "" 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 {}
}
} }
} }
} }
@@ -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
}
}
+6 -133
View File
@@ -15,7 +15,7 @@ Item {
property alias model: list.model property alias model: list.model
signal deleteChatRequest(content: ChatSession) signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession, index: int) signal loadChatRequest(content: ChatSession)
signal newChatRequest signal newChatRequest
CustomText { CustomText {
@@ -44,140 +44,13 @@ Item {
clip: true clip: true
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
delegate: Component { delegate: ChatDelegate {
Item { id: chat
id: chatItem
property alias chat: chat implicitWidth: ListView.view.width
property bool closing: false
property bool editVisible: false
required property int index
required property var modelData
property real startY: 0.0
anchors.fill: undefined onClicked: content => root.loadChatRequest(content)
implicitHeight: chat.layout.implicitHeight + chat.layout.anchors.topMargin + chat.layout.anchors.bottomMargin onRemove: content => root.deleteChatRequest(content)
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();
}
}
}
}
}
} }
} }
@@ -43,20 +43,19 @@ Item {
active: ChatState.isWindow && root.width > (ChatState.screen.width / 4) active: ChatState.isWindow && root.width > (ChatState.screen.width / 4)
anchors.fill: parent anchors.fill: parent
sourceComponent: SidebarView { sourceComponent: SidebarView {}
}
} }
IconButton { // IconButton {
anchors.right: parent.right // anchors.right: parent.right
icon: "close" // icon: "close"
visible: !ChatState.isWindow // visible: !ChatState.isWindow
//
onClicked: { // onClicked: {
Detach.create(); // Detach.create();
Visibilities.getForActive().sidebar = false; // Visibilities.getForActive().sidebar = false;
} // }
} // }
Component { Component {
id: chatList id: chatList
@@ -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
}
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
}
@@ -8,26 +8,49 @@ import ZShell.Llm
import qs.Components import qs.Components
import qs.Services import qs.Services
RowLayout { Item {
id: root id: root
ChatList { property int breakpoint: 700
id: chatList property alias conversationModel: sidebar.model
property var currentConversation: null // whatever model item is "open"
Layout.fillHeight: true readonly property bool isWide: root.width >= root.breakpoint
Layout.fillWidth: true property bool narrowShowsSidebar: true
Layout.maximumWidth: Config.sidebar.sizes.width
Layout.minimumWidth: Config.sidebar.sizes.width / 2 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.currentIndex: -1
list.highlightFollowsCurrentItem: false list.highlightFollowsCurrentItem: false
Behavior on anchors.right {
AnchorAnim {}
}
Behavior on implicitWidth {
Anim {}
}
list.highlight: CustomRect { list.highlight: CustomRect {
color: Colors.palette.m3primary color: Colors.palette.m3primary
implicitHeight: chatList.list.currentItem?.implicitHeight ?? 0 implicitHeight: sidebar.list.currentItem?.implicitHeight ?? 0
implicitWidth: chatList.list.width implicitWidth: sidebar.list.width
radius: Tokens.rounding.medium radius: Tokens.rounding.medium
x: chatList.list.currentItem?.chat.x ?? 0 x: sidebar.list.currentItem?.chat.x ?? 0
y: chatList.list.currentItem?.y ?? 0 y: sidebar.list.currentItem?.y ?? 0
Behavior on y { Behavior on y {
Anim { Anim {
@@ -40,18 +63,29 @@ RowLayout {
values: Chat.chats.values values: Chat.chats.values
} }
anchors.onRightChanged: {
if (anchors.right === undefined)
implicitWidth = 20;
}
onLoadChatRequest: (chat, index) => { onLoadChatRequest: (chat, index) => {
chatList.list.currentIndex = index; sidebar.list.currentIndex = index;
chatContent.chatData = chat; root.openConversation(chat);
} }
} }
ChatContent { CustomClippingWrapperRect {
id: chatContent anchors.bottom: parent.bottom
anchors.left: sidebar.right
anchors.right: parent.right
anchors.top: parent.top
child: conversationView
}
Layout.fillHeight: true ChatContent {
Layout.fillWidth: true id: conversationView
Layout.minimumWidth: Config.sidebar.sizes.width
Layout.preferredWidth: Math.round(Config.sidebar.sizes.width * 1.5) anchors.fill: root
anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0
chatData: root.currentConversation
} }
} }
+19 -5
View File
@@ -45,14 +45,21 @@ Item {
Item { Item {
id: pages id: pages
anchors.fill: parent anchors.bottom: parent.bottom
anchors.top: parent.top
implicitWidth: parent.width
opacity: root.props.currentTab === 0 ? 1 : 0 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 { Behavior on opacity {
Anim { Anim {
} }
} }
Behavior on x {
Anim {
}
}
CustomRect { CustomRect {
anchors.fill: parent anchors.fill: parent
@@ -69,15 +76,22 @@ Item {
Item { Item {
id: chatPage id: chatPage
anchors.fill: parent anchors.bottom: parent.bottom
opacity: visible ? 1 : 0 anchors.top: parent.top
visible: root.props.currentTab === 1 implicitWidth: parent.width
opacity: root.props.currentTab === 1 ? 1 : 0
visible: opacity > 0
x: root.props.currentTab === 0 ? root.width : 0
z: 1 z: 1
Behavior on opacity { Behavior on opacity {
Anim { Anim {
} }
} }
Behavior on x {
Anim {
}
}
CustomRect { CustomRect {
anchors.fill: parent anchors.fill: parent
@@ -12,13 +12,11 @@ import qs.Services
import qs.Helpers import qs.Helpers
import qs.Daemons import qs.Daemons
import qs.Modules.Settings import qs.Modules.Settings
import qs.Modules.Bar.Popouts as BarPopouts
CustomRect { CustomRect {
id: root id: root
readonly property bool needExtraRow: quickToggles.length > 6 readonly property bool needExtraRow: quickToggles.length > 6
required property BarPopouts.Wrapper popouts
readonly property var quickToggles: { readonly property var quickToggles: {
const seenIds = new Set(); const seenIds = new Set();
@@ -1,14 +1,12 @@
import Quickshell import Quickshell
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import qs.Modules.Bar.Popouts as BarPopouts
import qs.Modules.Notifications.Sidebar.Utils.Cards import qs.Modules.Notifications.Sidebar.Utils.Cards
import ZShell.Config import ZShell.Config
Item { Item {
id: root id: root
required property BarPopouts.Wrapper popouts
required property PersistentProperties props required property PersistentProperties props
required property var visibilities required property var visibilities
@@ -21,8 +19,7 @@ Item {
anchors.fill: parent anchors.fill: parent
spacing: 8 spacing: 8
IdleInhibit { IdleInhibit {}
}
Record { Record {
props: root.props props: root.props
@@ -32,7 +29,6 @@ Item {
Toggles { Toggles {
Layout.fillWidth: true Layout.fillWidth: true
popouts: root.popouts
visibilities: root.visibilities visibilities: root.visibilities
} }
} }
@@ -4,13 +4,11 @@ import QtQuick
import Quickshell import Quickshell
import ZShell.Config import ZShell.Config
import qs.Components import qs.Components
import qs.Modules.Bar.Popouts as BarPopouts
Item { Item {
id: root id: root
property real offsetScale: shouldBeActive ? 0 : 1 property real offsetScale: shouldBeActive ? 0 : 1
required property BarPopouts.Wrapper popouts
readonly property PersistentProperties props: PersistentProperties { readonly property PersistentProperties props: PersistentProperties {
property string recordingConfirmDelete property string recordingConfirmDelete
property bool recordingListExpanded: false property bool recordingListExpanded: false
@@ -18,17 +16,14 @@ Item {
reloadableId: "utilities" reloadableId: "utilities"
} }
readonly property bool shouldBeActive: visibilities.sidebar readonly property bool shouldBeActive: visibilities.sidebar && !sidebar.chatActive
required property Item sidebar required property Item sidebar
required property var visibilities required property var visibilities
anchors.bottomMargin: (-implicitHeight - 5) * offsetScale anchors.bottomMargin: (-implicitHeight - 5) * offsetScale
clip: chatActive implicitHeight: content.implicitHeight + Tokens.padding.small * 2
implicitHeight: chatActive ? 21 : content.implicitHeight + 8 * 2
implicitWidth: sidebar.width * (1 - sidebar.offsetScale) implicitWidth: sidebar.width * (1 - sidebar.offsetScale)
opacity: (1 - offsetScale) * chatFade opacity: 1 - offsetScale
readonly property bool chatActive: sidebar.chatActive
property real chatFade: chatActive ? 0 : 1
visible: offsetScale < 1 visible: offsetScale < 1
Behavior on offsetScale { 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 { Loader {
id: content id: content
@@ -62,7 +43,6 @@ Item {
sourceComponent: Content { sourceComponent: Content {
implicitWidth: root.implicitWidth - 8 * 2 implicitWidth: root.implicitWidth - 8 * 2
popouts: root.popouts
props: root.props props: root.props
visibilities: root.visibilities visibilities: root.visibilities
} }
+5 -2
View File
@@ -2,9 +2,12 @@ qml_module(ZShell-llm
URI ZShell.Llm URI ZShell.Llm
SOURCES SOURCES
chat.hpp chat.cpp chat.hpp chat.cpp
session.hpp session.cpp
message.hpp message.cpp
chatstore.hpp chatstore.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 LIBRARIES
Qt::Network Qt::Network
Qt::Sql Qt::Sql
+102 -609
View File
@@ -2,111 +2,119 @@
#include "config.hpp" #include "config.hpp"
#include "llm.hpp" #include "llm.hpp"
#include "llmclient.hpp"
#include <QDateTime>
#include <QDebug> #include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSet>
#include <QUrl>
namespace ZShell { namespace ZShell::llm {
QString Chat::titleFrom(const QString& content) { Chat::Chat(QObject* parent)
const QString flat = content.simplified(); : QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) {
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)) {
if (!config::Config::instance()) if (!config::Config::instance())
new config::Config(); new config::Config();
m_store->setLlmClient(m_client);
const auto* llm = config::Config::instance()->llm(); const auto* llm = config::Config::instance()->llm();
m_endpoint = llm->endpoint(); m_client->setEndpoint(llm->endpoint());
m_temperature = llm->temperature(); m_client->setModel(llm->model());
m_model = llm->model(); m_client->setTemperature(llm->temperature());
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
if (m_endpoint != llm->endpoint()) { m_client->setEndpoint(llm->endpoint());
m_endpoint = llm->endpoint();
Q_EMIT endpointChanged();
probeContextSize();
}
if (m_model.isEmpty())
refreshModels();
}); });
connect(llm, &config::Llm::modelChanged, this, [this, llm]() { connect(llm, &config::Llm::modelChanged, this, [this, llm]() {
if (m_model == llm->model()) m_client->setModel(llm->model());
return; });
m_model = llm->model(); connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() {
Q_EMIT modelChanged(); m_client->setTemperature(llm->temperature());
if (m_model.isEmpty()) });
refreshModels();
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( connect(
llm, m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
&config::Llm::temperatureChanged, connect(
m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect(
m_client,
&LlmClient::availableModelsChanged,
this, this,
[this, llm]() { m_temperature = llm->temperature(); }); &Chat::availableModelsChanged);
connect(
if (m_model.isEmpty()) m_client,
refreshModels(); &LlmClient::contextSizeChanged,
probeContextSize(); 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() { bool Chat::busy() const {
if (m_reply) return m_client->busy();
m_reply->abort(); }
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; Chat* Chat::s_instance = nullptr;
@@ -117,283 +125,8 @@ Chat* Chat::create(QQmlEngine*, QJSEngine*) {
return s_instance; 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<void(QString result)> 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() { void Chat::stop() {
if (!m_busy) m_client->stop();
return;
if (m_reply)
m_reply->abort();
} }
void Chat::dismissError() { void Chat::dismissError() {
@@ -403,256 +136,16 @@ void Chat::dismissError() {
Q_EMIT lastErrorChanged(); 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() { void Chat::refreshModels() {
const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); m_client->refreshModels();
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<QString> 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 Chat::selectModel(const QString& id) { void Chat::selectModel(const QString& id) {
if (id.isEmpty()) if (id.isEmpty())
return; return;
m_model = id; m_client->setModel(id);
Q_EMIT modelChanged(); if (auto* config = config::Config::instance())
config::Config::instance()->llm()->set_model(id); config->llm()->set_model(id);
} }
void Chat::setContextSize(int size) { } // namespace ZShell::llm
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<int>(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
+15 -61
View File
@@ -1,23 +1,22 @@
#pragma once #pragma once
#include <QByteArray>
#include <QList>
#include <QNetworkAccessManager>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QtQml> #include <QtQml>
#include <functional>
#include "chatstore.hpp" #include "chatstore.hpp"
class QQmlEngine; class QQmlEngine;
class QJSEngine; 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 { class Chat : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -29,27 +28,22 @@ class Chat : public QObject {
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged)
Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) 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) Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged)
public: public:
explicit Chat(QObject* parent = nullptr); explicit Chat(QObject* parent = nullptr);
~Chat();
[[nodiscard]] bool busy() const { return m_busy; } [[nodiscard]] bool busy() const;
[[nodiscard]] QString endpoint() const { return m_endpoint; } [[nodiscard]] QString endpoint() const;
[[nodiscard]] QString model() const { return m_model; } [[nodiscard]] QString model() const;
[[nodiscard]] QStringList availableModels() const { return m_availableModels; } [[nodiscard]] QStringList availableModels() const;
[[nodiscard]] int contextSize() const { return m_contextSize; } [[nodiscard]] int contextSize() const;
[[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; }
[[nodiscard]] QString lastError() const { return m_lastError; } [[nodiscard]] QString lastError() const { return m_lastError; }
[[nodiscard]] ChatStore* chats() const { return m_store; } [[nodiscard]] ChatStore* chats() const { return m_store; }
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } [[nodiscard]] QString streamingChatId() const;
[[nodiscard]] ChatSession* streamingSession() const { return m_active; }
Q_INVOKABLE void send(const QString& chatId, const QString& content);
Q_INVOKABLE void stop(); Q_INVOKABLE void stop();
Q_INVOKABLE void clearConversation(const QString& chatId);
Q_INVOKABLE void dismissError(); Q_INVOKABLE void dismissError();
Q_INVOKABLE void refreshModels(); Q_INVOKABLE void refreshModels();
Q_INVOKABLE void selectModel(const QString& id); Q_INVOKABLE void selectModel(const QString& id);
@@ -67,51 +61,11 @@ class Chat : public QObject {
void streamingChatIdChanged(); void streamingChatIdChanged();
private: 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<void(QString result)> 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; ChatStore* m_store = nullptr;
QNetworkReply* m_reply = nullptr; LlmClient* m_client = 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;
QString m_lastError; QString m_lastError;
QString m_streamingChatId;
double m_temperature = 0.7;
int m_contextSize = 0;
int m_lastTokenCount = 0;
static Chat* s_instance; static Chat* s_instance;
friend class ChatStore;
}; };
} // namespace ZShell } // namespace ZShell::llm
+117 -62
View File
@@ -1,6 +1,6 @@
#include "chatstore.hpp" #include "chatstore.hpp"
#include "chat.hpp" #include "llmclient.hpp"
#include "message.hpp" #include "message.hpp"
#include <QDateTime> #include <QDateTime>
@@ -15,7 +15,7 @@
#include <algorithm> #include <algorithm>
namespace ZShell { namespace ZShell::llm {
ChatStore::ChatStore(QObject* parent) ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) { : QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
@@ -67,38 +67,42 @@ void ChatStore::openDb() {
")")); ")"));
} }
{ {
QSqlQuery info(db); QSqlQuery query(db);
bool hasIcon = false; query.exec(
if (info.exec(QStringLiteral("PRAGMA table_info(sessions)"))) QStringLiteral(
while (info.next()) "CREATE TABLE IF NOT EXISTS messages (\n"
if (info.value(1).toString() == QLatin1String("icon")) { " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
hasIcon = true; " session_id TEXT NOT NULL REFERENCES sessions (id) "
break; "ON DELETE CASCADE,\n"
} " role TEXT NOT NULL,\n"
if (!hasIcon) { " timestamp INTEGER NOT NULL\n"
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); QSqlQuery query(db);
query.exec( query.exec(
QStringLiteral( QStringLiteral(
"CREATE TABLE IF NOT EXISTS messages (\n" "CREATE TABLE IF NOT EXISTS generations (\n"
" session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE " " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
"CASCADE,\n" " message_id INTEGER NOT NULL REFERENCES messages "
" role TEXT NOT NULL,\n" "(id) ON DELETE CASCADE,\n"
" content TEXT,\n" " content TEXT,\n"
" reasoning TEXT,\n" " reasoning TEXT,\n"
" timestamp INTEGER NOT NULL,\n" " timestamp INTEGER NOT NULL,\n"
" reasoning_elapsed_ms INTEGER NOT NULL DEFAULT 0,\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 { int ChatStore::count() const {
@@ -121,7 +125,7 @@ ChatSession* ChatStore::at(int index) const {
ChatSession* ChatStore::insert(int index) { ChatSession* ChatStore::insert(int index) {
const qint64 now = QDateTime::currentMSecsSinceEpoch(); const qint64 now = QDateTime::currentMSecsSinceEpoch();
const QString id = QString::number(now); const QString id = QUuid::createUuid().toString();
{ {
QSqlQuery query(db()); QSqlQuery query(db());
query.prepare( query.prepare(
@@ -136,8 +140,7 @@ ChatSession* ChatStore::insert(int index) {
} }
auto* session = new ChatSession(id, this); auto* session = new ChatSession(id, this);
session->setMeta(QString(), now, now, 0); session->setMeta(QString(), now, now, 0);
const int pos = const int pos = index >= 0 && index <= m_sessions.size() ? index : 0;
index >= 0 && index <= m_sessions.size() ? index : 0;
m_sessions.insert(pos, session); m_sessions.insert(pos, session);
Q_EMIT countChanged(); Q_EMIT countChanged();
Q_EMIT valuesChanged(); Q_EMIT valuesChanged();
@@ -155,12 +158,8 @@ void ChatStore::remove(ChatSession* chat) {
void ChatStore::removeSession(ChatSession* session) { void ChatStore::removeSession(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session))
return; return;
if (auto* chat = qobject_cast<Chat*>(parent()))
if (chat->m_active == session) {
chat->stop();
chat->endStream();
}
const QList<ChatSession*> before = m_sessions; const QList<ChatSession*> before = m_sessions;
Q_EMIT sessionRemoved(session);
{ {
QSqlQuery query(db()); QSqlQuery query(db());
query.prepare("DELETE FROM sessions WHERE id = :id"); query.prepare("DELETE FROM sessions WHERE id = :id");
@@ -174,7 +173,7 @@ void ChatStore::removeSession(ChatSession* session) {
void ChatStore::move(int from, int to) { void ChatStore::move(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 || if (from < 0 || from >= m_sessions.size() || to < 0 ||
to >= m_sessions.size() || from == to) to >= m_sessions.size() || from == to)
return; return;
m_sessions.move(from, to); m_sessions.move(from, to);
Q_EMIT valuesChanged(); Q_EMIT valuesChanged();
@@ -193,6 +192,10 @@ ChatSession* ChatStore::sessionById(const QString& id) {
return nullptr; return nullptr;
} }
void ChatStore::setLlmClient(LlmClient* client) {
m_llmClient = client;
}
void ChatStore::persist(ChatSession* session) { void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session))
return; return;
@@ -243,30 +246,58 @@ bool ChatStore::saveSession(ChatSession* session) {
ok = query.exec(); ok = query.exec();
} }
if (ok) { if (ok) {
QSqlQuery insert(handle); QSqlQuery messageInsert(handle);
ok = insert.prepare( ok = messageInsert.prepare(
"INSERT INTO messages (session_id, role, content, reasoning, " "INSERT INTO messages (session_id, role, timestamp) "
"timestamp, reasoning_elapsed_ms, content_elapsed_ms) " "VALUES (:id, :role, :timestamp)");
"VALUES (:id, :role, :content, :reasoning, :timestamp, " QSqlQuery generationInsert(handle);
":reasoning_elapsed_ms, :content_elapsed_ms)"); ok = ok && generationInsert.prepare(
for (const auto* message : session->messages()) { "INSERT INTO generations (message_id, content, reasoning, "
insert.bindValue(":id", session->id()); "timestamp, reasoning_elapsed_ms, content_elapsed_ms, "
insert.bindValue( "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", ":role",
message->role() == ChatMessage::Role::User message->role() == ChatMessage::Role::User
? QStringLiteral("user") ? QStringLiteral("user")
: QStringLiteral("assistant")); : QStringLiteral("assistant"));
insert.bindValue(":content", message->content()); messageInsert.bindValue(":timestamp", message->timestamp());
insert.bindValue(":reasoning", message->reasoning()); if (!messageInsert.exec()) {
insert.bindValue(":timestamp", message->timestamp());
insert.bindValue(":reasoning_elapsed_ms", message->reasoningElapsedMs());
insert.bindValue(":content_elapsed_ms", message->contentElapsedMs());
if (!insert.exec()) {
ok = false; ok = false;
qWarning() << "ChatStore: saveSession" << id << "insert failed:" qWarning() << "ChatStore: saveSession" << id
<< insert.lastError().text(); << "message insert failed:"
<< messageInsert.lastError().text();
break; 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()) { if (!ok || !handle.commit()) {
@@ -280,28 +311,52 @@ bool ChatStore::saveSession(ChatSession* session) {
} }
void ChatStore::loadMessagesInto(ChatSession* session) { void ChatStore::loadMessagesInto(ChatSession* session) {
// Newest first so the model receives rows in display order.
QSqlQuery query(db()); QSqlQuery query(db());
query.prepare( query.prepare(
"SELECT role, content, reasoning, timestamp, reasoning_elapsed_ms, " "SELECT id, role, timestamp FROM messages WHERE session_id = :id "
"content_elapsed_ms FROM messages WHERE session_id = :id ORDER BY rowid"); "ORDER BY rowid DESC");
query.bindValue(":id", session->id()); query.bindValue(":id", session->id());
if (!query.exec()) { if (!query.exec()) {
qWarning() << "ChatStore: failed to load messages for" << session->id() qWarning() << "ChatStore: failed to load messages for" << session->id()
<< ":" << query.lastError().text(); << ":" << query.lastError().text();
return; return;
} }
auto* model = session->messagesModel();
QList<ChatMessage*> messages; QList<ChatMessage*> messages;
while (query.next()) { while (query.next()) {
auto* message = new ChatMessage( const int messageId = query.value(0).toInt();
query.value(0).toString() == QLatin1String("user") auto* message = model->createMessage(
query.value(1).toString() == QLatin1String("user")
? ChatMessage::Role::User ? ChatMessage::Role::User
: ChatMessage::Role::Assistant, : ChatMessage::Role::Assistant,
query.value(1).toString(), query.value(2).toLongLong());
query.value(3).toLongLong(), QSqlQuery generationQuery(db());
session); generationQuery.prepare(
message->setReasoning(query.value(2).toString()); "SELECT content, reasoning, timestamp, reasoning_elapsed_ms, "
message->setElapsedMs( "content_elapsed_ms, is_active FROM generations "
query.value(4).toLongLong(), query.value(5).toLongLong()); "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); messages.append(message);
} }
session->adoptMessages(messages); session->adoptMessages(messages);
@@ -351,4 +406,4 @@ void ChatStore::notify(const QList<ChatSession*>& before) {
Q_EMIT valuesChanged(); Q_EMIT valuesChanged();
} }
} // namespace ZShell } // namespace ZShell::llm
+11 -8
View File
@@ -7,9 +7,9 @@
#include <QString> #include <QString>
#include <QVariantList> #include <QVariantList>
namespace ZShell { namespace ZShell::llm {
class Chat; class LlmClient;
class ChatStore : public QObject { class ChatStore : public QObject {
Q_OBJECT Q_OBJECT
@@ -20,19 +20,22 @@ class ChatStore : public QObject {
public: public:
explicit ChatStore(QObject* parent = nullptr); explicit ChatStore(QObject* parent = nullptr);
~ChatStore(); ~ChatStore() override;
[[nodiscard]] int count() const; [[nodiscard]] int count() const;
[[nodiscard]] QVariantList values() const; [[nodiscard]] QVariantList values() const;
[[nodiscard]] ChatSession* at(int index) 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(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 move(int from, int to);
Q_INVOKABLE void clear(); Q_INVOKABLE void clear();
[[nodiscard]] ChatSession* sessionById(const QString& id); [[nodiscard]] ChatSession* sessionById(const QString& id);
[[nodiscard]] LlmClient* llmClient() const { return m_llmClient; }
void setLlmClient(LlmClient* client);
void persist(ChatSession* session); void persist(ChatSession* session);
void saveMeta(ChatSession* session); void saveMeta(ChatSession* session);
void loadMessagesInto(ChatSession* session); void loadMessagesInto(ChatSession* session);
@@ -40,6 +43,7 @@ class ChatStore : public QObject {
Q_SIGNALS: Q_SIGNALS:
void countChanged(); void countChanged();
void valuesChanged(); void valuesChanged();
void sessionRemoved(ZShell::llm::ChatSession* session);
private: private:
void openDb(); void openDb();
@@ -50,11 +54,10 @@ class ChatStore : public QObject {
void notify(const QList<ChatSession*>& before); void notify(const QList<ChatSession*>& before);
QList<ChatSession*> m_sessions; QList<ChatSession*> m_sessions;
LlmClient* m_llmClient = nullptr;
QString m_connectionName; QString m_connectionName;
[[nodiscard]] QSqlDatabase db() const; [[nodiscard]] QSqlDatabase db() const;
friend class Chat;
}; };
} // namespace ZShell } // namespace ZShell::llm
+126
View File
@@ -0,0 +1,126 @@
#include "generation.hpp"
#include <QDateTime>
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
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <QObject>
#include <QString>
#include <QTimer>
#include <QtQml>
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
+568
View File
@@ -0,0 +1,568 @@
#include "llmclient.hpp"
#include "message.hpp"
#include "messagemodel.hpp"
#include "session.hpp"
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSet>
#include <QUrl>
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<ChatMessage*>(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<void(QString result)> 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<ChatSession>(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<ChatSession>(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<ChatMessage*>(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<QString> 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<int>(used));
}
} // namespace ZShell::llm
+103
View File
@@ -0,0 +1,103 @@
#pragma once
#include <QByteArray>
#include <QNetworkAccessManager>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
#include <functional>
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<void(QString result)> 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<ChatSession> 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
+67 -100
View File
@@ -1,123 +1,90 @@
#include "message.hpp" #include "message.hpp"
#include <QDateTime> #include "messagemodel.hpp"
#include "session.hpp"
namespace ZShell { namespace ZShell::llm {
ChatMessage::ChatMessage( namespace {
Role role,
const QString& content, ChatSession* sessionOf(const ChatMessage* message) {
if (auto* model = qobject_cast<ChatMessageModel*>(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, qint64 timestamp,
QObject* parent) const QString& content,
: QObject(parent), m_role(role), m_content(content), m_timestamp(timestamp) { const QString& reasoning,
m_timer.setParent(this); qint64 reasoningElapsedMs,
m_timer.setInterval(500); qint64 contentElapsedMs) {
m_timer.setTimerType(Qt::CoarseTimer); auto* generation = new ChatGeneration(timestamp, this);
connect(&m_timer, &QTimer::timeout, this, [this]() { generation->setContent(content);
if (!reasoningInFlight() && !contentInFlight()) { generation->setReasoning(reasoning);
m_timer.stop(); generation->setElapsedMs(reasoningElapsedMs, contentElapsedMs);
return; m_generations.append(generation);
} if (m_active < 0)
Q_EMIT elapsedMsChanged(); m_active = static_cast<int>(m_generations.size() - 1);
}); Q_EMIT generationsChanged();
return generation;
} }
qint64 ChatMessage::reasoningElapsedMs() const { ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) {
if (m_reasoningStartedAt <= 0) auto* generation =
return 0; addGeneration(timestamp, QString(), QString(), 0, 0);
const qint64 end = m_reasoningEndedAt > 0 setActiveInternal(static_cast<int>(m_generations.size() - 1));
? m_reasoningEndedAt return generation;
: QDateTime::currentMSecsSinceEpoch();
return end - m_reasoningStartedAt;
} }
qint64 ChatMessage::contentElapsedMs() const { void ChatMessage::removeGeneration(ChatGeneration* generation) {
if (m_contentStartedAt <= 0) const int index = static_cast<int>(m_generations.indexOf(generation));
return 0; if (index < 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)
return; return;
m_reasoningActive = active; const bool wasActive = index == m_active;
Q_EMIT reasoningActiveChanged(); m_generations.removeAt(index);
delete generation;
if (m_generations.isEmpty()) {
m_active = -1;
} else if (m_active >= m_generations.size()) {
m_active = static_cast<int>(m_generations.size() - 1);
}
Q_EMIT generationsChanged();
if (wasActive)
Q_EMIT activeGenerationChanged();
} }
void ChatMessage::appendContent(const QString& piece) { void ChatMessage::setActiveInternal(int index) {
if (piece.isEmpty()) if (index < 0 || index >= m_generations.size() || index == m_active)
return; return;
if (m_content.isEmpty()) { m_active = index;
const qint64 now = QDateTime::currentMSecsSinceEpoch(); Q_EMIT activeGenerationChanged();
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 ChatMessage::appendReasoning(const QString& piece) { void ChatMessage::setActiveGeneration(int index) {
if (piece.isEmpty()) setActiveInternal(index);
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::setReasoning(const QString& value) { void ChatMessage::edit(const QString& newContent) {
if (m_reasoning == value) if (auto* generation = activeGeneration())
return; generation->setContent(newContent);
m_reasoning = value; if (auto* session = sessionOf(this))
Q_EMIT reasoningChanged(); session->persist();
} }
void ChatMessage::setElapsedMs(qint64 reasoningMs, qint64 contentMs) { void ChatMessage::retry() {
if (reasoningMs > 0) { if (auto* session = sessionOf(this))
m_reasoningStartedAt = m_timestamp; session->retry(this);
m_reasoningEndedAt = m_timestamp + reasoningMs;
}
if (contentMs > 0) {
m_contentStartedAt = m_timestamp;
m_contentEndedAt = m_timestamp + contentMs;
}
} }
void ChatMessage::setStreaming(bool value) { void ChatMessage::generate() {
if (m_streaming == value) if (auto* session = sessionOf(this))
return; session->continueFrom(this);
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 } // namespace ZShell::llm
+48 -52
View File
@@ -1,27 +1,29 @@
#pragma once #pragma once
#include "generation.hpp"
#include <QList>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QTimer>
#include <QtQml> #include <QtQml>
namespace ZShell { namespace ZShell::llm {
class Chat;
class ChatMessage : public QObject { class ChatMessage : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
QML_UNCREATABLE("Chat messages are created by the Chat singleton") QML_UNCREATABLE("Chat messages are created by the Chat singleton")
Q_PROPERTY(Role role READ role NOTIFY roleChanged) Q_PROPERTY(Role role READ role CONSTANT)
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(qint64 timestamp READ timestamp CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(int generationCount READ generationCount NOTIFY generationsChanged)
Q_PROPERTY(
QList<ZShell::llm::ChatGeneration*> generations READ generations
NOTIFY generationsChanged)
Q_PROPERTY(
ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration
NOTIFY activeGenerationChanged)
Q_PROPERTY(int activeGenerationIndex READ activeGenerationIndex NOTIFY activeGenerationChanged)
public: public:
enum class Role : int { enum class Role : int {
@@ -31,57 +33,51 @@ class ChatMessage : public QObject {
Q_ENUM(Role) Q_ENUM(Role)
explicit ChatMessage( explicit ChatMessage(
Role role, Role role, qint64 timestamp, QObject* parent = nullptr);
const QString& content,
qint64 timestamp,
QObject* parent = nullptr);
[[nodiscard]] Role role() const { return m_role; } [[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]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] int generationCount() const {
return static_cast<int>(m_generations.size());
}
[[nodiscard]] QList<ChatGeneration*> 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); Q_INVOKABLE void setActiveGeneration(int index);
void appendReasoning(const QString& piece); Q_INVOKABLE void edit(const QString& newContent);
void setReasoning(const QString& value); Q_INVOKABLE void retry();
void setElapsedMs(qint64 reasoningMs, qint64 contentMs); Q_INVOKABLE void generate();
void setStreaming(bool value);
ChatGeneration* addGeneration(
qint64 timestamp,
const QString& content,
const QString& reasoning,
qint64 reasoningElapsedMs,
qint64 contentElapsedMs);
ChatGeneration* appendGeneration(qint64 timestamp);
void removeGeneration(ChatGeneration* generation);
Q_SIGNALS: Q_SIGNALS:
void roleChanged(); void generationsChanged();
void contentChanged(); void activeGenerationChanged();
void reasoningChanged();
void reasoningActiveChanged();
void elapsedMsChanged();
void streamingChanged();
private: 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; Role m_role;
QString m_content;
QString m_reasoning;
bool m_reasoningActive = false;
bool m_streaming = false;
qint64 m_timestamp; qint64 m_timestamp;
qint64 m_reasoningStartedAt = 0; QList<ChatGeneration*> m_generations;
qint64 m_reasoningEndedAt = 0; int m_active = -1;
qint64 m_contentStartedAt = 0;
qint64 m_contentEndedAt = 0;
friend class Chat;
}; };
} // namespace ZShell } // namespace ZShell::llm
+107
View File
@@ -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<int>(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<int, QByteArray> 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<int>(m_messages.size() - 1));
qDeleteAll(m_messages);
m_messages.clear();
endRemoveRows();
emit lastMessageChanged();
}
void ChatMessageModel::loadMessages(QList<ChatMessage*> messages) {
beginResetModel();
qDeleteAll(m_messages);
m_messages = std::move(messages);
endResetModel();
emit lastMessageChanged();
}
} // namespace ZShell::llm
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include "message.hpp"
#include <QAbstractListModel>
#include <QHash>
#include <QList>
#include <QtQml>
#include <qtmetamacros.h>
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<int, QByteArray> roleNames() const override;
[[nodiscard]] ChatSession* session() const { return m_session; }
// Most recent message first.
[[nodiscard]] QList<ChatMessage*> 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<ChatMessage*> messages);
signals:
void lastMessageChanged();
private:
ChatSession* m_session;
QList<ChatMessage*> m_messages;
};
} // namespace ZShell::llm
+157 -43
View File
@@ -1,102 +1,216 @@
#include "session.hpp" #include "session.hpp"
#include "chatstore.hpp" #include "chatstore.hpp"
#include "llmclient.hpp"
#include <QDebug> #include <QDebug>
#include <QDateTime>
#include <QtGlobal> #include <QtGlobal>
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) 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) { void ChatSession::setTitle(const QString& value) {
if (m_title == value) if (m_title == value) return;
return;
m_title = value; m_title = value;
Q_EMIT titleChanged(); Q_EMIT titleChanged();
persist();
} }
void ChatSession::setIcon(const QString& value) { void ChatSession::setIcon(const QString& value) {
if (m_icon == value) if (m_icon == value) return;
return;
m_icon = value; m_icon = value;
Q_EMIT iconChanged(); Q_EMIT iconChanged();
} }
void ChatSession::setUpdatedAt(qint64 value) { void ChatSession::setUpdatedAt(qint64 value) {
if (m_updatedAt == value) if (m_updatedAt == value) return;
return;
m_updatedAt = value; m_updatedAt = value;
Q_EMIT updatedAtChanged(); Q_EMIT updatedAtChanged();
} }
void ChatSession::setPinned(bool value) { void ChatSession::setPinned(bool value) {
if (m_pinned == value) if (m_pinned == value) return;
return;
m_pinned = value; m_pinned = value;
Q_EMIT pinnedChanged(); Q_EMIT pinnedChanged();
} }
void ChatSession::setCount(int value) { void ChatSession::setCount(int value) {
if (m_messageCount == value) if (m_messageCount == value) return;
return;
m_messageCount = value; m_messageCount = value;
Q_EMIT messageCountChanged(); Q_EMIT messageCountChanged();
} }
void ChatSession::setMeta( void ChatSession::setMeta(
const QString& title, const QString& title, qint64 createdAt, qint64 updatedAt, int messageCount) {
qint64 createdAt,
qint64 updatedAt,
int messageCount) {
m_title = title; m_title = title;
m_createdAt = createdAt; m_createdAt = createdAt;
m_updatedAt = updatedAt; m_updatedAt = updatedAt;
m_messageCount = messageCount; m_messageCount = messageCount;
} }
void ChatSession::setLastTokenCount(int value) {
m_lastTokenCount = value;
}
LlmClient* ChatSession::client() const {
if (auto* store = qobject_cast<ChatStore*>(parent()))
return store->llmClient();
return nullptr;
}
void ChatSession::ensureLoaded() { void ChatSession::ensureLoaded() {
if (m_loaded) if (m_loaded) return;
return;
m_loaded = true; m_loaded = true;
if (auto* store = qobject_cast<ChatStore*>(parent())) if (auto* store = qobject_cast<ChatStore*>(parent()))
store->loadMessagesInto(this); store->loadMessagesInto(this);
} }
void ChatSession::adoptMessages(QList<ChatMessage*> messages) { ChatMessageModel* ChatSession::messagesModel() {
qDeleteAll(m_messages); ensureLoaded();
m_messages = messages; return m_model;
setCount(m_messages.size());
Q_EMIT messagesChanged();
} }
ChatMessage* ChatSession::appendMessage( void ChatSession::adoptMessages(QList<ChatMessage*> messages) {
m_model->loadMessages(std::move(messages));
}
void ChatSession::persist() {
if (auto* store = qobject_cast<ChatStore*>(parent())) store->persist(this);
}
ChatMessage* ChatSession::appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp) { ChatMessage::Role role, const QString& content, qint64 timestamp) {
ensureLoaded(); return m_model->appendNewest(role, content, timestamp);
auto* message = new ChatMessage(role, content, timestamp, this);
m_messages.append(message);
setCount(m_messages.size());
Q_EMIT messagesChanged();
return message;
} }
void ChatSession::removeMessage(ChatMessage* message) { void ChatSession::removeMessage(ChatMessage* message) {
if (!message || !m_messages.removeOne(message)) m_model->removeMessage(message);
return;
delete message;
setCount(m_messages.size());
Q_EMIT messagesChanged();
} }
void ChatSession::clearMessages() { void ChatSession::clearMessages() {
ensureLoaded(); m_model->clear();
if (m_messages.isEmpty())
return;
qDeleteAll(m_messages);
m_messages.clear();
setCount(0);
Q_EMIT messagesChanged();
} }
} // 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
+29 -15
View File
@@ -1,14 +1,16 @@
#pragma once #pragma once
#include "messagemodel.hpp"
#include "message.hpp" #include "message.hpp"
#include <QDateTime> #include <QDateTime>
#include <QList>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QtQml> #include <QtQml>
namespace ZShell { namespace ZShell::llm {
class LlmClient;
class ChatSession : public QObject { class ChatSession : public QObject {
Q_OBJECT Q_OBJECT
@@ -16,13 +18,14 @@ class ChatSession : public QObject {
QML_UNCREATABLE("Chat sessions are managed by Chat.chats") QML_UNCREATABLE("Chat sessions are managed by Chat.chats")
Q_PROPERTY(QString id READ id CONSTANT) 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(QString icon READ icon NOTIFY iconChanged)
Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT)
Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged)
Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged) Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged)
Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged)
Q_PROPERTY(QList<ChatMessage*> messages READ messages NOTIFY messagesChanged) Q_PROPERTY(
ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT)
public: public:
explicit ChatSession(const QString& id, QObject* parent = nullptr); explicit ChatSession(const QString& id, QObject* parent = nullptr);
@@ -40,10 +43,14 @@ class ChatSession : public QObject {
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
[[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] bool pinned() const { return m_pinned; }
[[nodiscard]] int messageCount() const { return m_messageCount; } [[nodiscard]] int messageCount() const { return m_messageCount; }
[[nodiscard]] QList<ChatMessage*> messages() { // Loads the messages from the store on first access.
ensureLoaded(); [[nodiscard]] ChatMessageModel* messagesModel();
return m_messages; 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 setTitle(const QString& value);
void setIcon(const QString& value); void setIcon(const QString& value);
@@ -56,23 +63,29 @@ class ChatSession : public QObject {
qint64 updatedAt, qint64 updatedAt,
int messageCount); int messageCount);
void ensureLoaded(); // Replaces the model's rows with `messages` (most recent first).
[[nodiscard]] bool isLoaded() const { return m_loaded; }
void adoptMessages(QList<ChatMessage*> messages); void adoptMessages(QList<ChatMessage*> messages);
ChatMessage* appendMessage( void persist();
ChatMessage::Role role, const QString& content, qint64 timestamp);
void removeMessage(ChatMessage* message); void removeMessage(ChatMessage* message);
void clearMessages(); 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: Q_SIGNALS:
void titleChanged(); void titleChanged();
void iconChanged(); void iconChanged();
void updatedAtChanged(); void updatedAtChanged();
void pinnedChanged(); void pinnedChanged();
void messageCountChanged(); void messageCountChanged();
void messagesChanged();
private: private:
void onModelRowsChanged();
ChatMessage* appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp);
void startGeneration(ChatMessage* target);
void setCount(int value); void setCount(int value);
QString m_id; QString m_id;
@@ -82,8 +95,9 @@ class ChatSession : public QObject {
qint64 m_updatedAt = 0; qint64 m_updatedAt = 0;
bool m_pinned = false; bool m_pinned = false;
int m_messageCount = 0; int m_messageCount = 0;
QList<ChatMessage*> m_messages; ChatMessageModel* m_model = nullptr;
bool m_loaded = false; bool m_loaded = false;
int m_lastTokenCount = 0;
}; };
} // namespace ZShell } // namespace ZShell::llm