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:
Petr Mironychev
2026-07-19 22:45:04 +02:00
committed by GitHub
parent bd809edb8a
commit af85efb554
83 changed files with 5421 additions and 4620 deletions

View File

@@ -66,10 +66,13 @@ qt_add_qml_module(QodeAssistChatView
ChatWidget.hpp ChatWidget.cpp
ChatModel.hpp ChatModel.cpp
ChatRootView.hpp ChatRootView.cpp
ClientInterface.hpp ClientInterface.cpp
ChatController.hpp ChatController.cpp
LlmChatBackend.hpp LlmChatBackend.cpp
MessagePart.hpp
ChatUtils.h ChatUtils.cpp
ChatSerializer.hpp ChatSerializer.cpp
ChatHistoryBridge.hpp ChatHistoryBridge.cpp
TurnContextAdapters.hpp TurnContextAdapters.cpp
ChatView.hpp ChatView.cpp
ChatData.hpp
FileItem.hpp FileItem.cpp
@@ -94,6 +97,7 @@ target_link_libraries(QodeAssistChatView
QtCreator::Utils
QodeAssistSettings
Context
QodeAssistSession
QodeAssistUIControlsplugin
QodeAssistLogger
LLMQore

View File

@@ -5,16 +5,16 @@
#include "ChatCompressor.hpp"
#include <LLMQore/BaseClient.hpp>
#include "ChatModel.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 <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QUuid>
@@ -25,7 +25,8 @@ ChatCompressor::ChatCompressor(QObject *parent)
: QObject(parent)
{}
void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *chatModel)
void ChatCompressor::startCompression(
const QString &chatFilePath, const Session::ConversationHistory &history)
{
if (m_isCompressing) {
emit compressionFailed(tr("Compression already in progress"));
@@ -37,7 +38,8 @@ void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *ch
return;
}
if (!chatModel || chatModel->rowCount() == 0) {
const QList<Session::MessageRow> rows = Session::projectToRows(history);
if (rows.isEmpty()) {
emit compressionFailed(tr("Chat is empty, nothing to compress"));
return;
}
@@ -60,7 +62,7 @@ void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *ch
}
m_isCompressing = true;
m_chatModel = chatModel;
m_rows = rows;
m_originalChatPath = chatFilePath;
m_accumulatedSummary.clear();
@@ -178,15 +180,14 @@ void ChatCompressor::buildRequestPayload(
"Your summaries preserve key information, technical details, and the flow of discussion.");
QVector<LLMCore::Message> messages;
for (const auto &msg : m_chatModel->getChatHistory()) {
if (msg.role == ChatModel::ChatRole::Tool
|| msg.role == ChatModel::ChatRole::FileEdit
|| msg.role == ChatModel::ChatRole::Thinking)
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 = (msg.role == ChatModel::ChatRole::User) ? "user" : "assistant";
apiMessage.content = msg.content;
apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant";
apiMessage.content = row.content;
messages.append(apiMessage);
}
@@ -204,33 +205,21 @@ void ChatCompressor::buildRequestPayload(
bool ChatCompressor::createCompressedChatFile(
const QString &sourcePath, const QString &destPath, const QString &summary)
{
QFile sourceFile(sourcePath);
if (!sourceFile.open(QIODevice::ReadOnly)) {
if (!QFileInfo(sourcePath).isReadable()) {
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();
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)});
if (doc.isNull() || !doc.isObject()) {
LOG_MESSAGE(QString("Invalid JSON in chat file: %1 (Error: %2)")
.arg(sourcePath, parseError.errorString()));
return false;
}
Session::ConversationHistory history;
history.append(summaryMessage);
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};
QJsonObject root = Session::HistorySerializer::toJson(history);
root["compressedFrom"] = sourcePath;
root["compressedAt"] = QDateTime::currentDateTime().toString(Qt::ISODate);
@@ -243,7 +232,16 @@ bool ChatCompressor::createCompressedChatFile(
return false;
}
destFile.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
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;
}
@@ -288,7 +286,7 @@ void ChatCompressor::cleanupState()
m_currentRequestId.clear();
m_originalChatPath.clear();
m_accumulatedSummary.clear();
m_chatModel = nullptr;
m_rows.clear();
m_provider = nullptr;
}

View File

@@ -9,6 +9,8 @@
#include <QObject>
#include <QString>
#include "session/HistoryProjection.hpp"
namespace QodeAssist::Providers {
class Provider;
} // namespace QodeAssist::Providers
@@ -19,8 +21,6 @@ class PromptTemplate;
namespace QodeAssist::Chat {
class ChatModel;
class ChatCompressor : public QObject
{
Q_OBJECT
@@ -28,7 +28,8 @@ class ChatCompressor : public QObject
public:
explicit ChatCompressor(QObject *parent = nullptr);
void startCompression(const QString &chatFilePath, ChatModel *chatModel);
void startCompression(
const QString &chatFilePath, const Session::ConversationHistory &history);
bool isCompressing() const;
void cancelCompression();
@@ -59,7 +60,7 @@ private:
QString m_originalChatPath;
QString m_accumulatedSummary;
Providers::Provider *m_provider = nullptr;
ChatModel *m_chatModel = nullptr;
QList<Session::MessageRow> m_rows;
QList<QMetaObject::Connection> m_connections;
};

View 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

View File

@@ -0,0 +1,79 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QString>
#include "ChatModel.hpp"
#include "session/Session.hpp"
#include "templates/IPromptProvider.hpp"
#include <context/ContextManager.hpp>
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class ChatHistoryBridge;
class LlmChatBackend;
class ChatController : public QObject
{
Q_OBJECT
public:
explicit ChatController(
ChatModel *chatModel, Templates::IPromptProvider *promptProvider, QObject *parent = nullptr);
void setSkillsManager(Skills::SkillsManager *skillsManager);
void sendMessage(
const QString &message,
const QList<QString> &attachments = {},
const QList<QString> &linkedFiles = {},
bool useTools = false,
bool useThinking = false);
void clearMessages();
void cancelRequest();
void resetToRow(int rowIndex);
Session::Session *session() const;
Context::ContextManager *contextManager() const;
void setChatFilePath(const QString &filePath);
QString chatFilePath() const;
signals:
void errorOccurred(const QString &error);
void messageReceivedCompletely();
void requestStarted(const QString &requestId);
void messageUsageReceived(
int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens);
private:
QList<Session::ContentBlock> composeUserBlocks(
const QString &message, const QList<QString> &attachments);
std::optional<Session::TurnContext> buildTurnContext(
const QString &message, const QList<QString> &linkedFiles) const;
void recordFileEditStatus(
const QString &editId, const QString &status, const QString &fallbackMessage);
void registerHistoricalEdits();
bool isImageFile(const QString &filePath) const;
QString getMediaTypeForImage(const QString &filePath) const;
QString encodeImageToBase64(const QString &filePath) const;
Templates::IPromptProvider *m_promptProvider = nullptr;
ChatModel *m_chatModel = nullptr;
Context::ContextManager *m_contextManager = nullptr;
Skills::SkillsManager *m_skillsManager = nullptr;
Session::Session *m_session = nullptr;
LlmChatBackend *m_backend = nullptr;
QString m_chatFilePath;
};
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,107 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatHistoryBridge.hpp"
#include "ChatModel.hpp"
#include "session/Session.hpp"
namespace QodeAssist::Chat {
namespace {
ChatModel::ChatRole toChatRole(Session::RowKind kind)
{
switch (kind) {
case Session::RowKind::System:
return ChatModel::ChatRole::System;
case Session::RowKind::User:
return ChatModel::ChatRole::User;
case Session::RowKind::Assistant:
return ChatModel::ChatRole::Assistant;
case Session::RowKind::Tool:
return ChatModel::ChatRole::Tool;
case Session::RowKind::FileEdit:
return ChatModel::ChatRole::FileEdit;
case Session::RowKind::Thinking:
return ChatModel::ChatRole::Thinking;
}
return ChatModel::ChatRole::Assistant;
}
ChatModel::Message toChatMessage(const Session::MessageRow &row)
{
ChatModel::Message message;
message.role = toChatRole(row.kind);
message.content = row.content;
message.id = row.id;
message.isRedacted = row.redacted;
message.signature = row.signature;
message.toolName = row.toolName;
message.toolArguments = row.toolArguments;
message.toolResult = row.toolResult;
for (const Session::AttachmentBlock &attachment : row.attachments)
message.attachments.append(Context::ContentFile{attachment.fileName, attachment.storedPath});
for (const Session::ImageBlock &image : row.images)
message.images.append(
ChatModel::ImageAttachment{image.fileName, image.storedPath, image.mediaType});
message.promptTokens = row.usage.promptTokens;
message.completionTokens = row.usage.completionTokens;
message.cachedPromptTokens = row.usage.cachedPromptTokens;
message.reasoningTokens = row.usage.reasoningTokens;
return message;
}
QVector<ChatModel::Message> toChatMessages(const QList<Session::MessageRow> &rows)
{
QVector<ChatModel::Message> messages;
messages.reserve(rows.size());
for (const Session::MessageRow &row : rows)
messages.append(toChatMessage(row));
return messages;
}
} // namespace
ChatHistoryBridge::ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent)
: QObject(parent)
, m_model(model)
{
connect(session, &Session::Session::rowsReset, this, &ChatHistoryBridge::onRowsReset);
connect(session, &Session::Session::rowsAppended, this, &ChatHistoryBridge::onRowsAppended);
connect(session, &Session::Session::rowUpdated, this, &ChatHistoryBridge::onRowUpdated);
connect(session, &Session::Session::rowsRemoved, this, &ChatHistoryBridge::onRowsRemoved);
onRowsReset(session->rows());
}
void ChatHistoryBridge::onRowsReset(const QList<Session::MessageRow> &rows)
{
if (m_model)
m_model->resetMessages(toChatMessages(rows));
}
void ChatHistoryBridge::onRowsAppended(const QList<Session::MessageRow> &rows)
{
if (m_model)
m_model->appendMessages(toChatMessages(rows));
}
void ChatHistoryBridge::onRowUpdated(int index, const Session::MessageRow &row)
{
if (m_model)
m_model->updateMessage(index, toChatMessage(row));
}
void ChatHistoryBridge::onRowsRemoved(int first, int count)
{
if (m_model)
m_model->removeMessages(first, count);
}
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,37 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QList>
#include <QObject>
#include <QPointer>
#include "session/HistoryProjection.hpp"
namespace QodeAssist::Session {
class Session;
}
namespace QodeAssist::Chat {
class ChatModel;
class ChatHistoryBridge : public QObject
{
Q_OBJECT
public:
ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent = nullptr);
private:
void onRowsReset(const QList<Session::MessageRow> &rows);
void onRowsAppended(const QList<Session::MessageRow> &rows);
void onRowUpdated(int index, const Session::MessageRow &row);
void onRowsRemoved(int first, int count);
QPointer<ChatModel> m_model;
};
} // namespace QodeAssist::Chat

View File

