feat: Add saving and loading chat history

* feat: Add chat history path
* feat: Add save and load chat
* fix: Change badge width calculation
* refactor: Move chat action to top
* feat: Add autosave of messageReceived
* feat: Add settings for autosave
This commit is contained in:
Petr Mironychev 2024-12-23 18:34:01 +01:00 committed by GitHub
parent 63f0900511
commit e544e46d76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 427 additions and 43 deletions

View File

@ -20,6 +20,7 @@ qt_add_qml_module(QodeAssistChatView
ClientInterface.hpp ClientInterface.cpp
MessagePart.hpp
ChatUtils.h ChatUtils.cpp
ChatSerializer.hpp ChatSerializer.cpp
)
target_link_libraries(QodeAssistChatView

View File

@ -18,12 +18,22 @@
*/
#include "ChatRootView.hpp"
#include <QtGui/qclipboard.h>
#include <QClipboard>
#include <QFileDialog>
#include <coreplugin/icore.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectexplorer.h>
#include <projectexplorer/projectmanager.h>
#include <utils/theme/theme.h>
#include <utils/utilsicons.h>
#include "ChatAssistantSettings.hpp"
#include "ChatSerializer.hpp"
#include "GeneralSettings.hpp"
#include "Logger.hpp"
#include "ProjectSettings.hpp"
namespace QodeAssist::Chat {
@ -44,6 +54,12 @@ ChatRootView::ChatRootView(QQuickItem *parent)
this,
&ChatRootView::isSharingCurrentFileChanged);
connect(
m_clientInterface,
&ClientInterface::messageReceivedCompletely,
this,
&ChatRootView::autosave);
generateColors();
}
@ -115,6 +131,26 @@ QColor ChatRootView::generateColor(const QColor &baseColor,
return QColor::fromHslF(h, s, l, a);
}
QString ChatRootView::getChatsHistoryDir() const
{
QString path;
if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
Settings::ProjectSettings projectSettings(project);
path = projectSettings.chatHistoryPath().toString();
} else {
path = QString("%1/qodeassist/chat_history").arg(Core::ICore::userResourcePath().toString());
}
QDir dir(path);
if (!dir.exists() && !dir.mkpath(".")) {
LOG_MESSAGE(QString("Failed to create directory: %1").arg(path));
return QString();
}
return path;
}
QString ChatRootView::currentTemplate() const
{
auto &settings = Settings::generalSettings();
@ -141,4 +177,123 @@ bool ChatRootView::isSharingCurrentFile() const
return Settings::chatAssistantSettings().sharingCurrentFile();
}
void ChatRootView::saveHistory(const QString &filePath)
{
auto result = ChatSerializer::saveToFile(m_chatModel, filePath);
if (!result.success) {
LOG_MESSAGE(QString("Failed to save chat history: %1").arg(result.errorMessage));
}
}
void ChatRootView::loadHistory(const QString &filePath)
{
auto result = ChatSerializer::loadFromFile(m_chatModel, filePath);
if (!result.success) {
LOG_MESSAGE(QString("Failed to load chat history: %1").arg(result.errorMessage));
} else {
m_recentFilePath = filePath;
}
}
void ChatRootView::showSaveDialog()
{
QString initialDir = getChatsHistoryDir();
QFileDialog *dialog = new QFileDialog(nullptr, tr("Save Chat History"));
dialog->setAcceptMode(QFileDialog::AcceptSave);
dialog->setFileMode(QFileDialog::AnyFile);
dialog->setNameFilter(tr("JSON files (*.json)"));
dialog->setDefaultSuffix("json");
if (!initialDir.isEmpty()) {
dialog->setDirectory(initialDir);
dialog->selectFile(getSuggestedFileName() + ".json");
}
connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
if (result == QFileDialog::Accepted) {
QStringList files = dialog->selectedFiles();
if (!files.isEmpty()) {
saveHistory(files.first());
}
}
dialog->deleteLater();
});
dialog->open();
}
void ChatRootView::showLoadDialog()
{
QString initialDir = getChatsHistoryDir();
QFileDialog *dialog = new QFileDialog(nullptr, tr("Load Chat History"));
dialog->setAcceptMode(QFileDialog::AcceptOpen);
dialog->setFileMode(QFileDialog::ExistingFile);
dialog->setNameFilter(tr("JSON files (*.json)"));
if (!initialDir.isEmpty()) {
dialog->setDirectory(initialDir);
}
connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
if (result == QFileDialog::Accepted) {
QStringList files = dialog->selectedFiles();
if (!files.isEmpty()) {
loadHistory(files.first());
}
}
dialog->deleteLater();
});
dialog->open();
}
QString ChatRootView::getSuggestedFileName() const
{
QStringList parts;
if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
QString projectName = project->projectDirectory().fileName();
parts << projectName;
}
if (m_chatModel->rowCount() > 0) {
QString firstMessage
= m_chatModel->data(m_chatModel->index(0), ChatModel::Content).toString();
QString shortMessage = firstMessage.split('\n').first().simplified().left(30);
shortMessage.replace(QRegularExpression("[^a-zA-Z0-9_-]"), "_");
parts << shortMessage;
}
parts << QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm");
return parts.join("_");
}
void ChatRootView::autosave()
{
if (m_chatModel->rowCount() == 0 || !Settings::chatAssistantSettings().autosave()) {
return;
}
QString filePath = getAutosaveFilePath();
if (!filePath.isEmpty()) {
ChatSerializer::saveToFile(m_chatModel, filePath);
m_recentFilePath = filePath;
}
}
QString ChatRootView::getAutosaveFilePath() const
{
if (!m_recentFilePath.isEmpty()) {
return m_recentFilePath;
}
QString dir = getChatsHistoryDir();
if (dir.isEmpty()) {
return QString();
}
return QDir(dir).filePath(getSuggestedFileName() + ".json");
}
} // namespace QodeAssist::Chat

