diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index ceaa8c5..14ed915 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -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 diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index c3b58c8..3aac9fe 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -18,12 +18,22 @@ */ #include "ChatRootView.hpp" -#include + +#include +#include + +#include +#include +#include +#include #include #include #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 diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index c0d9614..81681c8 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -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 @@ 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); @@ -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 diff --git a/ChatView/ChatSerializer.cpp b/ChatView/ChatSerializer.cpp new file mode 100644 index 0000000..3875278 --- /dev/null +++ b/ChatView/ChatSerializer.cpp @@ -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 . + */ + +#include "ChatSerializer.hpp" +#include "Logger.hpp" + +#include +#include +#include +#include + +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(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(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 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 diff --git a/ChatView/ChatSerializer.hpp b/ChatView/ChatSerializer.hpp new file mode 100644 index 0000000..3d6e9ef --- /dev/null +++ b/ChatView/ChatSerializer.hpp @@ -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 . + */ + +#pragma once + +#include +#include +#include + +#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 diff --git a/ChatView/ChatUtils.h b/ChatView/ChatUtils.h index 2584b2c..2acd8ae 100644 --- a/ChatView/ChatUtils.h +++ b/ChatView/ChatUtils.h @@ -23,7 +23,6 @@ #include namespace QodeAssist::Chat { -// Q_NAMESPACE class ChatUtils : public QObject { diff --git a/ChatView/ClientInterface.cpp b/ChatView/ClientInterface.cpp index 06b7554..c58e485 100644 --- a/ChatView/ClientInterface.cpp +++ b/ChatView/ClientInterface.cpp @@ -166,6 +166,7 @@ void ClientInterface::handleLLMResponse(const QString &response, if (isComplete) { LOG_MESSAGE( "Message completed. Final response for message " + messageId + ": " + response); + emit messageReceivedCompletely(); } } } diff --git a/ChatView/ClientInterface.hpp b/ChatView/ClientInterface.hpp index 0fb1225..e47215a 100644 --- a/ChatView/ClientInterface.hpp +++ b/ChatView/ClientInterface.hpp @@ -42,6 +42,7 @@ class ClientInterface : public QObject signals: void errorOccurred(const QString &error); + void messageReceivedCompletely(); private: void handleLLMResponse(const QString &response, const QJsonObject &request, bool isComplete); diff --git a/ChatView/qml/Badge.qml b/ChatView/qml/Badge.qml index b227376..354e580 100644 --- a/ChatView/qml/Badge.qml +++ b/ChatView/qml/Badge.qml @@ -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" diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 7f3babf..4c0e6e0 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -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() } diff --git a/settings/ChatAssistantSettings.cpp b/settings/ChatAssistantSettings.cpp index 7fad551..1c732c1 100644 --- a/settings/ChatAssistantSettings.cpp +++ b/settings/ChatAssistantSettings.cpp @@ -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")), diff --git a/settings/ChatAssistantSettings.hpp b/settings/ChatAssistantSettings.hpp index a62cc8e..8bf9f84 100644 --- a/settings/ChatAssistantSettings.hpp +++ b/settings/ChatAssistantSettings.hpp @@ -36,6 +36,7 @@ class ChatAssistantSettings : public Utils::AspectContainer Utils::IntegerAspect chatTokensThreshold{this}; Utils::BoolAspect sharingCurrentFile{this}; Utils::BoolAspect stream{this}; + Utils::BoolAspect autosave{this}; // General Parameters Settings Utils::DoubleAspect temperature{this}; diff --git a/settings/ProjectSettings.cpp b/settings/ProjectSettings.cpp index 7fb7cc4..b5edfe0 100644 --- a/settings/ProjectSettings.cpp +++ b/settings/ProjectSettings.cpp @@ -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(); } diff --git a/settings/ProjectSettings.hpp b/settings/ProjectSettings.hpp index 9fd35f2..a423a5b 100644 --- a/settings/ProjectSettings.hpp +++ b/settings/ProjectSettings.hpp @@ -38,6 +38,7 @@ class ProjectSettings : public Utils::AspectContainer Utils::BoolAspect enableQodeAssist{this}; Utils::BoolAspect useGlobalSettings{this}; + Utils::FilePathAspect chatHistoryPath{this}; }; } // namespace QodeAssist::Settings diff --git a/settings/ProjectSettingsPanel.cpp b/settings/ProjectSettingsPanel.cpp index 8bfa3d1..a32df3e 100644 --- a/settings/ProjectSettingsPanel.cpp +++ b/settings/ProjectSettingsPanel.cpp @@ -66,6 +66,8 @@ static ProjectSettingsWidget *createProjectPanel(Project *project) Column{ settings->enableQodeAssist, + Space{8}, + settings->chatHistoryPath, } .attachTo(widget); diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index 6a83675..0018cc0 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -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";