@@ -16,15 +16,15 @@
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include "ChatModel.hpp"
#include "Logger.hpp"
#include "ProjectSettings.hpp"
#include "session/Session.hpp"
namespace QodeAssist::Chat {
ChatHistoryStore::ChatHistoryStore(ChatModel *chatModel, QObject *parent)
ChatHistoryStore::ChatHistoryStore(Session::Session *session, QObject *parent)
: QObject(parent)
, m_chatModel(chatModel)
, m_session(session)
{}
QString ChatHistoryStore::historyDir() const
@@ -52,18 +52,15 @@ QString ChatHistoryStore::suggestedFileName() const
{
QString shortMessage;
if (m_chatModel->rowCount() > 0) {
QString firstMessage
= m_chatModel->data(m_chatModel->index(0), ChatModel::Content).toString();
shortMessage = firstMessage.split('\n').first().simplified().left(30);
if (!m_session)
return generateChatFileName(shortMessage, historyDir());
if (shortMessage.isEmpty()) {
QVariantList images
= m_chatModel->data(m_chatModel->index(0), ChatModel::Images).toList();
if (!images.isEmpty()) {
shortMessage = "image_chat";
}
}
const QList<Session::MessageRow> &rows = m_session->rows();
if (!rows.isEmpty()) {
shortMessage = rows.first().content.split('\n').first().simplified().left(30);
if (shortMessage.isEmpty() && !rows.first().images.isEmpty())
shortMessage = "image_chat";
}
return generateChatFileName(shortMessage, historyDir());
@@ -107,12 +104,22 @@ QString ChatHistoryStore::autosaveFilePath(
SerializationResult ChatHistoryStore::save(const QString &filePath) const
{
return ChatSerializer::saveToFile(m_chatModel, filePath);
if (!m_session)
return {false, QString("Chat session is no longer available")};
return ChatSerializer::saveToFile(m_session->history(), filePath);
}
SerializationResult ChatHistoryStore::load(const QString &filePath) const
{
return ChatSerializer::loadFromFile(m_chatModel, filePath);
if (!m_session)
return {false, QString("Chat session is no longer available")};
Session::ConversationHistory history;
const SerializationResult result = ChatSerializer::loadFromFile(history, filePath);
if (result.success)
m_session->setHistory(history);
return result;
}
void ChatHistoryStore::showSaveDialog()

View File

@@ -5,20 +5,23 @@
#pragma once
#include <QObject>
#include <QPointer>
#include <QString>
#include "ChatSerializer.hpp"
namespace QodeAssist::Chat {
namespace QodeAssist::Session {
class Session;
}
class ChatModel;
namespace QodeAssist::Chat {
class ChatHistoryStore : public QObject
{
Q_OBJECT
public:
explicit ChatHistoryStore(ChatModel *chatModel, QObject *parent = nullptr);
explicit ChatHistoryStore(Session::Session *session, QObject *parent = nullptr);
QString historyDir() const;
QString suggestedFileName() const;
@@ -42,7 +45,7 @@ signals:
private:
QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
ChatModel *m_chatModel;
QPointer<Session::Session> m_session;
};
} // namespace QodeAssist::Chat

View File

@@ -3,38 +3,41 @@
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ChatModel.hpp"
#include <utils/aspects.h>
#include <QDateTime>
#include <QDir>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <QUrl>
#include <QtQml>
#include "Logger.hpp"
#include "context/ChangesManager.h"
#include <algorithm>
#include <tuple>
#include "logger/Logger.hpp"
namespace QodeAssist::Chat {
namespace {
auto usageOf(const ChatModel::Message &message)
{
return std::tie(
message.promptTokens,
message.completionTokens,
message.cachedPromptTokens,
message.reasoningTokens);
}
bool carriesUsage(const ChatModel::Message &message)
{
return message.promptTokens != 0 || message.completionTokens != 0
|| message.cachedPromptTokens != 0 || message.reasoningTokens != 0;
}
} // namespace
ChatModel::ChatModel(QObject *parent)
: QAbstractListModel(parent)
{
connect(&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditApplied,
this,
&ChatModel::onFileEditApplied);
connect(&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditRejected,
this,
&ChatModel::onFileEditRejected);
connect(&Context::ChangesManager::instance(),
&Context::ChangesManager::fileEditArchived,
this,
&ChatModel::onFileEditArchived);
}
{}
int ChatModel::rowCount(const QModelIndex &parent) const
{
@@ -134,93 +137,70 @@ QHash<int, QByteArray> ChatModel::roleNames() const
return roles;
}
void ChatModel::addMessage(
const QString &content,
ChatRole role,
const QString &id,
const QList<Context::ContentFile> &attachments,
const QList<ImageAttachment> &images,
bool isRedacted,
const QString &signature)
{
if (!m_messages.isEmpty() && !id.isEmpty() && m_messages.last().id == id
&& m_messages.last().role == role) {
Message &lastMessage = m_messages.last();
lastMessage.content = content;
lastMessage.attachments = attachments;
lastMessage.images = images;
lastMessage.isRedacted = isRedacted;
lastMessage.signature = signature;
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
} else {
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message newMessage{role, content, id};
newMessage.attachments = attachments;
newMessage.images = images;
newMessage.isRedacted = isRedacted;
newMessage.signature = signature;
m_messages.append(newMessage);
endInsertRows();
if (m_loadingFromHistory && role == ChatRole::FileEdit) {
const QString marker = "QODEASSIST_FILE_EDIT:";
if (content.contains(marker)) {
int markerPos = content.indexOf(marker);
int jsonStart = markerPos + marker.length();
if (jsonStart < content.length()) {
QString jsonStr = content.mid(jsonStart);
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
if (doc.isObject()) {
QJsonObject editData = doc.object();
QString editId = editData.value("edit_id").toString();
QString filePath = editData.value("file").toString();
QString oldContent = editData.value("old_content").toString();
QString newContent = editData.value("new_content").toString();
QString originalStatus = editData.value("status").toString();
if (!editId.isEmpty() && !filePath.isEmpty()) {
Context::ChangesManager::instance().addFileEdit(
editId, filePath, oldContent, newContent, false, true);
editData["status"] = "archived";
editData["status_message"] = "Loaded from chat history";
QString updatedContent = marker
+ QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact));
m_messages.last().content = updatedContent;
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
LOG_MESSAGE(QString("Registered historical file edit: %1 (original status: %2, now: archived)")
.arg(editId, originalStatus));
}
}
}
}
}
}
}
QVector<ChatModel::Message> ChatModel::getChatHistory() const
{
return m_messages;
}
void ChatModel::clear()
void ChatModel::resetMessages(const QVector<Message> &messages)
{
beginResetModel();
m_messages.clear();
m_messages = messages;
endResetModel();
emit modelReseted();
emit sessionUsageChanged();
}
void ChatModel::appendMessages(const QVector<Message> &messages)
{
if (messages.isEmpty())
return;
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size() + messages.size() - 1);
m_messages.append(messages);
endInsertRows();
if (std::any_of(messages.cbegin(), messages.cend(), carriesUsage))
emit sessionUsageChanged();
}
void ChatModel::updateMessage(int index, const Message &message)
{
if (index < 0 || index >= m_messages.size()) {
LOG_MESSAGE(QString("Session/model desync: update of row %1 with %2 rows present")
.arg(index)
.arg(m_messages.size()));
return;
}
const bool usageChanged = usageOf(m_messages[index]) != usageOf(message);
m_messages[index] = message;
emit dataChanged(this->index(index), this->index(index));
if (usageChanged)
emit sessionUsageChanged();
}
void ChatModel::removeMessages(int first, int count)
{
if (first < 0 || count <= 0 || first + count > m_messages.size()) {
LOG_MESSAGE(QString("Session/model desync: removal of %1 rows at %2 with %3 rows present")
.arg(count)
.arg(first)
.arg(m_messages.size()));
return;
}
const bool usageChanged
= std::any_of(m_messages.cbegin() + first, m_messages.cbegin() + first + count, carriesUsage);
beginRemoveRows(QModelIndex(), first, first + count - 1);
m_messages.remove(first, count);
endRemoveRows();
if (usageChanged)
emit sessionUsageChanged();
}
QList<MessagePart> ChatModel::processMessageContent(const QString &content) const
{
QList<MessagePart> parts;
QRegularExpression codeBlockRegex("```(\\w*)\\n?([\\s\\S]*?)```");
static const QRegularExpression codeBlockRegex("```(\\w*)\\n?([\\s\\S]*?)```");
int lastIndex = 0;
auto blockMatches = codeBlockRegex.globalMatch(content);
@@ -249,7 +229,7 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
if (lastIndex < content.length()) {
QString remainingText = content.mid(lastIndex).trimmed();
QRegularExpression unclosedBlockRegex("```(\\w*)\\n?([\\s\\S]*)$");
static const QRegularExpression unclosedBlockRegex("```(\\w*)\\n?([\\s\\S]*)$");
auto unclosedMatch = unclosedBlockRegex.match(remainingText);
if (unclosedMatch.hasMatch()) {
@@ -277,65 +257,6 @@ QList<MessagePart> ChatModel::processMessageContent(const QString &content) cons
return parts;
}
QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) const
{
QJsonArray messages;
messages.append(QJsonObject{{"role", "system"}, {"content", systemPrompt}});
for (const auto &message : m_messages) {
QString role;
switch (message.role) {
case ChatRole::User:
role = "user";
break;
case ChatRole::Assistant:
role = "assistant";
break;
case ChatRole::Tool:
case ChatRole::FileEdit:
continue;
default:
continue;
}
QString content
= message.attachments.isEmpty()
? message.content
: message.content + "\n\nAttached files list:"
+ std::accumulate(
message.attachments.begin(),
message.attachments.end(),
QString(),
[](QString acc, const Context::ContentFile &attachment) {
return acc
+ QString("\nname: %1\nfile content:\n%2")
.arg(attachment.filename, attachment.content);
});
messages.append(QJsonObject{{"role", role}, {"content", content}});
}
return messages;
}
QString ChatModel::lastMessageId() const
{
return !m_messages.isEmpty() ? m_messages.last().id : "";
}
void ChatModel::resetModelTo(int index)
{
if (index < 0 || index >= m_messages.size())
return;
if (index < m_messages.size()) {
beginRemoveRows(QModelIndex(), index, m_messages.size() - 1);
m_messages.remove(index, m_messages.size() - index);
endRemoveRows();
emit sessionUsageChanged();
}
}
QVariantList ChatModel::userMessagePreviews(int maxLength) const
{
QVariantList result;
@@ -358,248 +279,6 @@ QVariantList ChatModel::userMessagePreviews(int maxLength) const
return result;
}
void ChatModel::addToolExecutionStatus(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments)
{
QString content = toolName;
LOG_MESSAGE(QString("Adding tool execution status: requestId=%1, toolId=%2, toolName=%3")
.arg(requestId, toolId, toolName));
if (!m_messages.isEmpty() && !toolId.isEmpty() && m_messages.last().id == toolId
&& m_messages.last().role == ChatRole::Tool) {
Message &lastMessage = m_messages.last();
lastMessage.content = content;
lastMessage.toolName = toolName;
lastMessage.toolArguments = toolArguments;
LOG_MESSAGE(QString("Updated existing tool message at index %1").arg(m_messages.size() - 1));
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
} else {
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message newMessage{ChatRole::Tool, content, toolId};
newMessage.toolName = toolName;
newMessage.toolArguments = toolArguments;
m_messages.append(newMessage);
endInsertRows();
LOG_MESSAGE(QString("Created new tool message at index %1 with toolId=%2")
.arg(m_messages.size() - 1)
.arg(toolId));
}
}
void ChatModel::dropTrailingAssistantMessage(const QString &requestId)
{
if (m_messages.isEmpty())
return;
const Message &last = m_messages.last();
if (last.role != ChatRole::Assistant || last.id != requestId)
return;
const int idx = m_messages.size() - 1;
beginRemoveRows(QModelIndex(), idx, idx);
m_messages.removeLast();
endRemoveRows();
LOG_MESSAGE(QString("Dropped leaked pre-tool assistant message at index %1").arg(idx));
}
void ChatModel::setToolMessageData(
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments,
const QString &toolResult)
{
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].role == ChatRole::Tool && m_messages[i].id == toolId) {
m_messages[i].toolName = toolName;
m_messages[i].toolArguments = toolArguments;
m_messages[i].toolResult = toolResult;
return;
}
}
}
void ChatModel::updateToolResult(
const QString &requestId, const QString &toolId, const QString &toolName, const QString &result)
{
if (m_messages.isEmpty() || toolId.isEmpty()) {
LOG_MESSAGE(QString("Cannot update tool result: messages empty=%1, toolId empty=%2")
.arg(m_messages.isEmpty())
.arg(toolId.isEmpty()));
return;
}
LOG_MESSAGE(
QString("Updating tool result: requestId=%1, toolId=%2, toolName=%3, result length=%4")
.arg(requestId, toolId, toolName)
.arg(result.length()));
bool toolMessageFound = false;
for (int i = m_messages.size() - 1; i >= 0; --i) {
if (m_messages[i].id == toolId && m_messages[i].role == ChatRole::Tool) {
m_messages[i].content = toolName + "\n" + result;
m_messages[i].toolName = toolName;
m_messages[i].toolResult = result;
emit dataChanged(index(i), index(i));
toolMessageFound = true;
LOG_MESSAGE(QString("Updated tool result at index %1").arg(i));
break;
}
}
if (!toolMessageFound) {
LOG_MESSAGE(QString("WARNING: Tool message with requestId=%1 toolId=%2 not found!")
.arg(requestId, toolId));
}
const QString marker = "QODEASSIST_FILE_EDIT:";
if (result.contains(marker)) {
LOG_MESSAGE(QString("File edit marker detected in tool result"));
int markerPos = result.indexOf(marker);
int jsonStart = markerPos + marker.length();
if (jsonStart < result.length()) {
QString jsonStr = result.mid(jsonStart);
QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
LOG_MESSAGE(QString("ERROR: Failed to parse file edit JSON at offset %1: %2")
.arg(parseError.offset)
.arg(parseError.errorString()));
} else if (!doc.isObject()) {
LOG_MESSAGE(
QString("ERROR: Parsed JSON is not an object, is array=%1").arg(doc.isArray()));
} else {
QJsonObject editData = doc.object();
QString editId = editData.value("edit_id").toString();
if (editId.isEmpty()) {
editId = QString("edit_%1").arg(QDateTime::currentMSecsSinceEpoch());
}
LOG_MESSAGE(QString("Adding FileEdit message, editId=%1").arg(editId));
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message fileEditMsg;
fileEditMsg.role = ChatRole::FileEdit;
fileEditMsg.content = result;
fileEditMsg.id = editId;
m_messages.append(fileEditMsg);
endInsertRows();
LOG_MESSAGE(QString("Added FileEdit message with editId=%1").arg(editId));
}
}
}
}
void ChatModel::addThinkingBlock(
const QString &requestId, const QString &thinking, const QString &signature)
{
LOG_MESSAGE(QString("Adding thinking block: requestId=%1, thinking length=%2, signature length=%3")
.arg(requestId)
.arg(thinking.length())
.arg(signature.length()));
QString displayContent = thinking;
if (!signature.isEmpty()) {
displayContent += "\n[Signature: " + signature.left(40) + "...]";
}
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].role == ChatRole::Thinking && m_messages[i].id == requestId) {
m_messages[i].content = displayContent;
m_messages[i].signature = signature;
emit dataChanged(index(i), index(i));
LOG_MESSAGE(QString("Updated existing thinking message at index %1").arg(i));
return;
}
}
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message thinkingMessage;
thinkingMessage.role = ChatRole::Thinking;
thinkingMessage.content = displayContent;
thinkingMessage.id = requestId;
thinkingMessage.isRedacted = false;
thinkingMessage.signature = signature;
m_messages.append(thinkingMessage);
endInsertRows();
LOG_MESSAGE(QString("Added thinking message at index %1 with signature length=%2")
.arg(m_messages.size() - 1).arg(signature.length()));
}
void ChatModel::addRedactedThinkingBlock(const QString &requestId, const QString &signature)
{
LOG_MESSAGE(
QString("Adding redacted thinking block: requestId=%1, signature length=%2")
.arg(requestId)
.arg(signature.length()));
QString displayContent = "[Thinking content redacted by safety systems]";
if (!signature.isEmpty()) {
displayContent += "\n[Signature: " + signature.left(40) + "...]";
}
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
Message thinkingMessage;
thinkingMessage.role = ChatRole::Thinking;
thinkingMessage.content = displayContent;
thinkingMessage.id = requestId;
thinkingMessage.isRedacted = true;
thinkingMessage.signature = signature;
m_messages.append(thinkingMessage);
endInsertRows();
LOG_MESSAGE(QString("Added redacted thinking message at index %1 with signature length=%2")
.arg(m_messages.size() - 1).arg(signature.length()));
}
void ChatModel::updateMessageContent(const QString &messageId, const QString &newContent)
{
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].id == messageId) {
m_messages[i].content = newContent;
emit dataChanged(index(i), index(i));
LOG_MESSAGE(QString("Updated message content for id: %1").arg(messageId));
break;
}
}
}
void ChatModel::setMessageUsage(
const QString &messageId,
int promptTokens,
int completionTokens,
int cachedPromptTokens,
int reasoningTokens)
{
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].id != messageId)
continue;
m_messages[i].promptTokens = promptTokens;
m_messages[i].completionTokens = completionTokens;
m_messages[i].cachedPromptTokens = cachedPromptTokens;
m_messages[i].reasoningTokens = reasoningTokens;
emit dataChanged(
index(i),
index(i),
{Roles::PromptTokens,
Roles::CompletionTokens,
Roles::CachedPromptTokens,
Roles::ReasoningTokens,
Roles::TotalTokens});
emit sessionUsageChanged();
return;
}
}
int ChatModel::sessionPromptTokens() const
{
int total = 0;
@@ -629,71 +308,6 @@ int ChatModel::sessionTotalTokens() const
return sessionPromptTokens() + sessionCompletionTokens();
}
void ChatModel::setLoadingFromHistory(bool loading)
{
m_loadingFromHistory = loading;
LOG_MESSAGE(QString("ChatModel loading from history: %1").arg(loading ? "true" : "false"));
}
bool ChatModel::isLoadingFromHistory() const
{
return m_loadingFromHistory;
}
void ChatModel::onFileEditApplied(const QString &editId)
{
updateFileEditStatus(editId, "applied", "Successfully applied");
}
void ChatModel::onFileEditRejected(const QString &editId)
{
updateFileEditStatus(editId, "rejected", "Rejected by user");
}
void ChatModel::onFileEditArchived(const QString &editId)
{
updateFileEditStatus(editId, "archived", "Archived (from previous conversation turn)");
}
void ChatModel::updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage)
{
const QString marker = "QODEASSIST_FILE_EDIT:";
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages[i].role == ChatRole::FileEdit && m_messages[i].id == editId) {
const QString &content = m_messages[i].content;
if (content.contains(marker)) {
int markerPos = content.indexOf(marker);
int jsonStart = markerPos + marker.length();
if (jsonStart < content.length()) {
QString jsonStr = content.mid(jsonStart);
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
if (doc.isObject()) {
QJsonObject editData = doc.object();
editData["status"] = status;
editData["status_message"] = statusMessage;
QString updatedContent = marker
+ QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact));
m_messages[i].content = updatedContent;
emit dataChanged(index(i), index(i));
LOG_MESSAGE(QString("Updated FileEdit message status: editId=%1, status=%2")
.arg(editId, status));
break;
}
}
}
}
}
}
void ChatModel::setChatFilePath(const QString &filePath)
{
m_chatFilePath = filePath;

View File

@@ -4,11 +4,9 @@
#pragma once
#include "llmcore/ContextData.hpp"
#include "MessagePart.hpp"
#include <QAbstractListModel>
#include <QJsonArray>
#include <QJsonObject>
#include <QtQmlIntegration>
@@ -77,62 +75,19 @@ public:
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE void addMessage(
const QString &content,
ChatRole role,
const QString &id,
const QList<Context::ContentFile> &attachments = {},
const QList<ImageAttachment> &images = {},
bool isRedacted = false,
const QString &signature = QString());
Q_INVOKABLE void clear();
void resetMessages(const QVector<Message> &messages);
void appendMessages(const QVector<Message> &messages);
void updateMessage(int index, const Message &message);
void removeMessages(int first, int count);
Q_INVOKABLE QList<MessagePart> processMessageContent(const QString &content) const;
QVector<Message> getChatHistory() const;
QJsonArray prepareMessagesForRequest(const QString &systemPrompt) const;
QString currentModel() const;
QString lastMessageId() const;
Q_INVOKABLE void resetModelTo(int index);
Q_INVOKABLE QVariantList userMessagePreviews(int maxLength = 80) const;
void addToolExecutionStatus(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments);
void dropTrailingAssistantMessage(const QString &requestId);
void setToolMessageData(
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments,
const QString &toolResult);
void updateToolResult(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QString &result);
void addThinkingBlock(
const QString &requestId, const QString &thinking, const QString &signature);
void addRedactedThinkingBlock(const QString &requestId, const QString &signature);
void updateMessageContent(const QString &messageId, const QString &newContent);
void setMessageUsage(
const QString &messageId,
int promptTokens,
int completionTokens,
int cachedPromptTokens,
int reasoningTokens);
int sessionPromptTokens() const;
int sessionCompletionTokens() const;
int sessionCachedPromptTokens() const;
int sessionTotalTokens() const;
void setLoadingFromHistory(bool loading);
bool isLoadingFromHistory() const;
void setChatFilePath(const QString &filePath);
QString chatFilePath() const;
@@ -140,16 +95,8 @@ signals:
void modelReseted();
void sessionUsageChanged();
private slots:
void onFileEditApplied(const QString &editId);
void onFileEditRejected(const QString &editId);
void onFileEditArchived(const QString &editId);
private:
void updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage);
QVector<Message> m_messages;
bool m_loadingFromHistory = false;
QString m_chatFilePath;
};

View File

@@ -75,16 +75,16 @@ ChatRootView::ChatRootView(QQuickItem *parent)
: QQuickItem(parent)
, m_chatModel(new ChatModel(this))
, m_promptProvider(Templates::PromptTemplateManager::instance())
, m_clientInterface(new ClientInterface(m_chatModel, &m_promptProvider, this))
, m_controller(new ChatController(m_chatModel, &m_promptProvider, this))
, m_fileManager(new ChatFileManager(this))
, m_isRequestInProgress(false)
, m_chatCompressor(new ChatCompressor(this))
, m_agentRoleController(new AgentRoleController(this))
, m_configurationController(new ChatConfigurationController(this))
, m_fileEditController(new FileEditController(m_chatModel, this))
, m_tokenCounter(
new InputTokenCounter(m_chatModel, m_clientInterface->contextManager(), this))
, m_historyStore(new ChatHistoryStore(m_chatModel, this))
, m_fileEditController(new FileEditController(this))
, m_tokenCounter(new InputTokenCounter(
m_controller->session(), m_controller->contextManager(), this))
, m_historyStore(new ChatHistoryStore(m_controller->session(), this))
{
m_isSyncOpenFiles = Settings::chatAssistantSettings().linkOpenFiles();
connect(
@@ -126,18 +126,18 @@ ChatRootView::ChatRootView(QQuickItem *parent)
&ChatRootView::currentConfigurationChanged);
connect(
m_clientInterface,
&ClientInterface::messageReceivedCompletely,
m_controller,
&ChatController::messageReceivedCompletely,
this,
&ChatRootView::autosave);
connect(m_clientInterface, &ClientInterface::messageReceivedCompletely, this, [this]() {
connect(m_controller, &ChatController::messageReceivedCompletely, this, [this]() {
this->setRequestProgressStatus(false);
});
connect(
m_clientInterface,
&ClientInterface::messageReceivedCompletely,
m_controller,
&ChatController::messageReceivedCompletely,
this,
&ChatRootView::updateInputTokensCount);
@@ -156,7 +156,6 @@ ChatRootView::ChatRootView(QQuickItem *parent)
connect(m_chatModel, &QAbstractItemModel::modelReset, this, maybeEmitTitle);
connect(m_chatModel, &QAbstractItemModel::rowsInserted, this, maybeEmitTitle);
connect(m_chatModel, &QAbstractItemModel::rowsRemoved, this, maybeEmitTitle);
connect(m_chatModel, &QAbstractItemModel::dataChanged, this, maybeEmitTitle);
connect(this, &ChatRootView::attachmentFilesChanged, this, [this]() {
m_tokenCounter->setAttachments(m_attachmentFiles);
});
@@ -227,21 +226,21 @@ ChatRootView::ChatRootView(QQuickItem *parent)
&Utils::BaseAspect::changed,
this,
&ChatRootView::textFormatChanged);
connect(m_clientInterface, &ClientInterface::errorOccurred, this, [this](const QString &error) {
connect(m_controller, &ChatController::errorOccurred, this, [this](const QString &error) {
this->setRequestProgressStatus(false);
m_lastErrorMessage = error;
emit lastErrorMessageChanged();
});
connect(
m_clientInterface,
&ClientInterface::requestStarted,
m_controller,
&ChatController::requestStarted,
this,
[this](const QString &requestId) { m_fileEditController->setCurrentRequestId(requestId); });
connect(
m_clientInterface,
&ClientInterface::messageUsageReceived,
m_controller,
&ChatController::messageUsageReceived,
this,
[this](int promptTokens, int /*completionTokens*/, int /*cached*/, int /*reasoning*/) {
m_tokenCounter->recordServerUsage(promptTokens);
@@ -480,13 +479,13 @@ void ChatRootView::dispatchSend(
}
m_tokenCounter->recordSent();
setRequestProgressStatus(true);
m_clientInterface->setSkillsManager(skillsManager());
m_clientInterface->sendMessage(message, attachments, linkedFiles, useToolsArg, useThinkingArg);
m_controller->setSkillsManager(skillsManager());
m_controller->sendMessage(message, attachments, linkedFiles, useToolsArg, useThinkingArg);
m_fileManager->clearIntermediateStorage();
clearAttachmentFiles();
setRequestProgressStatus(true);
}
void ChatRootView::copyToClipboard(const QString &text)
@@ -496,7 +495,7 @@ void ChatRootView::copyToClipboard(const QString &text)
void ChatRootView::cancelRequest()
{
m_clientInterface->cancelRequest();
m_controller->cancelRequest();
setRequestProgressStatus(false);
}
@@ -523,10 +522,16 @@ void ChatRootView::clearLinkedFiles()
void ChatRootView::clearMessages()
{
m_clientInterface->clearMessages();
m_controller->clearMessages();
clearLinkedFiles();
}
void ChatRootView::resetChatToMessage(int index)
{
m_controller->resetToRow(index);
setRequestProgressStatus(false);
}
QString ChatRootView::currentTemplate() const
{
auto &settings = Settings::generalSettings();
@@ -568,6 +573,11 @@ void ChatRootView::loadHistory(const QString &filePath)
LOG_MESSAGE(QString("Failed to load chat history: %1").arg(result.errorMessage));
} else {
setRecentFilePath(filePath);
setRequestProgressStatus(false);
if (!result.warningMessage.isEmpty()) {
m_lastErrorMessage = result.warningMessage;
emit lastErrorMessageChanged();
}
}
if (!m_pendingSend.active)
@@ -890,13 +900,10 @@ QString ChatRootView::chatTitle() const
QString ChatRootView::computeChatTitle() const
{
if (!m_chatModel)
return {};
const auto history = m_chatModel->getChatHistory();
for (const auto &msg : history) {
if (msg.role != ChatModel::User)
for (const Session::MessageRow &row : m_controller->session()->rows()) {
if (row.kind != Session::RowKind::User)
continue;
const QString content = msg.content.trimmed();
const QString content = row.content.trimmed();
if (content.isEmpty())
continue;
const QString firstLine = content.section(QChar('\n'), 0, 0).trimmed();
@@ -1057,7 +1064,7 @@ void ChatRootView::setRecentFilePath(const QString &filePath)
}
m_recentFilePath = filePath;
m_clientInterface->setChatFilePath(filePath);
m_controller->setChatFilePath(filePath);
m_fileManager->setChatFilePath(filePath);
emit chatFileNameChanged();
}
@@ -1066,7 +1073,7 @@ bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath)
{
auto project = ProjectExplorer::ProjectManager::projectForFile(filePath);
if (project
&& m_clientInterface->contextManager()
&& m_controller->contextManager()
->ignoreManager()
->shouldIgnore(filePath.toFSPathString(), project)) {
LOG_MESSAGE(QString("Ignoring file for attachment due to .qodeassistignore: %1")
@@ -1349,7 +1356,8 @@ void ChatRootView::compressCurrentChat()
autosave();
m_chatCompressor->startCompression(m_recentFilePath, m_chatModel);
m_chatCompressor->startCompression(
m_recentFilePath, m_controller->session()->history());
}
void ChatRootView::cancelCompression()

View File

@@ -8,9 +8,9 @@
#include <QQuickItem>
#include <QVariantList>
#include "ChatController.hpp"
#include "ChatFileManager.hpp"
#include "ChatModel.hpp"
#include "ClientInterface.hpp"
#include "templates/PromptProviderChat.hpp"
#include <coreplugin/editormanager/editormanager.h>
@@ -203,6 +203,7 @@ public slots:
void clearAttachmentFiles();
void clearLinkedFiles();
void clearMessages();
void resetChatToMessage(int index);
signals:
void chatModelChanged();
@@ -272,7 +273,7 @@ private:
ChatModel *m_chatModel;
Templates::PromptProviderChat m_promptProvider;
ClientInterface *m_clientInterface;
ChatController *m_controller;
ChatFileManager *m_fileManager;
QString m_currentTemplate;
QString m_recentFilePath;

View File

@@ -5,40 +5,45 @@
#include "ChatSerializer.hpp"
#include "Logger.hpp"
#include <QBuffer>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSaveFile>
#include <QUuid>
#include "session/HistorySerializer.hpp"
namespace QodeAssist::Chat {
const QString ChatSerializer::VERSION = "0.2";
SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QString &filePath)
SerializationResult ChatSerializer::saveToFile(
const Session::ConversationHistory &history, const QString &filePath)
{
if (!ensureDirectoryExists(filePath)) {
return {false, "Failed to create directory structure"};
}
QFile file(filePath);
const QJsonObject root = Session::HistorySerializer::toJson(history);
QSaveFile file(filePath);
if (!file.open(QIODevice::WriteOnly)) {
return {false, QString("Failed to open file for writing: %1").arg(filePath)};
}
QJsonObject root = serializeChat(model, filePath);
QJsonDocument doc(root);
if (file.write(doc.toJson(QJsonDocument::Indented)) == -1) {
if (file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) {
return {false, QString("Failed to write to file: %1").arg(file.errorString())};
}
if (!file.commit()) {
return {false, QString("Failed to save file: %1").arg(file.errorString())};
}
return {true, QString()};
}
SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString &filePath)
SerializationResult ChatSerializer::loadFromFile(
Session::ConversationHistory &history, const QString &filePath)
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) {
@@ -51,180 +56,37 @@ SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString
return {false, QString("JSON parse error: %1").arg(error.errorString())};
}
QJsonObject root = doc.object();
QString version = root["version"].toString();
const QJsonObject root = doc.object();
const QString version = root["version"].toString();
if (!validateVersion(version)) {
if (!Session::HistorySerializer::isSupportedVersion(version)) {
return {false, QString("Unsupported version: %1").arg(version)};
}
if (!deserializeChat(model, root, filePath)) {
return {false, "Failed to deserialize chat data"};
int droppedBlocks = 0;
const auto loaded = Session::HistorySerializer::fromJson(root, &droppedBlocks);
if (!loaded) {
return {false, QString("Failed to read chat history from: %1").arg(filePath)};
}
return {true, QString()};
}
QJsonObject ChatSerializer::serializeMessage(
const ChatModel::Message &message, const QString &chatFilePath)
{
QJsonObject messageObj;
messageObj["role"] = static_cast<int>(message.role);
messageObj["content"] = message.content;
messageObj["id"] = message.id;
if (message.isRedacted) {
messageObj["isRedacted"] = true;
if (version != Session::HistorySerializer::currentVersion()) {
LOG_MESSAGE(QString("Converted chat from format %1 to %2")
.arg(version, Session::HistorySerializer::currentVersion()));
}
if (!message.signature.isEmpty()) {
messageObj["signature"] = message.signature;
history = *loaded;
if (droppedBlocks > 0) {
const QString warning
= QString(
"%1 message part(s) in this chat could not be read and will be lost if "
"the chat is saved again")
.arg(droppedBlocks);
LOG_MESSAGE(QString("%1: %2").arg(filePath, warning));
return {true, QString(), warning};
}
if (message.role == ChatModel::ChatRole::Tool) {
if (!message.toolName.isEmpty())
messageObj["toolName"] = message.toolName;
if (!message.toolArguments.isEmpty())
messageObj["toolArguments"] = message.toolArguments;
if (!message.toolResult.isEmpty())
messageObj["toolResult"] = message.toolResult;
}
if (!message.attachments.isEmpty()) {
QJsonArray attachmentsArray;
for (const auto &attachment : message.attachments) {
QJsonObject attachmentObj;
attachmentObj["fileName"] = attachment.filename;
attachmentObj["storedPath"] = attachment.content;
attachmentsArray.append(attachmentObj);
}
messageObj["attachments"] = attachmentsArray;
}
if (!message.images.isEmpty()) {
QJsonArray imagesArray;
for (const auto &image : message.images) {
QJsonObject imageObj;
imageObj["fileName"] = image.fileName;
imageObj["storedPath"] = image.storedPath;
imageObj["mediaType"] = image.mediaType;
imagesArray.append(imageObj);
}
messageObj["images"] = imagesArray;
}
if (message.promptTokens > 0 || message.completionTokens > 0) {
QJsonObject usageObj;
usageObj["promptTokens"] = message.promptTokens;
usageObj["completionTokens"] = message.completionTokens;
if (message.cachedPromptTokens > 0)
usageObj["cachedPromptTokens"] = message.cachedPromptTokens;
if (message.reasoningTokens > 0)
usageObj["reasoningTokens"] = message.reasoningTokens;
messageObj["usage"] = usageObj;
}
return messageObj;
}
ChatModel::Message ChatSerializer::deserializeMessage(
const QJsonObject &json, const QString &chatFilePath)
{
ChatModel::Message message;
message.role = static_cast<ChatModel::ChatRole>(json["role"].toInt());
message.content = json["content"].toString();
message.id = json["id"].toString();
message.isRedacted = json["isRedacted"].toBool(false);
message.signature = json["signature"].toString();
message.toolName = json["toolName"].toString();
message.toolArguments = json["toolArguments"].toObject();
message.toolResult = json["toolResult"].toString();
if (json.contains("attachments")) {
QJsonArray attachmentsArray = json["attachments"].toArray();
for (const auto &attachmentValue : attachmentsArray) {
QJsonObject attachmentObj = attachmentValue.toObject();
Context::ContentFile attachment;
attachment.filename = attachmentObj["fileName"].toString();
attachment.content = attachmentObj["storedPath"].toString();
message.attachments.append(attachment);
}
}
if (json.contains("images")) {
QJsonArray imagesArray = json["images"].toArray();
for (const auto &imageValue : imagesArray) {
QJsonObject imageObj = imageValue.toObject();
ChatModel::ImageAttachment image;
image.fileName = imageObj["fileName"].toString();
image.storedPath = imageObj["storedPath"].toString();
image.mediaType = imageObj["mediaType"].toString();
message.images.append(image);
}
}
if (json.contains("usage")) {
const QJsonObject usageObj = json["usage"].toObject();
message.promptTokens = usageObj["promptTokens"].toInt();
message.completionTokens = usageObj["completionTokens"].toInt();
message.cachedPromptTokens = usageObj["cachedPromptTokens"].toInt();
message.reasoningTokens = usageObj["reasoningTokens"].toInt();
}
return message;
}
QJsonObject ChatSerializer::serializeChat(const ChatModel *model, const QString &chatFilePath)
{
QJsonArray messagesArray;
for (const auto &message : model->getChatHistory()) {
messagesArray.append(serializeMessage(message, chatFilePath));
}
QJsonObject root;
root["version"] = VERSION;
root["messages"] = messagesArray;
return root;
}
bool ChatSerializer::deserializeChat(
ChatModel *model, const QJsonObject &json, const QString &chatFilePath)
{
QJsonArray messagesArray = json["messages"].toArray();
QVector<ChatModel::Message> messages;
messages.reserve(messagesArray.size());
for (const auto &messageValue : messagesArray) {
messages.append(deserializeMessage(messageValue.toObject(), chatFilePath));
}
model->clear();
model->setLoadingFromHistory(true);
for (const auto &message : messages) {
model->addMessage(
message.content,
message.role,
message.id,
message.attachments,
message.images,
message.isRedacted,
message.signature);
if (message.role == ChatModel::ChatRole::Tool) {
model->setToolMessageData(
message.id, message.toolName, message.toolArguments, message.toolResult);
}
LOG_MESSAGE(QString("Loaded message with %1 image(s), isRedacted=%2, signature length=%3")
.arg(message.images.size())
.arg(message.isRedacted)
.arg(message.signature.length()));
}
model->setLoadingFromHistory(false);
return true;
return {true, QString(), QString()};
}
bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
@@ -234,22 +96,6 @@ bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
return dir.exists() || dir.mkpath(".");
}
bool ChatSerializer::validateVersion(const QString &version)
{
if (version == VERSION) {
return true;
}
if (version == "0.1") {
LOG_MESSAGE(
"Loading chat from old format 0.1 - images folder structure has changed from _images "
"to _content");
return true;
}
return false;
}
QString ChatSerializer::getChatContentFolder(const QString &chatFilePath)
{
QFileInfo fileInfo(chatFilePath);

View File

@@ -4,11 +4,9 @@
#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include "ChatModel.hpp"
#include "session/ConversationHistory.hpp"
namespace QodeAssist::Chat {
@@ -16,34 +14,27 @@ struct SerializationResult
{
bool success{false};
QString errorMessage;
QString warningMessage;
};
class ChatSerializer
{
public:
static SerializationResult saveToFile(const ChatModel *model, const QString &filePath);
static SerializationResult loadFromFile(ChatModel *model, const QString &filePath);
// Public for testing purposes
static QJsonObject serializeMessage(const ChatModel::Message &message, const QString &chatFilePath);
static ChatModel::Message deserializeMessage(const QJsonObject &json, const QString &chatFilePath);
static QJsonObject serializeChat(const ChatModel *model, const QString &chatFilePath);
static bool deserializeChat(ChatModel *model, const QJsonObject &json, const QString &chatFilePath);
static SerializationResult saveToFile(
const Session::ConversationHistory &history, const QString &filePath);
static SerializationResult loadFromFile(
Session::ConversationHistory &history, const QString &filePath);
// Content management (images and text files)
static QString getChatContentFolder(const QString &chatFilePath);
static bool saveContentToStorage(const QString &chatFilePath,
static bool saveContentToStorage(const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath);
static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath);
private:
static const QString VERSION;
static constexpr int CURRENT_VERSION = 1;
static bool ensureDirectoryExists(const QString &filePath);
static bool validateVersion(const QString &version);
};
} // namespace QodeAssist::Chat

View File

@@ -1,745 +0,0 @@
// 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 "ClientInterface.hpp"
#include <LLMQore/BaseClient.hpp>
#include <projectexplorer/buildconfiguration.h>
#include <projectexplorer/target.h>
#include <texteditor/textdocument.h>
#include <QFile>
#include <QFileInfo>
#include <QImageReader>
#include <QJsonArray>
#include <QJsonDocument>
#include <QMimeDatabase>
#include <QRegularExpression>
#include <QUuid>
#include <coreplugin/editormanager/editormanager.h>
#include <coreplugin/editormanager/ieditor.h>
#include <coreplugin/idocument.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectexplorer.h>
#include <projectexplorer/projectmanager.h>
#include <texteditor/textdocument.h>
#include <texteditor/texteditor.h>
#include <LLMQore/ToolsManager.hpp>
#include "tools/ReadOriginalHistoryTool.hpp"
#include "tools/TodoTool.hpp"
#include "ChatAssistantSettings.hpp"
#include "ChatSerializer.hpp"
#include "GeneralSettings.hpp"
#include "Logger.hpp"
#include "ProjectSettings.hpp"
#include "providers/ProvidersManager.hpp"
#include "SkillsSettings.hpp"
#include "ToolsSettings.hpp"
#include "context/RulesLoader.hpp"
#include <context/ChangesManager.h>
#include "skills/SkillsManager.hpp"
namespace QodeAssist::Chat {
ClientInterface::ClientInterface(
ChatModel *chatModel, Templates::IPromptProvider *promptProvider, QObject *parent)
: QObject(parent)
, m_promptProvider(promptProvider)
, m_chatModel(chatModel)
, m_contextManager(new Context::ContextManager(this))
{}
void ClientInterface::setSkillsManager(Skills::SkillsManager *skillsManager)
{
m_skillsManager = skillsManager;
}
ClientInterface::~ClientInterface()
{
cancelRequest();
}
void ClientInterface::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;
}
cancelRequest();
m_accumulatedResponses.clear();
Context::ChangesManager::instance().archiveAllNonArchivedEdits();
QList<QString> imageFiles;
QList<QString> textFiles;
for (const QString &filePath : attachments) {
if (isImageFile(filePath)) {
imageFiles.append(filePath);
} else {
textFiles.append(filePath);
}
}
QList<Context::ContentFile> storedAttachments;
if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
auto attachFiles = m_contextManager->getContentFiles(textFiles);
for (const auto &file : attachFiles) {
QString storedPath;
if (ChatSerializer::saveContentToStorage(
m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) {
Context::ContentFile storedFile;
storedFile.filename = file.filename;
storedFile.content = storedPath;
storedAttachments.append(storedFile);
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()));
}
QList<ChatModel::ImageAttachment> imageAttachments;
if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
for (const QString &imagePath : imageFiles) {
QString base64Data = encodeImageToBase64(imagePath);
if (base64Data.isEmpty()) {
continue;
}
QString storedPath;
QFileInfo fileInfo(imagePath);
if (ChatSerializer::saveContentToStorage(
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
ChatModel::ImageAttachment imageAttachment;
imageAttachment.fileName = fileInfo.fileName();
imageAttachment.storedPath = storedPath;
imageAttachment.mediaType = getMediaTypeForImage(imagePath);
imageAttachments.append(imageAttachment);
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()));
}
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", storedAttachments, imageAttachments);
auto &chatAssistantSettings = Settings::chatAssistantSettings();
auto providerName = Settings::generalSettings().caProvider();
auto provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
if (!provider) {
LOG_MESSAGE(QString("No provider found with name: %1").arg(providerName));
return;
}
auto templateName = Settings::generalSettings().caTemplate();
auto promptTemplate = m_promptProvider->getTemplateByName(templateName);
if (!promptTemplate) {
LOG_MESSAGE(QString("No template found with name: %1").arg(templateName));
return;
}
LLMCore::ContextData context;
const bool isToolsEnabled = useTools;
if (chatAssistantSettings.useSystemPrompt()) {
QString systemPrompt = chatAssistantSettings.systemPrompt();
const QString lastRoleId = chatAssistantSettings.lastUsedRoleId();
if (!lastRoleId.isEmpty()) {
const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
if (!role.id.isEmpty())
systemPrompt = systemPrompt + "\n\n" + role.systemPrompt;
}
auto project = Context::RulesLoader::getActiveProject();
if (project) {
systemPrompt += QString("\n# Active project: %1").arg(project->displayName());
systemPrompt += QString(
"\n# Project source root: %1"
"\n# All new source files, headers, QML and CMake edits MUST be "
"created or modified under this directory. Use absolute paths "
"rooted here, or project-relative paths.")
.arg(project->projectDirectory().toUrlishString());
if (auto target = project->activeTarget()) {
if (auto buildConfig = target->activeBuildConfiguration()) {
systemPrompt
+= QString(
"\n# Build output directory (compiler artifacts only — do NOT "
"create or edit source files here): %1")
.arg(buildConfig->buildDirectory().toUrlishString());
}
}
QString projectRules
= Context::RulesLoader::loadRulesForProject(project, Context::RulesContext::Chat);
if (!projectRules.isEmpty()) {
systemPrompt += QString("\n# Project Rules\n\n") + projectRules;
}
} else {
systemPrompt += QString("\n# No active project in IDE");
}
if (m_skillsManager && Settings::skillsSettings().enableSkills()) {
QStringList projectSkillDirs;
if (project) {
Settings::ProjectSettings projectSettings(project);
projectSkillDirs = Settings::SkillsSettings::splitLines(
projectSettings.projectSkillDirs());
}
m_skillsManager->configure(
project ? project->projectDirectory().toFSPathString() : QString(),
Settings::SkillsSettings::splitPaths(
Settings::skillsSettings().globalSkillRoots()),
projectSkillDirs);
const QString alwaysOnSkills = m_skillsManager->alwaysOnBodies();
if (!alwaysOnSkills.isEmpty())
systemPrompt += QString("\n\n") + alwaysOnSkills;
const QString skillsCatalog = m_skillsManager->catalogText();
if (!skillsCatalog.isEmpty())
systemPrompt += QString("\n\n") + skillsCatalog;
static const QRegularExpression skillCommand(
QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)"));
QStringList invokedSkillNames;
auto skillMatch = skillCommand.globalMatch(message);
while (skillMatch.hasNext()) {
const QString skillName = skillMatch.next().captured(1);
if (invokedSkillNames.contains(skillName))
continue;
const auto invokedSkill = m_skillsManager->findByName(skillName);
if (invokedSkill && !invokedSkill->body.isEmpty()) {
invokedSkillNames << skillName;
systemPrompt += QString("\n\n# Invoked Skill: %1\n\n%2")
.arg(invokedSkill->name, invokedSkill->body);
}
}
}
if (!linkedFiles.isEmpty()) {
systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles);
}
context.systemPrompt = systemPrompt;
}
const bool toolHistory = promptTemplate->supportsToolHistory();
QVector<LLMCore::Message> messages;
int toolCallMsgIdx = -1;
for (const auto &msg : m_chatModel->getChatHistory()) {
if (msg.role == ChatModel::ChatRole::Tool) {
if (!toolHistory || msg.toolName.isEmpty()) {
continue;
}
if (toolCallMsgIdx < 0) {
LLMCore::Message assistantCall;
assistantCall.role = "assistant";
messages.append(assistantCall);
toolCallMsgIdx = messages.size() - 1;
}
LLMCore::ToolCall call;
call.id = msg.id;
call.name = msg.toolName;
call.arguments = msg.toolArguments;
messages[toolCallMsgIdx].toolCalls.append(call);
LLMCore::Message toolResult;
toolResult.role = "tool";
toolResult.toolCallId = msg.id;
toolResult.toolName = msg.toolName;
toolResult.content = msg.toolResult;
messages.append(toolResult);
continue;
}
toolCallMsgIdx = -1;
if (msg.role == ChatModel::ChatRole::FileEdit) {
continue;
}
LLMCore::Message apiMessage;
apiMessage.role = msg.role == ChatModel::ChatRole::User ? "user" : "assistant";
apiMessage.content = msg.content;
if (!msg.attachments.isEmpty() && !m_chatFilePath.isEmpty()) {
apiMessage.content += "\n\nAttached files:";
for (const auto &attachment : msg.attachments) {
QString fileContent = ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.content);
if (!fileContent.isEmpty()) {
QString decodedContent = QString::fromUtf8(QByteArray::fromBase64(fileContent.toUtf8()));
apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```")
.arg(attachment.filename, decodedContent);
}
}
}
apiMessage.isThinking = (msg.role == ChatModel::ChatRole::Thinking);
apiMessage.isRedacted = msg.isRedacted;
apiMessage.signature = msg.signature;
if (provider->capabilities().testFlag(Providers::ProviderCapability::Image)
&& !m_chatFilePath.isEmpty() && !msg.images.isEmpty()) {
auto apiImages = loadImagesFromStorage(msg.images);
if (!apiImages.isEmpty()) {
apiMessage.images = apiImages;
}
}
messages.append(apiMessage);
}
if (!imageFiles.isEmpty()
&& !provider->capabilities().testFlag(Providers::ProviderCapability::Image)) {
LOG_MESSAGE(QString("Provider %1 doesn't support images, %2 ignored")
.arg(provider->name(), QString::number(imageFiles.size())));
}
context.history = messages;
QJsonObject payload{
{"model", Settings::generalSettings().caModel()}, {"stream", true}};
provider->prepareRequest(
payload,
promptTemplate,
context,
LLMCore::RequestType::Chat,
useTools,
useThinking);
provider->client()->setMaxToolContinuations(
Settings::toolsSettings().maxToolContinuations());
provider->client()->setTransferTimeout(
static_cast<int>(Settings::generalSettings().requestTimeout() * 1000));
connect(
provider->client(),
&::LLMQore::BaseClient::chunkReceived,
this,
&ClientInterface::handlePartialResponse,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestCompleted,
this,
&ClientInterface::handleFullResponse,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestFinalized,
this,
&ClientInterface::handleRequestFinalized,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::requestFailed,
this,
&ClientInterface::handleRequestFailed,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::toolStarted,
this,
&ClientInterface::handleToolExecutionStarted,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::toolResultReady,
this,
&ClientInterface::handleToolExecutionCompleted,
Qt::UniqueConnection);
connect(
provider->client(),
&::LLMQore::BaseClient::thinkingBlockReceived,
this,
&ClientInterface::handleThinkingBlockReceived,
Qt::UniqueConnection);
const QString customEndpoint = Settings::generalSettings().caCustomEndpoint();
const QString endpoint = !customEndpoint.isEmpty() ? customEndpoint
: promptTemplate->endpoint();
auto requestId
= provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint);
QJsonObject request{{"id", requestId}};
m_activeRequests[requestId] = {request, provider, !toolHistory};
emit requestStarted(requestId);
if (provider->capabilities().testFlag(Providers::ProviderCapability::Tools)
&& provider->toolsManager()) {
if (auto *todoTool = qobject_cast<QodeAssist::Tools::TodoTool *>(
provider->toolsManager()->tool("todo_tool"))) {
todoTool->setCurrentSessionId(m_chatFilePath);
}
if (auto *historyTool = qobject_cast<QodeAssist::Tools::ReadOriginalHistoryTool *>(
provider->toolsManager()->tool("read_original_history"))) {
historyTool->setCurrentSessionId(m_chatFilePath);
}
}
}
void ClientInterface::clearMessages()
{
const auto providerName = Settings::generalSettings().caProvider();
auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
if (provider && !m_chatFilePath.isEmpty()
&& provider->capabilities().testFlag(Providers::ProviderCapability::Tools)
&& provider->toolsManager()) {
if (auto *todoTool = qobject_cast<QodeAssist::Tools::TodoTool *>(
provider->toolsManager()->tool("todo_tool"))) {
todoTool->clearSession(m_chatFilePath);
}
}
m_chatModel->clear();
}
void ClientInterface::cancelRequest()
{
QSet<Providers::Provider *> providers;
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
if (it.value().provider) {
providers.insert(it.value().provider);
}
}
for (auto *provider : providers) {
disconnect(provider->client(), nullptr, this, nullptr);
}
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
const RequestContext &ctx = it.value();
if (ctx.provider) {
ctx.provider->cancelRequest(it.key());
}
}
m_activeRequests.clear();
m_accumulatedResponses.clear();
m_awaitingContinuation.clear();
LOG_MESSAGE("All requests cancelled and state cleared");
}
void ClientInterface::handleLLMResponse(const QString &response, const QJsonObject &request)
{
const auto message = response.trimmed();
if (!message.isEmpty()) {
QString messageId = request["id"].toString();
m_chatModel->addMessage(message, ChatModel::ChatRole::Assistant, messageId);
}
}
QString ClientInterface::getCurrentFileContext() const
{
auto currentEditor = Core::EditorManager::currentEditor();
if (!currentEditor) {
LOG_MESSAGE("No active editor found");
return QString();
}
auto textDocument = qobject_cast<TextEditor::TextDocument *>(currentEditor->document());
if (!textDocument) {
LOG_MESSAGE("Current document is not a text document");
return QString();
}
QString fileInfo = QString("Language: %1\nFile: %2\n\n")
.arg(textDocument->mimeType(), textDocument->filePath().toFSPathString());
QString content = textDocument->document()->toPlainText();
LOG_MESSAGE(QString("Got context from file: %1").arg(textDocument->filePath().toFSPathString()));
return QString("Current file context:\n%1\nFile content:\n%2").arg(fileInfo, content);
}
QString ClientInterface::getSystemPromptWithLinkedFiles(
const QString &basePrompt, const QList<QString> &linkedFiles) const
{
QString updatedPrompt = basePrompt;
if (!linkedFiles.isEmpty()) {
updatedPrompt += "\n\nLinked files for reference:\n";
auto contentFiles = m_contextManager->getContentFiles(linkedFiles);
for (const auto &file : contentFiles) {
updatedPrompt += QString("\nFile: %1\nContent:\n%2\n").arg(file.filename, file.content);
}
}
return updatedPrompt;
}
Context::ContextManager *ClientInterface::contextManager() const
{
return m_contextManager;
}
void ClientInterface::handlePartialResponse(const QString &requestId, const QString &partialText)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
if (m_awaitingContinuation.remove(requestId)) {
m_accumulatedResponses[requestId].clear();
LOG_MESSAGE(
QString("Cleared accumulated responses for continuation request %1").arg(requestId));
}
m_accumulatedResponses[requestId] += partialText;
const RequestContext &ctx = it.value();
handleLLMResponse(m_accumulatedResponses[requestId], ctx.originalRequest);
}
void ClientInterface::handleFullResponse(const QString &requestId, const QString &fullText)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
const RequestContext &ctx = it.value();
QString finalText = !fullText.isEmpty() ? fullText : m_accumulatedResponses[requestId];
QString applyError;
bool applySuccess
= Context::ChangesManager::instance().applyPendingEditsForRequest(requestId, &applyError);
if (!applySuccess) {
LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2")
.arg(requestId, applyError));
}
LOG_MESSAGE(
"Message completed. Final response for message " + ctx.originalRequest["id"].toString()
+ ": " + finalText);
emit messageReceivedCompletely();
m_activeRequests.erase(it);
m_accumulatedResponses.remove(requestId);
m_awaitingContinuation.remove(requestId);
}
void ClientInterface::handleRequestFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
{
if (!m_activeRequests.contains(requestId))
return;
if (!info.usage)
return;
const auto &u = *info.usage;
m_chatModel->setMessageUsage(
requestId, u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens);
emit messageUsageReceived(
u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens);
LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
.arg(requestId)
.arg(u.promptTokens)
.arg(u.completionTokens)
.arg(u.cachedPromptTokens)
.arg(u.reasoningTokens));
}
void ClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error));
emit errorOccurred(error);
m_activeRequests.erase(it);
m_accumulatedResponses.remove(requestId);
m_awaitingContinuation.remove(requestId);
}
void ClientInterface::handleThinkingBlockReceived(
const QString &requestId, const QString &thinking, const QString &signature)
{
if (!m_activeRequests.contains(requestId)) {
LOG_MESSAGE(QString("Ignoring thinking block for non-chat request: %1").arg(requestId));
return;
}
if (m_awaitingContinuation.remove(requestId)) {
m_accumulatedResponses[requestId].clear();
LOG_MESSAGE(
QString("Cleared accumulated responses for continuation request %1").arg(requestId));
}
if (thinking.isEmpty()) {
m_chatModel->addRedactedThinkingBlock(requestId, signature);
} else {
m_chatModel->addThinkingBlock(requestId, thinking, signature);
}
}
void ClientInterface::handleToolExecutionStarted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &arguments)
{
const auto requestIt = m_activeRequests.constFind(requestId);
if (requestIt == m_activeRequests.constEnd()) {
LOG_MESSAGE(QString("Ignoring tool execution start for non-chat request: %1").arg(requestId));
return;
}
if (requestIt->dropPreToolText) {
m_chatModel->dropTrailingAssistantMessage(requestId);
}
m_chatModel->addToolExecutionStatus(requestId, toolId, toolName, arguments);
m_awaitingContinuation.insert(requestId);
}
void ClientInterface::handleToolExecutionCompleted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QString &toolOutput)
{
if (!m_activeRequests.contains(requestId)) {
LOG_MESSAGE(QString("Ignoring tool execution result for non-chat request: %1").arg(requestId));
return;
}
m_chatModel->updateToolResult(requestId, toolId, toolName, toolOutput);
}
bool ClientInterface::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 ClientInterface::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 ClientInterface::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();
}
QVector<LLMCore::ImageAttachment> ClientInterface::loadImagesFromStorage(
const QList<ChatModel::ImageAttachment> &storedImages) const
{
QVector<LLMCore::ImageAttachment> apiImages;
for (const auto &storedImage : storedImages) {
QString base64Data
= ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
if (base64Data.isEmpty()) {
LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath));
continue;
}
LLMCore::ImageAttachment apiImage;
apiImage.data = base64Data;
apiImage.mediaType = storedImage.mediaType;
apiImage.isUrl = false;
apiImages.append(apiImage);
}
return apiImages;
}
void ClientInterface::setChatFilePath(const QString &filePath)
{
if (!m_chatFilePath.isEmpty() && m_chatFilePath != filePath) {
const auto providerName = Settings::generalSettings().caProvider();
auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
if (provider
&& provider->capabilities().testFlag(Providers::ProviderCapability::Tools)
&& provider->toolsManager()) {
if (auto *todoTool = qobject_cast<QodeAssist::Tools::TodoTool *>(
provider->toolsManager()->tool("todo_tool"))) {
todoTool->clearSession(m_chatFilePath);
}
}
}
m_chatFilePath = filePath;
m_chatModel->setChatFilePath(filePath);
}
QString ClientInterface::chatFilePath() const
{
return m_chatFilePath;
}
} // namespace QodeAssist::Chat

View File

@@ -1,102 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QSet>
#include <QString>
#include <QVector>
#include "ChatModel.hpp"
#include "providers/Provider.hpp"
#include "templates/IPromptProvider.hpp"
#include <LLMQore/BaseClient.hpp>
#include <context/ContextManager.hpp>
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class ClientInterface : public QObject
{
Q_OBJECT
public:
explicit ClientInterface(
ChatModel *chatModel, Templates::IPromptProvider *promptProvider, QObject *parent = nullptr);
~ClientInterface();
void setSkillsManager(Skills::SkillsManager *skillsManager);
void sendMessage(
const QString &message,
const QList<QString> &attachments = {},
const QList<QString> &linkedFiles = {},
bool useTools = false,
bool useThinking = false);
void clearMessages();
void cancelRequest();
Context::ContextManager *contextManager() const;
void setChatFilePath(const QString &filePath);
QString chatFilePath() const;
signals:
void errorOccurred(const QString &error);
void messageReceivedCompletely();
void requestStarted(const QString &requestId);
void messageUsageReceived(
int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens);
private slots:
void handlePartialResponse(const QString &requestId, const QString &partialText);
void handleFullResponse(const QString &requestId, const QString &fullText);
void handleRequestFinalized(const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info);
void handleRequestFailed(const QString &requestId, const QString &error);
void handleThinkingBlockReceived(
const QString &requestId, const QString &thinking, const QString &signature);
void handleToolExecutionStarted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &arguments);
void handleToolExecutionCompleted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QString &toolOutput);
private:
void handleLLMResponse(const QString &response, const QJsonObject &request);
QString getCurrentFileContext() const;
QString getSystemPromptWithLinkedFiles(
const QString &basePrompt, const QList<QString> &linkedFiles) const;
bool isImageFile(const QString &filePath) const;
QString getMediaTypeForImage(const QString &filePath) const;
QString encodeImageToBase64(const QString &filePath) const;
QVector<LLMCore::ImageAttachment> loadImagesFromStorage(const QList<ChatModel::ImageAttachment> &storedImages) const;
struct RequestContext
{
QJsonObject originalRequest;
Providers::Provider *provider;
bool dropPreToolText = false;
};
Templates::IPromptProvider *m_promptProvider = nullptr;
ChatModel *m_chatModel;
Context::ContextManager *m_contextManager;
Skills::SkillsManager *m_skillsManager = nullptr;
QString m_chatFilePath;
QHash<QString, RequestContext> m_activeRequests;
QHash<QString, QString> m_accumulatedResponses;
QSet<QString> m_awaitingContinuation;
};
} // namespace QodeAssist::Chat

View File

@@ -10,15 +10,13 @@
#include <coreplugin/editormanager/editormanager.h>
#include <texteditor/texteditor.h>
#include "ChatModel.hpp"
#include "Logger.hpp"
#include "context/ChangesManager.h"
namespace QodeAssist::Chat {
FileEditController::FileEditController(ChatModel *chatModel, QObject *parent)
FileEditController::FileEditController(QObject *parent)
: QObject(parent)
, m_chatModel(chatModel)
{
auto &changes = Context::ChangesManager::instance();
connect(&changes, &Context::ChangesManager::fileEditAdded, this, [this](const QString &) {
@@ -80,7 +78,7 @@ void FileEditController::applyFileEdit(const QString &editId)
LOG_MESSAGE(QString("Applying file edit: %1").arg(editId));
if (Context::ChangesManager::instance().applyFileEdit(editId)) {
emit infoMessage(QString("File edit applied successfully"));
updateFileEditStatus(editId, "applied");
updateStats();
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred(
@@ -95,7 +93,7 @@ void FileEditController::rejectFileEdit(const QString &editId)
LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId));
if (Context::ChangesManager::instance().rejectFileEdit(editId)) {
emit infoMessage(QString("File edit rejected"));
updateFileEditStatus(editId, "rejected");
updateStats();
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred(
@@ -110,7 +108,7 @@ void FileEditController::undoFileEdit(const QString &editId)
LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId));
if (Context::ChangesManager::instance().undoFileEdit(editId)) {
emit infoMessage(QString("File edit undone successfully"));
updateFileEditStatus(editId, "rejected");
updateStats();
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred(
@@ -163,44 +161,6 @@ void FileEditController::openFileEditInEditor(const QString &editId)
LOG_MESSAGE(QString("Opened file in editor: %1").arg(edit.filePath));
}
void FileEditController::updateFileEditStatus(const QString &editId, const QString &status)
{
auto messages = m_chatModel->getChatHistory();
for (int i = 0; i < messages.size(); ++i) {
if (messages[i].role == Chat::ChatModel::FileEdit && messages[i].id == editId) {
QString content = messages[i].content;
const QString marker = "QODEASSIST_FILE_EDIT:";
int markerPos = content.indexOf(marker);
QString jsonStr = content;
if (markerPos >= 0) {
jsonStr = content.mid(markerPos + marker.length());
}
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
if (doc.isObject()) {
QJsonObject obj = doc.object();
obj["status"] = status;
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
if (!edit.statusMessage.isEmpty()) {
obj["status_message"] = edit.statusMessage;
}
QString updatedContent = marker
+ QString::fromUtf8(
QJsonDocument(obj).toJson(QJsonDocument::Compact));
m_chatModel->updateMessageContent(editId, updatedContent);
LOG_MESSAGE(QString("Updated file edit status to: %1").arg(status));
}
break;
}
}
updateStats();
}
void FileEditController::applyAllForCurrentMessage()
{
if (m_currentRequestId.isEmpty()) {
@@ -223,13 +183,6 @@ void FileEditController::applyAllForCurrentMessage()
: QString("Failed to apply some file edits:\n%1").arg(errorMsg));
}
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Applied) {
updateFileEditStatus(edit.editId, "applied");
}
}
updateStats();
}
@@ -255,13 +208,6 @@ void FileEditController::undoAllForCurrentMessage()
: QString("Failed to undo some file edits:\n%1").arg(errorMsg));
}
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Rejected) {
updateFileEditStatus(edit.editId, "rejected");
}
}
updateStats();
}

View File

@@ -9,14 +9,12 @@
namespace QodeAssist::Chat {
class ChatModel;
class FileEditController : public QObject
{
Q_OBJECT
public:
explicit FileEditController(ChatModel *chatModel, QObject *parent = nullptr);
explicit FileEditController(QObject *parent = nullptr);
void setCurrentRequestId(const QString &requestId);
void clearCurrentRequestId();
@@ -41,9 +39,6 @@ signals:
void errorOccurred(const QString &error);
private:
void updateFileEditStatus(const QString &editId, const QString &status);
ChatModel *m_chatModel;
QString m_currentRequestId;
int m_totalEdits{0};
int m_appliedEdits{0};

View File

@@ -7,25 +7,27 @@
#include <algorithm>
#include <LLMQore/ToolsManager.hpp>
#include <QDateTime>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <utils/aspects.h>
#include "ChatAssistantSettings.hpp"
#include "ChatModel.hpp"
#include "GeneralSettings.hpp"
#include "Logger.hpp"
#include "providers/ProvidersManager.hpp"
#include "context/ContextManager.hpp"
#include "context/TokenUtils.hpp"
#include "session/Session.hpp"
namespace QodeAssist::Chat {
InputTokenCounter::InputTokenCounter(
ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent)
Session::Session *session, Context::ContextManager *contextManager, QObject *parent)
: QObject(parent)
, m_chatModel(chatModel)
, m_session(session)
, m_contextManager(contextManager)
{
auto &settings = Settings::chatAssistantSettings();
@@ -47,10 +49,19 @@ InputTokenCounter::InputTokenCounter(
recompute();
});
m_recomputeTimer.setSingleShot(true);
m_recomputeTimer.setInterval(150);
connect(&m_recomputeTimer, &QTimer::timeout, this, &InputTokenCounter::recompute);
rewireToolsChangedConnection();
recompute();
}
void InputTokenCounter::recomputeSoon()
{
m_recomputeTimer.start();
}
int InputTokenCounter::inputTokens() const
{
return m_inputTokens;
@@ -59,7 +70,7 @@ int InputTokenCounter::inputTokens() const
void InputTokenCounter::setMessage(const QString &message)
{
m_messageTokens = Context::TokenUtils::estimateTokens(message);
recompute();
recomputeSoon();
}
void InputTokenCounter::setAttachments(const QStringList &attachments)
@@ -92,6 +103,37 @@ void InputTokenCounter::rewireToolsChangedConnection()
tm, &::LLMQore::ToolRegistry::toolsChanged, this, &InputTokenCounter::recompute);
}
int InputTokenCounter::estimateFileTokens(const QStringList &paths)
{
if (paths.isEmpty())
return 0;
int total = 0;
QStringList uncached;
for (const QString &path : paths) {
const QDateTime modified = QFileInfo(path).lastModified();
const auto cached = m_fileTokens.constFind(path);
if (cached != m_fileTokens.constEnd() && cached->modified == modified) {
total += cached->tokens;
continue;
}
uncached.append(path);
}
if (m_fileTokens.size() > 256)
m_fileTokens.clear();
for (const QString &path : std::as_const(uncached)) {
const int tokens = Context::TokenUtils::estimateFilesTokens(
m_contextManager->getContentFiles({path}));
total += tokens;
m_fileTokens.insert(path, {QFileInfo(path).lastModified(), tokens});
}
return total;
}
void InputTokenCounter::recompute()
{
int inputTokens = m_messageTokens;
@@ -115,25 +157,20 @@ void InputTokenCounter::recompute()
if (!m_attachments.isEmpty()) {
QStringList textPaths;
inputTokens += splitImageEstimate(m_attachments, textPaths);
if (!textPaths.isEmpty()) {
auto attachFiles = m_contextManager->getContentFiles(textPaths);
inputTokens += Context::TokenUtils::estimateFilesTokens(attachFiles);
}
inputTokens += estimateFileTokens(textPaths);
}
if (!m_linkedFiles.isEmpty()) {
QStringList textPaths;
inputTokens += splitImageEstimate(m_linkedFiles, textPaths);
if (!textPaths.isEmpty()) {
auto linkFiles = m_contextManager->getContentFiles(textPaths);
inputTokens += Context::TokenUtils::estimateFilesTokens(linkFiles);
}
inputTokens += estimateFileTokens(textPaths);
}
const auto &history = m_chatModel->getChatHistory();
for (const auto &message : history) {
inputTokens += Context::TokenUtils::estimateTokens(message.content);
inputTokens += 4; // + role
if (m_session) {
for (const Session::MessageRow &row : m_session->rows()) {
inputTokens += Context::TokenUtils::estimateTokens(row.content);
inputTokens += 4; // + role
}
}
if (settings.enableChatTools()) {
@@ -157,6 +194,11 @@ void InputTokenCounter::recompute()
void InputTokenCounter::recordSent()
{
if (m_recomputeTimer.isActive()) {
m_recomputeTimer.stop();
recompute();
}
m_lastSentEstimate = m_calibrationFactor > 0.0
? static_cast<int>(m_inputTokens / m_calibrationFactor)
: m_inputTokens;

View File

@@ -4,16 +4,22 @@
#pragma once
#include <QDateTime>
#include <QHash>
#include <QObject>
#include <QPointer>
#include <QStringList>
#include <QTimer>
namespace QodeAssist::Context {
class ContextManager;
}
namespace QodeAssist::Chat {
namespace QodeAssist::Session {
class Session;
}
class ChatModel;
namespace QodeAssist::Chat {
class InputTokenCounter : public QObject
{
@@ -21,7 +27,9 @@ class InputTokenCounter : public QObject
public:
InputTokenCounter(
ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent = nullptr);
Session::Session *session,
Context::ContextManager *contextManager,
QObject *parent = nullptr);
int inputTokens() const;
@@ -29,6 +37,7 @@ public:
void setAttachments(const QStringList &attachments);
void setLinkedFiles(const QStringList &linkedFiles);
void recompute();
void recomputeSoon();
void recordSent();
void recordServerUsage(int promptTokens);
@@ -37,11 +46,20 @@ signals:
void inputTokensChanged();
private:
void rewireToolsChangedConnection();
struct CachedFileTokens
{
QDateTime modified;
int tokens = 0;
};
ChatModel *m_chatModel;
void rewireToolsChangedConnection();
int estimateFileTokens(const QStringList &paths);
QPointer<Session::Session> m_session;
Context::ContextManager *m_contextManager;
QMetaObject::Connection m_toolsChangedConn;
QTimer m_recomputeTimer;
QHash<QString, CachedFileTokens> m_fileTokens;
QStringList m_attachments;
QStringList m_linkedFiles;

View File

@@ -0,0 +1,420 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "LlmChatBackend.hpp"
#include <LLMQore/ToolsManager.hpp>
#include <algorithm>
#include "ChatSerializer.hpp"
#include "llmcore/ContextData.hpp"
#include "logger/Logger.hpp"
#include "providers/ProvidersManager.hpp"
#include "session/FileEditPayload.hpp"
#include "session/HistoryProjection.hpp"
#include "settings/GeneralSettings.hpp"
#include "settings/ToolsSettings.hpp"
#include "tools/ReadOriginalHistoryTool.hpp"
#include "tools/TodoTool.hpp"
namespace QodeAssist::Chat {
LlmChatBackend::LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent)
: Session::ChatBackend(parent)
, m_promptProvider(promptProvider)
{}
LlmChatBackend::~LlmChatBackend()
{
cancel();
}
void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
{
cancel();
if (!request.history) {
emit sessionEvent(
Session::TurnFailed{.turnId = {}, .error = tr("Chat turn sent without a conversation")});
return;
}
const auto providerName = Settings::generalSettings().caProvider();
auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
if (!provider) {
const QString error = tr("No provider found with name: %1").arg(providerName);
LOG_MESSAGE(error);
emit sessionEvent(Session::TurnFailed{.turnId = {}, .error = error});
return;
}
const auto templateName = Settings::generalSettings().caTemplate();
auto *promptTemplate = m_promptProvider->getTemplateByName(templateName);
if (!promptTemplate) {
const QString error = tr("No template found with name: %1").arg(templateName);
LOG_MESSAGE(error);
emit sessionEvent(Session::TurnFailed{.turnId = {}, .error = error});
return;
}
LLMCore::ContextData context;
if (request.context)
context.systemPrompt = Session::renderSystemPrompt(*request.context);
context.history = renderHistory(*request.history, provider, promptTemplate);
QJsonObject payload{{"model", Settings::generalSettings().caModel()}, {"stream", true}};
provider->prepareRequest(
payload,
promptTemplate,
context,
LLMCore::RequestType::Chat,
request.options.useTools,
request.options.useThinking);
provider->client()->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations());
provider->client()->setTransferTimeout(
static_cast<int>(Settings::generalSettings().requestTimeout() * 1000));
connectClient(provider);
const QString customEndpoint = Settings::generalSettings().caCustomEndpoint();
const QString endpoint = !customEndpoint.isEmpty() ? customEndpoint
: promptTemplate->endpoint();
m_provider = provider;
m_dropPreToolText = !promptTemplate->supportsToolHistory();
m_requestId
= provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint);
emit sessionEvent(Session::TurnStarted{.turnId = m_requestId});
bindToolSessions(provider);
}
void LlmChatBackend::cancel()
{
if (!m_provider)
return;
auto *provider = m_provider;
const QString requestId = m_requestId;
releaseRequest();
if (!requestId.isEmpty())
provider->cancelRequest(requestId);
LOG_MESSAGE("Chat request cancelled and state cleared");
}
void LlmChatBackend::releaseRequest()
{
if (m_provider)
disconnect(m_provider->client(), nullptr, this, nullptr);
m_provider = nullptr;
m_requestId.clear();
m_dropPreToolText = false;
}
void LlmChatBackend::setChatFilePath(const QString &filePath)
{
m_chatFilePath = filePath;
}
void LlmChatBackend::clearToolSession(const QString &filePath)
{
if (filePath.isEmpty())
return;
const auto providerName = Settings::generalSettings().caProvider();
auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
if (!provider || !provider->capabilities().testFlag(Providers::ProviderCapability::Tools)
|| !provider->toolsManager()) {
return;
}
if (auto *todoTool = qobject_cast<Tools::TodoTool *>(
provider->toolsManager()->tool("todo_tool"))) {
todoTool->clearSession(filePath);
}
}
void LlmChatBackend::connectClient(Providers::Provider *provider)
{
auto *client = provider->client();
connect(
client,
&::LLMQore::BaseClient::chunkReceived,
this,
&LlmChatBackend::handleChunk,
Qt::UniqueConnection);
connect(
client,
&::LLMQore::BaseClient::requestCompleted,
this,
&LlmChatBackend::handleCompleted,
Qt::UniqueConnection);
connect(
client,
&::LLMQore::BaseClient::requestFinalized,
this,
&LlmChatBackend::handleFinalized,
Qt::UniqueConnection);
connect(
client,
&::LLMQore::BaseClient::requestFailed,
this,
&LlmChatBackend::handleFailed,
Qt::UniqueConnection);
connect(
client,
&::LLMQore::BaseClient::toolStarted,
this,
&LlmChatBackend::handleToolStarted,
Qt::UniqueConnection);
connect(
client,
&::LLMQore::BaseClient::toolResultReady,
this,
&LlmChatBackend::handleToolResult,
Qt::UniqueConnection);
connect(
client,
&::LLMQore::BaseClient::thinkingBlockReceived,
this,
&LlmChatBackend::handleThinkingBlock,
Qt::UniqueConnection);
}
void LlmChatBackend::bindToolSessions(Providers::Provider *provider)
{
if (!provider->capabilities().testFlag(Providers::ProviderCapability::Tools)
|| !provider->toolsManager()) {
return;
}
if (auto *todoTool = qobject_cast<Tools::TodoTool *>(
provider->toolsManager()->tool("todo_tool"))) {
todoTool->setCurrentSessionId(m_chatFilePath);
}
if (auto *historyTool = qobject_cast<Tools::ReadOriginalHistoryTool *>(
provider->toolsManager()->tool("read_original_history"))) {
historyTool->setCurrentSessionId(m_chatFilePath);
}
}
QVector<LLMCore::Message> LlmChatBackend::renderHistory(
const Session::ConversationHistory &history,
Providers::Provider *provider,
Templates::PromptTemplate *promptTemplate) const
{
const bool toolHistory = promptTemplate->supportsToolHistory();
QVector<LLMCore::Message> messages;
int toolCallMsgIdx = -1;
for (const Session::MessageRow &row : Session::projectToRows(history)) {
if (row.kind == Session::RowKind::Tool) {
if (!toolHistory || row.toolName.isEmpty())
continue;
if (toolCallMsgIdx < 0) {
LLMCore::Message assistantCall;
assistantCall.role = "assistant";
messages.append(assistantCall);
toolCallMsgIdx = messages.size() - 1;
}
LLMCore::ToolCall call;
call.id = row.id;
call.name = row.toolName;
call.arguments = row.toolArguments;
messages[toolCallMsgIdx].toolCalls.append(call);
LLMCore::Message toolResult;
toolResult.role = "tool";
toolResult.toolCallId = row.id;
toolResult.toolName = row.toolName;
toolResult.content = row.toolResult;
messages.append(toolResult);
continue;
}
toolCallMsgIdx = -1;
if (row.kind == Session::RowKind::FileEdit)
continue;
LLMCore::Message apiMessage;
apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant";
apiMessage.content = row.content;
if (!row.attachments.isEmpty() && !m_chatFilePath.isEmpty()) {
apiMessage.content += "\n\nAttached files:";
for (const Session::AttachmentBlock &attachment : row.attachments) {
const QString fileContent
= ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.storedPath);
if (fileContent.isEmpty())
continue;
const QString decodedContent = QString::fromUtf8(
QByteArray::fromBase64(fileContent.toUtf8()));
apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```")
.arg(attachment.fileName, decodedContent);
}
}
apiMessage.isThinking = row.kind == Session::RowKind::Thinking;
apiMessage.isRedacted = row.redacted;
apiMessage.signature = row.signature;
if (provider->capabilities().testFlag(Providers::ProviderCapability::Image)
&& !m_chatFilePath.isEmpty() && !row.images.isEmpty()) {
const auto apiImages = loadImagesFromStorage(row.images);
if (!apiImages.isEmpty())
apiMessage.images = apiImages;
}
messages.append(apiMessage);
}
return messages;
}
QVector<LLMCore::ImageAttachment> LlmChatBackend::loadImagesFromStorage(
const QList<Session::ImageBlock> &storedImages) const
{
QVector<LLMCore::ImageAttachment> apiImages;
for (const Session::ImageBlock &storedImage : storedImages) {
const QString base64Data
= ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
if (base64Data.isEmpty()) {
LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath));
continue;
}
LLMCore::ImageAttachment apiImage;
apiImage.data = base64Data;
apiImage.mediaType = storedImage.mediaType;
apiImage.isUrl = false;
apiImages.append(apiImage);
}
return apiImages;
}
void LlmChatBackend::handleChunk(const QString &requestId, const QString &chunk)
{
if (requestId != m_requestId)
return;
emit sessionEvent(Session::TextDelta{.turnId = requestId, .text = chunk});
}
void LlmChatBackend::handleCompleted(const QString &requestId, const QString &fullText)
{
if (requestId != m_requestId)
return;
LOG_MESSAGE(
QString("Chat request %1 completed, %2 characters").arg(requestId).arg(fullText.length()));
releaseRequest();
emit sessionEvent(Session::TurnCompleted{.turnId = requestId});
}
void LlmChatBackend::handleFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
{
if (requestId != m_requestId || !info.usage)
return;
const auto &usage = *info.usage;
LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
.arg(requestId)
.arg(usage.promptTokens)
.arg(usage.completionTokens)
.arg(usage.cachedPromptTokens)
.arg(usage.reasoningTokens));
emit sessionEvent(
Session::UsageReported{
.turnId = requestId,
.usage = Session::Usage{
.promptTokens = usage.promptTokens,
.completionTokens = usage.completionTokens,
.cachedPromptTokens = usage.cachedPromptTokens,
.reasoningTokens = usage.reasoningTokens}});
}
void LlmChatBackend::handleFailed(const QString &requestId, const QString &error)
{
if (requestId != m_requestId)
return;
LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error));
releaseRequest();
emit sessionEvent(Session::TurnFailed{.turnId = requestId, .error = error});
}
void LlmChatBackend::handleThinkingBlock(
const QString &requestId, const QString &thinking, const QString &signature)
{
if (requestId != m_requestId)
return;
emit sessionEvent(
Session::ThinkingReceived{
.turnId = requestId,
.text = thinking,
.signature = signature,
.redacted = thinking.isEmpty()});
}
void LlmChatBackend::handleToolStarted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &arguments)
{
if (requestId != m_requestId)
return;
emit sessionEvent(
Session::ToolCallStarted{
.turnId = requestId,
.toolId = toolId,
.name = toolName,
.arguments = arguments,
.dropPrecedingText = m_dropPreToolText});
}
void LlmChatBackend::handleToolResult(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QString &toolOutput)
{
if (requestId != m_requestId)
return;
emit sessionEvent(
Session::ToolCallCompleted{
.turnId = requestId, .toolId = toolId, .name = toolName, .result = toolOutput});
}
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,69 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QHash>
#include <QString>
#include <LLMQore/BaseClient.hpp>
#include "providers/Provider.hpp"
#include "session/ChatBackend.hpp"
#include "templates/IPromptProvider.hpp"
namespace QodeAssist::Chat {
class LlmChatBackend : public Session::ChatBackend
{
Q_OBJECT
public:
explicit LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent = nullptr);
~LlmChatBackend() override;
void sendTurn(const Session::TurnRequest &request) override;
void cancel() override;
void setChatFilePath(const QString &filePath);
void clearToolSession(const QString &filePath);
private:
void connectClient(Providers::Provider *provider);
void releaseRequest();
void bindToolSessions(Providers::Provider *provider);
QVector<LLMCore::Message> renderHistory(
const Session::ConversationHistory &history,
Providers::Provider *provider,
Templates::PromptTemplate *promptTemplate) const;
QVector<LLMCore::ImageAttachment> loadImagesFromStorage(
const QList<Session::ImageBlock> &storedImages) const;
void handleChunk(const QString &requestId, const QString &chunk);
void handleCompleted(const QString &requestId, const QString &fullText);
void handleFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info);
void handleFailed(const QString &requestId, const QString &error);
void handleThinkingBlock(
const QString &requestId, const QString &thinking, const QString &signature);
void handleToolStarted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &arguments);
void handleToolResult(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QString &toolOutput);
Templates::IPromptProvider *m_promptProvider = nullptr;
QString m_chatFilePath;
Providers::Provider *m_provider = nullptr;
QString m_requestId;
bool m_dropPreToolText = false;
};
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,106 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "TurnContextAdapters.hpp"
#include <projectexplorer/buildconfiguration.h>
#include <projectexplorer/project.h>
#include <projectexplorer/target.h>
#include "ProjectSettings.hpp"
#include "SkillsSettings.hpp"
#include "context/ContextManager.hpp"
#include "context/RulesLoader.hpp"
#include "skills/SkillsManager.hpp"
namespace QodeAssist::Chat {
ProjectContextQtCreator::ProjectContextQtCreator(ProjectExplorer::Project *project)
: m_project(project)
{}
Session::ProjectInfo ProjectContextQtCreator::projectInfo() const
{
if (!m_project)
return {};
Session::ProjectInfo info;
info.available = true;
info.displayName = m_project->displayName();
info.sourceRoot = m_project->projectDirectory().toUrlishString();
if (auto target = m_project->activeTarget()) {
if (auto buildConfig = target->activeBuildConfiguration())
info.buildDirectory = buildConfig->buildDirectory().toUrlishString();
}
return info;
}
QString ProjectContextQtCreator::projectRules() const
{
if (!m_project)
return {};
return Context::RulesLoader::loadRulesForProject(m_project, Context::RulesContext::Chat);
}
SkillsContextQtCreator::SkillsContextQtCreator(
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project)
: m_skillsManager(skillsManager)
{
QStringList projectSkillDirs;
if (project) {
Settings::ProjectSettings projectSettings(project);
projectSkillDirs = Settings::SkillsSettings::splitLines(projectSettings.projectSkillDirs());
}
m_skillsManager->configure(
project ? project->projectDirectory().toFSPathString() : QString(),
Settings::SkillsSettings::splitPaths(Settings::skillsSettings().globalSkillRoots()),
projectSkillDirs);
}
QString SkillsContextQtCreator::alwaysOnBodies() const
{
return m_skillsManager->alwaysOnBodies();
}
QString SkillsContextQtCreator::catalogText() const
{
return m_skillsManager->catalogText();
}
std::optional<Session::InvokedSkill> SkillsContextQtCreator::findSkill(const QString &name) const
{
const auto skill = m_skillsManager->findByName(name);
if (!skill)
return std::nullopt;
return Session::InvokedSkill{skill->name, skill->body};
}
LinkedFilesQtCreator::LinkedFilesQtCreator(Context::ContextManager *contextManager)
: m_contextManager(contextManager)
{}
QList<Session::LinkedFile> LinkedFilesQtCreator::readFiles(const QList<QString> &paths) const
{
QList<Session::LinkedFile> files;
for (const auto &file : m_contextManager->getContentFiles(paths))
files.append(Session::LinkedFile{file.filename, file.content});
return files;
}
std::unique_ptr<SkillsContextQtCreator> makeSkillsContext(
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project)
{
if (!skillsManager || !Settings::skillsSettings().enableSkills())
return nullptr;
return std::make_unique<SkillsContextQtCreator>(skillsManager, project);
}
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,64 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <memory>
#include "session/TurnContextPorts.hpp"
namespace ProjectExplorer {
class Project;
}
namespace QodeAssist::Context {
class ContextManager;
}
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
class ProjectContextQtCreator : public Session::IProjectContextPort
{
public:
explicit ProjectContextQtCreator(ProjectExplorer::Project *project);
Session::ProjectInfo projectInfo() const override;
QString projectRules() const override;
private:
ProjectExplorer::Project *m_project = nullptr;
};
class SkillsContextQtCreator : public Session::ISkillsContextPort
{
public:
SkillsContextQtCreator(Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project);
QString alwaysOnBodies() const override;
QString catalogText() const override;
std::optional<Session::InvokedSkill> findSkill(const QString &name) const override;
private:
Skills::SkillsManager *m_skillsManager = nullptr;
};
class LinkedFilesQtCreator : public Session::ILinkedFilesPort
{
public:
explicit LinkedFilesQtCreator(Context::ContextManager *contextManager);
QList<Session::LinkedFile> readFiles(const QList<QString> &paths) const override;
private:
Context::ContextManager *m_contextManager = nullptr;
};
std::unique_ptr<SkillsContextQtCreator> makeSkillsContext(
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project);
} // namespace QodeAssist::Chat

View File

@@ -343,7 +343,7 @@ ChatRootView {
onResetChatToMessage: function(idx) {
messageInput.text = model.content
messageInput.cursorPosition = model.content.length
root.chatModel.resetModelTo(idx)
root.resetChatToMessage(idx)
}
onOpenFileRequested: function(filePath) {