// Copyright (C) 2024-2026 Petr Mironychev // SPDX-License-Identifier: GPL-3.0-or-later // Additional attribution terms under GPLv3 §7(b) apply — see LICENSE #include "ChatCompressor.hpp" #include #include "GeneralSettings.hpp" #include "templates/PromptTemplateManager.hpp" #include "providers/ProvidersManager.hpp" #include "logger/Logger.hpp" #include "session/HistoryProjection.hpp" #include "session/HistorySerializer.hpp" #include #include #include #include #include #include namespace QodeAssist::Chat { ChatCompressor::ChatCompressor(QObject *parent) : QObject(parent) {} void ChatCompressor::startCompression( const QString &chatFilePath, const Session::ConversationHistory &history) { if (m_isCompressing) { emit compressionFailed(tr("Compression already in progress")); return; } if (chatFilePath.isEmpty()) { emit compressionFailed(tr("No chat file to compress")); return; } const QList rows = Session::projectToRows(history); if (rows.isEmpty()) { emit compressionFailed(tr("Chat is empty, nothing to compress")); return; } auto providerName = Settings::generalSettings().caProvider(); m_provider = Providers::ProvidersManager::instance().getProviderByName(providerName); if (!m_provider) { emit compressionFailed(tr("No provider available")); return; } auto templateName = Settings::generalSettings().caTemplate(); auto promptTemplate = Templates::PromptTemplateManager::instance().getChatTemplateByName( templateName); if (!promptTemplate) { emit compressionFailed(tr("No template available")); return; } m_isCompressing = true; m_rows = rows; m_originalChatPath = chatFilePath; m_accumulatedSummary.clear(); emit compressionStarted(); connectProviderSignals(); QJsonObject payload{ {"model", Settings::generalSettings().caModel()}, {"stream", true}}; buildRequestPayload(payload, promptTemplate); const QString customEndpoint = Settings::generalSettings().caCustomEndpoint(); const QString endpoint = !customEndpoint.isEmpty() ? customEndpoint : promptTemplate->endpoint(); m_provider->client()->setTransferTimeout( static_cast(Settings::generalSettings().requestTimeout() * 1000)); m_currentRequestId = m_provider->sendRequest( QUrl(Settings::generalSettings().caUrl()), payload, endpoint); LOG_MESSAGE(QString("Starting compression request: %1").arg(m_currentRequestId)); } 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, Templates::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 Session::MessageRow &row : std::as_const(m_rows)) { if (row.kind == Session::RowKind::Tool || row.kind == Session::RowKind::FileEdit || row.kind == Session::RowKind::Thinking) continue; LLMCore::Message apiMessage; apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant"; apiMessage.content = row.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) { if (!QFileInfo(sourcePath).isReadable()) { LOG_MESSAGE(QString("Failed to open source chat file: %1").arg(sourcePath)); return false; } Session::Message summaryMessage; summaryMessage.role = Session::MessageRole::Assistant; summaryMessage.id = QUuid::createUuid().toString(QUuid::WithoutBraces); summaryMessage.blocks.append( Session::TextBlock{QString("# Chat Summary\n\n%1").arg(summary)}); Session::ConversationHistory history; history.append(summaryMessage); QJsonObject root = Session::HistorySerializer::toJson(history); root["compressedFrom"] = sourcePath; root["compressedAt"] = QDateTime::currentDateTime().toString(Qt::ISODate); 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; } if (destFile.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) { LOG_MESSAGE(QString("Failed to write compressed chat file: %1").arg(destFile.errorString())); return false; } if (!destFile.flush()) { LOG_MESSAGE(QString("Failed to flush compressed chat file: %1").arg(destFile.errorString())); return false; } return true; } void ChatCompressor::connectProviderSignals() { auto *c = m_provider->client(); m_connections.append(connect( c, &::LLMQore::BaseClient::chunkReceived, this, &ChatCompressor::onPartialResponseReceived, Qt::UniqueConnection)); m_connections.append(connect( c, &::LLMQore::BaseClient::requestCompleted, this, &ChatCompressor::onFullResponseReceived, Qt::UniqueConnection)); m_connections.append(connect( c, &::LLMQore::BaseClient::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_rows.clear(); m_provider = nullptr; } } // namespace QodeAssist::Chat