View File

@ -29,10 +29,6 @@ namespace QodeAssist::Chat {
class ChatRootView : public QQuickItem
{
Q_OBJECT
// Possibly Qt bug: QTBUG-131004
// The class type name must be fully qualified
// including the namespace.
// Otherwise qmlls can't find it.
Q_PROPERTY(QodeAssist::Chat::ChatModel *chatModel READ chatModel NOTIFY chatModelChanged FINAL)
Q_PROPERTY(QString currentTemplate READ currentTemplate NOTIFY currentTemplateChanged FINAL)
Q_PROPERTY(QColor backgroundColor READ backgroundColor CONSTANT FINAL)
@ -57,6 +53,15 @@ public:
bool isSharingCurrentFile() const;
void saveHistory(const QString &filePath);
void loadHistory(const QString &filePath);
Q_INVOKABLE void showSaveDialog();
Q_INVOKABLE void showLoadDialog();
void autosave();
QString getAutosaveFilePath() const;
public slots:
void sendMessage(const QString &message, bool sharingCurrentFile = false) const;
void copyToClipboard(const QString &text);
@ -75,12 +80,16 @@ private:
float saturationMod,
float lightnessMod);
QString getChatsHistoryDir() const;
QString getSuggestedFileName() const;
ChatModel *m_chatModel;
ClientInterface *m_clientInterface;
QString m_currentTemplate;
QColor m_primaryColor;
QColor m_secondaryColor;
QColor m_codeColor;
QString m_recentFilePath;
};
} // namespace QodeAssist::Chat

145
ChatView/ChatSerializer.cpp Normal file
View File

