// 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) {} QString ChatCompressor::configurationIssue() { auto &settings = Settings::generalSettings(); if (settings.caProvider().isEmpty()) return tr("no provider is assigned to the chat feature"); if (settings.caModel().isEmpty()) return tr("no model is assigned to the chat feature"); if (settings.caTemplate().isEmpty()) return tr("no prompt template is assigned to the chat feature"); if (settings.caUrl().isEmpty()) return tr("the chat feature has no URL configured"); if (!Providers::ProvidersManager::instance().getProviderByName(settings.caProvider())) return tr("the provider \"%1\" is not available").arg(settings.caProvider()); if (!Templates::PromptTemplateManager::instance().getChatTemplateByName(settings.caTemplate())) return tr("the prompt template \"%1\" is not available").arg(settings.caTemplate()); return {}; } void ChatCompressor::startSummary(const Session::ConversationHistory &history) { beginCompression(QString(), history, /*summaryOnly*/ true); } void ChatCompressor::startCompression( const QString &chatFilePath, const Session::ConversationHistory &history) { beginCompression(chatFilePath, history, /*summaryOnly*/ false); } void ChatCompressor::beginCompression( const QString &chatFilePath, const Session::ConversationHistory &history, bool summaryOnly) { if (m_isCompressing) { emit compressionFailed(tr("Compression already in progress")); return; } if (!summaryOnly && 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_summaryOnly = summaryOnly; m_rows = rows; m_originalChatPath = chatFilePath; m_accumulatedSummary.clear(); emit compressionStarted(); emit compressingChanged(); 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())); if (m_summaryOnly) { const QString summary = m_accumulatedSummary.trimmed(); cleanupState(); if (summary.isEmpty()) { emit compressionFailed(tr("The summary came back empty")); return; } emit summaryReady(summary); return; } 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)) { const Session::RowTreatment treatment = Session::rowTreatmentFor(Session::RowAudience::Compression, row.kind); if (treatment == Session::RowTreatment::Omit) continue; LLMCore::Message apiMessage; apiMessage.role = treatment == Session::RowTreatment::UserText ? "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(); const bool wasCompressing = m_isCompressing; m_isCompressing = false; m_summaryOnly = false; m_currentRequestId.clear(); m_originalChatPath.clear(); m_accumulatedSummary.clear(); m_rows.clear(); m_provider = nullptr; if (wasCompressing) emit compressingChanged(); } } // namespace QodeAssist::Chat