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