mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-07-24 04:01:04 -04:00
refactor: Decoupling chat architecture (#367)
* refactor: Migrate tests to Qt Creator plugin framework and update llmqore transports * refactor: Move conversation history into IDE-free session library * refactor: Extract turn context assembly behind injected ports * build: Add RunQodeAssistTests target for the plugin test suite * refactor: Route the chat through a Session and ChatBackend seam
This commit is contained in:
319
sources/ChatView/ChatController.cpp
Normal file
319
sources/ChatView/ChatController.cpp
Normal file
@@ -0,0 +1,319 @@
|
||||
// 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 "ChatController.hpp"
|
||||
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QMimeDatabase>
|
||||
|
||||
#include "ChatHistoryBridge.hpp"
|
||||
#include "ChatSerializer.hpp"
|
||||
#include "LlmChatBackend.hpp"
|
||||
#include "TurnContextAdapters.hpp"
|
||||
#include "context/ChangesManager.h"
|
||||
#include "context/RulesLoader.hpp"
|
||||
#include "logger/Logger.hpp"
|
||||
#include "session/FileEditPayload.hpp"
|
||||
#include "session/TurnContextBuilder.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatController::ChatController(
|
||||
ChatModel *chatModel, Templates::IPromptProvider *promptProvider, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_promptProvider(promptProvider)
|
||||
, m_chatModel(chatModel)
|
||||
, m_contextManager(new Context::ContextManager(this))
|
||||
, m_session(new Session::Session(this))
|
||||
, m_backend(new LlmChatBackend(promptProvider, this))
|
||||
{
|
||||
new ChatHistoryBridge(m_session, chatModel, this);
|
||||
|
||||
m_session->setBackend(m_backend);
|
||||
|
||||
connect(m_session, &Session::Session::turnStarted, this, &ChatController::requestStarted);
|
||||
connect(m_session, &Session::Session::turnFailed, this, &ChatController::errorOccurred);
|
||||
|
||||
connect(m_session, &Session::Session::turnFinished, this, [this](const QString &turnId) {
|
||||
QString applyError;
|
||||
if (!Context::ChangesManager::instance().applyPendingEditsForRequest(turnId, &applyError)) {
|
||||
LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2")
|
||||
.arg(turnId, applyError));
|
||||
}
|
||||
emit messageReceivedCompletely();
|
||||
});
|
||||
|
||||
connect(m_session, &Session::Session::usageReceived, this, [this](const Session::Usage &usage) {
|
||||
emit messageUsageReceived(
|
||||
usage.promptTokens,
|
||||
usage.completionTokens,
|
||||
usage.cachedPromptTokens,
|
||||
usage.reasoningTokens);
|
||||
});
|
||||
|
||||
connect(m_session, &Session::Session::rowsReset, this, [this] { registerHistoricalEdits(); });
|
||||
|
||||
auto &changes = Context::ChangesManager::instance();
|
||||
connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &id) {
|
||||
m_session->updateFileEditStatus(id, "applied", "Successfully applied");
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &id) {
|
||||
m_session->updateFileEditStatus(id, "rejected", "Rejected by user");
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &id) {
|
||||
m_session->updateFileEditStatus(id, "archived", "Archived (from previous conversation turn)");
|
||||
});
|
||||
}
|
||||
|
||||
void ChatController::setSkillsManager(Skills::SkillsManager *skillsManager)
|
||||
{
|
||||
m_skillsManager = skillsManager;
|
||||
}
|
||||
|
||||
Session::Session *ChatController::session() const
|
||||
{
|
||||
return m_session;
|
||||
}
|
||||
|
||||
void ChatController::sendMessage(
|
||||
const QString &message,
|
||||
const QList<QString> &attachments,
|
||||
const QList<QString> &linkedFiles,
|
||||
bool useTools,
|
||||
bool useThinking)
|
||||
{
|
||||
if (message.trimmed().isEmpty() && attachments.isEmpty()) {
|
||||
LOG_MESSAGE("Ignoring empty chat message");
|
||||
return;
|
||||
}
|
||||
|
||||
Context::ChangesManager::instance().archiveAllNonArchivedEdits();
|
||||
|
||||
m_session->sendTurn(
|
||||
composeUserBlocks(message, attachments),
|
||||
buildTurnContext(message, linkedFiles),
|
||||
Session::TurnOptions{useTools, useThinking});
|
||||
}
|
||||
|
||||
void ChatController::clearMessages()
|
||||
{
|
||||
m_backend->clearToolSession(m_chatFilePath);
|
||||
m_session->clear();
|
||||
}
|
||||
|
||||
void ChatController::cancelRequest()
|
||||
{
|
||||
m_session->cancel();
|
||||
}
|
||||
|
||||
void ChatController::resetToRow(int rowIndex)
|
||||
{
|
||||
m_session->truncateRows(rowIndex);
|
||||
}
|
||||
|
||||
QList<Session::ContentBlock> ChatController::composeUserBlocks(
|
||||
const QString &message, const QList<QString> &attachments)
|
||||
{
|
||||
QList<QString> imageFiles;
|
||||
QList<QString> textFiles;
|
||||
|
||||
for (const QString &filePath : attachments) {
|
||||
if (isImageFile(filePath))
|
||||
imageFiles.append(filePath);
|
||||
else
|
||||
textFiles.append(filePath);
|
||||
}
|
||||
|
||||
QList<Session::ContentBlock> blocks{Session::TextBlock{message}};
|
||||
|
||||
if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
|
||||
for (const auto &file : m_contextManager->getContentFiles(textFiles)) {
|
||||
QString storedPath;
|
||||
if (!ChatSerializer::saveContentToStorage(
|
||||
m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) {
|
||||
continue;
|
||||
}
|
||||
blocks.append(Session::AttachmentBlock{file.filename, storedPath});
|
||||
LOG_MESSAGE(QString("Stored text file %1 as %2").arg(file.filename, storedPath));
|
||||
}
|
||||
} else if (!textFiles.isEmpty()) {
|
||||
LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 text file(s)")
|
||||
.arg(textFiles.size()));
|
||||
}
|
||||
|
||||
if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
|
||||
for (const QString &imagePath : imageFiles) {
|
||||
const QString base64Data = encodeImageToBase64(imagePath);
|
||||
if (base64Data.isEmpty())
|
||||
continue;
|
||||
|
||||
const QFileInfo fileInfo(imagePath);
|
||||
QString storedPath;
|
||||
if (!ChatSerializer::saveContentToStorage(
|
||||
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
blocks.append(
|
||||
Session::ImageBlock{fileInfo.fileName(), storedPath, getMediaTypeForImage(imagePath)});
|
||||
LOG_MESSAGE(QString("Stored image %1 as %2").arg(fileInfo.fileName(), storedPath));
|
||||
}
|
||||
} else if (!imageFiles.isEmpty()) {
|
||||
LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 image(s)")
|
||||
.arg(imageFiles.size()));
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
std::optional<Session::TurnContext> ChatController::buildTurnContext(
|
||||
const QString &message, const QList<QString> &linkedFiles) const
|
||||
{
|
||||
auto &chatAssistantSettings = Settings::chatAssistantSettings();
|
||||
if (!chatAssistantSettings.useSystemPrompt())
|
||||
return std::nullopt;
|
||||
|
||||
Session::TurnContextRequest contextRequest;
|
||||
contextRequest.message = message;
|
||||
contextRequest.basePrompt = chatAssistantSettings.systemPrompt();
|
||||
contextRequest.linkedFilePaths = linkedFiles;
|
||||
|
||||
const QString lastRoleId = chatAssistantSettings.lastUsedRoleId();
|
||||
if (!lastRoleId.isEmpty()) {
|
||||
const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
|
||||
if (!role.id.isEmpty())
|
||||
contextRequest.rolePrompt = role.systemPrompt;
|
||||
}
|
||||
|
||||
auto *project = Context::RulesLoader::getActiveProject();
|
||||
|
||||
ProjectContextQtCreator projectPort(project);
|
||||
LinkedFilesQtCreator linkedFilesPort(m_contextManager);
|
||||
auto skillsPort = makeSkillsContext(m_skillsManager, project);
|
||||
|
||||
const Session::TurnContextBuilder builder(projectPort, skillsPort.get(), linkedFilesPort);
|
||||
|
||||
return builder.build(contextRequest);
|
||||
}
|
||||
|
||||
void ChatController::recordFileEditStatus(
|
||||
const QString &editId, const QString &status, const QString &fallbackMessage)
|
||||
{
|
||||
const auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
const QString message = edit.statusMessage.isEmpty() ? fallbackMessage : edit.statusMessage;
|
||||
m_session->updateFileEditStatus(editId, status, message);
|
||||
}
|
||||
|
||||
void ChatController::registerHistoricalEdits()
|
||||
{
|
||||
const QList<Session::MessageRow> rows = m_session->rows();
|
||||
|
||||
for (const Session::MessageRow &row : rows) {
|
||||
if (row.kind != Session::RowKind::FileEdit)
|
||||
continue;
|
||||
|
||||
const auto payload = Session::parseFileEditPayload(row.content);
|
||||
if (!payload) {
|
||||
LOG_MESSAGE(QString("Skipping unreadable file edit payload in row %1").arg(row.id));
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString editId = payload->value("edit_id").toString();
|
||||
const QString filePath = payload->value("file").toString();
|
||||
if (editId.isEmpty() || filePath.isEmpty())
|
||||
continue;
|
||||
|
||||
if (!payload->value("old_content").isString() || !payload->value("new_content").isString()) {
|
||||
LOG_MESSAGE(
|
||||
QString("Skipping file edit %1: content fields are not strings").arg(editId));
|
||||
continue;
|
||||
}
|
||||
|
||||
Context::ChangesManager::instance().addFileEdit(
|
||||
editId,
|
||||
filePath,
|
||||
payload->value("old_content").toString(),
|
||||
payload->value("new_content").toString(),
|
||||
false,
|
||||
true);
|
||||
|
||||
m_session->updateFileEditStatus(editId, "archived", "Loaded from chat history");
|
||||
|
||||
LOG_MESSAGE(QString(
|
||||
"Registered historical file edit: %1 (original status: %2, now: "
|
||||
"archived)")
|
||||
.arg(editId, payload->value("status").toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Context::ContextManager *ChatController::contextManager() const
|
||||
{
|
||||
return m_contextManager;
|
||||
}
|
||||
|
||||
bool ChatController::isImageFile(const QString &filePath) const
|
||||
{
|
||||
static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"};
|
||||
|
||||
QFileInfo fileInfo(filePath);
|
||||
QString extension = fileInfo.suffix().toLower();
|
||||
|
||||
return imageExtensions.contains(extension);
|
||||
}
|
||||
|
||||
QString ChatController::getMediaTypeForImage(const QString &filePath) const
|
||||
{
|
||||
static const QHash<QString, QString> mediaTypes
|
||||
= {{"png", "image/png"},
|
||||
{"jpg", "image/jpeg"},
|
||||
{"jpeg", "image/jpeg"},
|
||||
{"gif", "image/gif"},
|
||||
{"webp", "image/webp"},
|
||||
{"bmp", "image/bmp"},
|
||||
{"svg", "image/svg+xml"}};
|
||||
|
||||
QFileInfo fileInfo(filePath);
|
||||
QString extension = fileInfo.suffix().toLower();
|
||||
|
||||
if (mediaTypes.contains(extension)) {
|
||||
return mediaTypes[extension];
|
||||
}
|
||||
|
||||
QMimeDatabase mimeDb;
|
||||
QMimeType mimeType = mimeDb.mimeTypeForFile(filePath);
|
||||
return mimeType.name();
|
||||
}
|
||||
|
||||
QString ChatController::encodeImageToBase64(const QString &filePath) const
|
||||
{
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
LOG_MESSAGE(QString("Failed to open image file: %1").arg(filePath));
|
||||
return QString();
|
||||
}
|
||||
|
||||
QByteArray imageData = file.readAll();
|
||||
file.close();
|
||||
|
||||
return imageData.toBase64();
|
||||
}
|
||||
|
||||
void ChatController::setChatFilePath(const QString &filePath)
|
||||
{
|
||||
if (!m_chatFilePath.isEmpty() && m_chatFilePath != filePath)
|
||||
m_backend->clearToolSession(m_chatFilePath);
|
||||
|
||||
m_chatFilePath = filePath;
|
||||
m_backend->setChatFilePath(filePath);
|
||||
m_chatModel->setChatFilePath(filePath);
|
||||
}
|
||||
|
||||
QString ChatController::chatFilePath() const
|
||||
{
|
||||
return m_chatFilePath;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
Reference in New Issue
Block a user