better chat list view + anchor to bottom. sqlite db for chats + LIFO-ordered
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
@@ -28,6 +28,9 @@ Flickable {
|
||||
interval: 10
|
||||
running: root.doneFakeFlick
|
||||
|
||||
onTriggered: root.doneFakeFlick = false
|
||||
onTriggered: {
|
||||
root.doneFakeFlick = false;
|
||||
root.returnToBounds();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -140,7 +140,6 @@ Item {
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
popouts: popouts
|
||||
sidebar: sidebar
|
||||
visibilities: root.visibilities
|
||||
}
|
||||
|
||||
+3
-6
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user