Skip to content

Commit

Permalink
feat: Add saving and loading chat history
Browse files Browse the repository at this point in the 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
  • Loading branch information
Palm1r authored Dec 23, 2024
1 parent 63f0900 commit e544e46
Show file tree
Hide file tree
Showing 16 changed files with 427 additions and 43 deletions.
1 change: 1 addition & 0 deletions ChatView/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
157 changes: 156 additions & 1 deletion ChatView/ChatRootView.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -44,6 +54,12 @@ ChatRootView::ChatRootView(QQuickItem *parent)
this,
&ChatRootView::isSharingCurrentFileChanged);

connect(
m_clientInterface,
&ClientInterface::messageReceivedCompletely,
this,
&ChatRootView::autosave);

generateColors();
}

Expand Down Expand Up @@ -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();
Expand All @@ -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
17 changes: 13 additions & 4 deletions ChatView/ChatRootView.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -57,6 +53,15 @@ class ChatRootView : public QQuickItem

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);
Expand All @@ -75,12 +80,16 @@ public slots:
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 changes: 145 additions & 0 deletions ChatView/ChatSerializer.cpp
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit e544e46

Please sign in to comment.