From 9657e5092af5d766aa92d19ce11ad523739e37ab Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 20 Aug 2026 17:40:33 +0200 Subject: [PATCH] add removal of chat sessions, auto title + icon, proper stick-to-bottom as response is streamed --- .../Sidebar/Chat/ChatContent.qml | 113 ++++-- .../Sidebar/Chat/ChatDelegate.qml | 88 +++++ .../Notifications/Sidebar/Chat/ChatList.qml | 166 ++++++-- .../Notifications/Sidebar/Chat/ChatPanel.qml | 3 + Plugins/ZShell/Llm/CMakeLists.txt | 1 + Plugins/ZShell/Llm/chat.cpp | 166 +++++++- Plugins/ZShell/Llm/chat.hpp | 9 + Plugins/ZShell/Llm/chatstore.cpp | 357 +++++++++++------- Plugins/ZShell/Llm/chatstore.hpp | 15 +- Plugins/ZShell/Llm/message.cpp | 12 +- Plugins/ZShell/Llm/session.cpp | 102 ++--- Plugins/ZShell/Llm/session.hpp | 17 +- 12 files changed, 776 insertions(+), 273 deletions(-) create mode 100644 Modules/Notifications/Sidebar/Chat/ChatDelegate.qml diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index a859b96..8438147 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -17,10 +17,6 @@ Item { signal requestClose - function atBottom(): bool { - return list.contentHeight - list.height - list.contentY < 48; - } - function send(): void { if (Chat.busy || input.text.trim() === "") return; @@ -29,30 +25,62 @@ Item { 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 { 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.bottomMargin: Tokens.spacing.medium anchors.left: parent.left anchors.right: parent.right - anchors.top: parent.top + anchors.top: header.bottom 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 @@ -176,6 +204,7 @@ Item { anchors.top: parent.top color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface text: messageRow.modelData.content + textFormat: CustomText.MarkdownText wrapMode: Text.WordWrap } @@ -204,7 +233,23 @@ Item { 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 { @@ -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 { id: inputRow @@ -258,23 +328,12 @@ Item { id: actionBtn enabled: Chat.busy || input.text.trim() !== "" - font.pointSize: 20 + font.pointSize: Tokens.font.size.normal * 2 icon: Chat.busy ? "stop" : "send" + isRound: true 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() - } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml new file mode 100644 index 0000000..129958a --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -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() ?? "" + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index c98c385..20979fb 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -13,55 +13,165 @@ Item { property alias model: list.model + signal deleteChatRequest(content: ChatSession) signal loadChatRequest(content: ChatSession) 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 { 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 clip: true spacing: Tokens.spacing.medium - delegate: CustomRect { - id: chatItem + delegate: Component { + 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 - implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin - implicitWidth: ListView.view.width - radius: Tokens.rounding.medium + anchors.fill: undefined + implicitHeight: chat.layout.implicitHeight + chat.layout.anchors.topMargin + chat.layout.anchors.bottomMargin + implicitWidth: ListView.view.width - ColumnLayout { - id: layout + onEditVisibleChanged: if (!editVisible) + chat.x = 0 - 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 + ParallelAnimation { + id: removeAnim - CustomText { - id: title + onFinished: root.deleteChatRequest(chatItem.modelData) + 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 { - id: timestamp + ChatDelegate { + id: chat - 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") + implicitWidth: chatItem.implicitWidth + modelData: chatItem.modelData + radius: Tokens.rounding.medium + + Behavior on x { + Anim { + } + } + Behavior on y { + Anim { + } + } + + StateLayer { + cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.PointingHandCursor + drag.axis: Drag.XAxis + drag.target: chat + hoverEnabled: true + preventStealing: true + + onClicked: { + if (chatItem.editVisible) + chatItem.editVisible = false; + else + root.loadChatRequest(chatItem.modelData); + } + 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 { - onClicked: { - root.loadChatRequest(chatItem.modelData); + CustomClippingRect { + anchors.left: chat.right + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.extraSmall + anchors.verticalCenter: chat.verticalCenter + implicitHeight: editTools.implicitHeight + + RowLayout { + id: editTools + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + IconButton { + id: deleteChatBtn + + font.pointSize: Tokens.font.size.large + icon: "close" + radius: Tokens.rounding.full + + transform: [ + Scale { + property bool shouldBeActive: (chat.x < -deleteChatBtn.implicitWidth) && !chatItem.closing + + origin.x: deleteChatBtn.implicitWidth / 2 + origin.y: deleteChatBtn.implicitWidth / 2 + xScale: shouldBeActive ? 1 : 0 + yScale: shouldBeActive ? 1 : 0 + + Behavior on xScale { + Anim { + } + } + Behavior on yScale { + Anim { + } + } + } + ] + + onClicked: { + removeAnim.start(); + } + } + } } } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml index 57674de..bc87a94 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -42,6 +42,9 @@ Item { values: Chat.chats.values } + onDeleteChatRequest: chat => { + Chat.chats.remove(chat); + } onLoadChatRequest: chat => { stack.push(chatContent, { "chatData": chat diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index 9474ceb..be25202 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -7,6 +7,7 @@ qml_module(ZShell-llm chatstore.hpp chatstore.cpp LIBRARIES Qt::Network + Qt::Sql ZShell-config ) diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index f053599..98e429e 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -140,8 +140,15 @@ void Chat::send(const QString& chatId, const QString& content) { QDateTime::currentMSecsSinceEpoch()); while (session->messageCount() > 200) session->removeMessage(session->messages().first()); - if (session->title().isEmpty()) + if (session->title().isEmpty()) { session->setTitle(titleFrom(text)); + qInfo() << "Chat: new session" << chatId + << "fallback title" << session->title() + << "- requesting generated title and icon, model" << m_model + << "endpoint" << m_endpoint; + requestTitle(chatId, text); + requestIcon(chatId, text); + } if (m_contextSize > 0 && m_lastTokenCount > m_contextSize * 4 / 5) trimHistory(session, m_contextSize); @@ -226,6 +233,162 @@ void Chat::beginAssistant() { }); } +void Chat::shortRequest( + const QString& tag, + const QString& systemPrompt, + const QString& userText, + std::function onResult) { + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) + return; + + qInfo() << "Chat:" << tag << "request POST" << url.toString() + << "model=" << m_model; + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + + QJsonArray messages; + QJsonObject system; + system[QStringLiteral("role")] = QStringLiteral("system"); + system[QStringLiteral("content")] = systemPrompt; + messages.append(system); + QJsonObject user; + user[QStringLiteral("role")] = QStringLiteral("user"); + user[QStringLiteral("content")] = userText.simplified().mid(0, 512); + messages.append(user); + + QJsonObject body; + if (!m_model.isEmpty()) + body[QStringLiteral("model")] = m_model; + body[QStringLiteral("stream")] = false; + body[QStringLiteral("temperature")] = 0.3; + body[QStringLiteral("max_tokens")] = 128; + QJsonObject templateKwargs; + templateKwargs[QStringLiteral("enable_thinking")] = false; + body[QStringLiteral("chat_template_kwargs")] = templateKwargs; + body[QStringLiteral("messages")] = messages; + + auto* reply = m_manager.post(request, QJsonDocument(body).toJson()); + connect( + reply, + &QNetworkReply::finished, + this, + [this, reply, tag, onResult = std::move(onResult)]() { + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + qInfo() << "Chat:" << tag << "request finished" + << "error=" << reply->error() << reply->errorString() + << "http=" << reply->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt() + << "response=" + << QString::fromUtf8(data.left(400)).simplified(); + + if (reply->error() != QNetworkReply::NoError) + return; + + const QJsonDocument doc = QJsonDocument::fromJson(data); + const QJsonArray choices = + doc.object()[QStringLiteral("choices")].toArray(); + if (choices.isEmpty()) + return; + const QString result = + choices.at(0).toObject()[QStringLiteral("message")].toObject() + [QStringLiteral("content")].toString() + .trimmed(); + qInfo() << "Chat:" << tag << "raw result" << result; + onResult(result); + }); +} + +void Chat::requestTitle(const QString& chatId, const QString& userText) { + shortRequest( + QStringLiteral("title"), + QStringLiteral( + "Write a short, concise title for a chat conversation starting " + "with the user's message below. At most six words, no quotation " + "marks, no trailing punctuation. Reply with the title only."), + userText, + [this, chatId](QString title) { + ChatSession* session = m_store->sessionById(chatId); + if (!session) { + qWarning() << "Chat: title request: session gone" << chatId; + return; + } + const auto isQuote = [](QChar c) { + return c == QLatin1Char('"') || c == QLatin1Char('\'') || + c == QChar(u'\u201C') || c == QChar(u'\u201D') || + c == QChar(u'\u2018') || c == QChar(u'\u2019'); + }; + while (title.size() >= 2 && isQuote(title.at(0)) && + isQuote(title.at(title.size() - 1))) + title = title.mid(1, title.size() - 2).simplified(); + while (!title.isEmpty() && + (title.endsWith(QLatin1Char('.')) || + title.endsWith(QLatin1Char('!')) || + title.endsWith(QLatin1Char('?')))) + title.chop(1); + if (title.size() < 2) { + qWarning() << "Chat: title rejected (too short)" << chatId + << title; + return; + } + if (title.size() > 48) + title = title.left(47) + QStringLiteral("…"); + qInfo() << "Chat: applying generated title" << chatId << title + << "(was" << session->title() << ")"; + session->setTitle(title); + m_store->saveMeta(session); + }); +} + +void Chat::requestIcon(const QString& chatId, const QString& userText) { + const QStringList icons = { + "chat", "lightbulb", "code", + "description", "article", "school", + "work", "build", "science", + "palette", "music_note", "sports_esports", + "takeout_dining", "flight", "photo_camera", + "psychology_alt", "favorite", "savings", + "gamepad", "auto_awesome", + }; + const QString prompt = + QStringLiteral( + "Pick the single icon name from this list that best matches the " + "topic of the user's message below: %1. Reply with only the icon " + "name, exactly as written in the list, and nothing else.") + .arg(icons.join(QStringLiteral(", "))); + shortRequest( + QStringLiteral("icon"), + prompt, + userText, + [this, chatId, icons](QString name) { + ChatSession* session = m_store->sessionById(chatId); + if (!session) { + qWarning() << "Chat: icon request: session gone" << chatId; + return; + } + name = name.simplified().toLower(); + while (name.size() >= 2 && + (name.at(0) == QLatin1Char('"') || + name.at(0) == QLatin1Char('\''))) + name = name.mid(1).left(name.size() - 2).simplified(); + name.replace(QLatin1Char(' '), QLatin1Char('_')); + if (!icons.contains(name)) { + qWarning() << "Chat: icon not in list, using default" << chatId + << name; + name = QStringLiteral("chat"); + } + qInfo() << "Chat: applying generated icon" << chatId << name + << "(was" << session->icon() << ")"; + session->setIcon(name); + m_store->saveMeta(session); + }); +} + void Chat::stop() { if (!m_busy) return; @@ -275,6 +438,7 @@ void Chat::endStream() { if (m_streaming->content().isEmpty() && m_streaming->reasoning().isEmpty() && m_active) m_active->removeMessage(m_streaming); m_streaming = nullptr; + m_active = nullptr; setBusy(this, false); setStreamingChatId(this, QString()); } diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index 8ba0944..3270668 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -8,6 +8,8 @@ #include #include +#include + #include "chatstore.hpp" class QQmlEngine; @@ -76,6 +78,13 @@ class Chat : public QObject { void updateTokenUsage(const QJsonObject& data); static void trimHistory(ChatSession* session, int contextSize); + void shortRequest( + const QString& tag, + const QString& systemPrompt, + const QString& userText, + std::function onResult); + void requestTitle(const QString& chatId, const QString& userText); + void requestIcon(const QString& chatId, const QString& userText); static QString titleFrom(const QString& content); static QString completionsPath(const QString& endpoint, const QString& subpath); static QString serverErrorMessage( diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 4dfb055..7990323 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -1,54 +1,104 @@ #include "chatstore.hpp" #include "chat.hpp" +#include "message.hpp" #include #include #include -#include #include -#include -#include -#include -#include -#include #include +#include +#include +#include +#include #include namespace ZShell { -ChatStore::ChatStore(QObject* parent) : QObject(parent) { +ChatStore::ChatStore(QObject* parent) +: QObject(parent), m_connectionName(QUuid::createUuid().toString()) { + openDb(); load(); } -QString ChatStore::dir() { - const QString base = - QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + - QStringLiteral("/zshell"); - return base + QStringLiteral("/chats"); +ChatStore::~ChatStore() { + if (m_connectionName.isEmpty()) + return; + QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); + db.close(); + QSqlDatabase::removeDatabase(m_connectionName); } -QString ChatStore::pathFor(const QString& id) { - return dir() + QStringLiteral("/") + id + QStringLiteral(".json"); +QSqlDatabase ChatStore::db() const { + 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()); - QSaveFile file(path); - if (!file.open(QIODevice::WriteOnly)) { - qWarning() << "ChatStore: failed to open" << path << "for writing:" - << file.errorString(); - return false; + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); + db.setDatabaseName(path); + if (!db.open()) { + qWarning() << "ChatStore: failed to open database" << path << ":" + << db.lastError().text(); + return; } - if (file.write(QJsonDocument(doc).toJson(QJsonDocument::Indented)) < 0 || - !file.commit()) { - qWarning() << "ChatStore: failed to write" << path << ":" - << file.errorString(); - return false; + { + QSqlQuery pragma(db); + pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON")); + } + { + 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 { @@ -71,8 +121,20 @@ ChatSession* ChatStore::at(int index) const { ChatSession* ChatStore::insert(int index) { const qint64 now = QDateTime::currentMSecsSinceEpoch(); - auto* session = new ChatSession(QString::number(now), this); - session->setPath(pathFor(session->id())); + const QString id = QString::number(now); + { + 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); const int pos = index >= 0 && index <= m_sessions.size() ? index : 0; @@ -93,15 +155,18 @@ void ChatStore::remove(ChatSession* 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 before = m_sessions; - QFile::remove(session->path()); if (auto* chat = qobject_cast(parent())) - if (chat->m_active == session) - chat->m_active = nullptr; + if (chat->m_active == session) { + chat->stop(); + chat->endStream(); + } + const QList 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); session->deleteLater(); notify(before); @@ -116,18 +181,9 @@ void ChatStore::move(int from, int to) { } void ChatStore::clear() { - const QList before = m_sessions; - QList 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); + const QList sessions = m_sessions; + for (ChatSession* session : sessions) + removeSession(session); } ChatSession* ChatStore::sessionById(const QString& id) { @@ -141,14 +197,134 @@ void ChatStore::persist(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); - if (!writeFile(session->path(), session->document())) + session->ensureLoaded(); + if (!saveSession(session)) return; sortAndNotify(); } -bool ChatStore::isStreaming(ChatSession* session) const { - const auto* chat = qobject_cast(parent()); - return chat && chat->streamingSession() == session; +void ChatStore::saveMeta(ChatSession* session) { + if (!session || !m_sessions.contains(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 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() { @@ -157,6 +333,8 @@ void ChatStore::sortAndNotify() { m_sessions.begin(), m_sessions.end(), [](const ChatSession* a, const ChatSession* b) { + if (a->pinned() != b->pinned()) + return a->pinned() > b->pinned(); return a->updatedAtMs() > b->updatedAtMs(); }); notify(before); @@ -173,79 +351,4 @@ void ChatStore::notify(const QList& before) { 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 diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp index 3f6d299..8526216 100644 --- a/Plugins/ZShell/Llm/chatstore.hpp +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -2,8 +2,9 @@ #include "session.hpp" -#include #include +#include +#include #include namespace ZShell { @@ -19,6 +20,7 @@ class ChatStore : public QObject { public: explicit ChatStore(QObject* parent = nullptr); + ~ChatStore(); [[nodiscard]] int count() const; [[nodiscard]] QVariantList values() const; @@ -32,24 +34,25 @@ class ChatStore : public QObject { [[nodiscard]] ChatSession* sessionById(const QString& id); void persist(ChatSession* session); + void saveMeta(ChatSession* session); + void loadMessagesInto(ChatSession* session); Q_SIGNALS: void countChanged(); void valuesChanged(); private: + void openDb(); void load(); - void migrateLegacy(); + bool saveSession(ChatSession* session); void sortAndNotify(); void removeSession(ChatSession* session); - bool isStreaming(ChatSession* session) const; void notify(const QList& before); QList m_sessions; + QString m_connectionName; - static QString dir(); - static QString pathFor(const QString& id); - static bool writeFile(const QString& path, const QJsonObject& doc); + [[nodiscard]] QSqlDatabase db() const; friend class Chat; }; diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index 4153587..4f41aee 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -41,7 +41,7 @@ qint64 ChatMessage::contentElapsedMs() const { } 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) return; m_reasoningActive = active; @@ -69,7 +69,8 @@ void ChatMessage::appendReasoning(const QString& piece) { if (piece.isEmpty()) return; if (m_reasoning.isEmpty()) { - m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); + if (m_reasoningStartedAt <= 0) + m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); if (!m_timer.isActive()) m_timer.start(); } @@ -102,7 +103,12 @@ void ChatMessage::setStreaming(bool value) { return; m_streaming = value; 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(); if (reasoningInFlight()) m_reasoningEndedAt = now; diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp index 4e7d4a1..7986108 100644 --- a/Plugins/ZShell/Llm/session.cpp +++ b/Plugins/ZShell/Llm/session.cpp @@ -1,16 +1,14 @@ #include "session.hpp" +#include "chatstore.hpp" + #include -#include -#include -#include -#include +#include namespace ZShell { ChatSession::ChatSession(const QString& id, QObject* parent) -: QObject(parent), m_id(id) { -} +: QObject(parent), m_id(id) {} void ChatSession::setTitle(const QString& value) { if (m_title == value) @@ -19,6 +17,13 @@ void ChatSession::setTitle(const QString& value) { 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) { if (m_updatedAt == value) return; @@ -26,6 +31,13 @@ void ChatSession::setUpdatedAt(qint64 value) { Q_EMIT updatedAtChanged(); } +void ChatSession::setPinned(bool value) { + if (m_pinned == value) + return; + m_pinned = value; + Q_EMIT pinnedChanged(); +} + void ChatSession::setCount(int value) { if (m_messageCount == value) return; @@ -45,47 +57,17 @@ void ChatSession::setMeta( } void ChatSession::ensureLoaded() { - if (m_loaded || m_path.isEmpty()) + if (m_loaded) return; m_loaded = true; - loadMessages(); + if (auto* store = qobject_cast(parent())) + store->loadMessagesInto(this); } -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(); +void ChatSession::adoptMessages(QList messages) { + qDeleteAll(m_messages); + m_messages = messages; + setCount(m_messages.size()); Q_EMIT messagesChanged(); } @@ -94,7 +76,7 @@ ChatMessage* ChatSession::appendMessage( ensureLoaded(); auto* message = new ChatMessage(role, content, timestamp, this); m_messages.append(message); - setCount(int(m_messages.size())); + setCount(m_messages.size()); Q_EMIT messagesChanged(); return message; } @@ -103,7 +85,7 @@ void ChatSession::removeMessage(ChatMessage* message) { if (!message || !m_messages.removeOne(message)) return; delete message; - setCount(int(m_messages.size())); + setCount(m_messages.size()); Q_EMIT messagesChanged(); } @@ -117,34 +99,4 @@ void ChatSession::clearMessages() { 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 diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index 0de284a..1180374 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -3,7 +3,6 @@ #include "message.hpp" #include -#include #include #include #include @@ -18,8 +17,10 @@ class ChatSession : public QObject { Q_PROPERTY(QString id READ id CONSTANT) 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 updatedAt READ updatedAt NOTIFY updatedAtChanged) + Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged) Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) Q_PROPERTY(QList messages READ messages NOTIFY messagesChanged) @@ -28,6 +29,7 @@ class ChatSession : public QObject { [[nodiscard]] QString id() const { return m_id; } [[nodiscard]] QString title() const { return m_title; } + [[nodiscard]] QString icon() const { return m_icon; } [[nodiscard]] QDateTime createdAt() const { return QDateTime::fromMSecsSinceEpoch(m_createdAt); } @@ -36,6 +38,7 @@ class ChatSession : public QObject { } [[nodiscard]] qint64 createdAtMs() const { return m_createdAt; } [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } + [[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] int messageCount() const { return m_messageCount; } [[nodiscard]] QList messages() { ensureLoaded(); @@ -43,10 +46,10 @@ class ChatSession : public QObject { } void setTitle(const QString& value); + void setIcon(const QString& 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( const QString& title, qint64 createdAt, @@ -55,28 +58,30 @@ class ChatSession : public QObject { void ensureLoaded(); [[nodiscard]] bool isLoaded() const { return m_loaded; } + void adoptMessages(QList messages); ChatMessage* appendMessage( ChatMessage::Role role, const QString& content, qint64 timestamp); void removeMessage(ChatMessage* message); void clearMessages(); - [[nodiscard]] QJsonObject document(); Q_SIGNALS: void titleChanged(); + void iconChanged(); void updatedAtChanged(); + void pinnedChanged(); void messageCountChanged(); void messagesChanged(); private: - void loadMessages(); void setCount(int value); QString m_id; QString m_title; + QString m_icon; qint64 m_createdAt = 0; qint64 m_updatedAt = 0; + bool m_pinned = false; int m_messageCount = 0; - QString m_path; QList m_messages; bool m_loaded = false; };