From 9f050aec67f63ac9349db9854a5d6adf411cdd9e Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:08:23 +0100 Subject: [PATCH] feat: Add summarize chat (#289) --- ChatView/CMakeLists.txt | 2 + ChatView/ChatCompressor.cpp | 310 +++++++++++++++++++++++++++++++ ChatView/ChatCompressor.hpp | 79 ++++++++ ChatView/ChatRootView.cpp | 52 ++++++ ChatView/ChatRootView.hpp | 21 ++- ChatView/icons/compress-icon.svg | 10 + ChatView/qml/RootItem.qml | 19 ++ ChatView/qml/controls/TopBar.qml | 49 +++++ 8 files changed, 539 insertions(+), 3 deletions(-) create mode 100644 ChatView/ChatCompressor.cpp create mode 100644 ChatView/ChatCompressor.hpp create mode 100644 ChatView/icons/compress-icon.svg diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index 5fa10e9..13b4f4d 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -53,6 +53,7 @@ qt_add_qml_module(QodeAssistChatView icons/tools-icon-on.svg icons/tools-icon-off.svg icons/settings-icon.svg + icons/compress-icon.svg SOURCES ChatWidget.hpp ChatWidget.cpp @@ -66,6 +67,7 @@ qt_add_qml_module(QodeAssistChatView ChatData.hpp FileItem.hpp FileItem.cpp ChatFileManager.hpp ChatFileManager.cpp + ChatCompressor.hpp ChatCompressor.cpp ) target_link_libraries(QodeAssistChatView diff --git a/ChatView/ChatCompressor.cpp b/ChatView/ChatCompressor.cpp new file mode 100644 index 0000000..0743e86 --- /dev/null +++ b/ChatView/ChatCompressor.cpp @@ -0,0 +1,310 @@ +/* + * Copyright (C) 2024-2025 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 "ChatCompressor.hpp" +#include "ChatModel.hpp" +#include "GeneralSettings.hpp" +#include "PromptTemplateManager.hpp" +#include "ProvidersManager.hpp" +#include "logger/Logger.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace QodeAssist::Chat { + +ChatCompressor::ChatCompressor(QObject *parent) + : QObject(parent) +{} + +void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *chatModel) +{ + if (m_isCompressing) { + emit compressionFailed(tr("Compression already in progress")); + return; + } + + if (chatFilePath.isEmpty()) { + emit compressionFailed(tr("No chat file to compress")); + return; + } + + if (!chatModel || chatModel->rowCount() == 0) { + emit compressionFailed(tr("Chat is empty, nothing to compress")); + return; + } + + auto providerName = Settings::generalSettings().caProvider(); + m_provider = LLMCore::ProvidersManager::instance().getProviderByName(providerName); + + if (!m_provider) { + emit compressionFailed(tr("No provider available")); + return; + } + + auto templateName = Settings::generalSettings().caTemplate(); + auto promptTemplate = LLMCore::PromptTemplateManager::instance().getChatTemplateByName( + templateName); + + if (!promptTemplate) { + emit compressionFailed(tr("No template available")); + return; + } + + m_isCompressing = true; + m_chatModel = chatModel; + m_originalChatPath = chatFilePath; + m_accumulatedSummary.clear(); + m_currentRequestId = QUuid::createUuid().toString(QUuid::WithoutBraces); + + emit compressionStarted(); + + connectProviderSignals(); + + QUrl requestUrl; + QJsonObject payload; + + if (m_provider->providerID() == LLMCore::ProviderID::GoogleAI) { + requestUrl = QUrl(QString("%1/models/%2:streamGenerateContent?alt=sse") + .arg(Settings::generalSettings().caUrl(), + Settings::generalSettings().caModel())); + } else { + requestUrl = QUrl(QString("%1%2").arg(Settings::generalSettings().caUrl(), + m_provider->chatEndpoint())); + payload["model"] = Settings::generalSettings().caModel(); + payload["stream"] = true; + } + + buildRequestPayload(payload, promptTemplate); + + LOG_MESSAGE(QString("Starting compression request: %1").arg(m_currentRequestId)); + m_provider->sendRequest(m_currentRequestId, requestUrl, payload); +} + +bool ChatCompressor::isCompressing() const +{ + return m_isCompressing; +} + +void ChatCompressor::cancelCompression() +{ + if (!m_isCompressing) + return; + + LOG_MESSAGE("Cancelling compression request"); + + if (m_provider && !m_currentRequestId.isEmpty()) + m_provider->cancelRequest(m_currentRequestId); + + cleanupState(); + emit compressionFailed(tr("Compression cancelled")); +} + +void ChatCompressor::onPartialResponseReceived(const QString &requestId, const QString &partialText) +{ + if (!m_isCompressing || requestId != m_currentRequestId) + return; + + m_accumulatedSummary += partialText; +} + +void ChatCompressor::onFullResponseReceived(const QString &requestId, const QString &fullText) +{ + Q_UNUSED(fullText) + + if (!m_isCompressing || requestId != m_currentRequestId) + return; + + LOG_MESSAGE( + QString("Received summary, length: %1 characters").arg(m_accumulatedSummary.length())); + + QString compressedPath = createCompressedChatPath(m_originalChatPath); + if (!createCompressedChatFile(m_originalChatPath, compressedPath, m_accumulatedSummary)) { + handleCompressionError(tr("Failed to save compressed chat")); + return; + } + + LOG_MESSAGE(QString("Compression completed: %1").arg(compressedPath)); + cleanupState(); + emit compressionCompleted(compressedPath); +} + +void ChatCompressor::onRequestFailed(const QString &requestId, const QString &error) +{ + if (!m_isCompressing || requestId != m_currentRequestId) + return; + + LOG_MESSAGE(QString("Compression request failed: %1").arg(error)); + handleCompressionError(tr("Compression failed: %1").arg(error)); +} + +void ChatCompressor::handleCompressionError(const QString &error) +{ + cleanupState(); + emit compressionFailed(error); +} + +QString ChatCompressor::createCompressedChatPath(const QString &originalPath) const +{ + QFileInfo fileInfo(originalPath); + QString hash = QString::number(QDateTime::currentMSecsSinceEpoch() % 100000, 16); + return QString("%1/%2_%3.%4") + .arg(fileInfo.absolutePath(), fileInfo.completeBaseName(), hash, fileInfo.suffix()); +} + +QString ChatCompressor::buildCompressionPrompt() const +{ + return QStringLiteral( + "Please create a comprehensive summary of our entire conversation above. " + "The summary should:\n" + "1. Preserve all important context, decisions, and key information\n" + "2. Maintain technical details, code snippets, file references, and specific examples\n" + "3. Keep the chronological flow of the discussion\n" + "4. Be significantly shorter than the original (aim for 30-40% of original length)\n" + "5. Be written in clear, structured format\n" + "6. Use markdown formatting for better readability\n\n" + "Create the summary now:"); +} + +void ChatCompressor::buildRequestPayload( + QJsonObject &payload, LLMCore::PromptTemplate *promptTemplate) +{ + LLMCore::ContextData context; + + context.systemPrompt = QStringLiteral( + "You are a helpful assistant that creates concise summaries of conversations. " + "Your summaries preserve key information, technical details, and the flow of discussion."); + + QVector messages; + for (const auto &msg : m_chatModel->getChatHistory()) { + if (msg.role == ChatModel::ChatRole::Tool + || msg.role == ChatModel::ChatRole::FileEdit + || msg.role == ChatModel::ChatRole::Thinking) + continue; + + LLMCore::Message apiMessage; + apiMessage.role = (msg.role == ChatModel::ChatRole::User) ? "user" : "assistant"; + apiMessage.content = msg.content; + messages.append(apiMessage); + } + + LLMCore::Message compressionRequest; + compressionRequest.role = "user"; + compressionRequest.content = buildCompressionPrompt(); + messages.append(compressionRequest); + + context.history = messages; + + m_provider->prepareRequest( + payload, promptTemplate, context, LLMCore::RequestType::Chat, false, false); +} + +bool ChatCompressor::createCompressedChatFile( + const QString &sourcePath, const QString &destPath, const QString &summary) +{ + QFile sourceFile(sourcePath); + if (!sourceFile.open(QIODevice::ReadOnly)) { + LOG_MESSAGE(QString("Failed to open source chat file: %1").arg(sourcePath)); + return false; + } + + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(sourceFile.readAll(), &parseError); + sourceFile.close(); + + if (doc.isNull() || !doc.isObject()) { + LOG_MESSAGE(QString("Invalid JSON in chat file: %1 (Error: %2)") + .arg(sourcePath, parseError.errorString())); + return false; + } + + QJsonObject root = doc.object(); + + QJsonObject summaryMessage; + summaryMessage["role"] = "assistant"; + summaryMessage["content"] = QString("# Chat Summary\n\n%1").arg(summary); + summaryMessage["id"] = QUuid::createUuid().toString(QUuid::WithoutBraces); + summaryMessage["isRedacted"] = false; + summaryMessage["attachments"] = QJsonArray(); + summaryMessage["images"] = QJsonArray(); + + root["messages"] = QJsonArray{summaryMessage}; + + if (QFile::exists(destPath)) + QFile::remove(destPath); + + QFile destFile(destPath); + if (!destFile.open(QIODevice::WriteOnly)) { + LOG_MESSAGE(QString("Failed to create compressed chat file: %1").arg(destPath)); + return false; + } + + destFile.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + return true; +} + +void ChatCompressor::connectProviderSignals() +{ + m_connections.append(connect( + m_provider, + &LLMCore::Provider::partialResponseReceived, + this, + &ChatCompressor::onPartialResponseReceived, + Qt::UniqueConnection)); + + m_connections.append(connect( + m_provider, + &LLMCore::Provider::fullResponseReceived, + this, + &ChatCompressor::onFullResponseReceived, + Qt::UniqueConnection)); + + m_connections.append(connect( + m_provider, + &LLMCore::Provider::requestFailed, + this, + &ChatCompressor::onRequestFailed, + Qt::UniqueConnection)); +} + +void ChatCompressor::disconnectAllSignals() +{ + for (const auto &connection : std::as_const(m_connections)) + disconnect(connection); + m_connections.clear(); +} + +void ChatCompressor::cleanupState() +{ + disconnectAllSignals(); + + m_isCompressing = false; + m_currentRequestId.clear(); + m_originalChatPath.clear(); + m_accumulatedSummary.clear(); + m_chatModel = nullptr; + m_provider = nullptr; +} + +} // namespace QodeAssist::Chat diff --git a/ChatView/ChatCompressor.hpp b/ChatView/ChatCompressor.hpp new file mode 100644 index 0000000..54ef607 --- /dev/null +++ b/ChatView/ChatCompressor.hpp @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2024-2025 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 + +namespace QodeAssist::LLMCore { +class Provider; +class PromptTemplate; +} // namespace QodeAssist::LLMCore + +namespace QodeAssist::Chat { + +class ChatModel; + +class ChatCompressor : public QObject +{ + Q_OBJECT + +public: + explicit ChatCompressor(QObject *parent = nullptr); + + void startCompression(const QString &chatFilePath, ChatModel *chatModel); + + bool isCompressing() const; + void cancelCompression(); + +signals: + void compressionStarted(); + void compressionCompleted(const QString &compressedChatPath); + void compressionFailed(const QString &error); + +private slots: + void onPartialResponseReceived(const QString &requestId, const QString &partialText); + void onFullResponseReceived(const QString &requestId, const QString &fullText); + void onRequestFailed(const QString &requestId, const QString &error); + +private: + QString createCompressedChatPath(const QString &originalPath) const; + QString buildCompressionPrompt() const; + bool createCompressedChatFile( + const QString &sourcePath, const QString &destPath, const QString &summary); + void connectProviderSignals(); + void disconnectAllSignals(); + void cleanupState(); + void handleCompressionError(const QString &error); + void buildRequestPayload(QJsonObject &payload, LLMCore::PromptTemplate *promptTemplate); + + bool m_isCompressing = false; + QString m_currentRequestId; + QString m_originalChatPath; + QString m_accumulatedSummary; + LLMCore::Provider *m_provider = nullptr; + ChatModel *m_chatModel = nullptr; + + QList m_connections; +}; + +} // namespace QodeAssist::Chat diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 7ecfe0f..1203ed1 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -36,6 +36,7 @@ #include "AgentRole.hpp" #include "ChatAssistantSettings.hpp" +#include "ChatCompressor.hpp" #include "ChatSerializer.hpp" #include "ConfigurationManager.hpp" #include "GeneralSettings.hpp" @@ -58,6 +59,7 @@ ChatRootView::ChatRootView(QQuickItem *parent) , m_clientInterface(new ClientInterface(m_chatModel, &m_promptProvider, this)) , m_fileManager(new ChatFileManager(this)) , m_isRequestInProgress(false) + , m_chatCompressor(new ChatCompressor(this)) { m_isSyncOpenFiles = Settings::chatAssistantSettings().linkOpenFiles(); connect( @@ -245,6 +247,27 @@ ChatRootView::ChatRootView(QQuickItem *parent) m_lastErrorMessage = error; emit lastErrorMessageChanged(); }); + + // ChatCompressor signals + connect(m_chatCompressor, &ChatCompressor::compressionStarted, this, [this]() { + emit isCompressingChanged(); + }); + + connect(m_chatCompressor, &ChatCompressor::compressionCompleted, this, [this](const QString &compressedChatPath) { + emit isCompressingChanged(); + m_lastInfoMessage = tr("Chat compressed successfully!"); + emit lastInfoMessageChanged(); + emit compressionCompleted(compressedChatPath); + + loadHistory(compressedChatPath); + }); + + connect(m_chatCompressor, &ChatCompressor::compressionFailed, this, [this](const QString &error) { + emit isCompressingChanged(); + m_lastErrorMessage = error; + emit lastErrorMessageChanged(); + emit compressionFailed(error); + }); } ChatModel *ChatRootView::chatModel() const @@ -1470,4 +1493,33 @@ void ChatRootView::openAgentRolesSettings() Core::ICore::showOptionsDialog(Utils::Id("QodeAssist.AgentRoles")); } +void ChatRootView::compressCurrentChat() +{ + if (m_chatCompressor->isCompressing()) { + m_lastErrorMessage = tr("Compression is already in progress"); + emit lastErrorMessageChanged(); + return; + } + + if (m_recentFilePath.isEmpty()) { + m_lastErrorMessage = tr("No chat file to compress. Please save the chat first."); + emit lastErrorMessageChanged(); + return; + } + + autosave(); + + m_chatCompressor->startCompression(m_recentFilePath, m_chatModel); +} + +void ChatRootView::cancelCompression() +{ + m_chatCompressor->cancelCompression(); +} + +bool ChatRootView::isCompressing() const +{ + return m_chatCompressor->isCompressing(); +} + } // namespace QodeAssist::Chat diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index 5c018b9..1eb290d 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -21,14 +21,16 @@ #include +#include "ChatFileManager.hpp" #include "ChatModel.hpp" #include "ClientInterface.hpp" -#include "ChatFileManager.hpp" #include "llmcore/PromptProviderChat.hpp" #include namespace QodeAssist::Chat { +class ChatCompressor; + class ChatRootView : public QQuickItem { Q_OBJECT @@ -64,6 +66,7 @@ class ChatRootView : public QQuickItem Q_PROPERTY(QString baseSystemPrompt READ baseSystemPrompt NOTIFY baseSystemPromptChanged FINAL) Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL) Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL) + Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged FINAL) QML_ELEMENT @@ -150,7 +153,10 @@ public: Q_INVOKABLE void applyConfiguration(const QString &configName); QStringList availableConfigurations() const; QString currentConfiguration() const; - + + Q_INVOKABLE void compressCurrentChat(); + Q_INVOKABLE void cancelCompression(); + Q_INVOKABLE void loadAvailableAgentRoles(); Q_INVOKABLE void applyAgentRole(const QString &roleId); Q_INVOKABLE void openAgentRolesSettings(); @@ -168,6 +174,8 @@ public: QString lastInfoMessage() const; bool isThinkingSupport() const; + + bool isCompressing() const; public slots: void sendMessage(const QString &message); @@ -205,10 +213,15 @@ signals: void isThinkingSupportChanged(); void availableConfigurationsChanged(); void currentConfigurationChanged(); + void availableAgentRolesChanged(); void currentAgentRoleChanged(); void baseSystemPromptChanged(); + void isCompressingChanged(); + void compressionCompleted(const QString &compressedChatPath); + void compressionFailed(const QString &error); + private: void updateFileEditStatus(const QString &editId, const QString &status); QString getChatsHistoryDir() const; @@ -241,9 +254,11 @@ private: QStringList m_availableConfigurations; QString m_currentConfiguration; - + QStringList m_availableAgentRoles; QString m_currentAgentRole; + + ChatCompressor *m_chatCompressor; }; } // namespace QodeAssist::Chat diff --git a/ChatView/icons/compress-icon.svg b/ChatView/icons/compress-icon.svg new file mode 100644 index 0000000..f210b8a --- /dev/null +++ b/ChatView/icons/compress-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index ad32c42..e7631c2 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -88,9 +88,12 @@ ChatRootView { Layout.preferredWidth: parent.width Layout.preferredHeight: childrenRect.height + 10 + isCompressing: root.isCompressing saveButton.onClicked: root.showSaveDialog() loadButton.onClicked: root.showLoadDialog() clearButton.onClicked: root.clearChat() + compressButton.onClicked: compressConfirmDialog.open() + cancelCompressButton.onClicked: root.cancelCompression() tokensBadge { text: qsTr("%1/%2").arg(root.inputTokensCount).arg(root.chatModel.tokensThreshold) } @@ -499,6 +502,22 @@ ChatRootView { scrollToBottom() } + Dialog { + id: compressConfirmDialog + + anchors.centerIn: parent + title: qsTr("Compress Chat") + modal: true + standardButtons: Dialog.Yes | Dialog.No + + Label { + text: qsTr("Create a summarized copy of this chat?\n\nThe summary will be generated by LLM and saved as a new chat file.") + wrapMode: Text.WordWrap + } + + onAccepted: root.compressCurrentChat() + } + Toast { id: errorToast z: 1000 diff --git a/ChatView/qml/controls/TopBar.qml b/ChatView/qml/controls/TopBar.qml index 33649e7..440da1d 100644 --- a/ChatView/qml/controls/TopBar.qml +++ b/ChatView/qml/controls/TopBar.qml @@ -29,6 +29,8 @@ Rectangle { property alias saveButton: saveButtonId property alias loadButton: loadButtonId property alias clearButton: clearButtonId + property alias compressButton: compressButtonId + property alias cancelCompressButton: cancelCompressButtonId property alias tokensBadge: tokensBadgeId property alias recentPath: recentPathId property alias openChatHistory: openChatHistoryId @@ -40,6 +42,8 @@ Rectangle { property alias configSelector: configSelectorId property alias roleSelector: roleSelector + property bool isCompressing: false + color: palette.window.hslLightness > 0.5 ? Qt.darker(palette.window, 1.1) : Qt.lighter(palette.window, 1.1) @@ -243,6 +247,51 @@ Rectangle { ToolTip.text: qsTr("Clean chat") } + QoAButton { + id: compressButtonId + + visible: !root.isCompressing + + icon { + source: "qrc:/qt/qml/ChatView/icons/compress-icon.svg" + height: 15 + width: 15 + } + ToolTip.visible: hovered + ToolTip.delay: 250 + ToolTip.text: qsTr("Compress chat (create summarized copy using LLM)") + } + + Row { + id: compressingRow + visible: root.isCompressing + spacing: 6 + + BusyIndicator { + id: compressBusyIndicator + running: root.isCompressing + width: 16 + height: 16 + } + + Text { + text: qsTr("Compressing...") + color: palette.text + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + height: parent.height + } + + QoAButton { + id: cancelCompressButtonId + text: qsTr("Cancel") + + ToolTip.visible: hovered + ToolTip.delay: 250 + ToolTip.text: qsTr("Cancel compression") + } + } + QoAButton { id: openChatHistoryId