initial commit for llm chat tab in sidebar
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property ChatSession chatData
|
||||
property bool following: true
|
||||
readonly property int messageCount: chatData.messages.length
|
||||
|
||||
signal requestClose
|
||||
|
||||
function atBottom(): bool {
|
||||
return list.contentHeight - list.height - list.contentY < 48;
|
||||
}
|
||||
|
||||
function send(): void {
|
||||
if (Chat.busy || input.text.trim() === "")
|
||||
return;
|
||||
following = true;
|
||||
Chat.send(chatData.id, input.text);
|
||||
input.text = "";
|
||||
}
|
||||
|
||||
VerticalFadeListView {
|
||||
id: list
|
||||
|
||||
anchors.bottom: inputRow.top
|
||||
anchors.bottomMargin: Tokens.spacing.medium
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
cacheBuffer: height * 20
|
||||
clip: true
|
||||
currentIndex: messages.values.length - 1
|
||||
fadeAmount: 0.1
|
||||
highlightFollowsCurrentItem: true
|
||||
highlightRangeMode: ListView.ApplyRange
|
||||
preferredHighlightEnd: list.height
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
CustomScrollBar.vertical: CustomScrollBar {
|
||||
flickable: list
|
||||
}
|
||||
Behavior on contentY {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
delegate: ColumnLayout {
|
||||
id: messageRow
|
||||
|
||||
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
||||
required property var modelData
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: messageRow.isUser ? 0 : Tokens.spacing.extraSmall
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: messageRow.isUser ? Tokens.spacing.extraSmall : 0
|
||||
|
||||
Loader {
|
||||
Layout.fillWidth: true
|
||||
Layout.rightMargin: Tokens.spacing.extraLarge
|
||||
active: !messageRow.isUser && messageRow.modelData.reasoning !== ""
|
||||
|
||||
sourceComponent: CustomRect {
|
||||
id: reasoning
|
||||
|
||||
property bool expanded: false
|
||||
|
||||
color: Colors.palette.m3surfaceContainer
|
||||
implicitHeight: expanded ? expandedText.implicitHeight : collapsedText.implicitHeight + Tokens.padding.medium * 2
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: spinnerReasoning
|
||||
|
||||
active: opacity > 0
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
opacity: messageRow.modelData.reasoningActive ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
sourceComponent: LoadingIndicator {
|
||||
implicitSize: collapsedText.implicitHeight
|
||||
}
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
id: reasoningDone
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
font.pointSize: Tokens.font.size.large
|
||||
opacity: messageRow.modelData.reasoningActive || reasoning.expanded ? 0 : 1
|
||||
text: "check"
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: collapsedText
|
||||
|
||||
anchors.left: messageRow.modelData.reasoningActive ? spinnerReasoning.right : reasoningDone.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
opacity: reasoning.expanded ? 0 : 1
|
||||
text: messageRow.modelData.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((messageRow.modelData.reasoningElapsedMs / 1000).toFixed(1))
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: expandedText
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
color: Colors.palette.m3onSurface
|
||||
opacity: reasoning.expanded ? 1 : 0
|
||||
text: messageRow.modelData.reasoning
|
||||
visible: opacity > 0
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
onClicked: reasoning.expanded = !reasoning.expanded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
Layout.alignment: messageRow.isUser ? Qt.AlignRight : Qt.AlignLeft
|
||||
color: messageRow.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
||||
implicitHeight: Math.max(msgText.implicitHeight + Tokens.padding.medium * 2, spinnerLoader.implicitHeight)
|
||||
implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, messageRow.ListView.view.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge)
|
||||
radius: Tokens.rounding.medium
|
||||
visible: !messageRow.modelData.reasoningActive
|
||||
|
||||
CustomText {
|
||||
id: msgText
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
||||
text: messageRow.modelData.content
|
||||
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 {
|
||||
id: messages
|
||||
|
||||
values: root.chatData.messages
|
||||
}
|
||||
|
||||
Component.onCompleted: positionViewAtEnd()
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: emptyState
|
||||
|
||||
anchors.fill: parent
|
||||
spacing: Tokens.spacing.small
|
||||
visible: root.messageCount === 0 && !Chat.busy
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: inputRow
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: Tokens.spacing.extraSmall
|
||||
|
||||
CustomTextField {
|
||||
id: input
|
||||
|
||||
Layout.fillWidth: true
|
||||
placeholderText: qsTr("Send a message...")
|
||||
type: CustomTextField.Filled
|
||||
|
||||
onAccepted: root.send()
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: actionBtn
|
||||
|
||||
enabled: Chat.busy || input.text.trim() !== ""
|
||||
font.pointSize: 20
|
||||
icon: Chat.busy ? "stop" : "send"
|
||||
type: IconButton.Filled
|
||||
|
||||
onClicked: Chat.busy ? Chat.stop() : root.send()
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
icon: "arrow_back"
|
||||
inactiveColor: Colors.tPalette.m3surfaceContainerHigh
|
||||
inactiveOnColor: Colors.palette.m3onSurfaceVariant
|
||||
isRound: true
|
||||
type: IconButton.Tonal
|
||||
|
||||
onClicked: root.requestClose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Llm
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
import qs.Components
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property alias model: list.model
|
||||
|
||||
signal loadChatRequest(content: ChatSession)
|
||||
signal newChatRequest
|
||||
|
||||
CustomListView {
|
||||
id: list
|
||||
|
||||
anchors.fill: parent
|
||||
cacheBuffer: height * 2
|
||||
clip: true
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
delegate: CustomRect {
|
||||
id: chatItem
|
||||
|
||||
required property var modelData
|
||||
|
||||
color: Colors.tPalette.m3surfaceContainer
|
||||
implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin
|
||||
implicitWidth: ListView.view.width
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
|
||||
anchors.bottomMargin: Tokens.padding.small
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Tokens.padding.medium
|
||||
anchors.rightMargin: Tokens.padding.medium
|
||||
anchors.topMargin: Tokens.padding.small
|
||||
spacing: 0
|
||||
|
||||
CustomText {
|
||||
id: title
|
||||
|
||||
text: chatItem.modelData.title
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: timestamp
|
||||
|
||||
color: Colors.palette.m3outline
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: chatItem.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
onClicked: {
|
||||
root.loadChatRequest(chatItem.modelData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: newChatBtn
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: Tokens.padding.small
|
||||
anchors.right: parent.right
|
||||
font.pointSize: Math.round(18 * 1.2)
|
||||
icon: "add"
|
||||
padding: 8
|
||||
radius: Tokens.rounding.full
|
||||
|
||||
onClicked: {
|
||||
root.newChatRequest();
|
||||
}
|
||||
|
||||
Elevation {
|
||||
anchors.fill: parent
|
||||
level: newChatBtn.stateLayer.containsMouse ? 4 : 3
|
||||
radius: parent.radius
|
||||
z: -1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import qs.Modules.Notifications.Sidebar
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool chatOpen: false
|
||||
required property Props props
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.small
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.props.inChat) {
|
||||
stack.push(chatList);
|
||||
stack.push(chatContent, {
|
||||
"chatData": root.props.chatSession
|
||||
});
|
||||
} else
|
||||
stack.push(chatList);
|
||||
}
|
||||
|
||||
StackView {
|
||||
id: stack
|
||||
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
Component {
|
||||
id: chatList
|
||||
|
||||
ChatList {
|
||||
model: ScriptModel {
|
||||
values: Chat.chats.values
|
||||
}
|
||||
|
||||
onLoadChatRequest: chat => {
|
||||
stack.push(chatContent, {
|
||||
"chatData": chat
|
||||
});
|
||||
root.props.inChat = true;
|
||||
root.props.chatSession = chat;
|
||||
}
|
||||
onNewChatRequest: {
|
||||
const data = Chat.chats.insert();
|
||||
stack.push(chatContent, {
|
||||
"chatData": data
|
||||
});
|
||||
root.props.inChat = true;
|
||||
root.props.chatSession = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: chatContent
|
||||
|
||||
ChatContent {
|
||||
onRequestClose: {
|
||||
stack.pop();
|
||||
root.props.inChat = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import qs.Components
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Components
|
||||
import qs.Modules.Notifications.Sidebar.Chat
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
@@ -16,15 +20,64 @@ Item {
|
||||
anchors.fill: parent
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
CustomRect {
|
||||
Tabs {
|
||||
Layout.fillWidth: true
|
||||
dashState: root.props
|
||||
nonAnimWidth: layout.width
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
color: Colors.tPalette.m3surfaceContainerLow
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
NotifDock {
|
||||
props: root.props
|
||||
visibilities: root.visibilities
|
||||
Item {
|
||||
id: pages
|
||||
|
||||
anchors.fill: parent
|
||||
opacity: root.props.currentTab === 0 ? 1 : 0
|
||||
visible: opacity > 0.01
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
anchors.fill: parent
|
||||
color: Colors.tPalette.m3surfaceContainerLow
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
NotifDock {
|
||||
props: root.props
|
||||
visibilities: root.visibilities
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: chatPage
|
||||
|
||||
anchors.fill: parent
|
||||
opacity: visible ? 1 : 0
|
||||
visible: root.props.currentTab === 1
|
||||
z: 1
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
anchors.fill: parent
|
||||
color: Colors.tPalette.m3surfaceContainerLow
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
ChatPanel {
|
||||
anchors.fill: parent
|
||||
props: root.props
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import Quickshell
|
||||
import ZShell.Llm
|
||||
|
||||
PersistentProperties {
|
||||
property ChatSession chatSession
|
||||
property int currentTab: 0
|
||||
property list<string> expandedNotifs: []
|
||||
property bool inChat: false
|
||||
|
||||
reloadableId: "sidebar"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Templates
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property alias count: bar.count
|
||||
required property PersistentProperties dashState
|
||||
required property real nonAnimWidth
|
||||
|
||||
implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight
|
||||
|
||||
TabBar {
|
||||
id: bar
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
background: null
|
||||
currentIndex: root.dashState.currentTab
|
||||
implicitHeight: contentHeight
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: bar.contentModel
|
||||
}
|
||||
}
|
||||
|
||||
onCurrentIndexChanged: root.state.currentTab = currentIndex
|
||||
|
||||
Tab {
|
||||
iconName: "notifications"
|
||||
text: qsTr("Notifications")
|
||||
}
|
||||
|
||||
Tab {
|
||||
iconName: "chat"
|
||||
text: qsTr("Chat")
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: indicator
|
||||
|
||||
anchors.top: bar.bottom
|
||||
clip: true
|
||||
implicitHeight: 3
|
||||
implicitWidth: bar.currentItem.implicitWidth
|
||||
x: {
|
||||
const tab = bar.currentItem;
|
||||
const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count;
|
||||
return width * tab.TabBar.index + (width - tab.implicitWidth) / 2;
|
||||
}
|
||||
|
||||
Behavior on implicitWidth {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
Behavior on x {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
color: Colors.palette.m3primary
|
||||
implicitHeight: parent.implicitHeight * 2
|
||||
radius: Tokens.rounding.full
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: separator
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: indicator.bottom
|
||||
color: Colors.palette.m3outlineVariant
|
||||
implicitHeight: 1
|
||||
}
|
||||
|
||||
component Tab: TabButton {
|
||||
id: tab
|
||||
|
||||
readonly property bool current: TabBar.tabBar.currentItem === this
|
||||
required property string iconName
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 1
|
||||
background: null
|
||||
implicitHeight: implicitContentHeight
|
||||
implicitWidth: implicitContentWidth
|
||||
|
||||
contentItem: Item {
|
||||
implicitHeight: icon.height + label.height
|
||||
implicitWidth: Math.max(icon.width, label.width)
|
||||
|
||||
StateLayer {
|
||||
color: tab.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
onClicked: root.dashState.currentTab = tab.TabBar.index
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
id: icon
|
||||
|
||||
anchors.bottom: label.top
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant
|
||||
fill: tab.current ? 1 : 0
|
||||
font.pointSize: 18
|
||||
text: tab.iconName
|
||||
|
||||
Behavior on fill {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: label
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant
|
||||
text: tab.text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,12 @@ Item {
|
||||
required property var visibilities
|
||||
|
||||
anchors.bottomMargin: (-implicitHeight - 5) * offsetScale
|
||||
implicitHeight: content.implicitHeight + 8 * 2
|
||||
clip: chatActive
|
||||
implicitHeight: chatActive ? 21 : content.implicitHeight + 8 * 2
|
||||
implicitWidth: sidebar.width * (1 - sidebar.offsetScale)
|
||||
opacity: 1 - offsetScale
|
||||
opacity: (1 - offsetScale) * chatFade
|
||||
readonly property bool chatActive: sidebar.chatActive
|
||||
property real chatFade: chatActive ? 0 : 1
|
||||
visible: offsetScale < 1
|
||||
|
||||
Behavior on offsetScale {
|
||||
@@ -35,6 +38,20 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on chatFade {
|
||||
Anim {
|
||||
duration: Tokens.anim.durations.expressiveDefaultSpatial
|
||||
easing: Tokens.anim.expressiveDefaultSpatial
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
duration: Tokens.anim.durations.expressiveDefaultSpatial
|
||||
easing: Tokens.anim.expressiveDefaultSpatial
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: content
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.Components
|
||||
import qs.Components.Toast
|
||||
import ZShell
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
@@ -12,6 +16,7 @@ Item {
|
||||
readonly property Props props: Props {
|
||||
}
|
||||
readonly property bool shouldBeActive: root.visibilities.sidebar && Config.sidebar.enabled
|
||||
readonly property bool chatActive: props.currentTab === 1
|
||||
required property var visibilities
|
||||
|
||||
anchors.rightMargin: (-implicitWidth - 5) * offsetScale
|
||||
@@ -26,6 +31,17 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Chat
|
||||
|
||||
function onErrorOccurred(message: string): void {
|
||||
if (root.shouldBeActive && root.chatActive)
|
||||
return;
|
||||
|
||||
Toaster.toast(qsTr("Chat"), message, "error_outline", Toast.Error);
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: content
|
||||
|
||||
|
||||
@@ -79,3 +79,4 @@ add_subdirectory(Services)
|
||||
add_subdirectory(Components)
|
||||
add_subdirectory(Blobs)
|
||||
add_subdirectory(Config)
|
||||
add_subdirectory(Llm)
|
||||
|
||||
@@ -16,6 +16,7 @@ qml_module(ZShell-config
|
||||
dock.hpp
|
||||
general.hpp
|
||||
launcher.hpp
|
||||
llm.hpp
|
||||
lock.hpp
|
||||
notifs.hpp
|
||||
osd.hpp
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "dock.hpp"
|
||||
#include "general.hpp"
|
||||
#include "launcher.hpp"
|
||||
#include "llm.hpp"
|
||||
#include "lock.hpp"
|
||||
#include "notifs.hpp"
|
||||
#include "osd.hpp"
|
||||
@@ -33,6 +34,8 @@
|
||||
|
||||
namespace ZShell::config {
|
||||
|
||||
Config* Config::s_instance = nullptr;
|
||||
|
||||
Config::Config(QObject* parent)
|
||||
: ConfigObject(parent)
|
||||
, m_appearance(new Appearance(this))
|
||||
@@ -44,6 +47,7 @@ Config::Config(QObject* parent)
|
||||
, m_dock(new Dock(this))
|
||||
, m_general(new General(this))
|
||||
, m_launcher(new Launcher(this))
|
||||
, m_llm(new Llm(this))
|
||||
, m_lock(new Lock(this))
|
||||
, m_notifs(new Notifs(this))
|
||||
, m_osd(new Osd(this))
|
||||
@@ -51,6 +55,7 @@ Config::Config(QObject* parent)
|
||||
, m_services(new Services(this))
|
||||
, m_sidebar(new Sidebar(this))
|
||||
, m_utilities(new Utilities(this)) {
|
||||
s_instance = this;
|
||||
connect(this, &ConfigObject::propertiesChanged, this, &Config::scheduleSave);
|
||||
|
||||
m_saveTimer.setSingleShot(true);
|
||||
@@ -81,8 +86,14 @@ Config::Config(QObject* parent)
|
||||
m_firstLoadDone = true;
|
||||
}
|
||||
|
||||
Config* Config::instance() {
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
Config* Config::create(QQmlEngine*, QJSEngine*) {
|
||||
return new Config();
|
||||
if (!s_instance)
|
||||
s_instance = new Config();
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
QString Config::filePath() const {
|
||||
|
||||
@@ -24,6 +24,7 @@ class Dashboard;
|
||||
class Dock;
|
||||
class General;
|
||||
class Launcher;
|
||||
class Llm;
|
||||
class Lock;
|
||||
class Notifs;
|
||||
class Osd;
|
||||
@@ -46,6 +47,7 @@ class Config : public ConfigObject {
|
||||
Q_MOC_INCLUDE("dock.hpp")
|
||||
Q_MOC_INCLUDE("general.hpp")
|
||||
Q_MOC_INCLUDE("launcher.hpp")
|
||||
Q_MOC_INCLUDE("llm.hpp")
|
||||
Q_MOC_INCLUDE("lock.hpp")
|
||||
Q_MOC_INCLUDE("notifs.hpp")
|
||||
Q_MOC_INCLUDE("osd.hpp")
|
||||
@@ -63,6 +65,7 @@ class Config : public ConfigObject {
|
||||
CONFIG_SUBOBJECT(Dock, dock)
|
||||
CONFIG_SUBOBJECT(General, general)
|
||||
CONFIG_SUBOBJECT(Launcher, launcher)
|
||||
CONFIG_SUBOBJECT(Llm, llm)
|
||||
CONFIG_SUBOBJECT(Lock, lock)
|
||||
CONFIG_SUBOBJECT(Notifs, notifs)
|
||||
CONFIG_SUBOBJECT(Osd, osd)
|
||||
@@ -74,6 +77,7 @@ class Config : public ConfigObject {
|
||||
public:
|
||||
explicit Config(QObject* parent = nullptr);
|
||||
static Config* create(QQmlEngine*, QJSEngine*);
|
||||
[[nodiscard]] static Config* instance();
|
||||
|
||||
Q_INVOKABLE void load();
|
||||
Q_INVOKABLE void saveNow();
|
||||
@@ -103,6 +107,8 @@ class Config : public ConfigObject {
|
||||
bool m_loading = false;
|
||||
bool m_firstLoadDone = false;
|
||||
QFuture<void> m_loadFuture;
|
||||
|
||||
static Config* s_instance;
|
||||
};
|
||||
|
||||
} // namespace ZShell::config
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
#include "configobject.hpp"
|
||||
#include <qqmlintegration.h>
|
||||
|
||||
namespace ZShell::config {
|
||||
|
||||
class Llm : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
CFG_PROPERTY(QString, endpoint, "http://localhost:8080")
|
||||
CFG_PROPERTY(QString, model, "")
|
||||
CFG_PROPERTY(double, temperature, 0.7)
|
||||
|
||||
public:
|
||||
explicit Llm(QObject* parent = nullptr) : ConfigObject(parent) {}
|
||||
};
|
||||
|
||||
} // namespace ZShell::config
|
||||
@@ -0,0 +1,13 @@
|
||||
qml_module(ZShell-llm
|
||||
URI ZShell.Llm
|
||||
SOURCES
|
||||
chat.hpp chat.cpp
|
||||
session.hpp session.cpp
|
||||
message.hpp message.cpp
|
||||
chatstore.hpp chatstore.cpp
|
||||
LIBRARIES
|
||||
Qt::Network
|
||||
ZShell-config
|
||||
)
|
||||
|
||||
target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Config)
|
||||
@@ -0,0 +1,494 @@
|
||||
#include "chat.hpp"
|
||||
|
||||
#include "config.hpp"
|
||||
#include "llm.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSet>
|
||||
#include <QUrl>
|
||||
|
||||
namespace ZShell {
|
||||
|
||||
QString Chat::titleFrom(const QString& content) {
|
||||
const QString flat = content.simplified();
|
||||
if (flat.isEmpty())
|
||||
return QString();
|
||||
if (flat.size() <= 48)
|
||||
return flat;
|
||||
return flat.left(47) + QStringLiteral("…");
|
||||
}
|
||||
|
||||
QString Chat::completionsPath(const QString& endpoint, const QString& subpath) {
|
||||
QString base = endpoint.trimmed();
|
||||
while (base.endsWith('/'))
|
||||
base.chop(1);
|
||||
if (!base.endsWith("/v1"))
|
||||
base += "/v1";
|
||||
return base + subpath;
|
||||
}
|
||||
|
||||
QString Chat::serverErrorMessage(
|
||||
const QByteArray& body, const QString& fallback) {
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(body);
|
||||
if (doc.isObject()) {
|
||||
const QJsonObject obj = doc.object();
|
||||
if (obj.contains("error")) {
|
||||
const QJsonValue errorValue = obj["error"];
|
||||
if (errorValue.isObject()) {
|
||||
const QString message = errorValue.toObject()["message"].toString();
|
||||
if (!message.isEmpty())
|
||||
return message;
|
||||
} else if (!errorValue.toString().isEmpty()) {
|
||||
return errorValue.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed")
|
||||
: fallback;
|
||||
}
|
||||
|
||||
void Chat::setBusy(Chat* chat, bool value) {
|
||||
if (chat->m_busy == value)
|
||||
return;
|
||||
chat->m_busy = value;
|
||||
Q_EMIT chat->busyChanged();
|
||||
}
|
||||
|
||||
void Chat::setStreamingChatId(Chat* chat, const QString& id) {
|
||||
if (chat->m_streamingChatId == id)
|
||||
return;
|
||||
chat->m_streamingChatId = id;
|
||||
Q_EMIT chat->streamingChatIdChanged();
|
||||
}
|
||||
|
||||
Chat::Chat(QObject* parent) : QObject(parent), m_store(new ChatStore(this)) {
|
||||
if (!config::Config::instance())
|
||||
new config::Config();
|
||||
|
||||
const auto* llm = config::Config::instance()->llm();
|
||||
m_endpoint = llm->endpoint();
|
||||
m_temperature = llm->temperature();
|
||||
m_model = llm->model();
|
||||
|
||||
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
|
||||
if (m_endpoint != llm->endpoint()) {
|
||||
m_endpoint = llm->endpoint();
|
||||
Q_EMIT endpointChanged();
|
||||
probeContextSize();
|
||||
}
|
||||
if (m_model.isEmpty())
|
||||
refreshModels();
|
||||
});
|
||||
connect(llm, &config::Llm::modelChanged, this, [this, llm]() {
|
||||
if (m_model == llm->model())
|
||||
return;
|
||||
m_model = llm->model();
|
||||
Q_EMIT modelChanged();
|
||||
if (m_model.isEmpty())
|
||||
refreshModels();
|
||||
});
|
||||
connect(
|
||||
llm,
|
||||
&config::Llm::temperatureChanged,
|
||||
this,
|
||||
[this, llm]() { m_temperature = llm->temperature(); });
|
||||
|
||||
if (m_model.isEmpty())
|
||||
refreshModels();
|
||||
probeContextSize();
|
||||
}
|
||||
|
||||
Chat::~Chat() {
|
||||
if (m_reply)
|
||||
m_reply->abort();
|
||||
}
|
||||
|
||||
Chat* Chat::s_instance = nullptr;
|
||||
|
||||
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
|
||||
if (!s_instance)
|
||||
s_instance = new Chat();
|
||||
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));
|
||||
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::stop() {
|
||||
if (!m_busy)
|
||||
return;
|
||||
if (m_reply)
|
||||
m_reply->abort();
|
||||
}
|
||||
|
||||
void Chat::dismissError() {
|
||||
if (m_lastError.isEmpty())
|
||||
return;
|
||||
m_lastError.clear();
|
||||
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;
|
||||
setBusy(this, false);
|
||||
setStreamingChatId(this, QString());
|
||||
}
|
||||
|
||||
void Chat::fail(const QString& message) {
|
||||
qWarning() << "Chat:" << message;
|
||||
|
||||
const bool overflow = message.contains("overflow") ||
|
||||
message.contains("exceed") ||
|
||||
m_lastTokenCount > m_contextSize;
|
||||
const QString shown = overflow
|
||||
? QStringLiteral(
|
||||
"%1 (using ~%2 of %3 tokens; the oldest messages were auto-removed "
|
||||
"so the next one will fit)")
|
||||
.arg(message, QString::number(m_lastTokenCount),
|
||||
QString::number(m_contextSize))
|
||||
: message;
|
||||
m_lastError = shown;
|
||||
Q_EMIT lastErrorChanged();
|
||||
|
||||
if (m_streaming) {
|
||||
ChatSession* session = m_active;
|
||||
endStream();
|
||||
if (overflow)
|
||||
trimHistory(session, m_contextSize);
|
||||
if (session && m_pendingClearChatId == session->id()) {
|
||||
session->clearMessages();
|
||||
m_pendingClearChatId.clear();
|
||||
}
|
||||
if (session)
|
||||
m_store->persist(session);
|
||||
}
|
||||
Q_EMIT errorOccurred(shown);
|
||||
}
|
||||
|
||||
void Chat::drainBuffer() {
|
||||
while (true) {
|
||||
const qsizetype newline = m_buffer.indexOf('\n');
|
||||
if (newline < 0)
|
||||
break;
|
||||
const QByteArray line = m_buffer.left(newline).trimmed();
|
||||
m_buffer.remove(0, newline + 1);
|
||||
handleLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
void Chat::handleLine(const QByteArray& line) {
|
||||
if (line.isEmpty() || line.startsWith('#') || !line.startsWith("data:"))
|
||||
return;
|
||||
|
||||
const QByteArray data = line.mid(5).trimmed();
|
||||
if (data == "[DONE]") {
|
||||
finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(data);
|
||||
if (!doc.isObject())
|
||||
return;
|
||||
const QJsonObject obj = doc.object();
|
||||
updateTokenUsage(obj);
|
||||
|
||||
if (obj.contains("error")) {
|
||||
const QJsonObject error = obj["error"].toObject();
|
||||
const QString message = error["message"].toString();
|
||||
fail(message.isEmpty() ? QStringLiteral("LLM server returned an error") : message);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const QJsonValue& choiceValue : obj["choices"].toArray()) {
|
||||
const QJsonObject delta = choiceValue.toObject()["delta"].toObject();
|
||||
if (!m_streaming)
|
||||
continue;
|
||||
m_streaming->appendContent(delta["content"].toString());
|
||||
QString reasoning = delta["reasoning_content"].toString();
|
||||
if (reasoning.isEmpty())
|
||||
reasoning = delta["reasoning"].toString();
|
||||
m_streaming->appendReasoning(reasoning);
|
||||
}
|
||||
}
|
||||
|
||||
void Chat::refreshModels() {
|
||||
const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
|
||||
if (!url.isValid() || url.host().isEmpty())
|
||||
return;
|
||||
|
||||
auto* reply = m_manager.get(QNetworkRequest(url));
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||
const QNetworkReply::NetworkError error = reply->error();
|
||||
const QByteArray data = reply->readAll();
|
||||
reply->deleteLater();
|
||||
|
||||
if (error != QNetworkReply::NoError) {
|
||||
qWarning() << "Chat: failed to fetch models:" << error;
|
||||
return;
|
||||
}
|
||||
const QJsonArray dataArr =
|
||||
QJsonDocument::fromJson(data).object()["data"].toArray();
|
||||
|
||||
QStringList models;
|
||||
QSet<QString> seen;
|
||||
for (const QJsonValue& value : dataArr) {
|
||||
const QString id = value.toObject()["id"].toString();
|
||||
if (!id.isEmpty() && !seen.contains(id)) {
|
||||
seen.insert(id);
|
||||
models.append(id);
|
||||
}
|
||||
}
|
||||
if (models.isEmpty())
|
||||
return;
|
||||
|
||||
m_availableModels = models;
|
||||
Q_EMIT availableModelsChanged();
|
||||
|
||||
if (m_model.isEmpty()) {
|
||||
m_model = models.first();
|
||||
Q_EMIT modelChanged();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void Chat::selectModel(const QString& id) {
|
||||
if (id.isEmpty())
|
||||
return;
|
||||
m_model = id;
|
||||
Q_EMIT modelChanged();
|
||||
config::Config::instance()->llm()->set_model(id);
|
||||
}
|
||||
|
||||
void Chat::setContextSize(int size) {
|
||||
if (size <= 0 || m_contextSize == size)
|
||||
return;
|
||||
m_contextSize = size;
|
||||
Q_EMIT contextSizeChanged();
|
||||
}
|
||||
|
||||
void Chat::probeContextSize() {
|
||||
QString base = m_endpoint.trimmed();
|
||||
while (base.endsWith('/'))
|
||||
base.chop(1);
|
||||
const QUrl url = QUrl::fromUserInput(base + "/props");
|
||||
if (!url.isValid() || url.host().isEmpty())
|
||||
return;
|
||||
|
||||
auto* reply = m_manager.get(QNetworkRequest(url));
|
||||
connect(
|
||||
reply,
|
||||
&QNetworkReply::finished,
|
||||
this,
|
||||
[this, reply]() {
|
||||
const QNetworkReply::NetworkError error = reply->error();
|
||||
const QByteArray data = reply->readAll();
|
||||
reply->deleteLater();
|
||||
|
||||
int size = 0;
|
||||
if (error == QNetworkReply::NoError) {
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(data);
|
||||
if (doc.isArray()) {
|
||||
for (const auto& value : doc.array()) {
|
||||
const QJsonObject slot = value.toObject();
|
||||
if (slot.contains("n_ctx")) {
|
||||
size = slot["n_ctx"].toInt(0);
|
||||
if (size > 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (doc.isObject()) {
|
||||
const QJsonObject obj = doc.object();
|
||||
size = obj["n_ctx"].toInt(0);
|
||||
if (size <= 0)
|
||||
size = obj["default_generation_settings"].toObject()["n_ctx"].toInt(0);
|
||||
}
|
||||
}
|
||||
setContextSize(size > 0 ? size : 4096);
|
||||
});
|
||||
}
|
||||
|
||||
void Chat::updateTokenUsage(const QJsonObject& data) {
|
||||
if (m_contextSize <= 0)
|
||||
return;
|
||||
const QJsonObject usage = data["usage"].toObject();
|
||||
if (usage.isEmpty())
|
||||
return;
|
||||
const double used = usage.value("prompt_tokens").toDouble() +
|
||||
usage.value("completion_tokens").toDouble();
|
||||
if (used > 0)
|
||||
m_lastTokenCount = qMin(static_cast<int>(used), m_contextSize * 2);
|
||||
}
|
||||
|
||||
void Chat::trimHistory(ChatSession* session, int contextSize) {
|
||||
if (!session || contextSize <= 0)
|
||||
return;
|
||||
const int budget = contextSize * 4 / 5;
|
||||
auto estimate = [&](const ChatMessage* message) -> qsizetype {
|
||||
return (message->content().size() +
|
||||
message->reasoning().size()) /
|
||||
4;
|
||||
};
|
||||
qsizetype total = 0;
|
||||
for (const auto* message : session->messages())
|
||||
total += estimate(message);
|
||||
while (total > budget && session->messageCount() >= 2) {
|
||||
ChatMessage* first = session->messages().first();
|
||||
const qsizetype used = estimate(first);
|
||||
session->removeMessage(first);
|
||||
total -= used;
|
||||
if (session->messageCount() >= 2 &&
|
||||
session->messages().first()->role() == ChatMessage::Role::Assistant) {
|
||||
ChatMessage* second = session->messages().first();
|
||||
const qsizetype usedSecond = estimate(second);
|
||||
session->removeMessage(second);
|
||||
total -= usedSecond;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ZShell
|
||||
@@ -0,0 +1,108 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QList>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QtQml>
|
||||
|
||||
#include "chatstore.hpp"
|
||||
|
||||
class QQmlEngine;
|
||||
class QJSEngine;
|
||||
class QNetworkReply;
|
||||
|
||||
namespace ZShell {
|
||||
|
||||
class Chat : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_SINGLETON
|
||||
|
||||
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||
Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged)
|
||||
Q_PROPERTY(QString model READ model NOTIFY modelChanged)
|
||||
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged)
|
||||
Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged)
|
||||
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
|
||||
Q_PROPERTY(ChatStore* chats READ chats CONSTANT)
|
||||
Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged)
|
||||
|
||||
public:
|
||||
explicit Chat(QObject* parent = nullptr);
|
||||
~Chat();
|
||||
|
||||
[[nodiscard]] bool busy() const { return m_busy; }
|
||||
[[nodiscard]] QString endpoint() const { return m_endpoint; }
|
||||
[[nodiscard]] QString model() const { return m_model; }
|
||||
[[nodiscard]] QStringList availableModels() const { return m_availableModels; }
|
||||
[[nodiscard]] int contextSize() const { return m_contextSize; }
|
||||
[[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; }
|
||||
[[nodiscard]] QString lastError() const { return m_lastError; }
|
||||
[[nodiscard]] ChatStore* chats() const { return m_store; }
|
||||
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
|
||||
[[nodiscard]] ChatSession* streamingSession() const { return m_active; }
|
||||
|
||||
Q_INVOKABLE void send(const QString& chatId, const QString& content);
|
||||
Q_INVOKABLE void stop();
|
||||
Q_INVOKABLE void clearConversation(const QString& chatId);
|
||||
Q_INVOKABLE void dismissError();
|
||||
Q_INVOKABLE void refreshModels();
|
||||
Q_INVOKABLE void selectModel(const QString& id);
|
||||
|
||||
static Chat* create(QQmlEngine*, QJSEngine*);
|
||||
|
||||
Q_SIGNALS:
|
||||
void busyChanged();
|
||||
void endpointChanged();
|
||||
void modelChanged();
|
||||
void availableModelsChanged();
|
||||
void contextSizeChanged();
|
||||
void errorOccurred(const QString& message);
|
||||
void lastErrorChanged();
|
||||
void streamingChatIdChanged();
|
||||
|
||||
private:
|
||||
void beginAssistant();
|
||||
void endStream();
|
||||
void finalize();
|
||||
void fail(const QString& message);
|
||||
void handleLine(const QByteArray& line);
|
||||
void drainBuffer();
|
||||
void probeContextSize();
|
||||
void setContextSize(int size);
|
||||
void updateTokenUsage(const QJsonObject& data);
|
||||
static void trimHistory(ChatSession* session, int contextSize);
|
||||
|
||||
static QString titleFrom(const QString& content);
|
||||
static QString completionsPath(const QString& endpoint, const QString& subpath);
|
||||
static QString serverErrorMessage(
|
||||
const QByteArray& body, const QString& fallback);
|
||||
static void setBusy(Chat* chat, bool value);
|
||||
static void setStreamingChatId(Chat* chat, const QString& id);
|
||||
|
||||
QNetworkAccessManager m_manager;
|
||||
ChatStore* m_store = nullptr;
|
||||
QNetworkReply* m_reply = nullptr;
|
||||
QByteArray m_buffer;
|
||||
ChatSession* m_active = nullptr;
|
||||
ChatMessage* m_streaming = nullptr;
|
||||
QString m_pendingClearChatId;
|
||||
bool m_busy = false;
|
||||
QString m_endpoint;
|
||||
QString m_model;
|
||||
QStringList m_availableModels;
|
||||
QString m_lastError;
|
||||
QString m_streamingChatId;
|
||||
double m_temperature = 0.7;
|
||||
int m_contextSize = 0;
|
||||
int m_lastTokenCount = 0;
|
||||
|
||||
static Chat* s_instance;
|
||||
|
||||
friend class ChatStore;
|
||||
};
|
||||
|
||||
} // namespace ZShell
|
||||
@@ -0,0 +1,251 @@
|
||||
#include "chatstore.hpp"
|
||||
|
||||
#include "chat.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QFileInfoList>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace ZShell {
|
||||
|
||||
ChatStore::ChatStore(QObject* parent) : QObject(parent) {
|
||||
load();
|
||||
}
|
||||
|
||||
QString ChatStore::dir() {
|
||||
const QString base =
|
||||
QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) +
|
||||
QStringLiteral("/zshell");
|
||||
return base + QStringLiteral("/chats");
|
||||
}
|
||||
|
||||
QString ChatStore::pathFor(const QString& id) {
|
||||
return dir() + QStringLiteral("/") + id + QStringLiteral(".json");
|
||||
}
|
||||
|
||||
bool ChatStore::writeFile(const QString& path, const QJsonObject& doc) {
|
||||
QDir().mkpath(QFileInfo(path).absolutePath());
|
||||
|
||||
QSaveFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
qWarning() << "ChatStore: failed to open" << path << "for writing:"
|
||||
<< file.errorString();
|
||||
return false;
|
||||
}
|
||||
if (file.write(QJsonDocument(doc).toJson(QJsonDocument::Indented)) < 0 ||
|
||||
!file.commit()) {
|
||||
qWarning() << "ChatStore: failed to write" << path << ":"
|
||||
<< file.errorString();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int ChatStore::count() const {
|
||||
return static_cast<int>(m_sessions.size());
|
||||
}
|
||||
|
||||
QVariantList ChatStore::values() const {
|
||||
QVariantList vals;
|
||||
vals.reserve(m_sessions.size());
|
||||
for (const auto* session : m_sessions)
|
||||
vals.append(QVariant::fromValue(session));
|
||||
return vals;
|
||||
}
|
||||
|
||||
ChatSession* ChatStore::at(int index) const {
|
||||
if (index < 0 || index >= m_sessions.size())
|
||||
return nullptr;
|
||||
return m_sessions.at(index);
|
||||
}
|
||||
|
||||
ChatSession* ChatStore::insert(int index) {
|
||||
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||
auto* session = new ChatSession(QString::number(now), this);
|
||||
session->setPath(pathFor(session->id()));
|
||||
session->setMeta(QString(), now, now, 0);
|
||||
const int pos =
|
||||
index >= 0 && index <= m_sessions.size() ? index : 0;
|
||||
m_sessions.insert(pos, session);
|
||||
Q_EMIT countChanged();
|
||||
Q_EMIT valuesChanged();
|
||||
return session;
|
||||
}
|
||||
|
||||
void ChatStore::remove(int index) {
|
||||
removeSession(at(index));
|
||||
}
|
||||
|
||||
void ChatStore::remove(ChatSession* chat) {
|
||||
removeSession(chat);
|
||||
}
|
||||
|
||||
void ChatStore::removeSession(ChatSession* session) {
|
||||
if (!session || !m_sessions.contains(session))
|
||||
return;
|
||||
if (isStreaming(session)) {
|
||||
qWarning() << "ChatStore: cannot remove a chat that is streaming";
|
||||
return;
|
||||
}
|
||||
const QList<ChatSession*> before = m_sessions;
|
||||
QFile::remove(session->path());
|
||||
if (auto* chat = qobject_cast<Chat*>(parent()))
|
||||
if (chat->m_active == session)
|
||||
chat->m_active = nullptr;
|
||||
m_sessions.removeOne(session);
|
||||
session->deleteLater();
|
||||
notify(before);
|
||||
}
|
||||
|
||||
void ChatStore::move(int from, int to) {
|
||||
if (from < 0 || from >= m_sessions.size() || to < 0 ||
|
||||
to >= m_sessions.size() || from == to)
|
||||
return;
|
||||
m_sessions.move(from, to);
|
||||
Q_EMIT valuesChanged();
|
||||
}
|
||||
|
||||
void ChatStore::clear() {
|
||||
const QList<ChatSession*> before = m_sessions;
|
||||
QList<ChatSession*> keep;
|
||||
for (ChatSession* session : std::as_const(m_sessions)) {
|
||||
if (isStreaming(session)) {
|
||||
keep.append(session);
|
||||
continue;
|
||||
}
|
||||
QFile::remove(session->path());
|
||||
session->deleteLater();
|
||||
}
|
||||
m_sessions = keep;
|
||||
notify(before);
|
||||
}
|
||||
|
||||
ChatSession* ChatStore::sessionById(const QString& id) {
|
||||
for (auto* session : m_sessions)
|
||||
if (session->id() == id)
|
||||
return session;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ChatStore::persist(ChatSession* session) {
|
||||
if (!session || !m_sessions.contains(session))
|
||||
return;
|
||||
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
|
||||
if (!writeFile(session->path(), session->document()))
|
||||
return;
|
||||
sortAndNotify();
|
||||
}
|
||||
|
||||
bool ChatStore::isStreaming(ChatSession* session) const {
|
||||
const auto* chat = qobject_cast<const Chat*>(parent());
|
||||
return chat && chat->streamingSession() == session;
|
||||
}
|
||||
|
||||
void ChatStore::sortAndNotify() {
|
||||
const QList<ChatSession*> before = m_sessions;
|
||||
std::stable_sort(
|
||||
m_sessions.begin(),
|
||||
m_sessions.end(),
|
||||
[](const ChatSession* a, const ChatSession* b) {
|
||||
return a->updatedAtMs() > b->updatedAtMs();
|
||||
});
|
||||
notify(before);
|
||||
}
|
||||
|
||||
void ChatStore::notify(const QList<ChatSession*>& before) {
|
||||
if (before.size() != m_sessions.size())
|
||||
Q_EMIT countChanged();
|
||||
bool same = before.size() == m_sessions.size();
|
||||
for (int i = 0; same && i < m_sessions.size(); ++i)
|
||||
if (before.at(i) != m_sessions.at(i))
|
||||
same = false;
|
||||
if (!same)
|
||||
Q_EMIT valuesChanged();
|
||||
}
|
||||
|
||||
void ChatStore::load() {
|
||||
const QFileInfoList files = QDir(dir()).entryInfoList(
|
||||
QStringList{QStringLiteral("*.json")},
|
||||
QDir::Files,
|
||||
QDir::Time);
|
||||
for (const QFileInfo& info : files) {
|
||||
QFile file(info.absoluteFilePath());
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
continue;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||
file.close();
|
||||
if (!doc.isObject())
|
||||
continue;
|
||||
const QJsonObject obj = doc.object();
|
||||
auto* session =
|
||||
new ChatSession(obj["id"].toString(info.completeBaseName()), this);
|
||||
session->setPath(info.absoluteFilePath());
|
||||
session->setMeta(
|
||||
obj["title"].toString(),
|
||||
obj["createdAt"].toVariant().toLongLong(),
|
||||
obj["updatedAt"].toVariant().toLongLong(),
|
||||
int(obj["messages"].toArray().size()));
|
||||
m_sessions.append(session);
|
||||
}
|
||||
sortAndNotify();
|
||||
migrateLegacy();
|
||||
}
|
||||
|
||||
void ChatStore::migrateLegacy() {
|
||||
const QString path = dir() + QStringLiteral("/chat.json");
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||
file.close();
|
||||
const QJsonArray arr = doc.isArray() ? doc.array() : QJsonArray();
|
||||
|
||||
QString title;
|
||||
qint64 first = 0;
|
||||
qint64 last = 0;
|
||||
QJsonArray messages;
|
||||
for (const QJsonValue& value : arr) {
|
||||
const QJsonObject msg = value.toObject();
|
||||
const QString content = msg["content"].toString();
|
||||
if (content.isEmpty())
|
||||
continue;
|
||||
if (first == 0)
|
||||
first = msg["timestamp"].toVariant().toLongLong();
|
||||
last = msg["timestamp"].toVariant().toLongLong();
|
||||
if (title.isEmpty() && msg["role"].toString() == "user")
|
||||
title = Chat::titleFrom(content);
|
||||
messages.append(msg);
|
||||
}
|
||||
if (messages.isEmpty())
|
||||
return;
|
||||
|
||||
const QString id = QString::number(QDateTime::currentMSecsSinceEpoch());
|
||||
auto* session = new ChatSession(id, this);
|
||||
session->setPath(pathFor(id));
|
||||
|
||||
QJsonObject out;
|
||||
out[QStringLiteral("id")] = id;
|
||||
out[QStringLiteral("title")] = title;
|
||||
out[QStringLiteral("createdAt")] = first;
|
||||
out[QStringLiteral("updatedAt")] = last;
|
||||
out[QStringLiteral("messages")] = messages;
|
||||
if (!writeFile(session->path(), out))
|
||||
return;
|
||||
QFile::remove(path);
|
||||
|
||||
session->setMeta(title, first, last, int(messages.size()));
|
||||
m_sessions.append(session);
|
||||
sortAndNotify();
|
||||
}
|
||||
|
||||
} // namespace ZShell
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include "session.hpp"
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QVariantList>
|
||||
|
||||
namespace ZShell {
|
||||
|
||||
class Chat;
|
||||
|
||||
class ChatStore : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||
Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged)
|
||||
|
||||
public:
|
||||
explicit ChatStore(QObject* parent = nullptr);
|
||||
|
||||
[[nodiscard]] int count() const;
|
||||
[[nodiscard]] QVariantList values() const;
|
||||
[[nodiscard]] ChatSession* at(int index) const;
|
||||
|
||||
Q_INVOKABLE ChatSession* insert(int index = -1);
|
||||
Q_INVOKABLE void remove(int index);
|
||||
Q_INVOKABLE void remove(ChatSession* chat);
|
||||
Q_INVOKABLE void move(int from, int to);
|
||||
Q_INVOKABLE void clear();
|
||||
|
||||
[[nodiscard]] ChatSession* sessionById(const QString& id);
|
||||
void persist(ChatSession* session);
|
||||
|
||||
Q_SIGNALS:
|
||||
void countChanged();
|
||||
void valuesChanged();
|
||||
|
||||
private:
|
||||
void load();
|
||||
void migrateLegacy();
|
||||
void sortAndNotify();
|
||||
void removeSession(ChatSession* session);
|
||||
bool isStreaming(ChatSession* session) const;
|
||||
void notify(const QList<ChatSession*>& before);
|
||||
|
||||
QList<ChatSession*> m_sessions;
|
||||
|
||||
static QString dir();
|
||||
static QString pathFor(const QString& id);
|
||||
static bool writeFile(const QString& path, const QJsonObject& doc);
|
||||
|
||||
friend class Chat;
|
||||
};
|
||||
|
||||
} // namespace ZShell
|
||||
@@ -0,0 +1,117 @@
|
||||
#include "message.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
|
||||
namespace ZShell {
|
||||
|
||||
ChatMessage::ChatMessage(
|
||||
Role role,
|
||||
const QString& content,
|
||||
qint64 timestamp,
|
||||
QObject* parent)
|
||||
: QObject(parent), m_role(role), m_content(content), m_timestamp(timestamp) {
|
||||
m_timer.setParent(this);
|
||||
m_timer.setInterval(500);
|
||||
m_timer.setTimerType(Qt::CoarseTimer);
|
||||
connect(&m_timer, &QTimer::timeout, this, [this]() {
|
||||
if (!reasoningInFlight() && !contentInFlight()) {
|
||||
m_timer.stop();
|
||||
return;
|
||||
}
|
||||
Q_EMIT elapsedMsChanged();
|
||||
});
|
||||
}
|
||||
|
||||
qint64 ChatMessage::reasoningElapsedMs() const {
|
||||
if (m_reasoningStartedAt <= 0)
|
||||
return 0;
|
||||
const qint64 end = m_reasoningEndedAt > 0
|
||||
? m_reasoningEndedAt
|
||||
: QDateTime::currentMSecsSinceEpoch();
|
||||
return end - m_reasoningStartedAt;
|
||||
}
|
||||
|
||||
qint64 ChatMessage::contentElapsedMs() const {
|
||||
if (m_contentStartedAt <= 0)
|
||||
return 0;
|
||||
const qint64 end = m_contentEndedAt > 0
|
||||
? m_contentEndedAt
|
||||
: QDateTime::currentMSecsSinceEpoch();
|
||||
return end - m_contentStartedAt;
|
||||
}
|
||||
|
||||
void ChatMessage::updateReasoningActive() {
|
||||
const bool active = m_streaming && !m_reasoning.isEmpty() && m_content.isEmpty();
|
||||
if (m_reasoningActive == active)
|
||||
return;
|
||||
m_reasoningActive = active;
|
||||
Q_EMIT reasoningActiveChanged();
|
||||
}
|
||||
|
||||
void ChatMessage::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 ChatMessage::appendReasoning(const QString& piece) {
|
||||
if (piece.isEmpty())
|
||||
return;
|
||||
if (m_reasoning.isEmpty()) {
|
||||
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) {
|
||||
if (m_reasoning == value)
|
||||
return;
|
||||
m_reasoning = value;
|
||||
Q_EMIT reasoningChanged();
|
||||
}
|
||||
|
||||
void ChatMessage::setElapsedMs(qint64 reasoningMs, qint64 contentMs) {
|
||||
if (reasoningMs > 0) {
|
||||
m_reasoningStartedAt = m_timestamp;
|
||||
m_reasoningEndedAt = m_timestamp + reasoningMs;
|
||||
}
|
||||
if (contentMs > 0) {
|
||||
m_contentStartedAt = m_timestamp;
|
||||
m_contentEndedAt = m_timestamp + contentMs;
|
||||
}
|
||||
}
|
||||
|
||||
void ChatMessage::setStreaming(bool value) {
|
||||
if (m_streaming == value)
|
||||
return;
|
||||
m_streaming = value;
|
||||
Q_EMIT streamingChanged();
|
||||
if (!value) {
|
||||
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||
if (reasoningInFlight())
|
||||
m_reasoningEndedAt = now;
|
||||
if (contentInFlight())
|
||||
m_contentEndedAt = now;
|
||||
m_timer.stop();
|
||||
Q_EMIT elapsedMsChanged();
|
||||
}
|
||||
updateReasoningActive();
|
||||
}
|
||||
|
||||
} // namespace ZShell
|
||||
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
#include <QtQml>
|
||||
|
||||
namespace ZShell {
|
||||
|
||||
class Chat;
|
||||
|
||||
class ChatMessage : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("Chat messages are created by the Chat singleton")
|
||||
|
||||
Q_PROPERTY(Role role READ role NOTIFY roleChanged)
|
||||
Q_PROPERTY(QString content READ content NOTIFY contentChanged)
|
||||
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged)
|
||||
Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged)
|
||||
Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged)
|
||||
Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
|
||||
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
|
||||
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
|
||||
|
||||
public:
|
||||
enum class Role : int {
|
||||
User = 0,
|
||||
Assistant
|
||||
};
|
||||
Q_ENUM(Role)
|
||||
|
||||
explicit ChatMessage(
|
||||
Role role,
|
||||
const QString& content,
|
||||
qint64 timestamp,
|
||||
QObject* parent = nullptr);
|
||||
|
||||
[[nodiscard]] Role role() const { return m_role; }
|
||||
[[nodiscard]] QString content() const { return m_content; }
|
||||
[[nodiscard]] QString reasoning() const { return m_reasoning; }
|
||||
[[nodiscard]] bool reasoningActive() const { return m_reasoningActive; }
|
||||
[[nodiscard]] qint64 reasoningElapsedMs() const;
|
||||
[[nodiscard]] qint64 contentElapsedMs() const;
|
||||
[[nodiscard]] bool streaming() const { return m_streaming; }
|
||||
[[nodiscard]] qint64 timestamp() const { return m_timestamp; }
|
||||
|
||||
void appendContent(const QString& piece);
|
||||
void appendReasoning(const QString& piece);
|
||||
void setReasoning(const QString& value);
|
||||
void setElapsedMs(qint64 reasoningMs, qint64 contentMs);
|
||||
void setStreaming(bool value);
|
||||
|
||||
Q_SIGNALS:
|
||||
void roleChanged();
|
||||
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;
|
||||
Role m_role;
|
||||
QString m_content;
|
||||
QString m_reasoning;
|
||||
bool m_reasoningActive = false;
|
||||
bool m_streaming = false;
|
||||
qint64 m_timestamp;
|
||||
qint64 m_reasoningStartedAt = 0;
|
||||
qint64 m_reasoningEndedAt = 0;
|
||||
qint64 m_contentStartedAt = 0;
|
||||
qint64 m_contentEndedAt = 0;
|
||||
|
||||
friend class Chat;
|
||||
};
|
||||
|
||||
} // namespace ZShell
|
||||
@@ -0,0 +1,150 @@
|
||||
#include "session.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace ZShell {
|
||||
|
||||
ChatSession::ChatSession(const QString& id, QObject* parent)
|
||||
: QObject(parent), m_id(id) {
|
||||
}
|
||||
|
||||
void ChatSession::setTitle(const QString& value) {
|
||||
if (m_title == value)
|
||||
return;
|
||||
m_title = value;
|
||||
Q_EMIT titleChanged();
|
||||
}
|
||||
|
||||
void ChatSession::setUpdatedAt(qint64 value) {
|
||||
if (m_updatedAt == value)
|
||||
return;
|
||||
m_updatedAt = value;
|
||||
Q_EMIT updatedAtChanged();
|
||||
}
|
||||
|
||||
void ChatSession::setCount(int value) {
|
||||
if (m_messageCount == value)
|
||||
return;
|
||||
m_messageCount = value;
|
||||
Q_EMIT messageCountChanged();
|
||||
}
|
||||
|
||||
void ChatSession::setMeta(
|
||||
const QString& title,
|
||||
qint64 createdAt,
|
||||
qint64 updatedAt,
|
||||
int messageCount) {
|
||||
m_title = title;
|
||||
m_createdAt = createdAt;
|
||||
m_updatedAt = updatedAt;
|
||||
m_messageCount = messageCount;
|
||||
}
|
||||
|
||||
void ChatSession::ensureLoaded() {
|
||||
if (m_loaded || m_path.isEmpty())
|
||||
return;
|
||||
m_loaded = true;
|
||||
loadMessages();
|
||||
}
|
||||
|
||||
void ChatSession::loadMessages() {
|
||||
QFile file(m_path);
|
||||
if (file.open(QIODevice::ReadOnly)) {
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||
file.close();
|
||||
if (doc.isObject()) {
|
||||
const QJsonObject obj = doc.object();
|
||||
m_title = obj["title"].toString(m_title);
|
||||
m_createdAt = obj["createdAt"].toVariant().toLongLong() || m_createdAt;
|
||||
m_updatedAt = obj["updatedAt"].toVariant().toLongLong() || m_updatedAt;
|
||||
for (const QJsonValue& value : obj["messages"].toArray()) {
|
||||
const QJsonObject msg = value.toObject();
|
||||
const QString content = msg["content"].toString();
|
||||
const QString reasoning = msg["reasoning"].toString();
|
||||
if (content.isEmpty() && reasoning.isEmpty())
|
||||
continue;
|
||||
auto* message = new ChatMessage(
|
||||
msg["role"].toString() == "user" ? ChatMessage::Role::User
|
||||
: ChatMessage::Role::Assistant,
|
||||
content,
|
||||
msg["timestamp"].toVariant().toLongLong(),
|
||||
this);
|
||||
message->setReasoning(reasoning);
|
||||
message->setElapsedMs(
|
||||
msg["reasoningElapsedMs"].toVariant().toLongLong(),
|
||||
msg["contentElapsedMs"].toVariant().toLongLong());
|
||||
m_messages.append(message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
qWarning() << "ChatSession: failed to load" << m_path << ":"
|
||||
<< file.errorString();
|
||||
}
|
||||
setCount(int(m_messages.size()));
|
||||
Q_EMIT titleChanged();
|
||||
Q_EMIT messagesChanged();
|
||||
}
|
||||
|
||||
ChatMessage* ChatSession::appendMessage(
|
||||
ChatMessage::Role role, const QString& content, qint64 timestamp) {
|
||||
ensureLoaded();
|
||||
auto* message = new ChatMessage(role, content, timestamp, this);
|
||||
m_messages.append(message);
|
||||
setCount(int(m_messages.size()));
|
||||
Q_EMIT messagesChanged();
|
||||
return message;
|
||||
}
|
||||
|
||||
void ChatSession::removeMessage(ChatMessage* message) {
|
||||
if (!message || !m_messages.removeOne(message))
|
||||
return;
|
||||
delete message;
|
||||
setCount(int(m_messages.size()));
|
||||
Q_EMIT messagesChanged();
|
||||
}
|
||||
|
||||
void ChatSession::clearMessages() {
|
||||
ensureLoaded();
|
||||
if (m_messages.isEmpty())
|
||||
return;
|
||||
qDeleteAll(m_messages);
|
||||
m_messages.clear();
|
||||
setCount(0);
|
||||
Q_EMIT messagesChanged();
|
||||
}
|
||||
|
||||
QJsonObject ChatSession::document() {
|
||||
ensureLoaded();
|
||||
QJsonArray arr;
|
||||
for (const auto* message : m_messages) {
|
||||
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")] = message->reasoning();
|
||||
const qint64 reasoningMs = message->reasoningElapsedMs();
|
||||
if (reasoningMs > 0)
|
||||
messageObj[QStringLiteral("reasoningElapsedMs")] = reasoningMs;
|
||||
const qint64 contentMs = message->contentElapsedMs();
|
||||
if (contentMs > 0)
|
||||
messageObj[QStringLiteral("contentElapsedMs")] = contentMs;
|
||||
messageObj[QStringLiteral("timestamp")] = message->timestamp();
|
||||
arr.append(messageObj);
|
||||
}
|
||||
QJsonObject doc;
|
||||
doc[QStringLiteral("id")] = m_id;
|
||||
doc[QStringLiteral("title")] = m_title;
|
||||
doc[QStringLiteral("createdAt")] = m_createdAt;
|
||||
doc[QStringLiteral("updatedAt")] = m_updatedAt;
|
||||
doc[QStringLiteral("messages")] = arr;
|
||||
return doc;
|
||||
}
|
||||
|
||||
} // namespace ZShell
|
||||
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
|
||||
#include "message.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QtQml>
|
||||
|
||||
namespace ZShell {
|
||||
|
||||
class ChatSession : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("Chat sessions are managed by Chat.chats")
|
||||
|
||||
Q_PROPERTY(QString id READ id CONSTANT)
|
||||
Q_PROPERTY(QString title READ title NOTIFY titleChanged)
|
||||
Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT)
|
||||
Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged)
|
||||
Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged)
|
||||
Q_PROPERTY(QList<ChatMessage*> messages READ messages NOTIFY messagesChanged)
|
||||
|
||||
public:
|
||||
explicit ChatSession(const QString& id, QObject* parent = nullptr);
|
||||
|
||||
[[nodiscard]] QString id() const { return m_id; }
|
||||
[[nodiscard]] QString title() const { return m_title; }
|
||||
[[nodiscard]] QDateTime createdAt() const {
|
||||
return QDateTime::fromMSecsSinceEpoch(m_createdAt);
|
||||
}
|
||||
[[nodiscard]] QDateTime updatedAt() const {
|
||||
return QDateTime::fromMSecsSinceEpoch(m_updatedAt);
|
||||
}
|
||||
[[nodiscard]] qint64 createdAtMs() const { return m_createdAt; }
|
||||
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
|
||||
[[nodiscard]] int messageCount() const { return m_messageCount; }
|
||||
[[nodiscard]] QList<ChatMessage*> messages() {
|
||||
ensureLoaded();
|
||||
return m_messages;
|
||||
}
|
||||
|
||||
void setTitle(const QString& value);
|
||||
void setUpdatedAt(qint64 value);
|
||||
|
||||
void setPath(const QString& value) { m_path = value; }
|
||||
[[nodiscard]] QString path() const { return m_path; }
|
||||
void setMeta(
|
||||
const QString& title,
|
||||
qint64 createdAt,
|
||||
qint64 updatedAt,
|
||||
int messageCount);
|
||||
|
||||
void ensureLoaded();
|
||||
[[nodiscard]] bool isLoaded() const { return m_loaded; }
|
||||
ChatMessage* appendMessage(
|
||||
ChatMessage::Role role, const QString& content, qint64 timestamp);
|
||||
void removeMessage(ChatMessage* message);
|
||||
void clearMessages();
|
||||
[[nodiscard]] QJsonObject document();
|
||||
|
||||
Q_SIGNALS:
|
||||
void titleChanged();
|
||||
void updatedAtChanged();
|
||||
void messageCountChanged();
|
||||
void messagesChanged();
|
||||
|
||||
private:
|
||||
void loadMessages();
|
||||
void setCount(int value);
|
||||
|
||||
QString m_id;
|
||||
QString m_title;
|
||||
qint64 m_createdAt = 0;
|
||||
qint64 m_updatedAt = 0;
|
||||
int m_messageCount = 0;
|
||||
QString m_path;
|
||||
QList<ChatMessage*> m_messages;
|
||||
bool m_loaded = false;
|
||||
};
|
||||
|
||||
} // namespace ZShell
|
||||
Reference in New Issue
Block a user