mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-07-23 19:51:05 -04:00
349 lines
11 KiB
C++
349 lines
11 KiB
C++
// 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 <LLMQore/BaseClient.hpp>
|
|
#include "GeneralSettings.hpp"
|
|
#include "templates/PromptTemplateManager.hpp"
|
|
#include "providers/ProvidersManager.hpp"
|
|
#include "logger/Logger.hpp"
|
|
#include "session/HistoryProjection.hpp"
|
|
#include "session/HistorySerializer.hpp"
|
|
|
|
#include <QDateTime>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QUuid>
|
|
|
|
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<Session::MessageRow> 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<int>(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<LLMCore::Message> 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
|