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
MaxColumnWidth=-1
NewlineType=native
NormalizeOrder=true
GroupAttributesTogether=true
ObjectsSpacing=true
SemicolonRule=always
SingleLineEmptyObjects=true
SortImports=false
UseTabs=true
+4 -1
View File
@@ -28,6 +28,9 @@ Flickable {
interval: 10
running: root.doneFakeFlick
onTriggered: root.doneFakeFlick = false
onTriggered: {
root.doneFakeFlick = false;
root.returnToBounds();
}
}
}
+17 -14
View File
@@ -10,24 +10,27 @@ ListView {
maximumFlickVelocity: 3000
rebound: Transition {
onRunningChanged: {
if (!running && !root.doneFakeFlick) {
root.doneFakeFlick = true;
root.flick(1, 1);
root.flick(-1, -1);
Qt.callLater(() => root.cancelFlick());
}
}
// onRunningChanged: {
// if (!running && !root.doneFakeFlick) {
// root.doneFakeFlick = true;
// root.flick(1, 1);
// root.flick(-1, -1);
// Qt.callLater(() => root.cancelFlick());
// }
// }
Anim {
properties: "x,y"
type: Anim.DefaultEffects
}
}
Timer {
interval: 10
running: root.doneFakeFlick
onTriggered: root.doneFakeFlick = false
}
// Timer {
// interval: 10
// running: root.doneFakeFlick
//
// onTriggered: {
// root.doneFakeFlick = false;
// }
// }
}
+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 fadeAmount: 0.1
property real fadeThreshold: 0.0
readonly property bool horizontal: orientation === ListView.Horizontal
property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1
@@ -28,11 +29,11 @@ CustomListView {
}
function marginEnd(): real {
return horizontal ? rightMargin : bottomMargin;
return horizontal ? rightMargin - fadeThreshold : bottomMargin - fadeThreshold;
}
function marginStart(): real {
return horizontal ? leftMargin : topMargin;
return horizontal ? leftMargin - fadeThreshold : topMargin - fadeThreshold;
}
function overshootStart(): real {
-1
View File
@@ -140,7 +140,6 @@ Item {
anchors.bottom: parent.bottom
anchors.right: parent.right
popouts: popouts
sidebar: sidebar
visibilities: root.visibilities
}
+3 -6
View File
@@ -63,12 +63,10 @@ CustomWindow {
name: "Bar"
Behavior on fsTransitionProg {
Anim {
}
Anim {}
}
Behavior on surfaceColor {
CAnim {
}
CAnim {}
}
contentItem.Keys.onEscapePressed: {
@@ -293,8 +291,7 @@ CustomWindow {
y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0)
Behavior on extraExtent {
Anim {
}
Anim {}
}
}
@@ -1,10 +1,8 @@
pragma ComponentBehavior: Bound
import ZShell.Config
import ZShell.Llm
import Quickshell
import QtQuick
import QtQuick.Layouts
import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Components
import qs.Services
@@ -13,18 +11,23 @@ Item {
property ChatSession chatData
property bool following: true
readonly property int messageCount: chatData.messages.length
signal requestClose
function send(): void {
if (Chat.busy || input.text.trim() === "")
function scrollToBottom(): void {
Qt.callLater(list.positionViewAtBeginning);
}
function send(text: string): void {
if (text.trim() === "")
return;
following = true;
Chat.send(chatData.id, input.text);
chatData.sendMessage(text);
input.text = "";
}
Component.onCompleted: input.focus = true
RowLayout {
id: header
@@ -54,194 +57,58 @@ Item {
VerticalFadeListView {
id: list
readonly property real bottomThreshold: Tokens.spacing.extraSmall
property bool stickToBottom: true
property bool userScrolledUp: false
function scrollToEnd(animated: bool): void {
const target = Math.max(0, list.contentHeight - list.height);
if (!animated) {
scrollAnim.stop();
list.contentY = target;
return;
}
scrollAnim.to = target;
scrollAnim.restart();
}
anchors.bottom: inputRow.top
anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.medium
anchors.left: parent.left
anchors.right: parent.right
anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.medium
cacheBuffer: height * 20
clip: true
currentIndex: messages.values.length - 1
fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel
spacing: Tokens.spacing.medium
verticalLayoutDirection: VerticalFadeListView.BottomToTop
CustomScrollBar.vertical: CustomScrollBar {
flickable: list
}
delegate: ColumnLayout {
id: messageRow
readonly property bool isUser: modelData.role === ChatMessage.Role.User
required property var modelData
anchors.left: parent.left
anchors.leftMargin: messageRow.isUser ? 0 : Tokens.spacing.extraSmall
anchors.right: parent.right
anchors.rightMargin: messageRow.isUser ? Tokens.spacing.extraSmall : 0
Loader {
Layout.fillWidth: true
Layout.rightMargin: Tokens.spacing.extraLarge
active: !messageRow.isUser && messageRow.modelData.reasoning !== ""
sourceComponent: CustomRect {
id: reasoning
property bool expanded: false
color: Colors.palette.m3surfaceContainer
implicitHeight: expanded ? expandedText.implicitHeight : collapsedText.implicitHeight + Tokens.padding.medium * 2
radius: Tokens.rounding.medium
Behavior on implicitHeight {
Anim {
}
}
Loader {
id: spinnerReasoning
active: opacity > 0
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.verticalCenter: parent.verticalCenter
opacity: messageRow.modelData.reasoningActive ? 1 : 0
Behavior on opacity {
Anim {
}
}
sourceComponent: LoadingIndicator {
implicitSize: collapsedText.implicitHeight
}
}
MaterialIcon {
id: reasoningDone
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.verticalCenter: parent.verticalCenter
font.pointSize: Tokens.font.size.large
opacity: messageRow.modelData.reasoningActive || reasoning.expanded ? 0 : 1
text: "check"
Behavior on opacity {
Anim {
}
}
}
CustomText {
id: collapsedText
anchors.left: messageRow.modelData.reasoningActive ? spinnerReasoning.right : reasoningDone.right
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
opacity: reasoning.expanded ? 0 : 1
text: messageRow.modelData.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((messageRow.modelData.reasoningElapsedMs / 1000).toFixed(1))
visible: opacity > 0
Behavior on opacity {
Anim {
}
}
}
CustomText {
id: expandedText
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
color: Colors.palette.m3onSurface
opacity: reasoning.expanded ? 1 : 0
text: messageRow.modelData.reasoning
visible: opacity > 0
wrapMode: Text.WordWrap
Behavior on opacity {
Anim {
}
}
}
StateLayer {
onClicked: reasoning.expanded = !reasoning.expanded
}
}
}
CustomRect {
Layout.alignment: messageRow.isUser ? Qt.AlignRight : Qt.AlignLeft
color: messageRow.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitHeight: Math.max(msgText.implicitHeight + Tokens.padding.medium * 2, spinnerLoader.implicitHeight)
implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, messageRow.ListView.view.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge)
radius: Tokens.rounding.medium
visible: !messageRow.modelData.reasoningActive
CustomText {
id: msgText
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
text: messageRow.modelData.content
textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap
}
Loader {
id: spinnerLoader
active: messageRow.modelData.streaming && msgText.text.length === 0
anchors.left: parent.left
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
sourceComponent: Item {
implicitHeight: 18
implicitWidth: 18
LoadingIndicator {
anchors.fill: parent
}
}
}
add: Transition {
Anim {
from: list.width
property: "x"
to: 0
}
}
model: ScriptModel {
id: messages
values: root.chatData.messages
delegate: MessageDelegate {}
displaced: Transition {
Anim {
property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
}
Component.onCompleted: scrollToEnd(false)
Component.onCompleted: positionViewAtBeginning()
onAtYEndChanged: {
if (atYEnd)
userScrolledUp = false;
}
onContentHeightChanged: {
if (list.stickToBottom)
list.scrollToEnd(false);
if (!userScrolledUp && atYEnd)
root.scrollToBottom();
}
onMovementEnded: {
list.stickToBottom = (list.contentY >= list.contentHeight - list.height - list.bottomThreshold);
onCountChanged: {
if (!userScrolledUp)
root.scrollToBottom();
}
onMovingChanged: {
if (moving)
userScrolledUp = !atYEnd;
}
onMovementStarted: list.stickToBottom = false
Anim {
id: scrollAnim
@@ -252,88 +119,53 @@ Item {
}
}
ColumnLayout {
EmptyBackground {
id: emptyState
anchors.fill: parent
spacing: Tokens.spacing.small
visible: (root.messageCount === 0 && !Chat.busy) || !root.chatData
Item {
Layout.fillHeight: true
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant
font.pointSize: 36
text: "chat"
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant
text: !root.chatData ? qsTr("Open a previous chat or start a new one") : qsTr("No messages yet")
}
Item {
Layout.fillHeight: true
}
visible: !root.chatData
}
IconButton {
id: scrollToBottom
anchors.bottom: inputRow.top
anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.extraLarge
anchors.horizontalCenter: parent.horizontalCenter
font.pointSize: Tokens.font.size.large
icon: "arrow_downward"
isRound: true
padding: Tokens.padding.extraSmall
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 ? 1 : 0
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0
type: IconButton.Filled
visible: scale > 0
Behavior on scale {
Anim {
}
Anim {}
}
onClicked: {
list.stickToBottom = true;
list.scrollToEnd(true);
list.positionViewAtBeginning();
}
}
RowLayout {
id: inputRow
ChatInput {
id: input
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
spacing: Tokens.spacing.extraSmall
bg.border.color: Colors.palette.m3outlineVariant
bg.color: Colors.tPalette.m3surfaceContainerLowest
font.pointSize: Tokens.font.size.normal
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
placeholderText: qsTr("Send a message")
sendIcon.font.pointSize: Tokens.font.size.large
sendIcon.icon: "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall
CustomTextField {
id: input
Layout.fillWidth: true
placeholderText: qsTr("Send a message...")
type: CustomTextField.Filled
onAccepted: root.send()
}
IconButton {
id: actionBtn
enabled: Chat.busy || input.text.trim() !== ""
font.pointSize: Tokens.font.size.normal * 2
icon: Chat.busy ? "stop" : "send"
isRound: true
type: IconButton.Filled
onClicked: Chat.busy ? Chat.stop() : root.send()
}
onAccepted: root.send(text)
onSendPressed: root.send(text)
}
}
@@ -1,91 +1,311 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
CustomRect {
CustomClippingRect {
id: root
required property Item chatItem
property alias layout: layout
property bool expanded: false
required property ChatSession modelData
signal clicked(content: ChatSession)
signal remove(content: ChatSession)
color: Colors.tPalette.m3surfaceContainer
implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin
implicitHeight: {
let h = 0;
h += infoContainer.implicitHeight;
if (expanded)
h += body.implicitHeight + body.topMargin + actionRow.implicitHeight + actionRow.anchors.topMargin;
else
h += preview.implicitHeight + preview.topMargin;
h += Tokens.padding.small;
const icon = chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
if (h < icon)
return icon;
return h;
}
radius: Tokens.rounding.large
Behavior on implicitHeight {
enabled: titleText.readOnly
Anim {}
}
MaterialIcon {
id: chatIcon
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter
color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
anchors.top: parent.top
anchors.topMargin: Tokens.padding.large
font.pointSize: Tokens.font.size.extraLarge
text: root.modelData.icon || "check"
}
ColumnLayout {
id: layout
Item {
id: infoContainer
anchors.bottom: parent.bottom
anchors.bottomMargin: Tokens.padding.small
anchors.left: chatIcon.right
anchors.leftMargin: Tokens.padding.medium
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.medium
anchors.leftMargin: Tokens.spacing.medium
anchors.top: parent.top
anchors.topMargin: Tokens.padding.small
spacing: Tokens.spacing.extraSmall
anchors.right: expandBtn.left
anchors.rightMargin: Tokens.spacing.medium
implicitHeight: {
let h = 0;
h += title.implicitHeight + title.anchors.topMargin;
h += timestamp.implicitHeight + timestamp.anchors.topMargin;
if (root.expanded)
h += created.implicitHeight + created.anchors.topMargin;
return h;
}
CustomRect {
id: titleContainer
id: title
readonly property bool enoughSpace: width > title.implicitWidth + timeSep.implicitWidth + timeSep.anchors.leftMargin + timestamp.implicitWidth + timestamp.anchors.leftMargin
anchors.left: parent.left
anchors.topMargin: Tokens.padding.extraSmall
anchors.top: parent.top
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 1)
implicitHeight: titleText.implicitHeight + Tokens.padding.extraSmall * 2
radius: Tokens.rounding.full
implicitWidth: titleText.implicitWidth + Tokens.padding.large * 2
opacity: titleText.readOnly ? 0 : 1
Layout.fillWidth: true
implicitHeight: enoughSpace ? title.implicitHeight : title.implicitHeight + timestamp.implicitHeight + timestamp.anchors.topMargin
Behavior on opacity {
Anim {}
}
}
CustomText {
id: title
TextEditBase {
id: titleText
color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
text: root.modelData.title
anchors.left: parent.left
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
anchors.top: parent.top
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
font.pointSize: Tokens.font.size.normal
text: root.modelData.title
readOnly: true
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on anchors.topMargin {
Anim {}
}
CustomText {
id: timeSep
anchors.left: title.right
anchors.leftMargin: Tokens.spacing.small
anchors.verticalCenter: title.verticalCenter
color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant
text: "•"
visible: titleContainer.enoughSpace
}
CustomText {
id: timestamp
anchors.left: titleContainer.enoughSpace ? timeSep.right : parent.left
anchors.leftMargin: titleContainer.enoughSpace ? Tokens.spacing.small : 0
anchors.top: titleContainer.enoughSpace ? undefined : title.bottom
anchors.topMargin: Tokens.spacing.extraSmall
anchors.verticalCenter: titleContainer.enoughSpace ? title.verticalCenter : undefined
color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")
onEditingFinished: root.modelData.title = text
onReadOnlyChanged: {
if (!readOnly) {
this.forceActiveFocus();
cursorPosition = text.length;
} else {
root.forceActiveFocus();
}
}
}
CustomText {
Layout.fillWidth: true
color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant
elide: Text.ElideRight
id: created
anchors.top: title.bottom
anchors.topMargin: Tokens.spacing.extraSmall
anchors.left: parent.left
anchors.right: parent.right
font.pointSize: Tokens.font.size.small
maximumLineCount: 1
text: root.modelData.messages[root.modelData.messages.length - 1]?.content.replace(/\s+/g, " ").trim() ?? ""
color: Colors.palette.m3onSurfaceVariant
text: qsTr("Created on: %1").arg(root.modelData.createdAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP"))
opacity: root.expanded ? 1 : 0
Behavior on opacity {
Anim {}
}
}
CustomText {
id: updated
anchors.top: created.bottom
anchors.topMargin: Tokens.spacing.small
anchors.left: parent.left
font.pointSize: Tokens.font.size.small
color: Colors.palette.m3onSurfaceVariant
text: qsTr("Updated on: ")
opacity: root.expanded ? 1 : 0
Behavior on opacity {
Anim {}
}
}
CustomText {
id: timestamp
anchors.left: parent.left
anchors.top: titleText.bottom
anchors.topMargin: Tokens.spacing.extraSmall
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
target: timestamp
anchors.left: updated.right
anchors.top: undefined
anchors.verticalCenter: updated.verticalCenter
}
}
transitions: Transition {
AnchorAnim {}
}
}
}
CustomText {
id: preview
readonly property int topMargin: Tokens.spacing.extraSmall
anchors.left: chatIcon.right
anchors.margins: Tokens.spacing.medium
anchors.right: parent.right
y: infoContainer.implicitHeight + topMargin
elide: Text.ElideRight
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
maximumLineCount: 1
opacity: !root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? ""
Behavior on y {
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
CustomText {
id: body
readonly property int topMargin: root.expanded ? Tokens.spacing.medium : Tokens.spacing.extraSmall
anchors.left: chatIcon.right
anchors.margins: Tokens.spacing.medium
anchors.right: parent.right
y: infoContainer.implicitHeight + topMargin
animate: true
color: Colors.palette.m3onSurfaceVariant
maximumLineCount: 5
opacity: root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? ""
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on y {
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
StateLayer {
id: sLayer
property int startY
onClicked: {
root.clicked(root.modelData);
}
}
ButtonRow {
id: actionRow
anchors.top: body.bottom
anchors.left: chatIcon.right
anchors.right: parent.right
anchors.topMargin: Tokens.spacing.small
anchors.leftMargin: Tokens.spacing.medium
anchors.rightMargin: Tokens.padding.medium
spacing: Tokens.spacing.medium
IconButton {
icon: "close"
fillWidth: true
shapeMorph: true
isRound: true
font.pointSize: Tokens.font.size.large
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
inactiveOnColor: Colors.palette.m3onSurfaceVariant
onClicked: root.remove(root.modelData)
}
IconButton {
icon: titleText.readOnly ? "edit" : "check"
label.animate: true
isToggle: true
fillWidth: true
shapeMorph: true
font.pointSize: Tokens.font.size.large
isRound: true
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
inactiveOnColor: Colors.palette.m3onSurfaceVariant
activeColor: Colors.palette.m3secondary
activeOnColor: Colors.palette.m3onSecondary
checked: !titleText.readOnly
onClicked: titleText.readOnly = !titleText.readOnly
}
}
CustomRect {
id: expandBtn
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Tokens.padding.small
implicitHeight: expandIcon.implicitHeight
implicitWidth: expandIcon.implicitHeight
radius: Tokens.rounding.full
color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 3)
StateLayer {
onClicked: root.expanded = !root.expanded
}
MaterialIcon {
id: expandIcon
anchors.centerIn: parent
anchors.verticalCenterOffset: root.expanded ? -1 : 1
rotation: root.expanded ? 180 : 0
text: "expand_more"
Behavior on anchors.verticalCenterOffset {
Anim {}
}
Behavior on rotation {
Anim {}
}
}
}
}
@@ -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
signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession, index: int)
signal loadChatRequest(content: ChatSession)
signal newChatRequest
CustomText {
@@ -44,140 +44,13 @@ Item {
clip: true
spacing: Tokens.spacing.medium
delegate: Component {
Item {
id: chatItem
delegate: ChatDelegate {
id: chat
property alias chat: chat
property bool closing: false
property bool editVisible: false
required property int index
required property var modelData
property real startY: 0.0
implicitWidth: ListView.view.width
anchors.fill: undefined
implicitHeight: chat.layout.implicitHeight + chat.layout.anchors.topMargin + chat.layout.anchors.bottomMargin
implicitWidth: ListView.view.width
onEditVisibleChanged: if (!editVisible)
chat.x = 0
ParallelAnimation {
id: removeAnim
onFinished: root.deleteChatRequest(chatItem.modelData)
onStarted: chatItem.closing = true
Anim {
property: "implicitHeight"
target: chatItem
to: 0
}
Anim {
property: "x"
target: chat
to: -chat.implicitWidth
}
Anim {
property: "opacity"
target: chatItem
to: 0
}
}
ChatDelegate {
id: chat
chatItem: chatItem
implicitWidth: chatItem.implicitWidth
modelData: chatItem.modelData
radius: Tokens.rounding.medium
Behavior on x {
Anim {
}
}
Behavior on y {
Anim {
}
}
StateLayer {
cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.PointingHandCursor
drag.axis: Drag.XAxis
drag.target: chat
hoverEnabled: true
preventStealing: true
onClicked: {
if (chatItem.editVisible)
chatItem.editVisible = false;
else
root.loadChatRequest(chatItem.modelData, chatItem.index);
}
onPressed: event => {
chatItem.startY = event.y;
}
onReleased: event => {
if (chat.x > -(editTools.implicitWidth + Tokens.padding.extraSmall)) {
chat.x = 0;
} else {
chatItem.editVisible = true;
chat.x = -(editTools.implicitWidth + Tokens.spacing.medium + Tokens.padding.extraSmall);
}
}
}
}
CustomClippingRect {
anchors.left: chat.right
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.extraSmall
anchors.verticalCenter: chat.verticalCenter
implicitHeight: editTools.implicitHeight
RowLayout {
id: editTools
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
IconButton {
id: deleteChatBtn
font.pointSize: Tokens.font.size.large
icon: "close"
radius: Tokens.rounding.full
transform: [
Scale {
property bool shouldBeActive: (chat.x < -deleteChatBtn.implicitWidth) && !chatItem.closing
origin.x: deleteChatBtn.implicitWidth / 2
origin.y: deleteChatBtn.implicitWidth / 2
xScale: shouldBeActive ? 1 : 0
yScale: shouldBeActive ? 1 : 0
Behavior on xScale {
Anim {
}
}
Behavior on yScale {
Anim {
}
}
}
]
onClicked: {
removeAnim.start();
}
}
}
}
}
onClicked: content => root.loadChatRequest(content)
onRemove: content => root.deleteChatRequest(content)
}
}
@@ -43,20 +43,19 @@ Item {
active: ChatState.isWindow && root.width > (ChatState.screen.width / 4)
anchors.fill: parent
sourceComponent: SidebarView {
}
sourceComponent: SidebarView {}
}
IconButton {
anchors.right: parent.right
icon: "close"
visible: !ChatState.isWindow
onClicked: {
Detach.create();
Visibilities.getForActive().sidebar = false;
}
}
// IconButton {
// anchors.right: parent.right
// icon: "close"
// visible: !ChatState.isWindow
//
// onClicked: {
// Detach.create();
// Visibilities.getForActive().sidebar = false;
// }
// }
Component {
id: chatList
@@ -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.Services
RowLayout {
Item {
id: root
ChatList {
id: chatList
property int breakpoint: 700
property alias conversationModel: sidebar.model
property var currentConversation: null // whatever model item is "open"
Layout.fillHeight: true
Layout.fillWidth: true
Layout.maximumWidth: Config.sidebar.sizes.width
Layout.minimumWidth: Config.sidebar.sizes.width / 2
readonly property bool isWide: root.width >= root.breakpoint
property bool narrowShowsSidebar: true
function closeConversation() {
narrowShowsSidebar = true;
}
function openConversation(conv) {
currentConversation = conv;
narrowShowsSidebar = false; // in narrow mode, jump to the chat
}
ChatList {
id: sidebar
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: root.isWide ? undefined : parent.right
anchors.top: parent.top
implicitWidth: 20
list.currentIndex: -1
list.highlightFollowsCurrentItem: false
Behavior on anchors.right {
AnchorAnim {}
}
Behavior on implicitWidth {
Anim {}
}
list.highlight: CustomRect {
color: Colors.palette.m3primary
implicitHeight: chatList.list.currentItem?.implicitHeight ?? 0
implicitWidth: chatList.list.width
implicitHeight: sidebar.list.currentItem?.implicitHeight ?? 0
implicitWidth: sidebar.list.width
radius: Tokens.rounding.medium
x: chatList.list.currentItem?.chat.x ?? 0
y: chatList.list.currentItem?.y ?? 0
x: sidebar.list.currentItem?.chat.x ?? 0
y: sidebar.list.currentItem?.y ?? 0
Behavior on y {
Anim {
@@ -40,18 +63,29 @@ RowLayout {
values: Chat.chats.values
}
anchors.onRightChanged: {
if (anchors.right === undefined)
implicitWidth = 20;
}
onLoadChatRequest: (chat, index) => {
chatList.list.currentIndex = index;
chatContent.chatData = chat;
sidebar.list.currentIndex = index;
root.openConversation(chat);
}
}
ChatContent {
id: chatContent
CustomClippingWrapperRect {
anchors.bottom: parent.bottom
anchors.left: sidebar.right
anchors.right: parent.right
anchors.top: parent.top
child: conversationView
}
Layout.fillHeight: true
Layout.fillWidth: true
Layout.minimumWidth: Config.sidebar.sizes.width
Layout.preferredWidth: Math.round(Config.sidebar.sizes.width * 1.5)
ChatContent {
id: conversationView
anchors.fill: root
anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0
chatData: root.currentConversation
}
}
+19 -5
View File
@@ -45,14 +45,21 @@ Item {
Item {
id: pages
anchors.fill: parent
anchors.bottom: parent.bottom
anchors.top: parent.top
implicitWidth: parent.width
opacity: root.props.currentTab === 0 ? 1 : 0
visible: opacity > 0.01
visible: opacity > 0
x: root.props.currentTab === 0 ? 0 : -root.width
Behavior on opacity {
Anim {
}
}
Behavior on x {
Anim {
}
}
CustomRect {
anchors.fill: parent
@@ -69,15 +76,22 @@ Item {
Item {
id: chatPage
anchors.fill: parent
opacity: visible ? 1 : 0
visible: root.props.currentTab === 1
anchors.bottom: parent.bottom
anchors.top: parent.top
implicitWidth: parent.width
opacity: root.props.currentTab === 1 ? 1 : 0
visible: opacity > 0
x: root.props.currentTab === 0 ? root.width : 0
z: 1
Behavior on opacity {
Anim {
}
}
Behavior on x {
Anim {
}
}
CustomRect {
anchors.fill: parent
@@ -12,13 +12,11 @@ import qs.Services
import qs.Helpers
import qs.Daemons
import qs.Modules.Settings
import qs.Modules.Bar.Popouts as BarPopouts
CustomRect {
id: root
readonly property bool needExtraRow: quickToggles.length > 6
required property BarPopouts.Wrapper popouts
readonly property var quickToggles: {
const seenIds = new Set();
@@ -1,14 +1,12 @@
import Quickshell
import QtQuick
import QtQuick.Layouts
import qs.Modules.Bar.Popouts as BarPopouts
import qs.Modules.Notifications.Sidebar.Utils.Cards
import ZShell.Config
Item {
id: root
required property BarPopouts.Wrapper popouts
required property PersistentProperties props
required property var visibilities
@@ -21,8 +19,7 @@ Item {
anchors.fill: parent
spacing: 8
IdleInhibit {
}
IdleInhibit {}
Record {
props: root.props
@@ -32,7 +29,6 @@ Item {
Toggles {
Layout.fillWidth: true
popouts: root.popouts
visibilities: root.visibilities
}
}
@@ -4,13 +4,11 @@ import QtQuick
import Quickshell
import ZShell.Config
import qs.Components
import qs.Modules.Bar.Popouts as BarPopouts
Item {
id: root
property real offsetScale: shouldBeActive ? 0 : 1
required property BarPopouts.Wrapper popouts
readonly property PersistentProperties props: PersistentProperties {
property string recordingConfirmDelete
property bool recordingListExpanded: false
@@ -18,17 +16,14 @@ Item {
reloadableId: "utilities"
}
readonly property bool shouldBeActive: visibilities.sidebar
readonly property bool shouldBeActive: visibilities.sidebar && !sidebar.chatActive
required property Item sidebar
required property var visibilities
anchors.bottomMargin: (-implicitHeight - 5) * offsetScale
clip: chatActive
implicitHeight: chatActive ? 21 : content.implicitHeight + 8 * 2
implicitHeight: content.implicitHeight + Tokens.padding.small * 2
implicitWidth: sidebar.width * (1 - sidebar.offsetScale)
opacity: (1 - offsetScale) * chatFade
readonly property bool chatActive: sidebar.chatActive
property real chatFade: chatActive ? 0 : 1
opacity: 1 - offsetScale
visible: offsetScale < 1
Behavior on offsetScale {
@@ -38,20 +33,6 @@ Item {
}
}
Behavior on chatFade {
Anim {
duration: Tokens.anim.durations.expressiveDefaultSpatial
easing: Tokens.anim.expressiveDefaultSpatial
}
}
Behavior on implicitHeight {
Anim {
duration: Tokens.anim.durations.expressiveDefaultSpatial
easing: Tokens.anim.expressiveDefaultSpatial
}
}
Loader {
id: content
@@ -62,7 +43,6 @@ Item {
sourceComponent: Content {
implicitWidth: root.implicitWidth - 8 * 2
popouts: root.popouts
props: root.props
visibilities: root.visibilities
}
+5 -2
View File
@@ -2,9 +2,12 @@ qml_module(ZShell-llm
URI ZShell.Llm
SOURCES
chat.hpp chat.cpp
session.hpp session.cpp
message.hpp message.cpp
chatstore.hpp chatstore.cpp
generation.hpp generation.cpp
llmclient.hpp llmclient.cpp
message.hpp message.cpp
messagemodel.hpp messagemodel.cpp
session.hpp session.cpp
LIBRARIES
Qt::Network
Qt::Sql
+102 -609
View File
@@ -2,111 +2,119 @@
#include "config.hpp"
#include "llm.hpp"
#include "llmclient.hpp"
#include <QDateTime>
#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) {
const QString flat = content.simplified();
if (flat.isEmpty())
return QString();
if (flat.size() <= 48)
return flat;
return flat.left(47) + QStringLiteral("");
}
QString Chat::completionsPath(const QString& endpoint, const QString& subpath) {
QString base = endpoint.trimmed();
while (base.endsWith('/'))
base.chop(1);
if (!base.endsWith("/v1"))
base += "/v1";
return base + subpath;
}
QString Chat::serverErrorMessage(
const QByteArray& body, const QString& fallback) {
const QJsonDocument doc = QJsonDocument::fromJson(body);
if (doc.isObject()) {
const QJsonObject obj = doc.object();
if (obj.contains("error")) {
const QJsonValue errorValue = obj["error"];
if (errorValue.isObject()) {
const QString message = errorValue.toObject()["message"].toString();
if (!message.isEmpty())
return message;
} else if (!errorValue.toString().isEmpty()) {
return errorValue.toString();
}
}
}
return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed")
: fallback;
}
void Chat::setBusy(Chat* chat, bool value) {
if (chat->m_busy == value)
return;
chat->m_busy = value;
Q_EMIT chat->busyChanged();
}
void Chat::setStreamingChatId(Chat* chat, const QString& id) {
if (chat->m_streamingChatId == id)
return;
chat->m_streamingChatId = id;
Q_EMIT chat->streamingChatIdChanged();
}
Chat::Chat(QObject* parent) : QObject(parent), m_store(new ChatStore(this)) {
Chat::Chat(QObject* parent)
: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) {
if (!config::Config::instance())
new config::Config();
m_store->setLlmClient(m_client);
const auto* llm = config::Config::instance()->llm();
m_endpoint = llm->endpoint();
m_temperature = llm->temperature();
m_model = llm->model();
m_client->setEndpoint(llm->endpoint());
m_client->setModel(llm->model());
m_client->setTemperature(llm->temperature());
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
if (m_endpoint != llm->endpoint()) {
m_endpoint = llm->endpoint();
Q_EMIT endpointChanged();
probeContextSize();
}
if (m_model.isEmpty())
refreshModels();
m_client->setEndpoint(llm->endpoint());
});
connect(llm, &config::Llm::modelChanged, this, [this, llm]() {
if (m_model == llm->model())
return;
m_model = llm->model();
Q_EMIT modelChanged();
if (m_model.isEmpty())
refreshModels();
m_client->setModel(llm->model());
});
connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() {
m_client->setTemperature(llm->temperature());
});
connect(m_client, &LlmClient::busyChanged, this, [this]() {
// A fresh run supersedes the previous error.
if (m_client->busy() && !m_lastError.isEmpty()) {
m_lastError.clear();
Q_EMIT lastErrorChanged();
}
Q_EMIT busyChanged();
});
connect(
llm,
&config::Llm::temperatureChanged,
m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
connect(
m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect(
m_client,
&LlmClient::availableModelsChanged,
this,
[this, llm]() { m_temperature = llm->temperature(); });
if (m_model.isEmpty())
refreshModels();
probeContextSize();
&Chat::availableModelsChanged);
connect(
m_client,
&LlmClient::contextSizeChanged,
this,
&Chat::contextSizeChanged);
connect(
m_client,
&LlmClient::streamingChatIdChanged,
this,
&Chat::streamingChatIdChanged);
connect(
m_client,
&LlmClient::errorOccurred,
this,
[this](const QString& message) {
m_lastError = message;
Q_EMIT lastErrorChanged();
Q_EMIT errorOccurred(message);
});
connect(
m_store,
&ChatStore::sessionRemoved,
this,
[this](ChatSession* session) { m_client->sessionRemoved(session); });
connect(
m_client,
&LlmClient::titleSuggested,
this,
[this](ChatSession* session, const QString& title) {
qInfo() << "Chat: applying generated title" << session->id()
<< title << "(was" << session->title() << ")";
session->setTitle(title);
m_store->saveMeta(session);
});
connect(
m_client,
&LlmClient::iconSuggested,
this,
[this](ChatSession* session, const QString& icon) {
qInfo() << "Chat: applying generated icon" << session->id()
<< icon << "(was" << session->icon() << ")";
session->setIcon(icon);
m_store->saveMeta(session);
});
}
Chat::~Chat() {
if (m_reply)
m_reply->abort();
bool Chat::busy() const {
return m_client->busy();
}
QString Chat::endpoint() const {
return m_client->endpoint();
}
QString Chat::model() const {
return m_client->model();
}
QStringList Chat::availableModels() const {
return m_client->availableModels();
}
int Chat::contextSize() const {
return m_client->contextSize();
}
QString Chat::streamingChatId() const {
return m_client->streamingChatId();
}
Chat* Chat::s_instance = nullptr;
@@ -117,283 +125,8 @@ Chat* Chat::create(QQmlEngine*, QJSEngine*) {
return s_instance;
}
void Chat::send(const QString& chatId, const QString& content) {
const QString text = content.trimmed();
if (text.isEmpty() || m_busy)
return;
ChatSession* session = m_store->sessionById(chatId);
if (!session) {
qWarning() << "Chat: unknown chat id" << chatId;
return;
}
if (!m_lastError.isEmpty()) {
m_lastError.clear();
Q_EMIT lastErrorChanged();
}
session->ensureLoaded();
session->appendMessage(
ChatMessage::Role::User,
text,
QDateTime::currentMSecsSinceEpoch());
while (session->messageCount() > 200)
session->removeMessage(session->messages().first());
if (session->title().isEmpty()) {
session->setTitle(titleFrom(text));
qInfo() << "Chat: new session" << chatId
<< "fallback title" << session->title()
<< "- requesting generated title and icon, model" << m_model
<< "endpoint" << m_endpoint;
requestTitle(chatId, text);
requestIcon(chatId, text);
}
if (m_contextSize > 0 && m_lastTokenCount > m_contextSize * 4 / 5)
trimHistory(session, m_contextSize);
m_active = session;
beginAssistant();
m_store->persist(session);
}
void Chat::beginAssistant() {
m_streaming = m_active->appendMessage(
ChatMessage::Role::Assistant,
QString(),
QDateTime::currentMSecsSinceEpoch());
m_streaming->setStreaming(true);
setBusy(this, true);
setStreamingChatId(this, m_active->id());
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions"));
if (!url.isValid() || url.host().isEmpty()) {
fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint));
return;
}
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Accept", "text/event-stream");
QJsonArray messages;
for (const auto* message : m_active->messages()) {
if (message == m_streaming)
continue;
QJsonObject messageObj;
messageObj[QStringLiteral("role")] =
message->role() == ChatMessage::Role::User
? QStringLiteral("user")
: QStringLiteral("assistant");
messageObj[QStringLiteral("content")] = message->content();
if (!message->reasoning().isEmpty())
messageObj[QStringLiteral("reasoning_content")] = message->reasoning();
messages.append(messageObj);
}
QJsonObject body;
QJsonObject streamOptions;
streamOptions[QStringLiteral("include_usage")] = true;
body[QStringLiteral("stream_options")] = streamOptions;
body[QStringLiteral("messages")] = messages;
body[QStringLiteral("stream")] = true;
body[QStringLiteral("temperature")] = m_temperature;
if (!m_model.isEmpty())
body[QStringLiteral("model")] = m_model;
m_buffer.clear();
m_reply = m_manager.post(request, QJsonDocument(body).toJson());
connect(m_reply, &QNetworkReply::readyRead, this, [this]() {
if (m_reply)
m_buffer.append(m_reply->readAll());
drainBuffer();
});
connect(m_reply, &QNetworkReply::finished, this, [this]() {
QNetworkReply* reply = m_reply;
if (!reply)
return;
m_reply = nullptr;
const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
const QByteArray responseBody = reply->readAll();
reply->deleteLater();
drainBuffer();
if (!m_streaming)
return;
if (error == QNetworkReply::NoError ||
error == QNetworkReply::OperationCanceledError)
finalize();
else
fail(serverErrorMessage(responseBody, errorString));
});
}
void Chat::shortRequest(
const QString& tag,
const QString& systemPrompt,
const QString& userText,
std::function<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() {
if (!m_busy)
return;
if (m_reply)
m_reply->abort();
m_client->stop();
}
void Chat::dismissError() {
@@ -403,256 +136,16 @@ void Chat::dismissError() {
Q_EMIT lastErrorChanged();
}
void Chat::clearConversation(const QString& chatId) {
ChatSession* session = m_store->sessionById(chatId);
if (!session)
return;
if (m_busy && m_active == session) {
m_pendingClearChatId = chatId;
stop();
return;
}
session->ensureLoaded();
session->clearMessages();
m_store->persist(session);
}
void Chat::finalize() {
if (!m_streaming)
return;
ChatSession* session = m_active;
endStream();
if (session && m_pendingClearChatId == session->id()) {
session->clearMessages();
m_pendingClearChatId.clear();
}
if (session)
m_store->persist(session);
}
void Chat::endStream() {
if (!m_streaming)
return;
m_streaming->setStreaming(false);
if (m_streaming->content().isEmpty() && m_streaming->reasoning().isEmpty() && m_active)
m_active->removeMessage(m_streaming);
m_streaming = nullptr;
m_active = nullptr;
setBusy(this, false);
setStreamingChatId(this, QString());
}
void Chat::fail(const QString& message) {
qWarning() << "Chat:" << message;
const bool overflow = message.contains("overflow") ||
message.contains("exceed") ||
m_lastTokenCount > m_contextSize;
const QString shown = overflow
? QStringLiteral(
"%1 (using ~%2 of %3 tokens; the oldest messages were auto-removed "
"so the next one will fit)")
.arg(message, QString::number(m_lastTokenCount),
QString::number(m_contextSize))
: message;
m_lastError = shown;
Q_EMIT lastErrorChanged();
if (m_streaming) {
ChatSession* session = m_active;
endStream();
if (overflow)
trimHistory(session, m_contextSize);
if (session && m_pendingClearChatId == session->id()) {
session->clearMessages();
m_pendingClearChatId.clear();
}
if (session)
m_store->persist(session);
}
Q_EMIT errorOccurred(shown);
}
void Chat::drainBuffer() {
while (true) {
const qsizetype newline = m_buffer.indexOf('\n');
if (newline < 0)
break;
const QByteArray line = m_buffer.left(newline).trimmed();
m_buffer.remove(0, newline + 1);
handleLine(line);
}
}
void Chat::handleLine(const QByteArray& line) {
if (line.isEmpty() || line.startsWith('#') || !line.startsWith("data:"))
return;
const QByteArray data = line.mid(5).trimmed();
if (data == "[DONE]") {
finalize();
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject())
return;
const QJsonObject obj = doc.object();
updateTokenUsage(obj);
if (obj.contains("error")) {
const QJsonObject error = obj["error"].toObject();
const QString message = error["message"].toString();
fail(message.isEmpty() ? QStringLiteral("LLM server returned an error") : message);
return;
}
for (const QJsonValue& choiceValue : obj["choices"].toArray()) {
const QJsonObject delta = choiceValue.toObject()["delta"].toObject();
if (!m_streaming)
continue;
m_streaming->appendContent(delta["content"].toString());
QString reasoning = delta["reasoning_content"].toString();
if (reasoning.isEmpty())
reasoning = delta["reasoning"].toString();
m_streaming->appendReasoning(reasoning);
}
}
void Chat::refreshModels() {
const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
if (!url.isValid() || url.host().isEmpty())
return;
auto* reply = m_manager.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QByteArray data = reply->readAll();
reply->deleteLater();
if (error != QNetworkReply::NoError) {
qWarning() << "Chat: failed to fetch models:" << error;
return;
}
const QJsonArray dataArr =
QJsonDocument::fromJson(data).object()["data"].toArray();
QStringList models;
QSet<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();
}
});
m_client->refreshModels();
}
void Chat::selectModel(const QString& id) {
if (id.isEmpty())
return;
m_model = id;
Q_EMIT modelChanged();
config::Config::instance()->llm()->set_model(id);
m_client->setModel(id);
if (auto* config = config::Config::instance())
config->llm()->set_model(id);
}
void Chat::setContextSize(int size) {
if (size <= 0 || m_contextSize == size)
return;
m_contextSize = size;
Q_EMIT contextSizeChanged();
}
void Chat::probeContextSize() {
QString base = m_endpoint.trimmed();
while (base.endsWith('/'))
base.chop(1);
const QUrl url = QUrl::fromUserInput(base + "/props");
if (!url.isValid() || url.host().isEmpty())
return;
auto* reply = m_manager.get(QNetworkRequest(url));
connect(
reply,
&QNetworkReply::finished,
this,
[this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QByteArray data = reply->readAll();
reply->deleteLater();
int size = 0;
if (error == QNetworkReply::NoError) {
const QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isArray()) {
for (const auto& value : doc.array()) {
const QJsonObject slot = value.toObject();
if (slot.contains("n_ctx")) {
size = slot["n_ctx"].toInt(0);
if (size > 0)
break;
}
}
} else if (doc.isObject()) {
const QJsonObject obj = doc.object();
size = obj["n_ctx"].toInt(0);
if (size <= 0)
size = obj["default_generation_settings"].toObject()["n_ctx"].toInt(0);
}
}
setContextSize(size > 0 ? size : 4096);
});
}
void Chat::updateTokenUsage(const QJsonObject& data) {
if (m_contextSize <= 0)
return;
const QJsonObject usage = data["usage"].toObject();
if (usage.isEmpty())
return;
const double used = usage.value("prompt_tokens").toDouble() +
usage.value("completion_tokens").toDouble();
if (used > 0)
m_lastTokenCount = qMin(static_cast<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
} // namespace ZShell::llm
+15 -61
View File
@@ -1,23 +1,22 @@
#pragma once
#include <QByteArray>
#include <QList>
#include <QNetworkAccessManager>
#include <QObject>
#include <QString>
#include <QStringList>
#include <QtQml>
#include <functional>
#include "chatstore.hpp"
class QQmlEngine;
class QJSEngine;
class QNetworkReply;
namespace ZShell {
namespace ZShell::llm {
class LlmClient;
// QML-facing facade. Persistence lives in ChatStore, network streaming in
// LlmClient; this class only wires them together and exposes the
// application-wide state.
class Chat : public QObject {
Q_OBJECT
QML_ELEMENT
@@ -29,27 +28,22 @@ class Chat : public QObject {
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged)
Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(ChatStore* chats READ chats CONSTANT)
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged)
public:
explicit Chat(QObject* parent = nullptr);
~Chat();
[[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] QString endpoint() const { return m_endpoint; }
[[nodiscard]] QString model() const { return m_model; }
[[nodiscard]] QStringList availableModels() const { return m_availableModels; }
[[nodiscard]] int contextSize() const { return m_contextSize; }
[[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; }
[[nodiscard]] bool busy() const;
[[nodiscard]] QString endpoint() const;
[[nodiscard]] QString model() const;
[[nodiscard]] QStringList availableModels() const;
[[nodiscard]] int contextSize() const;
[[nodiscard]] QString lastError() const { return m_lastError; }
[[nodiscard]] ChatStore* chats() const { return m_store; }
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
[[nodiscard]] ChatSession* streamingSession() const { return m_active; }
[[nodiscard]] QString streamingChatId() const;
Q_INVOKABLE void send(const QString& chatId, const QString& content);
Q_INVOKABLE void stop();
Q_INVOKABLE void clearConversation(const QString& chatId);
Q_INVOKABLE void dismissError();
Q_INVOKABLE void refreshModels();
Q_INVOKABLE void selectModel(const QString& id);
@@ -67,51 +61,11 @@ class Chat : public QObject {
void streamingChatIdChanged();
private:
void beginAssistant();
void endStream();
void finalize();
void fail(const QString& message);
void handleLine(const QByteArray& line);
void drainBuffer();
void probeContextSize();
void setContextSize(int size);
void updateTokenUsage(const QJsonObject& data);
static void trimHistory(ChatSession* session, int contextSize);
void shortRequest(
const QString& tag,
const QString& systemPrompt,
const QString& userText,
std::function<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;
QNetworkReply* m_reply = nullptr;
QByteArray m_buffer;
ChatSession* m_active = nullptr;
ChatMessage* m_streaming = nullptr;
QString m_pendingClearChatId;
bool m_busy = false;
QString m_endpoint;
QString m_model;
QStringList m_availableModels;
LlmClient* m_client = nullptr;
QString m_lastError;
QString m_streamingChatId;
double m_temperature = 0.7;
int m_contextSize = 0;
int m_lastTokenCount = 0;
static Chat* s_instance;
friend class ChatStore;
};
} // namespace ZShell
} // namespace ZShell::llm
+117 -62
View File
@@ -1,6 +1,6 @@
#include "chatstore.hpp"
#include "chat.hpp"
#include "llmclient.hpp"
#include "message.hpp"
#include <QDateTime>
@@ -15,7 +15,7 @@
#include <algorithm>
namespace ZShell {
namespace ZShell::llm {
ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
@@ -67,38 +67,42 @@ void ChatStore::openDb() {
")"));
}
{
QSqlQuery info(db);
bool hasIcon = false;
if (info.exec(QStringLiteral("PRAGMA table_info(sessions)")))
while (info.next())
if (info.value(1).toString() == QLatin1String("icon")) {
hasIcon = true;
break;
}
if (!hasIcon) {
QSqlQuery alter(db);
if (!alter.exec(QStringLiteral(
"ALTER TABLE sessions ADD COLUMN icon TEXT NOT NULL "
"DEFAULT ''")))
qWarning() << "ChatStore: failed to add icon column:"
<< alter.lastError().text();
}
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS messages (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" session_id TEXT NOT NULL REFERENCES sessions (id) "
"ON DELETE CASCADE,\n"
" role TEXT NOT NULL,\n"
" timestamp INTEGER NOT NULL\n"
")"));
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS messages (\n"
" session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE "
"CASCADE,\n"
" role TEXT NOT NULL,\n"
" content TEXT,\n"
" reasoning TEXT,\n"
"CREATE TABLE IF NOT EXISTS generations (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" message_id INTEGER NOT NULL REFERENCES messages "
"(id) ON DELETE CASCADE,\n"
" content TEXT,\n"
" reasoning TEXT,\n"
" timestamp INTEGER NOT NULL,\n"
" reasoning_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
" content_elapsed_ms INTEGER NOT NULL DEFAULT 0\n"
" content_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
" is_active INTEGER NOT NULL DEFAULT 1\n"
")"));
}
{
QSqlQuery query(db);
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_messages_session "
"ON messages (session_id)"));
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_generations_message "
"ON generations (message_id)"));
}
}
int ChatStore::count() const {
@@ -121,7 +125,7 @@ ChatSession* ChatStore::at(int index) const {
ChatSession* ChatStore::insert(int index) {
const qint64 now = QDateTime::currentMSecsSinceEpoch();
const QString id = QString::number(now);
const QString id = QUuid::createUuid().toString();
{
QSqlQuery query(db());
query.prepare(
@@ -136,8 +140,7 @@ ChatSession* ChatStore::insert(int index) {
}
auto* session = new ChatSession(id, this);
session->setMeta(QString(), now, now, 0);
const int pos =
index >= 0 && index <= m_sessions.size() ? index : 0;
const int pos = index >= 0 && index <= m_sessions.size() ? index : 0;
m_sessions.insert(pos, session);
Q_EMIT countChanged();
Q_EMIT valuesChanged();
@@ -155,12 +158,8 @@ void ChatStore::remove(ChatSession* chat) {
void ChatStore::removeSession(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
if (auto* chat = qobject_cast<Chat*>(parent()))
if (chat->m_active == session) {
chat->stop();
chat->endStream();
}
const QList<ChatSession*> before = m_sessions;
Q_EMIT sessionRemoved(session);
{
QSqlQuery query(db());
query.prepare("DELETE FROM sessions WHERE id = :id");
@@ -174,7 +173,7 @@ void ChatStore::removeSession(ChatSession* session) {
void ChatStore::move(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 ||
to >= m_sessions.size() || from == to)
to >= m_sessions.size() || from == to)
return;
m_sessions.move(from, to);
Q_EMIT valuesChanged();
@@ -193,6 +192,10 @@ ChatSession* ChatStore::sessionById(const QString& id) {
return nullptr;
}
void ChatStore::setLlmClient(LlmClient* client) {
m_llmClient = client;
}
void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
@@ -243,30 +246,58 @@ bool ChatStore::saveSession(ChatSession* session) {
ok = query.exec();
}
if (ok) {
QSqlQuery insert(handle);
ok = insert.prepare(
"INSERT INTO messages (session_id, role, content, reasoning, "
"timestamp, reasoning_elapsed_ms, content_elapsed_ms) "
"VALUES (:id, :role, :content, :reasoning, :timestamp, "
":reasoning_elapsed_ms, :content_elapsed_ms)");
for (const auto* message : session->messages()) {
insert.bindValue(":id", session->id());
insert.bindValue(
QSqlQuery messageInsert(handle);
ok = messageInsert.prepare(
"INSERT INTO messages (session_id, role, timestamp) "
"VALUES (:id, :role, :timestamp)");
QSqlQuery generationInsert(handle);
ok = ok && generationInsert.prepare(
"INSERT INTO generations (message_id, content, reasoning, "
"timestamp, reasoning_elapsed_ms, content_elapsed_ms, "
"is_active) VALUES (:mid, :content, :reasoning, :timestamp, "
":reasoning_elapsed_ms, :content_elapsed_ms, :is_active)");
// The model holds messages most recent first; the database keeps
// natural rowid order, so iterate from the oldest row up.
const auto* model = session->messagesModel();
for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
const auto* message = model->at(row);
messageInsert.bindValue(":id", session->id());
messageInsert.bindValue(
":role",
message->role() == ChatMessage::Role::User
? QStringLiteral("user")
: QStringLiteral("assistant"));
insert.bindValue(":content", message->content());
insert.bindValue(":reasoning", message->reasoning());
insert.bindValue(":timestamp", message->timestamp());
insert.bindValue(":reasoning_elapsed_ms", message->reasoningElapsedMs());
insert.bindValue(":content_elapsed_ms", message->contentElapsedMs());
if (!insert.exec()) {
messageInsert.bindValue(":timestamp", message->timestamp());
if (!messageInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id << "insert failed:"
<< insert.lastError().text();
qWarning() << "ChatStore: saveSession" << id
<< "message insert failed:"
<< messageInsert.lastError().text();
break;
}
const int messageId = messageInsert.lastInsertId().toInt();
for (int i = 0; ok && i < message->generationCount(); ++i) {
const auto* generation = message->generation(i);
generationInsert.bindValue(":mid", messageId);
generationInsert.bindValue(":content", generation->content());
generationInsert.bindValue(
":reasoning", generation->reasoning());
generationInsert.bindValue(":timestamp", generation->timestamp());
generationInsert.bindValue(
":reasoning_elapsed_ms", generation->reasoningElapsedMs());
generationInsert.bindValue(
":content_elapsed_ms", generation->contentElapsedMs());
generationInsert.bindValue(
":is_active",
i == message->activeGenerationIndex() ? 1 : 0);
if (!generationInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id
<< "generation insert failed:"
<< generationInsert.lastError().text();
break;
}
}
}
}
if (!ok || !handle.commit()) {
@@ -280,28 +311,52 @@ bool ChatStore::saveSession(ChatSession* session) {
}
void ChatStore::loadMessagesInto(ChatSession* session) {
// Newest first so the model receives rows in display order.
QSqlQuery query(db());
query.prepare(
"SELECT role, content, reasoning, timestamp, reasoning_elapsed_ms, "
"content_elapsed_ms FROM messages WHERE session_id = :id ORDER BY rowid");
"SELECT id, role, timestamp FROM messages WHERE session_id = :id "
"ORDER BY rowid DESC");
query.bindValue(":id", session->id());
if (!query.exec()) {
qWarning() << "ChatStore: failed to load messages for" << session->id()
<< ":" << query.lastError().text();
return;
}
auto* model = session->messagesModel();
QList<ChatMessage*> messages;
while (query.next()) {
auto* message = new ChatMessage(
query.value(0).toString() == QLatin1String("user")
const int messageId = query.value(0).toInt();
auto* message = model->createMessage(
query.value(1).toString() == QLatin1String("user")
? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
query.value(1).toString(),
query.value(3).toLongLong(),
session);
message->setReasoning(query.value(2).toString());
message->setElapsedMs(
query.value(4).toLongLong(), query.value(5).toLongLong());
query.value(2).toLongLong());
QSqlQuery generationQuery(db());
generationQuery.prepare(
"SELECT content, reasoning, timestamp, reasoning_elapsed_ms, "
"content_elapsed_ms, is_active FROM generations "
"WHERE message_id = :mid ORDER BY rowid");
generationQuery.bindValue(":mid", messageId);
int activeIndex = 0;
if (generationQuery.exec()) {
int index = 0;
while (generationQuery.next()) {
message->addGeneration(
generationQuery.value(2).toLongLong(),
generationQuery.value(0).toString(),
generationQuery.value(1).toString(),
generationQuery.value(3).toLongLong(),
generationQuery.value(4).toLongLong());
if (generationQuery.value(5).toInt() != 0)
activeIndex = index;
++index;
}
} else {
qWarning() << "ChatStore: failed to load generations for message"
<< messageId << ":"
<< generationQuery.lastError().text();
}
message->setActiveGeneration(activeIndex);
messages.append(message);
}
session->adoptMessages(messages);
@@ -351,4 +406,4 @@ void ChatStore::notify(const QList<ChatSession*>& before) {
Q_EMIT valuesChanged();
}
} // namespace ZShell
} // namespace ZShell::llm
+11 -8
View File
@@ -7,9 +7,9 @@
#include <QString>
#include <QVariantList>
namespace ZShell {
namespace ZShell::llm {
class Chat;
class LlmClient;
class ChatStore : public QObject {
Q_OBJECT
@@ -20,19 +20,22 @@ class ChatStore : public QObject {
public:
explicit ChatStore(QObject* parent = nullptr);
~ChatStore();
~ChatStore() override;
[[nodiscard]] int count() const;
[[nodiscard]] QVariantList values() const;
[[nodiscard]] ChatSession* at(int index) const;
Q_INVOKABLE ChatSession* insert(int index = -1);
Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1);
Q_INVOKABLE void remove(int index);
Q_INVOKABLE void remove(ChatSession* chat);
Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat);
Q_INVOKABLE void move(int from, int to);
Q_INVOKABLE void clear();
[[nodiscard]] ChatSession* sessionById(const QString& id);
[[nodiscard]] LlmClient* llmClient() const { return m_llmClient; }
void setLlmClient(LlmClient* client);
void persist(ChatSession* session);
void saveMeta(ChatSession* session);
void loadMessagesInto(ChatSession* session);
@@ -40,6 +43,7 @@ class ChatStore : public QObject {
Q_SIGNALS:
void countChanged();
void valuesChanged();
void sessionRemoved(ZShell::llm::ChatSession* session);
private:
void openDb();
@@ -50,11 +54,10 @@ class ChatStore : public QObject {
void notify(const QList<ChatSession*>& before);
QList<ChatSession*> m_sessions;
LlmClient* m_llmClient = nullptr;
QString m_connectionName;
[[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 <QDateTime>
#include "messagemodel.hpp"
#include "session.hpp"
namespace ZShell {
namespace ZShell::llm {
ChatMessage::ChatMessage(
Role role,
const QString& content,
namespace {
ChatSession* sessionOf(const ChatMessage* message) {
if (auto* model = qobject_cast<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,
QObject* parent)
: QObject(parent), m_role(role), m_content(content), m_timestamp(timestamp) {
m_timer.setParent(this);
m_timer.setInterval(500);
m_timer.setTimerType(Qt::CoarseTimer);
connect(&m_timer, &QTimer::timeout, this, [this]() {
if (!reasoningInFlight() && !contentInFlight()) {
m_timer.stop();
return;
}
Q_EMIT elapsedMsChanged();
});
const QString& content,
const QString& reasoning,
qint64 reasoningElapsedMs,
qint64 contentElapsedMs) {
auto* generation = new ChatGeneration(timestamp, this);
generation->setContent(content);
generation->setReasoning(reasoning);
generation->setElapsedMs(reasoningElapsedMs, contentElapsedMs);
m_generations.append(generation);
if (m_active < 0)
m_active = static_cast<int>(m_generations.size() - 1);
Q_EMIT generationsChanged();
return generation;
}
qint64 ChatMessage::reasoningElapsedMs() const {
if (m_reasoningStartedAt <= 0)
return 0;
const qint64 end = m_reasoningEndedAt > 0
? m_reasoningEndedAt
: QDateTime::currentMSecsSinceEpoch();
return end - m_reasoningStartedAt;
ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) {
auto* generation =
addGeneration(timestamp, QString(), QString(), 0, 0);
setActiveInternal(static_cast<int>(m_generations.size() - 1));
return generation;
}
qint64 ChatMessage::contentElapsedMs() const {
if (m_contentStartedAt <= 0)
return 0;
const qint64 end = m_contentEndedAt > 0
? m_contentEndedAt
: QDateTime::currentMSecsSinceEpoch();
return end - m_contentStartedAt;
}
void ChatMessage::updateReasoningActive() {
const bool active = m_streaming && m_content.isEmpty();
if (m_reasoningActive == active)
void ChatMessage::removeGeneration(ChatGeneration* generation) {
const int index = static_cast<int>(m_generations.indexOf(generation));
if (index < 0)
return;
m_reasoningActive = active;
Q_EMIT reasoningActiveChanged();
const bool wasActive = index == m_active;
m_generations.removeAt(index);
delete generation;
if (m_generations.isEmpty()) {
m_active = -1;
} else if (m_active >= m_generations.size()) {
m_active = static_cast<int>(m_generations.size() - 1);
}
Q_EMIT generationsChanged();
if (wasActive)
Q_EMIT activeGenerationChanged();
}
void ChatMessage::appendContent(const QString& piece) {
if (piece.isEmpty())
void ChatMessage::setActiveInternal(int index) {
if (index < 0 || index >= m_generations.size() || index == m_active)
return;
if (m_content.isEmpty()) {
const qint64 now = QDateTime::currentMSecsSinceEpoch();
if (reasoningInFlight())
m_reasoningEndedAt = now;
m_contentStartedAt = now;
if (!m_timer.isActive())
m_timer.start();
}
m_content += piece;
Q_EMIT contentChanged();
updateReasoningActive();
Q_EMIT elapsedMsChanged();
m_active = index;
Q_EMIT activeGenerationChanged();
}
void ChatMessage::appendReasoning(const QString& piece) {
if (piece.isEmpty())
return;
if (m_reasoning.isEmpty()) {
if (m_reasoningStartedAt <= 0)
m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch();
if (!m_timer.isActive())
m_timer.start();
}
m_reasoning += piece;
Q_EMIT reasoningChanged();
updateReasoningActive();
Q_EMIT elapsedMsChanged();
void ChatMessage::setActiveGeneration(int index) {
setActiveInternal(index);
}
void ChatMessage::setReasoning(const QString& value) {
if (m_reasoning == value)
return;
m_reasoning = value;
Q_EMIT reasoningChanged();
void ChatMessage::edit(const QString& newContent) {
if (auto* generation = activeGeneration())
generation->setContent(newContent);
if (auto* session = sessionOf(this))
session->persist();
}
void ChatMessage::setElapsedMs(qint64 reasoningMs, qint64 contentMs) {
if (reasoningMs > 0) {
m_reasoningStartedAt = m_timestamp;
m_reasoningEndedAt = m_timestamp + reasoningMs;
}
if (contentMs > 0) {
m_contentStartedAt = m_timestamp;
m_contentEndedAt = m_timestamp + contentMs;
}
void ChatMessage::retry() {
if (auto* session = sessionOf(this))
session->retry(this);
}
void ChatMessage::setStreaming(bool value) {
if (m_streaming == value)
return;
m_streaming = value;
Q_EMIT streamingChanged();
if (value) {
if (m_reasoningStartedAt <= 0)
m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch();
if (!m_timer.isActive())
m_timer.start();
} else {
const qint64 now = QDateTime::currentMSecsSinceEpoch();
if (reasoningInFlight())
m_reasoningEndedAt = now;
if (contentInFlight())
m_contentEndedAt = now;
m_timer.stop();
Q_EMIT elapsedMsChanged();
}
updateReasoningActive();
void ChatMessage::generate() {
if (auto* session = sessionOf(this))
session->continueFrom(this);
}
} // namespace ZShell
} // namespace ZShell::llm
+48 -52
View File
@@ -1,27 +1,29 @@
#pragma once
#include "generation.hpp"
#include <QList>
#include <QObject>
#include <QString>
#include <QTimer>
#include <QtQml>
namespace ZShell {
class Chat;
namespace ZShell::llm {
class ChatMessage : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Chat messages are created by the Chat singleton")
Q_PROPERTY(Role role READ role NOTIFY roleChanged)
Q_PROPERTY(QString content READ content NOTIFY contentChanged)
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged)
Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged)
Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
Q_PROPERTY(Role role READ role CONSTANT)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(int generationCount READ generationCount NOTIFY generationsChanged)
Q_PROPERTY(
QList<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:
enum class Role : int {
@@ -31,57 +33,51 @@ class ChatMessage : public QObject {
Q_ENUM(Role)
explicit ChatMessage(
Role role,
const QString& content,
qint64 timestamp,
QObject* parent = nullptr);
Role role, qint64 timestamp, QObject* parent = nullptr);
[[nodiscard]] Role role() const { return m_role; }
[[nodiscard]] QString content() const { return m_content; }
[[nodiscard]] QString reasoning() const { return m_reasoning; }
[[nodiscard]] bool reasoningActive() const { return m_reasoningActive; }
[[nodiscard]] qint64 reasoningElapsedMs() const;
[[nodiscard]] qint64 contentElapsedMs() const;
[[nodiscard]] bool streaming() const { return m_streaming; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] int generationCount() const {
return static_cast<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);
void appendReasoning(const QString& piece);
void setReasoning(const QString& value);
void setElapsedMs(qint64 reasoningMs, qint64 contentMs);
void setStreaming(bool value);
Q_INVOKABLE void setActiveGeneration(int index);
Q_INVOKABLE void edit(const QString& newContent);
Q_INVOKABLE void retry();
Q_INVOKABLE void generate();
ChatGeneration* addGeneration(
qint64 timestamp,
const QString& content,
const QString& reasoning,
qint64 reasoningElapsedMs,
qint64 contentElapsedMs);
ChatGeneration* appendGeneration(qint64 timestamp);
void removeGeneration(ChatGeneration* generation);
Q_SIGNALS:
void roleChanged();
void contentChanged();
void reasoningChanged();
void reasoningActiveChanged();
void elapsedMsChanged();
void streamingChanged();
void generationsChanged();
void activeGenerationChanged();
private:
void updateReasoningActive();
void setActiveInternal(int index);
[[nodiscard]] bool reasoningInFlight() const {
return m_reasoningStartedAt > 0 && m_reasoningEndedAt <= 0;
}
[[nodiscard]] bool contentInFlight() const {
return m_contentStartedAt > 0 && m_contentEndedAt <= 0;
}
QTimer m_timer;
Role m_role;
QString m_content;
QString m_reasoning;
bool m_reasoningActive = false;
bool m_streaming = false;
qint64 m_timestamp;
qint64 m_reasoningStartedAt = 0;
qint64 m_reasoningEndedAt = 0;
qint64 m_contentStartedAt = 0;
qint64 m_contentEndedAt = 0;
friend class Chat;
QList<ChatGeneration*> m_generations;
int m_active = -1;
};
} // 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 "chatstore.hpp"
#include "llmclient.hpp"
#include <QDebug>
#include <QDateTime>
#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)
: QObject(parent), m_id(id) {}
: QObject(parent), m_id(id) {
m_model = new ChatMessageModel(this, this);
connect(
m_model,
&QAbstractListModel::rowsInserted,
this,
&ChatSession::onModelRowsChanged);
connect(
m_model,
&QAbstractListModel::rowsRemoved,
this,
&ChatSession::onModelRowsChanged);
connect(
m_model,
&QAbstractListModel::modelReset,
this,
&ChatSession::onModelRowsChanged);
}
void ChatSession::onModelRowsChanged() {
setCount(m_model->rowCount());
}
void ChatSession::setTitle(const QString& value) {
if (m_title == value)
return;
if (m_title == value) return;
m_title = value;
Q_EMIT titleChanged();
persist();
}
void ChatSession::setIcon(const QString& value) {
if (m_icon == value)
return;
if (m_icon == value) return;
m_icon = value;
Q_EMIT iconChanged();
}
void ChatSession::setUpdatedAt(qint64 value) {
if (m_updatedAt == value)
return;
if (m_updatedAt == value) return;
m_updatedAt = value;
Q_EMIT updatedAtChanged();
}
void ChatSession::setPinned(bool value) {
if (m_pinned == value)
return;
if (m_pinned == value) return;
m_pinned = value;
Q_EMIT pinnedChanged();
}
void ChatSession::setCount(int value) {
if (m_messageCount == value)
return;
if (m_messageCount == value) return;
m_messageCount = value;
Q_EMIT messageCountChanged();
}
void ChatSession::setMeta(
const QString& title,
qint64 createdAt,
qint64 updatedAt,
int messageCount) {
const QString& title, qint64 createdAt, qint64 updatedAt, int messageCount) {
m_title = title;
m_createdAt = createdAt;
m_updatedAt = updatedAt;
m_messageCount = messageCount;
}
void ChatSession::setLastTokenCount(int value) {
m_lastTokenCount = value;
}
LlmClient* ChatSession::client() const {
if (auto* store = qobject_cast<ChatStore*>(parent()))
return store->llmClient();
return nullptr;
}
void ChatSession::ensureLoaded() {
if (m_loaded)
return;
if (m_loaded) return;
m_loaded = true;
if (auto* store = qobject_cast<ChatStore*>(parent()))
store->loadMessagesInto(this);
}
void ChatSession::adoptMessages(QList<ChatMessage*> messages) {
qDeleteAll(m_messages);
m_messages = messages;
setCount(m_messages.size());
Q_EMIT messagesChanged();
ChatMessageModel* ChatSession::messagesModel() {
ensureLoaded();
return m_model;
}
ChatMessage* ChatSession::appendMessage(
void ChatSession::adoptMessages(QList<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) {
ensureLoaded();
auto* message = new ChatMessage(role, content, timestamp, this);
m_messages.append(message);
setCount(m_messages.size());
Q_EMIT messagesChanged();
return message;
return m_model->appendNewest(role, content, timestamp);
}
void ChatSession::removeMessage(ChatMessage* message) {
if (!message || !m_messages.removeOne(message))
return;
delete message;
setCount(m_messages.size());
Q_EMIT messagesChanged();
m_model->removeMessage(message);
}
void ChatSession::clearMessages() {
ensureLoaded();
if (m_messages.isEmpty())
return;
qDeleteAll(m_messages);
m_messages.clear();
setCount(0);
Q_EMIT messagesChanged();
m_model->clear();
}
} // namespace ZShell
void ChatSession::startGeneration(ChatMessage* target) {
if (auto* generation = target->activeGeneration()) {
if (auto* clientObject = client())
clientObject->startGeneration(this, generation);
}
persist();
}
void ChatSession::sendMessage(const QString& text) {
const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
const qint64 now = QDateTime::currentMSecsSinceEpoch();
appendNewest(ChatMessage::Role::User, trimmed, now);
while (m_model->rowCount() > 200)
m_model->removeMessage(m_model->at(m_model->rowCount() - 1));
if (m_title.isEmpty()) {
m_title = titleFrom(trimmed);
Q_EMIT titleChanged();
qInfo() << "ChatSession:" << m_id << "new conversation,"
<< "fallback title" << m_title
<< "- requesting generated title and icon";
if (auto* clientObject = client()) {
clientObject->requestTitle(this, trimmed);
clientObject->requestIcon(this, trimmed);
}
}
auto* assistant =
appendNewest(ChatMessage::Role::Assistant, QString(), now);
startGeneration(assistant);
}
void ChatSession::retry(ChatMessage* target) {
if (!target || target->role() != ChatMessage::Role::Assistant) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
const int row = m_model->rowOf(target);
if (row < 0) return;
// Drop everything newer than the target, then regenerate from the
// context ending at the user message before it.
m_model->removeRange(0, row - 1);
if (m_model->rowCount() < 2) return;
const qint64 now = QDateTime::currentMSecsSinceEpoch();
target->appendGeneration(now);
startGeneration(target);
}
void ChatSession::continueFrom(ChatMessage* message) {
if (!message) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
const int row = m_model->rowOf(message);
if (row < 0) return;
m_model->removeRange(0, row - 1);
const qint64 now = QDateTime::currentMSecsSinceEpoch();
if (message->role() == ChatMessage::Role::Assistant) {
if (m_model->rowCount() < 2) return;
message->appendGeneration(now);
startGeneration(message);
} else {
auto* assistant =
appendNewest(ChatMessage::Role::Assistant, QString(), now);
startGeneration(assistant);
}
}
void ChatSession::clear() {
if (auto* clientObject = client()) {
if (clientObject->busy() && clientObject->streamingSession() == this) {
clientObject->clearOnFinish(this);
clientObject->stop();
return;
}
}
m_model->clear();
persist();
}
} // namespace ZShell::llm
+29 -15
View File
@@ -1,14 +1,16 @@
#pragma once
#include "messagemodel.hpp"
#include "message.hpp"
#include <QDateTime>
#include <QList>
#include <QObject>
#include <QString>
#include <QtQml>
namespace ZShell {
namespace ZShell::llm {
class LlmClient;
class ChatSession : public QObject {
Q_OBJECT
@@ -16,13 +18,14 @@ class ChatSession : public QObject {
QML_UNCREATABLE("Chat sessions are managed by Chat.chats")
Q_PROPERTY(QString id READ id CONSTANT)
Q_PROPERTY(QString title READ title NOTIFY titleChanged)
Q_PROPERTY(QString title READ title WRITE setTitle NOTIFY titleChanged)
Q_PROPERTY(QString icon READ icon NOTIFY iconChanged)
Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT)
Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged)
Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged)
Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged)
Q_PROPERTY(QList<ChatMessage*> messages READ messages NOTIFY messagesChanged)
Q_PROPERTY(
ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT)
public:
explicit ChatSession(const QString& id, QObject* parent = nullptr);
@@ -40,10 +43,14 @@ class ChatSession : public QObject {
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
[[nodiscard]] bool pinned() const { return m_pinned; }
[[nodiscard]] int messageCount() const { return m_messageCount; }
[[nodiscard]] QList<ChatMessage*> messages() {
ensureLoaded();
return m_messages;
}
// Loads the messages from the store on first access.
[[nodiscard]] ChatMessageModel* messagesModel();
void ensureLoaded();
[[nodiscard]] bool isLoaded() const { return m_loaded; }
[[nodiscard]] LlmClient* client() const;
[[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; }
void setLastTokenCount(int value);
void setTitle(const QString& value);
void setIcon(const QString& value);
@@ -56,23 +63,29 @@ class ChatSession : public QObject {
qint64 updatedAt,
int messageCount);
void ensureLoaded();
[[nodiscard]] bool isLoaded() const { return m_loaded; }
// Replaces the model's rows with `messages` (most recent first).
void adoptMessages(QList<ChatMessage*> messages);
ChatMessage* appendMessage(
ChatMessage::Role role, const QString& content, qint64 timestamp);
void persist();
void removeMessage(ChatMessage* message);
void clearMessages();
Q_INVOKABLE void sendMessage(const QString& text);
Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target);
Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message);
Q_INVOKABLE void clear();
Q_SIGNALS:
void titleChanged();
void iconChanged();
void updatedAtChanged();
void pinnedChanged();
void messageCountChanged();
void messagesChanged();
private:
void onModelRowsChanged();
ChatMessage* appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp);
void startGeneration(ChatMessage* target);
void setCount(int value);
QString m_id;
@@ -82,8 +95,9 @@ class ChatSession : public QObject {
qint64 m_updatedAt = 0;
bool m_pinned = false;
int m_messageCount = 0;
QList<ChatMessage*> m_messages;
ChatMessageModel* m_model = nullptr;
bool m_loaded = false;
int m_lastTokenCount = 0;
};
} // namespace ZShell
} // namespace ZShell::llm