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