@ -0,0 +1,145 @@
/*
* Copyright (C) 2024 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "ChatSerializer.hpp"
#include "Logger.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
namespace QodeAssist::Chat {
const QString ChatSerializer::VERSION = "0.1";
SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QString &filePath)
{
if (!ensureDirectoryExists(filePath)) {
return {false, "Failed to create directory structure"};
}
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly)) {
return {false, QString("Failed to open file for writing: %1").arg(filePath)};
}
QJsonObject root = serializeChat(model);
QJsonDocument doc(root);
if (file.write(doc.toJson(QJsonDocument::Indented)) == -1) {
return {false, QString("Failed to write to file: %1").arg(file.errorString())};
}
return {true, QString()};
}
SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString &filePath)
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) {
return {false, QString("Failed to open file for reading: %1").arg(filePath)};
}
QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
if (error.error != QJsonParseError::NoError) {
return {false, QString("JSON parse error: %1").arg(error.errorString())};
}
QJsonObject root = doc.object();
QString version = root["version"].toString();
if (!validateVersion(version)) {
return {false, QString("Unsupported version: %1").arg(version)};
}
if (!deserializeChat(model, root)) {
return {false, "Failed to deserialize chat data"};
}
return {true, QString()};
}
QJsonObject ChatSerializer::serializeMessage(const ChatModel::Message &message)
{
QJsonObject messageObj;
messageObj["role"] = static_cast<int>(message.role);
messageObj["content"] = message.content;
messageObj["tokenCount"] = message.tokenCount;
messageObj["id"] = message.id;
return messageObj;
}
ChatModel::Message ChatSerializer::deserializeMessage(const QJsonObject &json)
{
ChatModel::Message message;
message.role = static_cast<ChatModel::ChatRole>(json["role"].toInt());
message.content = json["content"].toString();
message.tokenCount = json["tokenCount"].toInt();
message.id = json["id"].toString();
return message;
}
QJsonObject ChatSerializer::serializeChat(const ChatModel *model)
{
QJsonArray messagesArray;
for (const auto &message : model->getChatHistory()) {
messagesArray.append(serializeMessage(message));
}
QJsonObject root;
root["version"] = VERSION;
root["messages"] = messagesArray;
root["totalTokens"] = model->totalTokens();
return root;
}
bool ChatSerializer::deserializeChat(ChatModel *model, const QJsonObject &json)
{
QJsonArray messagesArray = json["messages"].toArray();
QVector<ChatModel::Message> messages;
messages.reserve(messagesArray.size());
for (const auto &messageValue : messagesArray) {
messages.append(deserializeMessage(messageValue.toObject()));
}
model->clear();
for (const auto &message : messages) {
model->addMessage(message.content, message.role, message.id);
}
return true;
}
bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
{
QFileInfo fileInfo(filePath);
QDir dir = fileInfo.dir();
return dir.exists() || dir.mkpath(".");
}
bool ChatSerializer::validateVersion(const QString &version)
{
return version == VERSION;
}
} // namespace QodeAssist::Chat

View File

@ -0,0 +1,56 @@
/*
* Copyright (C) 2024 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include "ChatModel.hpp"
namespace QodeAssist::Chat {
struct SerializationResult
{
bool success{false};
QString errorMessage;
};
class ChatSerializer
{
public:
static SerializationResult saveToFile(const ChatModel *model, const QString &filePath);
static SerializationResult loadFromFile(ChatModel *model, const QString &filePath);
// Public for testing purposes
static QJsonObject serializeMessage(const ChatModel::Message &message);
static ChatModel::Message deserializeMessage(const QJsonObject &json);
static QJsonObject serializeChat(const ChatModel *model);
static bool deserializeChat(ChatModel *model, const QJsonObject &json);
private:
static const QString VERSION;
static constexpr int CURRENT_VERSION = 1;
static bool ensureDirectoryExists(const QString &filePath);
static bool validateVersion(const QString &version);
};
} // namespace QodeAssist::Chat

View File

@ -23,7 +23,6 @@
#include <qqmlintegration.h>
namespace QodeAssist::Chat {
// Q_NAMESPACE
class ChatUtils : public QObject
{

View File

@ -166,6 +166,7 @@ void ClientInterface::handleLLMResponse(const QString &response,
if (isComplete) {
LOG_MESSAGE(
"Message completed. Final response for message " + messageId + ": " + response);
emit messageReceivedCompletely();
}
}
}

View File

@ -42,6 +42,7 @@ public:
signals:
void errorOccurred(const QString &error);
void messageReceivedCompletely();
private:
void handleLLMResponse(const QString &response, const QJsonObject &request, bool isComplete);

View File

@ -25,10 +25,10 @@ Rectangle {
property alias text: badgeText.text
property alias fontColor: badgeText.color
width: badgeText.implicitWidth + radius
height: badgeText.implicitHeight + 6
implicitWidth: badgeText.implicitWidth + root.radius
implicitHeight: badgeText.implicitHeight + 6
color: "lightgreen"
radius: height / 2
radius: root.height / 2
border.width: 1
border.color: "gray"

View File

@ -35,10 +35,40 @@ ChatRootView {
}
ColumnLayout {
anchors {
fill: parent
anchors.fill: parent
RowLayout {
id: topBar
Layout.leftMargin: 5
Layout.rightMargin: 5
spacing: 10
Button {
text: qsTr("Save")
onClicked: root.showSaveDialog()
}
Button {
text: qsTr("Load")
onClicked: root.showLoadDialog()
}
Button {
text: qsTr("Clear")
onClicked: root.clearChat()
}
Item {
Layout.fillWidth: true
}
Badge {
text: qsTr("tokens:%1/%2").arg(root.chatModel.totalTokens).arg(root.chatModel.tokensThreshold)
color: root.codeColor
fontColor: root.primaryColor.hslLightness > 0.5 ? "black" : "white"
}
}
spacing: 10
ListView {
id: chatListView
@ -133,14 +163,6 @@ ChatRootView {
onClicked: root.cancelRequest()
}
Button {
id: clearButton
Layout.alignment: Qt.AlignBottom
text: qsTr("Clear Chat")
onClicked: root.clearChat()
}
CheckBox {
id: sharingCurrentFile
@ -150,26 +172,6 @@ ChatRootView {
}
}
Row {
id: bar
layoutDirection: Qt.RightToLeft
anchors {
left: parent.left
leftMargin: 5
right: parent.right
rightMargin: scroll.width
}
spacing: 10
Badge {
text: qsTr("tokens:%1/%2").arg(root.chatModel.totalTokens).arg(root.chatModel.tokensThreshold)
color: root.codeColor
fontColor: root.primaryColor.hslLightness > 0.5 ? "black" : "white"
}
}
function clearChat() {
root.chatModel.clear()
}

View File

@ -58,6 +58,10 @@ ChatAssistantSettings::ChatAssistantSettings()
stream.setDefaultValue(true);
stream.setLabelText(Tr::tr("Enable stream option"));
autosave.setSettingsKey(Constants::CA_AUTOSAVE);
autosave.setDefaultValue(true);
autosave.setLabelText(Tr::tr("Enable autosave when message received"));
// General Parameters Settings
temperature.setSettingsKey(Constants::CA_TEMPERATURE);
temperature.setLabelText(Tr::tr("Temperature:"));
@ -167,7 +171,7 @@ ChatAssistantSettings::ChatAssistantSettings()
Space{8},
Group{
title(Tr::tr("Chat Settings")),
Column{Row{chatTokensThreshold, Stretch{1}}, sharingCurrentFile, stream}},
Column{Row{chatTokensThreshold, Stretch{1}}, sharingCurrentFile, stream, autosave}},
Space{8},
Group{
title(Tr::tr("General Parameters")),

View File

@ -36,6 +36,7 @@ public:
Utils::IntegerAspect chatTokensThreshold{this};
Utils::BoolAspect sharingCurrentFile{this};
Utils::BoolAspect stream{this};
Utils::BoolAspect autosave{this};
// General Parameters Settings
Utils::DoubleAspect temperature{this};

View File

@ -38,12 +38,19 @@ ProjectSettings::ProjectSettings(ProjectExplorer::Project *project)
enableQodeAssist.setLabelText(Tr::tr("Enable Qode Assist"));
enableQodeAssist.setDefaultValue(false);
chatHistoryPath.setSettingsKey(Constants::QODE_ASSIST_CHAT_HISTORY_PATH);
chatHistoryPath.setExpectedKind(Utils::PathChooser::ExistingDirectory);
chatHistoryPath.setLabelText(Tr::tr("Chat History Path:"));
chatHistoryPath.setDefaultValue(
project->projectDirectory().toString() + "/.qodeassist/chat_history");
Utils::Store map = Utils::storeFromVariant(
project->namedSettings(Constants::QODE_ASSIST_PROJECT_SETTINGS_ID));
fromMap(map);
enableQodeAssist.addOnChanged(this, [this, project] { save(project); });
useGlobalSettings.addOnChanged(this, [this, project] { save(project); });
chatHistoryPath.addOnChanged(this, [this, project] { save(project); });
}
void ProjectSettings::setUseGlobalSettings(bool useGlobal)
@ -64,8 +71,6 @@ void ProjectSettings::save(ProjectExplorer::Project *project)
toMap(map);
project
->setNamedSettings(Constants::QODE_ASSIST_PROJECT_SETTINGS_ID, Utils::variantFromStore(map));
// This triggers a restart
generalSettings().apply();
}

View File

@ -38,6 +38,7 @@ public:
Utils::BoolAspect enableQodeAssist{this};
Utils::BoolAspect useGlobalSettings{this};
Utils::FilePathAspect chatHistoryPath{this};
};
} // namespace QodeAssist::Settings

View File

@ -66,6 +66,8 @@ static ProjectSettingsWidget *createProjectPanel(Project *project)
Column{
settings->enableQodeAssist,
Space{8},
settings->chatHistoryPath,
}
.attachTo(widget);

View File

@ -29,6 +29,7 @@ const char MENU_ID[] = "QodeAssist.Menu";
const char QODE_ASSIST_PROJECT_SETTINGS_ID[] = "QodeAssist.ProjectSettings";
const char QODE_ASSIST_USE_GLOBAL_SETTINGS[] = "QodeAssist.UseGlobalSettings";
const char QODE_ASSIST_ENABLE_IN_PROJECT[] = "QodeAssist.EnableInProject";
const char QODE_ASSIST_CHAT_HISTORY_PATH[] = "QodeAssist.ChatHistoryPath";
// new settings
const char CC_PROVIDER[] = "QodeAssist.ccProvider";
@ -61,6 +62,7 @@ const char CUSTOM_JSON_TEMPLATE[] = "QodeAssist.customJsonTemplate";
const char CA_TOKENS_THRESHOLD[] = "QodeAssist.caTokensThreshold";
const char CA_SHARING_CURRENT_FILE[] = "QodeAssist.caSharingCurrentFile";
const char CA_STREAM[] = "QodeAssist.caStream";
const char CA_AUTOSAVE[] = "QodeAssist.caAutosave";
const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions";
const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId";