add removal of chat sessions, auto title + icon, proper stick-to-bottom as response is streamed
This commit is contained in:
@@ -17,10 +17,6 @@ Item {
|
|||||||
|
|
||||||
signal requestClose
|
signal requestClose
|
||||||
|
|
||||||
function atBottom(): bool {
|
|
||||||
return list.contentHeight - list.height - list.contentY < 48;
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(): void {
|
function send(): void {
|
||||||
if (Chat.busy || input.text.trim() === "")
|
if (Chat.busy || input.text.trim() === "")
|
||||||
return;
|
return;
|
||||||
@@ -29,30 +25,62 @@ Item {
|
|||||||
input.text = "";
|
input.text = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: header
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
spacing: Tokens.spacing.large
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
icon: "arrow_back"
|
||||||
|
inactiveColor: Colors.tPalette.m3surfaceContainerHigh
|
||||||
|
inactiveOnColor: Colors.palette.m3onSurfaceVariant
|
||||||
|
isRound: true
|
||||||
|
type: IconButton.Tonal
|
||||||
|
|
||||||
|
onClicked: root.requestClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.pointSize: Tokens.font.size.larger
|
||||||
|
text: qsTr(root.chatData.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
VerticalFadeListView {
|
VerticalFadeListView {
|
||||||
id: list
|
id: list
|
||||||
|
|
||||||
|
readonly property real bottomThreshold: Tokens.spacing.extraSmall
|
||||||
|
property bool stickToBottom: true
|
||||||
|
|
||||||
|
function scrollToEnd(animated: bool): void {
|
||||||
|
const target = Math.max(0, list.contentHeight - list.height);
|
||||||
|
if (!animated) {
|
||||||
|
scrollAnim.stop();
|
||||||
|
list.contentY = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollAnim.to = target;
|
||||||
|
scrollAnim.restart();
|
||||||
|
}
|
||||||
|
|
||||||
anchors.bottom: inputRow.top
|
anchors.bottom: 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: parent.top
|
anchors.top: header.bottom
|
||||||
cacheBuffer: height * 20
|
cacheBuffer: height * 20
|
||||||
clip: true
|
clip: true
|
||||||
currentIndex: messages.values.length - 1
|
currentIndex: messages.values.length - 1
|
||||||
fadeAmount: 0.1
|
|
||||||
highlightFollowsCurrentItem: true
|
|
||||||
highlightRangeMode: ListView.ApplyRange
|
|
||||||
preferredHighlightEnd: list.height
|
|
||||||
spacing: Tokens.spacing.medium
|
spacing: Tokens.spacing.medium
|
||||||
|
|
||||||
CustomScrollBar.vertical: CustomScrollBar {
|
CustomScrollBar.vertical: CustomScrollBar {
|
||||||
flickable: list
|
flickable: list
|
||||||
}
|
}
|
||||||
Behavior on contentY {
|
|
||||||
Anim {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delegate: ColumnLayout {
|
delegate: ColumnLayout {
|
||||||
id: messageRow
|
id: messageRow
|
||||||
|
|
||||||
@@ -176,6 +204,7 @@ Item {
|
|||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
||||||
text: messageRow.modelData.content
|
text: messageRow.modelData.content
|
||||||
|
textFormat: CustomText.MarkdownText
|
||||||
wrapMode: Text.WordWrap
|
wrapMode: Text.WordWrap
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +233,23 @@ Item {
|
|||||||
values: root.chatData.messages
|
values: root.chatData.messages
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: positionViewAtEnd()
|
Component.onCompleted: scrollToEnd(false)
|
||||||
|
onContentHeightChanged: {
|
||||||
|
if (list.stickToBottom)
|
||||||
|
list.scrollToEnd(false);
|
||||||
|
}
|
||||||
|
onMovementEnded: {
|
||||||
|
list.stickToBottom = (list.contentY >= list.contentHeight - list.height - list.bottomThreshold);
|
||||||
|
}
|
||||||
|
onMovementStarted: list.stickToBottom = false
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
id: scrollAnim
|
||||||
|
|
||||||
|
property: "contentY"
|
||||||
|
target: list
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
@@ -236,6 +281,31 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: scrollToBottom
|
||||||
|
|
||||||
|
anchors.bottom: inputRow.top
|
||||||
|
anchors.bottomMargin: Tokens.spacing.extraLarge
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
font.pointSize: Tokens.font.size.large
|
||||||
|
icon: "arrow_downward"
|
||||||
|
isRound: true
|
||||||
|
padding: Tokens.padding.extraSmall
|
||||||
|
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 ? 1 : 0
|
||||||
|
type: IconButton.Filled
|
||||||
|
visible: scale > 0
|
||||||
|
|
||||||
|
Behavior on scale {
|
||||||
|
Anim {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
list.stickToBottom = true;
|
||||||
|
list.scrollToEnd(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
id: inputRow
|
id: inputRow
|
||||||
|
|
||||||
@@ -258,23 +328,12 @@ Item {
|
|||||||
id: actionBtn
|
id: actionBtn
|
||||||
|
|
||||||
enabled: Chat.busy || input.text.trim() !== ""
|
enabled: Chat.busy || input.text.trim() !== ""
|
||||||
font.pointSize: 20
|
font.pointSize: Tokens.font.size.normal * 2
|
||||||
icon: Chat.busy ? "stop" : "send"
|
icon: Chat.busy ? "stop" : "send"
|
||||||
|
isRound: true
|
||||||
type: IconButton.Filled
|
type: IconButton.Filled
|
||||||
|
|
||||||
onClicked: Chat.busy ? Chat.stop() : root.send()
|
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,88 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Config
|
||||||
|
import ZShell.Llm
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property alias layout: layout
|
||||||
|
required property ChatSession modelData
|
||||||
|
|
||||||
|
color: Colors.tPalette.m3surfaceContainer
|
||||||
|
implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
id: chatIcon
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Tokens.padding.large
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
font.pointSize: Tokens.font.size.extraLarge
|
||||||
|
text: root.modelData.icon || "check"
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: layout
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: Tokens.padding.small
|
||||||
|
anchors.left: chatIcon.right
|
||||||
|
anchors.leftMargin: Tokens.padding.medium
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Tokens.padding.medium
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: Tokens.padding.small
|
||||||
|
spacing: Tokens.spacing.extraSmall
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: titleContainer
|
||||||
|
|
||||||
|
readonly property bool enoughSpace: width > title.implicitWidth + timeSep.implicitWidth + timeSep.anchors.leftMargin + timestamp.implicitWidth + timestamp.anchors.leftMargin
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: enoughSpace ? title.implicitHeight : title.implicitHeight + timestamp.implicitHeight + timestamp.anchors.topMargin
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: title
|
||||||
|
|
||||||
|
text: root.modelData.title
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: timeSep
|
||||||
|
|
||||||
|
anchors.left: title.right
|
||||||
|
anchors.leftMargin: Tokens.spacing.small
|
||||||
|
anchors.verticalCenter: title.verticalCenter
|
||||||
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
|
text: "•"
|
||||||
|
visible: titleContainer.enoughSpace
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: timestamp
|
||||||
|
|
||||||
|
anchors.left: titleContainer.enoughSpace ? timeSep.right : parent.left
|
||||||
|
anchors.leftMargin: titleContainer.enoughSpace ? Tokens.spacing.small : 0
|
||||||
|
anchors.top: titleContainer.enoughSpace ? undefined : title.bottom
|
||||||
|
anchors.topMargin: Tokens.spacing.extraSmall
|
||||||
|
anchors.verticalCenter: titleContainer.enoughSpace ? title.verticalCenter : undefined
|
||||||
|
color: 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 {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
color: Colors.palette.m3outline
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.pointSize: Tokens.font.size.small
|
||||||
|
maximumLineCount: 1
|
||||||
|
text: root.modelData.messages[root.modelData.messages.length - 1]?.content.replace(/\s+/g, " ").trim() ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,55 +13,165 @@ Item {
|
|||||||
|
|
||||||
property alias model: list.model
|
property alias model: list.model
|
||||||
|
|
||||||
|
signal deleteChatRequest(content: ChatSession)
|
||||||
signal loadChatRequest(content: ChatSession)
|
signal loadChatRequest(content: ChatSession)
|
||||||
signal newChatRequest
|
signal newChatRequest
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: header
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.margins: Tokens.padding.extraSmall
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
color: Colors.palette.m3outline
|
||||||
|
font.family: "CaskaydiaCove NF"
|
||||||
|
font.pointSize: 13
|
||||||
|
font.weight: 500
|
||||||
|
text: qsTr("%1 Chat%2").arg(list.count).arg(list.count > 1 ? "s" : "")
|
||||||
|
}
|
||||||
|
|
||||||
CustomListView {
|
CustomListView {
|
||||||
id: list
|
id: list
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: header.bottom
|
||||||
|
anchors.topMargin: Tokens.spacing.medium
|
||||||
cacheBuffer: height * 2
|
cacheBuffer: height * 2
|
||||||
clip: true
|
clip: true
|
||||||
spacing: Tokens.spacing.medium
|
spacing: Tokens.spacing.medium
|
||||||
|
|
||||||
delegate: CustomRect {
|
delegate: Component {
|
||||||
id: chatItem
|
Item {
|
||||||
|
id: chatItem
|
||||||
|
|
||||||
required property var modelData
|
property bool closing: false
|
||||||
|
property bool editVisible: false
|
||||||
|
required property var modelData
|
||||||
|
property real startY: 0.0
|
||||||
|
|
||||||
color: Colors.tPalette.m3surfaceContainer
|
anchors.fill: undefined
|
||||||
implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin
|
implicitHeight: chat.layout.implicitHeight + chat.layout.anchors.topMargin + chat.layout.anchors.bottomMargin
|
||||||
implicitWidth: ListView.view.width
|
implicitWidth: ListView.view.width
|
||||||
radius: Tokens.rounding.medium
|
|
||||||
|
|
||||||
ColumnLayout {
|
onEditVisibleChanged: if (!editVisible)
|
||||||
id: layout
|
chat.x = 0
|
||||||
|
|
||||||
anchors.bottomMargin: Tokens.padding.small
|
ParallelAnimation {
|
||||||
anchors.fill: parent
|
id: removeAnim
|
||||||
anchors.leftMargin: Tokens.padding.medium
|
|
||||||
anchors.rightMargin: Tokens.padding.medium
|
|
||||||
anchors.topMargin: Tokens.padding.small
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
CustomText {
|
onFinished: root.deleteChatRequest(chatItem.modelData)
|
||||||
id: title
|
onStarted: chatItem.closing = true
|
||||||
|
|
||||||
text: chatItem.modelData.title
|
Anim {
|
||||||
|
property: "implicitHeight"
|
||||||
|
target: chatItem
|
||||||
|
to: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
property: "x"
|
||||||
|
target: chat
|
||||||
|
to: -chat.implicitWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
property: "opacity"
|
||||||
|
target: chatItem
|
||||||
|
to: 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomText {
|
ChatDelegate {
|
||||||
id: timestamp
|
id: chat
|
||||||
|
|
||||||
color: Colors.palette.m3outline
|
implicitWidth: chatItem.implicitWidth
|
||||||
font.pointSize: Tokens.font.size.small
|
modelData: chatItem.modelData
|
||||||
text: chatItem.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
StateLayer {
|
CustomClippingRect {
|
||||||
onClicked: {
|
anchors.left: chat.right
|
||||||
root.loadChatRequest(chatItem.modelData);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ Item {
|
|||||||
values: Chat.chats.values
|
values: Chat.chats.values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDeleteChatRequest: chat => {
|
||||||
|
Chat.chats.remove(chat);
|
||||||
|
}
|
||||||
onLoadChatRequest: chat => {
|
onLoadChatRequest: chat => {
|
||||||
stack.push(chatContent, {
|
stack.push(chatContent, {
|
||||||
"chatData": chat
|
"chatData": chat
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ qml_module(ZShell-llm
|
|||||||
chatstore.hpp chatstore.cpp
|
chatstore.hpp chatstore.cpp
|
||||||
LIBRARIES
|
LIBRARIES
|
||||||
Qt::Network
|
Qt::Network
|
||||||
|
Qt::Sql
|
||||||
ZShell-config
|
ZShell-config
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+165
-1
@@ -140,8 +140,15 @@ void Chat::send(const QString& chatId, const QString& content) {
|
|||||||
QDateTime::currentMSecsSinceEpoch());
|
QDateTime::currentMSecsSinceEpoch());
|
||||||
while (session->messageCount() > 200)
|
while (session->messageCount() > 200)
|
||||||
session->removeMessage(session->messages().first());
|
session->removeMessage(session->messages().first());
|
||||||
if (session->title().isEmpty())
|
if (session->title().isEmpty()) {
|
||||||
session->setTitle(titleFrom(text));
|
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)
|
if (m_contextSize > 0 && m_lastTokenCount > m_contextSize * 4 / 5)
|
||||||
trimHistory(session, m_contextSize);
|
trimHistory(session, m_contextSize);
|
||||||
|
|
||||||
@@ -226,6 +233,162 @@ void Chat::beginAssistant() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
if (!m_busy)
|
||||||
return;
|
return;
|
||||||
@@ -275,6 +438,7 @@ void Chat::endStream() {
|
|||||||
if (m_streaming->content().isEmpty() && m_streaming->reasoning().isEmpty() && m_active)
|
if (m_streaming->content().isEmpty() && m_streaming->reasoning().isEmpty() && m_active)
|
||||||
m_active->removeMessage(m_streaming);
|
m_active->removeMessage(m_streaming);
|
||||||
m_streaming = nullptr;
|
m_streaming = nullptr;
|
||||||
|
m_active = nullptr;
|
||||||
setBusy(this, false);
|
setBusy(this, false);
|
||||||
setStreamingChatId(this, QString());
|
setStreamingChatId(this, QString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <QtQml>
|
#include <QtQml>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
#include "chatstore.hpp"
|
#include "chatstore.hpp"
|
||||||
|
|
||||||
class QQmlEngine;
|
class QQmlEngine;
|
||||||
@@ -76,6 +78,13 @@ class Chat : public QObject {
|
|||||||
void updateTokenUsage(const QJsonObject& data);
|
void updateTokenUsage(const QJsonObject& data);
|
||||||
static void trimHistory(ChatSession* session, int contextSize);
|
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 titleFrom(const QString& content);
|
||||||
static QString completionsPath(const QString& endpoint, const QString& subpath);
|
static QString completionsPath(const QString& endpoint, const QString& subpath);
|
||||||
static QString serverErrorMessage(
|
static QString serverErrorMessage(
|
||||||
|
|||||||
+230
-127
@@ -1,54 +1,104 @@
|
|||||||
#include "chatstore.hpp"
|
#include "chatstore.hpp"
|
||||||
|
|
||||||
#include "chat.hpp"
|
#include "chat.hpp"
|
||||||
|
#include "message.hpp"
|
||||||
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QFileInfoList>
|
|
||||||
#include <QJsonArray>
|
|
||||||
#include <QJsonDocument>
|
|
||||||
#include <QJsonObject>
|
|
||||||
#include <QSaveFile>
|
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
|
#include <QSqlDatabase>
|
||||||
|
#include <QSqlError>
|
||||||
|
#include <QSqlQuery>
|
||||||
|
#include <QUuid>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
namespace ZShell {
|
namespace ZShell {
|
||||||
|
|
||||||
ChatStore::ChatStore(QObject* parent) : QObject(parent) {
|
ChatStore::ChatStore(QObject* parent)
|
||||||
|
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
|
||||||
|
openDb();
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ChatStore::dir() {
|
ChatStore::~ChatStore() {
|
||||||
const QString base =
|
if (m_connectionName.isEmpty())
|
||||||
QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) +
|
return;
|
||||||
QStringLiteral("/zshell");
|
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
|
||||||
return base + QStringLiteral("/chats");
|
db.close();
|
||||||
|
QSqlDatabase::removeDatabase(m_connectionName);
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ChatStore::pathFor(const QString& id) {
|
QSqlDatabase ChatStore::db() const {
|
||||||
return dir() + QStringLiteral("/") + id + QStringLiteral(".json");
|
return QSqlDatabase::database(m_connectionName);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatStore::writeFile(const QString& path, const QJsonObject& doc) {
|
void ChatStore::openDb() {
|
||||||
|
const QString path =
|
||||||
|
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) +
|
||||||
|
QStringLiteral("/zshell/chats.sqlite");
|
||||||
QDir().mkpath(QFileInfo(path).absolutePath());
|
QDir().mkpath(QFileInfo(path).absolutePath());
|
||||||
|
|
||||||
QSaveFile file(path);
|
QSqlDatabase db =
|
||||||
if (!file.open(QIODevice::WriteOnly)) {
|
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
||||||
qWarning() << "ChatStore: failed to open" << path << "for writing:"
|
db.setDatabaseName(path);
|
||||||
<< file.errorString();
|
if (!db.open()) {
|
||||||
return false;
|
qWarning() << "ChatStore: failed to open database" << path << ":"
|
||||||
|
<< db.lastError().text();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (file.write(QJsonDocument(doc).toJson(QJsonDocument::Indented)) < 0 ||
|
{
|
||||||
!file.commit()) {
|
QSqlQuery pragma(db);
|
||||||
qWarning() << "ChatStore: failed to write" << path << ":"
|
pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
|
||||||
<< file.errorString();
|
}
|
||||||
return false;
|
{
|
||||||
|
QSqlQuery query(db);
|
||||||
|
query.exec(
|
||||||
|
QStringLiteral(
|
||||||
|
"CREATE TABLE IF NOT EXISTS sessions (\n"
|
||||||
|
" id TEXT PRIMARY KEY,\n"
|
||||||
|
" title TEXT NOT NULL DEFAULT '',\n"
|
||||||
|
" icon TEXT NOT NULL DEFAULT '',\n"
|
||||||
|
" created_at INTEGER NOT NULL,\n"
|
||||||
|
" updated_at INTEGER NOT NULL,\n"
|
||||||
|
" pinned INTEGER NOT NULL DEFAULT 0\n"
|
||||||
|
")"));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
QSqlQuery info(db);
|
||||||
|
bool hasIcon = false;
|
||||||
|
if (info.exec(QStringLiteral("PRAGMA table_info(sessions)")))
|
||||||
|
while (info.next())
|
||||||
|
if (info.value(1).toString() == QLatin1String("icon")) {
|
||||||
|
hasIcon = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!hasIcon) {
|
||||||
|
QSqlQuery alter(db);
|
||||||
|
if (!alter.exec(QStringLiteral(
|
||||||
|
"ALTER TABLE sessions ADD COLUMN icon TEXT NOT NULL "
|
||||||
|
"DEFAULT ''")))
|
||||||
|
qWarning() << "ChatStore: failed to add icon column:"
|
||||||
|
<< alter.lastError().text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
QSqlQuery query(db);
|
||||||
|
query.exec(
|
||||||
|
QStringLiteral(
|
||||||
|
"CREATE TABLE IF NOT EXISTS messages (\n"
|
||||||
|
" session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE "
|
||||||
|
"CASCADE,\n"
|
||||||
|
" role TEXT NOT NULL,\n"
|
||||||
|
" content TEXT,\n"
|
||||||
|
" reasoning TEXT,\n"
|
||||||
|
" timestamp INTEGER NOT NULL,\n"
|
||||||
|
" reasoning_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
|
||||||
|
" content_elapsed_ms INTEGER NOT NULL DEFAULT 0\n"
|
||||||
|
")"));
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int ChatStore::count() const {
|
int ChatStore::count() const {
|
||||||
@@ -71,8 +121,20 @@ 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();
|
||||||
auto* session = new ChatSession(QString::number(now), this);
|
const QString id = QString::number(now);
|
||||||
session->setPath(pathFor(session->id()));
|
{
|
||||||
|
QSqlQuery query(db());
|
||||||
|
query.prepare(
|
||||||
|
"INSERT INTO sessions (id, title, created_at, updated_at) "
|
||||||
|
"VALUES (:id, '', :created_at, :updated_at)");
|
||||||
|
query.bindValue(":id", id);
|
||||||
|
query.bindValue(":created_at", now);
|
||||||
|
query.bindValue(":updated_at", now);
|
||||||
|
if (!query.exec())
|
||||||
|
qWarning() << "ChatStore: failed to insert session" << id << ":"
|
||||||
|
<< query.lastError().text();
|
||||||
|
}
|
||||||
|
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;
|
||||||
@@ -93,15 +155,18 @@ 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 (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 (auto* chat = qobject_cast<Chat*>(parent()))
|
||||||
if (chat->m_active == session)
|
if (chat->m_active == session) {
|
||||||
chat->m_active = nullptr;
|
chat->stop();
|
||||||
|
chat->endStream();
|
||||||
|
}
|
||||||
|
const QList<ChatSession*> before = m_sessions;
|
||||||
|
{
|
||||||
|
QSqlQuery query(db());
|
||||||
|
query.prepare("DELETE FROM sessions WHERE id = :id");
|
||||||
|
query.bindValue(":id", session->id());
|
||||||
|
query.exec();
|
||||||
|
}
|
||||||
m_sessions.removeOne(session);
|
m_sessions.removeOne(session);
|
||||||
session->deleteLater();
|
session->deleteLater();
|
||||||
notify(before);
|
notify(before);
|
||||||
@@ -116,18 +181,9 @@ void ChatStore::move(int from, int to) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ChatStore::clear() {
|
void ChatStore::clear() {
|
||||||
const QList<ChatSession*> before = m_sessions;
|
const QList<ChatSession*> sessions = m_sessions;
|
||||||
QList<ChatSession*> keep;
|
for (ChatSession* session : sessions)
|
||||||
for (ChatSession* session : std::as_const(m_sessions)) {
|
removeSession(session);
|
||||||
if (isStreaming(session)) {
|
|
||||||
keep.append(session);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
QFile::remove(session->path());
|
|
||||||
session->deleteLater();
|
|
||||||
}
|
|
||||||
m_sessions = keep;
|
|
||||||
notify(before);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatSession* ChatStore::sessionById(const QString& id) {
|
ChatSession* ChatStore::sessionById(const QString& id) {
|
||||||
@@ -141,14 +197,134 @@ void ChatStore::persist(ChatSession* session) {
|
|||||||
if (!session || !m_sessions.contains(session))
|
if (!session || !m_sessions.contains(session))
|
||||||
return;
|
return;
|
||||||
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
|
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
|
||||||
if (!writeFile(session->path(), session->document()))
|
session->ensureLoaded();
|
||||||
|
if (!saveSession(session))
|
||||||
return;
|
return;
|
||||||
sortAndNotify();
|
sortAndNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatStore::isStreaming(ChatSession* session) const {
|
void ChatStore::saveMeta(ChatSession* session) {
|
||||||
const auto* chat = qobject_cast<const Chat*>(parent());
|
if (!session || !m_sessions.contains(session))
|
||||||
return chat && chat->streamingSession() == session;
|
return;
|
||||||
|
QSqlQuery query(db());
|
||||||
|
query.prepare("UPDATE sessions SET title = :title, icon = :icon "
|
||||||
|
"WHERE id = :id");
|
||||||
|
query.bindValue(":title", session->title());
|
||||||
|
query.bindValue(":icon", session->icon());
|
||||||
|
query.bindValue(":id", session->id());
|
||||||
|
if (!query.exec())
|
||||||
|
qWarning() << "ChatStore: failed to save meta for" << session->id()
|
||||||
|
<< ":" << query.lastError().text();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatStore::saveSession(ChatSession* session) {
|
||||||
|
const QString id = session->id();
|
||||||
|
QSqlDatabase handle = db();
|
||||||
|
if (!handle.transaction()) {
|
||||||
|
qWarning() << "ChatStore: failed to begin transaction:"
|
||||||
|
<< handle.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool ok = true;
|
||||||
|
{
|
||||||
|
QSqlQuery query(handle);
|
||||||
|
query.prepare(
|
||||||
|
"UPDATE sessions SET title = :title, updated_at = :updated_at "
|
||||||
|
"WHERE id = :id");
|
||||||
|
query.bindValue(":title", session->title());
|
||||||
|
query.bindValue(":updated_at", session->updatedAtMs());
|
||||||
|
query.bindValue(":id", session->id());
|
||||||
|
ok = query.exec();
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
QSqlQuery query(handle);
|
||||||
|
query.prepare("DELETE FROM messages WHERE session_id = :id");
|
||||||
|
query.bindValue(":id", session->id());
|
||||||
|
ok = query.exec();
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
QSqlQuery insert(handle);
|
||||||
|
ok = insert.prepare(
|
||||||
|
"INSERT INTO messages (session_id, role, content, reasoning, "
|
||||||
|
"timestamp, reasoning_elapsed_ms, content_elapsed_ms) "
|
||||||
|
"VALUES (:id, :role, :content, :reasoning, :timestamp, "
|
||||||
|
":reasoning_elapsed_ms, :content_elapsed_ms)");
|
||||||
|
for (const auto* message : session->messages()) {
|
||||||
|
insert.bindValue(":id", session->id());
|
||||||
|
insert.bindValue(
|
||||||
|
":role",
|
||||||
|
message->role() == ChatMessage::Role::User
|
||||||
|
? QStringLiteral("user")
|
||||||
|
: QStringLiteral("assistant"));
|
||||||
|
insert.bindValue(":content", message->content());
|
||||||
|
insert.bindValue(":reasoning", message->reasoning());
|
||||||
|
insert.bindValue(":timestamp", message->timestamp());
|
||||||
|
insert.bindValue(":reasoning_elapsed_ms", message->reasoningElapsedMs());
|
||||||
|
insert.bindValue(":content_elapsed_ms", message->contentElapsedMs());
|
||||||
|
if (!insert.exec()) {
|
||||||
|
ok = false;
|
||||||
|
qWarning() << "ChatStore: saveSession" << id << "insert failed:"
|
||||||
|
<< insert.lastError().text();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok || !handle.commit()) {
|
||||||
|
qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back";
|
||||||
|
handle.rollback();
|
||||||
|
ok = false;
|
||||||
|
qWarning() << "ChatStore: failed to save session" << session->id() << ":"
|
||||||
|
<< handle.lastError().text();
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::loadMessagesInto(ChatSession* session) {
|
||||||
|
QSqlQuery query(db());
|
||||||
|
query.prepare(
|
||||||
|
"SELECT role, content, reasoning, timestamp, reasoning_elapsed_ms, "
|
||||||
|
"content_elapsed_ms FROM messages WHERE session_id = :id ORDER BY rowid");
|
||||||
|
query.bindValue(":id", session->id());
|
||||||
|
if (!query.exec()) {
|
||||||
|
qWarning() << "ChatStore: failed to load messages for" << session->id()
|
||||||
|
<< ":" << query.lastError().text();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QList<ChatMessage*> messages;
|
||||||
|
while (query.next()) {
|
||||||
|
auto* message = new ChatMessage(
|
||||||
|
query.value(0).toString() == QLatin1String("user")
|
||||||
|
? ChatMessage::Role::User
|
||||||
|
: ChatMessage::Role::Assistant,
|
||||||
|
query.value(1).toString(),
|
||||||
|
query.value(3).toLongLong(),
|
||||||
|
session);
|
||||||
|
message->setReasoning(query.value(2).toString());
|
||||||
|
message->setElapsedMs(
|
||||||
|
query.value(4).toLongLong(), query.value(5).toLongLong());
|
||||||
|
messages.append(message);
|
||||||
|
}
|
||||||
|
session->adoptMessages(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::load() {
|
||||||
|
QSqlQuery query(db());
|
||||||
|
query.exec(
|
||||||
|
"SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, "
|
||||||
|
"(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt "
|
||||||
|
"FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC");
|
||||||
|
while (query.next()) {
|
||||||
|
auto* session = new ChatSession(query.value(0).toString(), this);
|
||||||
|
session->setMeta(
|
||||||
|
query.value(1).toString(),
|
||||||
|
query.value(3).toLongLong(),
|
||||||
|
query.value(4).toLongLong(),
|
||||||
|
query.value(6).toInt());
|
||||||
|
session->setIcon(query.value(2).toString());
|
||||||
|
session->setPinned(query.value(5).toInt() != 0);
|
||||||
|
m_sessions.append(session);
|
||||||
|
}
|
||||||
|
sortAndNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatStore::sortAndNotify() {
|
void ChatStore::sortAndNotify() {
|
||||||
@@ -157,6 +333,8 @@ void ChatStore::sortAndNotify() {
|
|||||||
m_sessions.begin(),
|
m_sessions.begin(),
|
||||||
m_sessions.end(),
|
m_sessions.end(),
|
||||||
[](const ChatSession* a, const ChatSession* b) {
|
[](const ChatSession* a, const ChatSession* b) {
|
||||||
|
if (a->pinned() != b->pinned())
|
||||||
|
return a->pinned() > b->pinned();
|
||||||
return a->updatedAtMs() > b->updatedAtMs();
|
return a->updatedAtMs() > b->updatedAtMs();
|
||||||
});
|
});
|
||||||
notify(before);
|
notify(before);
|
||||||
@@ -173,79 +351,4 @@ void ChatStore::notify(const QList<ChatSession*>& before) {
|
|||||||
Q_EMIT valuesChanged();
|
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
|
} // namespace ZShell
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
#include "session.hpp"
|
#include "session.hpp"
|
||||||
|
|
||||||
#include <QJsonObject>
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QSqlDatabase>
|
||||||
|
#include <QString>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
|
|
||||||
namespace ZShell {
|
namespace ZShell {
|
||||||
@@ -19,6 +20,7 @@ class ChatStore : public QObject {
|
|||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ChatStore(QObject* parent = nullptr);
|
explicit ChatStore(QObject* parent = nullptr);
|
||||||
|
~ChatStore();
|
||||||
|
|
||||||
[[nodiscard]] int count() const;
|
[[nodiscard]] int count() const;
|
||||||
[[nodiscard]] QVariantList values() const;
|
[[nodiscard]] QVariantList values() const;
|
||||||
@@ -32,24 +34,25 @@ class ChatStore : public QObject {
|
|||||||
|
|
||||||
[[nodiscard]] ChatSession* sessionById(const QString& id);
|
[[nodiscard]] ChatSession* sessionById(const QString& id);
|
||||||
void persist(ChatSession* session);
|
void persist(ChatSession* session);
|
||||||
|
void saveMeta(ChatSession* session);
|
||||||
|
void loadMessagesInto(ChatSession* session);
|
||||||
|
|
||||||
Q_SIGNALS:
|
Q_SIGNALS:
|
||||||
void countChanged();
|
void countChanged();
|
||||||
void valuesChanged();
|
void valuesChanged();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void openDb();
|
||||||
void load();
|
void load();
|
||||||
void migrateLegacy();
|
bool saveSession(ChatSession* session);
|
||||||
void sortAndNotify();
|
void sortAndNotify();
|
||||||
void removeSession(ChatSession* session);
|
void removeSession(ChatSession* session);
|
||||||
bool isStreaming(ChatSession* session) const;
|
|
||||||
void notify(const QList<ChatSession*>& before);
|
void notify(const QList<ChatSession*>& before);
|
||||||
|
|
||||||
QList<ChatSession*> m_sessions;
|
QList<ChatSession*> m_sessions;
|
||||||
|
QString m_connectionName;
|
||||||
|
|
||||||
static QString dir();
|
[[nodiscard]] QSqlDatabase db() const;
|
||||||
static QString pathFor(const QString& id);
|
|
||||||
static bool writeFile(const QString& path, const QJsonObject& doc);
|
|
||||||
|
|
||||||
friend class Chat;
|
friend class Chat;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ qint64 ChatMessage::contentElapsedMs() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ChatMessage::updateReasoningActive() {
|
void ChatMessage::updateReasoningActive() {
|
||||||
const bool active = m_streaming && !m_reasoning.isEmpty() && m_content.isEmpty();
|
const bool active = m_streaming && m_content.isEmpty();
|
||||||
if (m_reasoningActive == active)
|
if (m_reasoningActive == active)
|
||||||
return;
|
return;
|
||||||
m_reasoningActive = active;
|
m_reasoningActive = active;
|
||||||
@@ -69,7 +69,8 @@ void ChatMessage::appendReasoning(const QString& piece) {
|
|||||||
if (piece.isEmpty())
|
if (piece.isEmpty())
|
||||||
return;
|
return;
|
||||||
if (m_reasoning.isEmpty()) {
|
if (m_reasoning.isEmpty()) {
|
||||||
m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch();
|
if (m_reasoningStartedAt <= 0)
|
||||||
|
m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch();
|
||||||
if (!m_timer.isActive())
|
if (!m_timer.isActive())
|
||||||
m_timer.start();
|
m_timer.start();
|
||||||
}
|
}
|
||||||
@@ -102,7 +103,12 @@ void ChatMessage::setStreaming(bool value) {
|
|||||||
return;
|
return;
|
||||||
m_streaming = value;
|
m_streaming = value;
|
||||||
Q_EMIT streamingChanged();
|
Q_EMIT streamingChanged();
|
||||||
if (!value) {
|
if (value) {
|
||||||
|
if (m_reasoningStartedAt <= 0)
|
||||||
|
m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch();
|
||||||
|
if (!m_timer.isActive())
|
||||||
|
m_timer.start();
|
||||||
|
} else {
|
||||||
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||||
if (reasoningInFlight())
|
if (reasoningInFlight())
|
||||||
m_reasoningEndedAt = now;
|
m_reasoningEndedAt = now;
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
#include "session.hpp"
|
#include "session.hpp"
|
||||||
|
|
||||||
|
#include "chatstore.hpp"
|
||||||
|
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QFile>
|
#include <QtGlobal>
|
||||||
#include <QJsonArray>
|
|
||||||
#include <QJsonDocument>
|
|
||||||
#include <QJsonObject>
|
|
||||||
|
|
||||||
namespace ZShell {
|
namespace ZShell {
|
||||||
|
|
||||||
ChatSession::ChatSession(const QString& id, QObject* parent)
|
ChatSession::ChatSession(const QString& id, QObject* parent)
|
||||||
: QObject(parent), m_id(id) {
|
: QObject(parent), m_id(id) {}
|
||||||
}
|
|
||||||
|
|
||||||
void ChatSession::setTitle(const QString& value) {
|
void ChatSession::setTitle(const QString& value) {
|
||||||
if (m_title == value)
|
if (m_title == value)
|
||||||
@@ -19,6 +17,13 @@ void ChatSession::setTitle(const QString& value) {
|
|||||||
Q_EMIT titleChanged();
|
Q_EMIT titleChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ChatSession::setIcon(const QString& value) {
|
||||||
|
if (m_icon == value)
|
||||||
|
return;
|
||||||
|
m_icon = value;
|
||||||
|
Q_EMIT iconChanged();
|
||||||
|
}
|
||||||
|
|
||||||
void ChatSession::setUpdatedAt(qint64 value) {
|
void ChatSession::setUpdatedAt(qint64 value) {
|
||||||
if (m_updatedAt == value)
|
if (m_updatedAt == value)
|
||||||
return;
|
return;
|
||||||
@@ -26,6 +31,13 @@ void ChatSession::setUpdatedAt(qint64 value) {
|
|||||||
Q_EMIT updatedAtChanged();
|
Q_EMIT updatedAtChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ChatSession::setPinned(bool value) {
|
||||||
|
if (m_pinned == value)
|
||||||
|
return;
|
||||||
|
m_pinned = value;
|
||||||
|
Q_EMIT pinnedChanged();
|
||||||
|
}
|
||||||
|
|
||||||
void ChatSession::setCount(int value) {
|
void ChatSession::setCount(int value) {
|
||||||
if (m_messageCount == value)
|
if (m_messageCount == value)
|
||||||
return;
|
return;
|
||||||
@@ -45,47 +57,17 @@ void ChatSession::setMeta(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ChatSession::ensureLoaded() {
|
void ChatSession::ensureLoaded() {
|
||||||
if (m_loaded || m_path.isEmpty())
|
if (m_loaded)
|
||||||
return;
|
return;
|
||||||
m_loaded = true;
|
m_loaded = true;
|
||||||
loadMessages();
|
if (auto* store = qobject_cast<ChatStore*>(parent()))
|
||||||
|
store->loadMessagesInto(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatSession::loadMessages() {
|
void ChatSession::adoptMessages(QList<ChatMessage*> messages) {
|
||||||
QFile file(m_path);
|
qDeleteAll(m_messages);
|
||||||
if (file.open(QIODevice::ReadOnly)) {
|
m_messages = messages;
|
||||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
setCount(m_messages.size());
|
||||||
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();
|
Q_EMIT messagesChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +76,7 @@ ChatMessage* ChatSession::appendMessage(
|
|||||||
ensureLoaded();
|
ensureLoaded();
|
||||||
auto* message = new ChatMessage(role, content, timestamp, this);
|
auto* message = new ChatMessage(role, content, timestamp, this);
|
||||||
m_messages.append(message);
|
m_messages.append(message);
|
||||||
setCount(int(m_messages.size()));
|
setCount(m_messages.size());
|
||||||
Q_EMIT messagesChanged();
|
Q_EMIT messagesChanged();
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
@@ -103,7 +85,7 @@ void ChatSession::removeMessage(ChatMessage* message) {
|
|||||||
if (!message || !m_messages.removeOne(message))
|
if (!message || !m_messages.removeOne(message))
|
||||||
return;
|
return;
|
||||||
delete message;
|
delete message;
|
||||||
setCount(int(m_messages.size()));
|
setCount(m_messages.size());
|
||||||
Q_EMIT messagesChanged();
|
Q_EMIT messagesChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,34 +99,4 @@ void ChatSession::clearMessages() {
|
|||||||
Q_EMIT messagesChanged();
|
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
|
} // namespace ZShell
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
#include "message.hpp"
|
#include "message.hpp"
|
||||||
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QJsonObject>
|
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
@@ -18,8 +17,10 @@ class ChatSession : public QObject {
|
|||||||
|
|
||||||
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 NOTIFY titleChanged)
|
||||||
|
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(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(QList<ChatMessage*> messages READ messages NOTIFY messagesChanged)
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ class ChatSession : public QObject {
|
|||||||
|
|
||||||
[[nodiscard]] QString id() const { return m_id; }
|
[[nodiscard]] QString id() const { return m_id; }
|
||||||
[[nodiscard]] QString title() const { return m_title; }
|
[[nodiscard]] QString title() const { return m_title; }
|
||||||
|
[[nodiscard]] QString icon() const { return m_icon; }
|
||||||
[[nodiscard]] QDateTime createdAt() const {
|
[[nodiscard]] QDateTime createdAt() const {
|
||||||
return QDateTime::fromMSecsSinceEpoch(m_createdAt);
|
return QDateTime::fromMSecsSinceEpoch(m_createdAt);
|
||||||
}
|
}
|
||||||
@@ -36,6 +38,7 @@ class ChatSession : public QObject {
|
|||||||
}
|
}
|
||||||
[[nodiscard]] qint64 createdAtMs() const { return m_createdAt; }
|
[[nodiscard]] qint64 createdAtMs() const { return m_createdAt; }
|
||||||
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
|
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
|
||||||
|
[[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() {
|
[[nodiscard]] QList<ChatMessage*> messages() {
|
||||||
ensureLoaded();
|
ensureLoaded();
|
||||||
@@ -43,10 +46,10 @@ class ChatSession : public QObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setTitle(const QString& value);
|
void setTitle(const QString& value);
|
||||||
|
void setIcon(const QString& value);
|
||||||
void setUpdatedAt(qint64 value);
|
void setUpdatedAt(qint64 value);
|
||||||
|
void setPinned(bool value);
|
||||||
|
|
||||||
void setPath(const QString& value) { m_path = value; }
|
|
||||||
[[nodiscard]] QString path() const { return m_path; }
|
|
||||||
void setMeta(
|
void setMeta(
|
||||||
const QString& title,
|
const QString& title,
|
||||||
qint64 createdAt,
|
qint64 createdAt,
|
||||||
@@ -55,28 +58,30 @@ class ChatSession : public QObject {
|
|||||||
|
|
||||||
void ensureLoaded();
|
void ensureLoaded();
|
||||||
[[nodiscard]] bool isLoaded() const { return m_loaded; }
|
[[nodiscard]] bool isLoaded() const { return m_loaded; }
|
||||||
|
void adoptMessages(QList<ChatMessage*> messages);
|
||||||
ChatMessage* appendMessage(
|
ChatMessage* appendMessage(
|
||||||
ChatMessage::Role role, const QString& content, qint64 timestamp);
|
ChatMessage::Role role, const QString& content, qint64 timestamp);
|
||||||
void removeMessage(ChatMessage* message);
|
void removeMessage(ChatMessage* message);
|
||||||
void clearMessages();
|
void clearMessages();
|
||||||
[[nodiscard]] QJsonObject document();
|
|
||||||
|
|
||||||
Q_SIGNALS:
|
Q_SIGNALS:
|
||||||
void titleChanged();
|
void titleChanged();
|
||||||
|
void iconChanged();
|
||||||
void updatedAtChanged();
|
void updatedAtChanged();
|
||||||
|
void pinnedChanged();
|
||||||
void messageCountChanged();
|
void messageCountChanged();
|
||||||
void messagesChanged();
|
void messagesChanged();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void loadMessages();
|
|
||||||
void setCount(int value);
|
void setCount(int value);
|
||||||
|
|
||||||
QString m_id;
|
QString m_id;
|
||||||
QString m_title;
|
QString m_title;
|
||||||
|
QString m_icon;
|
||||||
qint64 m_createdAt = 0;
|
qint64 m_createdAt = 0;
|
||||||
qint64 m_updatedAt = 0;
|
qint64 m_updatedAt = 0;
|
||||||
|
bool m_pinned = false;
|
||||||
int m_messageCount = 0;
|
int m_messageCount = 0;
|
||||||
QString m_path;
|
|
||||||
QList<ChatMessage*> m_messages;
|
QList<ChatMessage*> m_messages;
|
||||||
bool m_loaded = false;
|
bool m_loaded = false;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user