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
+35 -7
View File
@@ -94,8 +94,8 @@ jobs:
execute_process(
COMMAND sudo apt install
# build dependencies
libgl1-mesa-dev libgtest-dev libgmock-dev
# runtime dependencies for tests (Qt is downloaded outside package manager,
libgl1-mesa-dev
# runtime dependencies (Qt is downloaded outside package manager,
# thus minimal dependencies must be installed explicitly)
libsecret-1-0 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-render0
libxcb-shape0 libxcb-shm0 libxcb-sync1 libxcb-xfixes0 libxcb-xkb1 libxkbcommon-x11-0 xvfb
@@ -276,17 +276,45 @@ jobs:
message(FATAL_ERROR "Build failed")
endif()
- name: Build with tests
if: startsWith(matrix.config.os, 'ubuntu')
shell: cmake -P {0}
run: |
set(ENV{CC} ${{ matrix.config.cc }})
set(ENV{CXX} ${{ matrix.config.cxx }})
set(ENV{NINJA_STATUS} "[%f/%t %o/sec] ")
set(build_plugin_py "scripts/build_plugin.py")
foreach(dir "share/qtcreator/scripts" "Qt Creator.sdk/share/qtcreator/scripts" "Qt Creator.app/Contents/Resources/scripts" "Contents/Resources/scripts")
if(EXISTS "${{ steps.qt_creator.outputs.qtc_dir }}/${dir}/build_plugin.py")
set(build_plugin_py "${dir}/build_plugin.py")
break()
endif()
endforeach()
execute_process(
COMMAND python
-u
"${{ steps.qt_creator.outputs.qtc_dir }}/${build_plugin_py}"
--name "$ENV{PLUGIN_NAME}-tests"
--src .
--build build-tests
--qt-path "${{ steps.qt.outputs.qt_dir }}"
--qtc-path "${{ steps.qt_creator.outputs.qtc_dir }}"
--output-path "${{ runner.temp }}"
--add-config=-DWITH_TESTS=ON
RESULT_VARIABLE result
)
if (NOT result EQUAL 0)
message(FATAL_ERROR "Test build failed")
endif()
- name: Upload
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
path: ./${{ env.PLUGIN_NAME }}-v${{ steps.git.outputs.tag }}-QtC${{ matrix.qt_config.qt_creator_version }}-${{ matrix.config.artifact }}.7z
name: ${{ env.PLUGIN_NAME}}-v${{ steps.git.outputs.tag }}-QtC${{ matrix.qt_config.qt_creator_version }}-${{ matrix.config.artifact }}.7z
- name: Run unit tests
if: startsWith(matrix.config.os, 'ubuntu')
run: |
xvfb-run ./build/build/tests/QodeAssistTest
release:
if: contains(github.ref, 'tags/v')
runs-on: ubuntu-22.04
+29 -5
View File
@@ -11,8 +11,14 @@ set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
find_package(QtCreator REQUIRED COMPONENTS Core)
find_package(Qt6 COMPONENTS Core Gui Quick Widgets Network Svg Test LinguistTools REQUIRED)
find_package(GTest)
find_package(Qt6 COMPONENTS Core Gui Quick Widgets Network Svg LinguistTools REQUIRED)
set(QT_VERSION_MAJOR 6)
option(WITH_TESTS "Builds with tests" NO)
if(WITH_TESTS)
find_package(Qt6 REQUIRED COMPONENTS Test)
endif()
qt_standard_project_setup(I18N_TRANSLATED_LANGUAGES
en cs zh_CN zh_TW da de fr hr ja pl ru sl sv uk
@@ -37,9 +43,6 @@ add_definitions(
)
add_subdirectory(sources)
if(GTest_FOUND)
add_subdirectory(tests)
endif()
add_qtc_plugin(QodeAssist
PLUGIN_DEPENDS
@@ -60,6 +63,7 @@ add_qtc_plugin(QodeAssist
QtCreator::CPlusPlus
LLMQore
Skills
QodeAssistSession
QodeAssistChatViewplugin
SOURCES
.github/workflows/build_cmake.yml
@@ -171,6 +175,17 @@ add_qtc_plugin(QodeAssist
target_include_directories(QodeAssist PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/sources)
extend_qtc_plugin(QodeAssist
CONDITION WITH_TESTS
DEPENDS Qt::Test
SOURCES
tests/QodeAssistTest.hpp tests/QodeAssistTest.cpp
)
if(WITH_TESTS)
target_include_directories(QodeAssist PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests)
endif()
get_target_property(QtCreatorCorePath QtCreator::Core LOCATION)
find_program(QtCreatorExecutable
NAMES
@@ -186,6 +201,15 @@ if (QtCreatorExecutable)
DEPENDS QodeAssist
)
set_target_properties(RunQtCreator PROPERTIES FOLDER "qtc_runnable")
if (WITH_TESTS)
add_custom_target(RunQodeAssistTests
COMMAND ${QtCreatorExecutable} -pluginpath $<TARGET_FILE_DIR:QodeAssist> -test QodeAssist
DEPENDS QodeAssist
USES_TERMINAL
)
set_target_properties(RunQodeAssistTests PROPERTIES FOLDER "qtc_runnable")
endif()
endif()
#TODO change to TS_OUTPUT_DIRECTORY after removing Qt6.8
+1
View File
@@ -3,5 +3,6 @@ add_subdirectory(skills)
add_subdirectory(logger)
add_subdirectory(settings)
add_subdirectory(context)
add_subdirectory(session)
add_subdirectory(UIControls)
add_subdirectory(ChatView)
+5 -1
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
+32 -34
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;
}
+5 -4
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;
};
+319
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
+79
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
+107
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
+37
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
+23 -16
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()
+7 -4
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
+81 -467
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;
+6 -59
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;
};
+38 -30
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()
+3 -2
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;
+37 -191
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);
+7 -16
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
-745
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
-102
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
+4 -58
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();
}
+1 -6
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};
+58 -16
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;
+23 -5
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;
+420
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
+69
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
+106
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
+64
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
+1 -1
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) {
-23
View File
@@ -1,23 +0,0 @@
add_library(Session STATIC
Message.hpp Message.cpp
MessageSerializer.hpp MessageSerializer.cpp
PluginBlocks.hpp
LLMRequest.hpp
ResponseEvent.hpp
ConversationHistory.hpp ConversationHistory.cpp
ResponseRouter.hpp ResponseRouter.cpp
Session.hpp Session.cpp
SessionManager.hpp SessionManager.cpp
SystemPromptBuilder.hpp SystemPromptBuilder.cpp
)
target_link_libraries(Session
PUBLIC
Qt::Core
LLMQore
Agents
)
target_include_directories(Session PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
-83
View File
@@ -1,83 +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 "ConversationHistory.hpp"
namespace QodeAssist {
ConversationHistory::ConversationHistory(QObject *parent)
: QObject(parent)
{}
ConversationHistory::~ConversationHistory() = default;
void ConversationHistory::append(Message message)
{
m_messages.push_back(std::move(message));
emit messageAdded(static_cast<int>(m_messages.size()) - 1);
}
void ConversationHistory::appendBlockToLast(std::unique_ptr<LLMQore::ContentBlock> block)
{
if (m_messages.empty() || !block)
return;
m_messages.back().appendBlock(std::move(block));
emit messageUpdated(static_cast<int>(m_messages.size()) - 1);
}
void ConversationHistory::appendTextDeltaToLast(const QString &delta)
{
if (m_messages.empty() || delta.isEmpty())
return;
auto &last = m_messages.back();
if (auto *text = last.lastBlockOfType<LLMQore::TextContent>()) {
text->appendText(delta);
} else {
last.appendBlock(std::make_unique<LLMQore::TextContent>(delta));
}
emit messageUpdated(static_cast<int>(m_messages.size()) - 1);
}
void ConversationHistory::appendThinkingDeltaToLast(const QString &delta, const QString &signature)
{
if (m_messages.empty() || (delta.isEmpty() && signature.isEmpty()))
return;
auto &last = m_messages.back();
auto *thinking = last.lastBlockOfType<LLMQore::ThinkingContent>();
if (!thinking) {
auto fresh = std::make_unique<LLMQore::ThinkingContent>(delta, signature);
last.appendBlock(std::move(fresh));
} else {
if (!delta.isEmpty())
thinking->appendThinking(delta);
if (!signature.isEmpty())
thinking->setSignature(signature);
}
emit messageUpdated(static_cast<int>(m_messages.size()) - 1);
}
void ConversationHistory::clear()
{
if (m_messages.empty())
return;
m_messages.clear();
emit cleared();
}
void ConversationHistory::resetTo(int index)
{
if (index < 0)
index = 0;
if (static_cast<size_t>(index) >= m_messages.size())
return;
m_messages.resize(static_cast<size_t>(index));
emit reset();
}
} // namespace QodeAssist
-50
View File
@@ -1,50 +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 <LLMQore/ContentBlocks.hpp>
#include <QObject>
#include <memory>
#include <vector>
#include "Message.hpp"
namespace QodeAssist {
class ConversationHistory : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(ConversationHistory)
public:
explicit ConversationHistory(QObject *parent = nullptr);
~ConversationHistory() override;
const std::vector<Message> &messages() const noexcept { return m_messages; }
int size() const noexcept { return static_cast<int>(m_messages.size()); }
bool isEmpty() const noexcept { return m_messages.empty(); }
void append(Message message);
void appendBlockToLast(std::unique_ptr<LLMQore::ContentBlock> block);
void appendTextDeltaToLast(const QString &delta);
void appendThinkingDeltaToLast(const QString &delta, const QString &signature = QString());
void clear();
void resetTo(int index);
signals:
void messageAdded(int index);
void messageUpdated(int index);
void cleared();
void reset();
private:
std::vector<Message> m_messages;
};
} // namespace QodeAssist
-33
View File
@@ -1,33 +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 <QString>
#include <optional>
#include <vector>
#include "Message.hpp"
namespace QodeAssist {
struct LLMRequest
{
QString systemPrompt;
std::vector<Message> history;
bool toolsEnabled = false;
bool thinkingEnabled = false;
std::optional<QString> fimPrefix;
std::optional<QString> fimSuffix;
LLMRequest() = default;
LLMRequest(const LLMRequest &) = delete;
LLMRequest &operator=(const LLMRequest &) = delete;
LLMRequest(LLMRequest &&) noexcept = default;
LLMRequest &operator=(LLMRequest &&) noexcept = default;
};
} // namespace QodeAssist
-31
View File
@@ -1,31 +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 "Message.hpp"
namespace QodeAssist {
QString Message::text() const
{
QString out;
for (const auto &block : m_blocks) {
if (auto *t = dynamic_cast<LLMQore::TextContent *>(block.get())) {
if (!out.isEmpty())
out += QStringLiteral("\n\n");
out += t->text();
}
}
return out;
}
bool Message::hasToolUse() const
{
for (const auto &block : m_blocks) {
if (dynamic_cast<LLMQore::ToolUseContent *>(block.get()))
return true;
}
return false;
}
} // namespace QodeAssist
-74
View File
@@ -1,74 +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 <LLMQore/ContentBlocks.hpp>
#include <QString>
#include <memory>
#include <vector>
namespace QodeAssist {
class Message
{
public:
enum class Role { System, User, Assistant };
Message() = default;
explicit Message(Role role, QString id = QString())
: m_role(role)
, m_id(std::move(id))
{}
Message(const Message &) = delete;
Message &operator=(const Message &) = delete;
Message(Message &&) noexcept = default;
Message &operator=(Message &&) noexcept = default;
~Message() = default;
Role role() const noexcept { return m_role; }
const QString &id() const noexcept { return m_id; }
void setId(QString id) { m_id = std::move(id); }
const std::vector<std::unique_ptr<LLMQore::ContentBlock>> &blocks() const noexcept
{
return m_blocks;
}
void appendBlock(std::unique_ptr<LLMQore::ContentBlock> block)
{
if (block)
m_blocks.push_back(std::move(block));
}
template<typename T>
T *lastBlockOfType()
{
for (auto it = m_blocks.rbegin(); it != m_blocks.rend(); ++it) {
if (auto *p = dynamic_cast<T *>(it->get()))
return p;
}
return nullptr;
}
template<typename T>
const T *lastBlockOfType() const
{
return const_cast<Message *>(this)->lastBlockOfType<T>();
}
QString text() const;
bool hasToolUse() const;
private:
Role m_role = Role::User;
QString m_id;
std::vector<std::unique_ptr<LLMQore::ContentBlock>> m_blocks;
};
} // namespace QodeAssist
-213
View File
@@ -1,213 +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 "MessageSerializer.hpp"
#include "PluginBlocks.hpp"
#include <LLMQore/ContentBlocks.hpp>
#include <QDebug>
#include <QJsonArray>
#include <memory>
namespace QodeAssist {
namespace {
constexpr auto kKindText = "text";
constexpr auto kKindThinking = "thinking";
constexpr auto kKindRedactedThinking = "redacted_thinking";
constexpr auto kKindImage = "image";
constexpr auto kKindToolUse = "tool_use";
constexpr auto kKindToolResult = "tool_result";
constexpr auto kKindStoredImage = "stored_image";
constexpr auto kKindStoredAttachment = "stored_attachment";
constexpr auto kKindFileEdit = "file_edit";
QString roleToString(Message::Role role)
{
switch (role) {
case Message::Role::System: return QStringLiteral("system");
case Message::Role::User: return QStringLiteral("user");
case Message::Role::Assistant: return QStringLiteral("assistant");
}
return QStringLiteral("user");
}
bool roleFromString(const QString &str, Message::Role *out)
{
if (str == QLatin1String("system")) {
*out = Message::Role::System;
return true;
}
if (str == QLatin1String("user")) {
*out = Message::Role::User;
return true;
}
if (str == QLatin1String("assistant")) {
*out = Message::Role::Assistant;
return true;
}
return false;
}
QJsonObject blockToJson(const LLMQore::ContentBlock &block)
{
QJsonObject obj;
if (auto *t = dynamic_cast<const LLMQore::TextContent *>(&block)) {
obj["type"] = kKindText;
obj["text"] = t->text();
} else if (auto *th = dynamic_cast<const LLMQore::ThinkingContent *>(&block)) {
obj["type"] = kKindThinking;
obj["thinking"] = th->thinking();
obj["signature"] = th->signature();
} else if (auto *rth = dynamic_cast<const LLMQore::RedactedThinkingContent *>(&block)) {
obj["type"] = kKindRedactedThinking;
obj["signature"] = rth->signature();
} else if (auto *img = dynamic_cast<const LLMQore::ImageContent *>(&block)) {
obj["type"] = kKindImage;
obj["data"] = img->data();
obj["mediaType"] = img->mediaType();
obj["sourceType"] = (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url)
? QStringLiteral("url")
: QStringLiteral("base64");
} else if (auto *tu = dynamic_cast<const LLMQore::ToolUseContent *>(&block)) {
obj["type"] = kKindToolUse;
obj["id"] = tu->id();
obj["name"] = tu->name();
obj["input"] = tu->input();
} else if (auto *tr = dynamic_cast<const LLMQore::ToolResultContent *>(&block)) {
obj["type"] = kKindToolResult;
obj["toolUseId"] = tr->toolUseId();
obj["result"] = tr->result();
} else if (auto *si = dynamic_cast<const StoredImageContent *>(&block)) {
obj["type"] = kKindStoredImage;
obj["fileName"] = si->fileName();
obj["storedPath"] = si->storedPath();
obj["mediaType"] = si->mediaType();
} else if (auto *sa = dynamic_cast<const StoredAttachmentContent *>(&block)) {
obj["type"] = kKindStoredAttachment;
obj["fileName"] = sa->fileName();
obj["storedPath"] = sa->storedPath();
} else if (auto *fe = dynamic_cast<const FileEditContent *>(&block)) {
obj["type"] = kKindFileEdit;
obj["editId"] = fe->editId();
obj["filePath"] = fe->filePath();
obj["oldContent"] = fe->oldContent();
obj["newContent"] = fe->newContent();
obj["status"] = FileEditContent::statusToString(fe->status());
if (!fe->statusMessage().isEmpty())
obj["statusMessage"] = fe->statusMessage();
}
return obj;
}
std::unique_ptr<LLMQore::ContentBlock> blockFromJson(const QJsonObject &obj)
{
const QString type = obj.value("type").toString();
if (type == kKindText) {
return std::make_unique<LLMQore::TextContent>(obj.value("text").toString());
}
if (type == kKindThinking) {
return std::make_unique<LLMQore::ThinkingContent>(
obj.value("thinking").toString(), obj.value("signature").toString());
}
if (type == kKindRedactedThinking) {
return std::make_unique<LLMQore::RedactedThinkingContent>(
obj.value("signature").toString());
}
if (type == kKindImage) {
const auto sourceType
= (obj.value("sourceType").toString() == QLatin1String("url"))
? LLMQore::ImageContent::ImageSourceType::Url
: LLMQore::ImageContent::ImageSourceType::Base64;
return std::make_unique<LLMQore::ImageContent>(
obj.value("data").toString(), obj.value("mediaType").toString(), sourceType);
}
if (type == kKindToolUse) {
return std::make_unique<LLMQore::ToolUseContent>(
obj.value("id").toString(),
obj.value("name").toString(),
obj.value("input").toObject());
}
if (type == kKindToolResult) {
return std::make_unique<LLMQore::ToolResultContent>(
obj.value("toolUseId").toString(), obj.value("result").toString());
}
if (type == kKindStoredImage) {
return std::make_unique<StoredImageContent>(
obj.value("fileName").toString(),
obj.value("storedPath").toString(),
obj.value("mediaType").toString());
}
if (type == kKindStoredAttachment) {
return std::make_unique<StoredAttachmentContent>(
obj.value("fileName").toString(), obj.value("storedPath").toString());
}
if (type == kKindFileEdit) {
return std::make_unique<FileEditContent>(
obj.value("editId").toString(),
obj.value("filePath").toString(),
obj.value("oldContent").toString(),
obj.value("newContent").toString(),
FileEditContent::statusFromString(obj.value("status").toString()),
obj.value("statusMessage").toString());
}
return nullptr; // unknown type — skipped
}
} // namespace
QJsonObject MessageSerializer::toJson(const Message &message)
{
QJsonObject obj;
obj["role"] = roleToString(message.role());
if (!message.id().isEmpty())
obj["id"] = message.id();
QJsonArray blocks;
for (const auto &b : message.blocks()) {
if (b)
blocks.append(blockToJson(*b));
}
obj["blocks"] = blocks;
return obj;
}
Message MessageSerializer::fromJson(const QJsonObject &json, bool *ok)
{
Message::Role role;
if (!roleFromString(json.value("role").toString(), &role)) {
if (ok)
*ok = false;
return Message();
}
Message m(role, json.value("id").toString());
const QJsonArray blocks = json.value("blocks").toArray();
int unknownBlocks = 0;
for (const QJsonValue &v : blocks) {
const QJsonObject blockObj = v.toObject();
auto block = blockFromJson(blockObj);
if (block) {
m.appendBlock(std::move(block));
} else {
++unknownBlocks;
qWarning("[QodeAssist] MessageSerializer: unknown block type '%s' "
"in stored chat — skipped",
qUtf8Printable(blockObj.value("type").toString()));
}
}
if (ok) {
*ok = m.blocks().size() > 0 || blocks.isEmpty();
if (unknownBlocks > 0 && !blocks.isEmpty() && m.blocks().empty())
*ok = false;
}
return m;
}
} // namespace QodeAssist
-21
View File
@@ -1,21 +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 <QJsonObject>
#include "Message.hpp"
namespace QodeAssist {
class MessageSerializer
{
public:
static QJsonObject toJson(const Message &message);
static Message fromJson(const QJsonObject &json, bool *ok = nullptr);
};
} // namespace QodeAssist
-115
View File
@@ -1,115 +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 <LLMQore/ContentBlocks.hpp>
#include <QString>
namespace QodeAssist {
class StoredImageContent : public LLMQore::ContentBlock
{
public:
StoredImageContent(QString fileName, QString storedPath, QString mediaType)
: m_fileName(std::move(fileName))
, m_storedPath(std::move(storedPath))
, m_mediaType(std::move(mediaType))
{}
QString type() const override { return QStringLiteral("stored_image"); }
QString fileName() const { return m_fileName; }
QString storedPath() const { return m_storedPath; }
QString mediaType() const { return m_mediaType; }
private:
QString m_fileName;
QString m_storedPath;
QString m_mediaType;
};
class StoredAttachmentContent : public LLMQore::ContentBlock
{
public:
StoredAttachmentContent(QString fileName, QString storedPath)
: m_fileName(std::move(fileName))
, m_storedPath(std::move(storedPath))
{}
QString type() const override { return QStringLiteral("stored_attachment"); }
QString fileName() const { return m_fileName; }
QString storedPath() const { return m_storedPath; }
private:
QString m_fileName;
QString m_storedPath;
};
class FileEditContent : public LLMQore::ContentBlock
{
public:
enum class Status { Pending, Applied, Rejected, Archived };
FileEditContent(
QString editId,
QString filePath,
QString oldContent,
QString newContent,
Status status = Status::Pending,
QString statusMessage = QString())
: m_editId(std::move(editId))
, m_filePath(std::move(filePath))
, m_oldContent(std::move(oldContent))
, m_newContent(std::move(newContent))
, m_status(status)
, m_statusMessage(std::move(statusMessage))
{}
QString type() const override { return QStringLiteral("file_edit"); }
QString editId() const { return m_editId; }
QString filePath() const { return m_filePath; }
QString oldContent() const { return m_oldContent; }
QString newContent() const { return m_newContent; }
Status status() const { return m_status; }
QString statusMessage() const { return m_statusMessage; }
void setStatus(Status status) { m_status = status; }
void setStatusMessage(QString msg) { m_statusMessage = std::move(msg); }
static QString statusToString(Status s)
{
switch (s) {
case Status::Pending: return QStringLiteral("pending");
case Status::Applied: return QStringLiteral("applied");
case Status::Rejected: return QStringLiteral("rejected");
case Status::Archived: return QStringLiteral("archived");
}
return QStringLiteral("pending");
}
static Status statusFromString(const QString &s)
{
if (s == QLatin1String("applied"))
return Status::Applied;
if (s == QLatin1String("rejected"))
return Status::Rejected;
if (s == QLatin1String("archived"))
return Status::Archived;
return Status::Pending;
}
private:
QString m_editId;
QString m_filePath;
QString m_oldContent;
QString m_newContent;
Status m_status;
QString m_statusMessage;
};
} // namespace QodeAssist
-170
View File
@@ -1,170 +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 <QJsonObject>
#include <QString>
#include <variant>
namespace QodeAssist {
namespace ResponseEvents {
struct TextDelta
{
QString text;
};
struct ThinkingDelta
{
QString thinking;
QString signature;
};
struct ToolCallStart
{
QString id;
QString name;
};
struct ToolCallArgsDelta
{
QString id;
QString jsonFragment;
};
struct ToolCallEnd
{
QString id;
QJsonObject finalArgs;
};
struct ToolResult
{
QString toolUseId;
QString text;
bool isError = false;
};
struct Usage
{
int inputTokens = 0;
int outputTokens = 0;
};
struct Error
{
QString message;
};
struct MessageStop
{
QString stopReason;
};
} // namespace ResponseEvents
class ResponseEvent
{
public:
enum class Kind {
MessageStart,
TextDelta,
ThinkingDelta,
ToolCallStart,
ToolCallArgsDelta,
ToolCallEnd,
ToolResult,
Usage,
MessageStop,
Error,
};
Kind kind() const noexcept { return m_kind; }
template<typename T>
const T *as() const noexcept
{
return std::get_if<T>(&m_data);
}
static ResponseEvent messageStart() { return {Kind::MessageStart, std::monostate{}}; }
static ResponseEvent messageStop(QString stopReason = QString())
{
return {Kind::MessageStop, ResponseEvents::MessageStop{std::move(stopReason)}};
}
static ResponseEvent textDelta(QString text)
{
return {Kind::TextDelta, ResponseEvents::TextDelta{std::move(text)}};
}
static ResponseEvent thinkingDelta(QString thinking, QString signature = QString())
{
return {
Kind::ThinkingDelta,
ResponseEvents::ThinkingDelta{std::move(thinking), std::move(signature)}};
}
static ResponseEvent toolCallStart(QString id, QString name)
{
return {Kind::ToolCallStart, ResponseEvents::ToolCallStart{std::move(id), std::move(name)}};
}
static ResponseEvent toolCallArgsDelta(QString id, QString jsonFragment)
{
return {
Kind::ToolCallArgsDelta,
ResponseEvents::ToolCallArgsDelta{std::move(id), std::move(jsonFragment)}};
}
static ResponseEvent toolCallEnd(QString id, QJsonObject finalArgs)
{
return {
Kind::ToolCallEnd, ResponseEvents::ToolCallEnd{std::move(id), std::move(finalArgs)}};
}
static ResponseEvent toolResult(QString toolUseId, QString text, bool isError = false)
{
return {
Kind::ToolResult,
ResponseEvents::ToolResult{std::move(toolUseId), std::move(text), isError}};
}
static ResponseEvent usage(int inputTokens, int outputTokens)
{
return {Kind::Usage, ResponseEvents::Usage{inputTokens, outputTokens}};
}
static ResponseEvent error(QString message)
{
return {Kind::Error, ResponseEvents::Error{std::move(message)}};
}
private:
using Data = std::variant<
std::monostate,
ResponseEvents::TextDelta,
ResponseEvents::ThinkingDelta,
ResponseEvents::ToolCallStart,
ResponseEvents::ToolCallArgsDelta,
ResponseEvents::ToolCallEnd,
ResponseEvents::ToolResult,
ResponseEvents::Usage,
ResponseEvents::Error,
ResponseEvents::MessageStop>;
ResponseEvent(Kind kind, Data data)
: m_kind(kind)
, m_data(std::move(data))
{}
Kind m_kind;
Data m_data;
};
} // namespace QodeAssist
-164
View File
@@ -1,164 +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 "ResponseRouter.hpp"
#include <LLMQore/ContentBlocks.hpp>
#include <memory>
#include "ConversationHistory.hpp"
#include "Message.hpp"
namespace QodeAssist {
ResponseRouter::ResponseRouter(
LLMQore::BaseClient *client, ConversationHistory *history, QObject *parent)
: QObject(parent)
, m_client(client)
, m_history(history)
{
if (!m_client)
return;
connect(
m_client.data(),
&LLMQore::BaseClient::chunkReceived,
this,
&ResponseRouter::onChunk);
connect(
m_client.data(),
&LLMQore::BaseClient::thinkingBlockReceived,
this,
&ResponseRouter::onThinking);
connect(
m_client.data(),
&LLMQore::BaseClient::toolStarted,
this,
&ResponseRouter::onToolStarted);
connect(
m_client.data(),
&LLMQore::BaseClient::toolResultReady,
this,
&ResponseRouter::onToolResultReady);
connect(
m_client.data(),
&LLMQore::BaseClient::requestFinalized,
this,
&ResponseRouter::onFinalized);
connect(
m_client.data(),
&LLMQore::BaseClient::requestFailed,
this,
&ResponseRouter::onFailed);
}
ResponseRouter::~ResponseRouter() = default;
void ResponseRouter::beginRequest(const LLMQore::RequestID &id)
{
m_activeId = id;
resetTurnState();
}
void ResponseRouter::endRequest()
{
m_activeId.clear();
resetTurnState();
}
void ResponseRouter::resetTurnState()
{
m_assistantOpen = false;
m_inToolResults = false;
}
void ResponseRouter::ensureAssistantOpen()
{
if (m_assistantOpen && !m_inToolResults)
return;
if (m_history)
m_history->append(Message(Message::Role::Assistant));
emit event(ResponseEvent::messageStart());
m_assistantOpen = true;
m_inToolResults = false;
}
void ResponseRouter::onChunk(const LLMQore::RequestID &id, const QString &chunk)
{
if (id != m_activeId || chunk.isEmpty())
return;
ensureAssistantOpen();
if (m_history)
m_history->appendTextDeltaToLast(chunk);
emit event(ResponseEvent::textDelta(chunk));
}
void ResponseRouter::onThinking(
const LLMQore::RequestID &id, const QString &thinking, const QString &signature)
{
if (id != m_activeId || (thinking.isEmpty() && signature.isEmpty()))
return;
ensureAssistantOpen();
if (m_history)
m_history->appendThinkingDeltaToLast(thinking, signature);
emit event(ResponseEvent::thinkingDelta(thinking, signature));
}
void ResponseRouter::onToolStarted(
const LLMQore::RequestID &id, const QString &toolId, const QString &toolName)
{
if (id != m_activeId)
return;
ensureAssistantOpen();
if (m_history)
m_history->appendBlockToLast(
std::make_unique<LLMQore::ToolUseContent>(toolId, toolName));
emit event(ResponseEvent::toolCallStart(toolId, toolName));
}
void ResponseRouter::onToolResultReady(
const LLMQore::RequestID &id,
const QString &toolId,
const QString &toolName,
const QString &result)
{
Q_UNUSED(toolName);
if (id != m_activeId)
return;
if (m_history) {
if (m_inToolResults) {
m_history->appendBlockToLast(
std::make_unique<LLMQore::ToolResultContent>(toolId, result));
} else {
Message m(Message::Role::User);
m.appendBlock(std::make_unique<LLMQore::ToolResultContent>(toolId, result));
m_history->append(std::move(m));
}
}
m_assistantOpen = false;
m_inToolResults = true;
emit event(ResponseEvent::toolResult(toolId, result, /*isError=*/false));
}
void ResponseRouter::onFinalized(
const LLMQore::RequestID &id, const LLMQore::CompletionInfo &info)
{
if (id != m_activeId)
return;
emit event(ResponseEvent::messageStop(info.stopReason));
endRequest();
}
void ResponseRouter::onFailed(const LLMQore::RequestID &id, const QString &err)
{
if (id != m_activeId)
return;
emit event(ResponseEvent::error(err));
endRequest();
}
} // namespace QodeAssist
-65
View File
@@ -1,65 +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 <LLMQore/BaseClient.hpp>
#include <QObject>
#include <QPointer>
#include <QString>
#include "ResponseEvent.hpp"
namespace QodeAssist {
class ConversationHistory;
class ResponseRouter : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(ResponseRouter)
public:
ResponseRouter(
LLMQore::BaseClient *client,
ConversationHistory *history,
QObject *parent = nullptr);
~ResponseRouter() override;
void beginRequest(const LLMQore::RequestID &id);
void endRequest();
bool isActive() const noexcept { return !m_activeId.isEmpty(); }
LLMQore::RequestID activeRequestId() const noexcept { return m_activeId; }
signals:
void event(const QodeAssist::ResponseEvent &ev);
private slots:
void onChunk(const LLMQore::RequestID &id, const QString &chunk);
void onThinking(
const LLMQore::RequestID &id, const QString &thinking, const QString &signature);
void onToolStarted(
const LLMQore::RequestID &id, const QString &toolId, const QString &toolName);
void onToolResultReady(
const LLMQore::RequestID &id,
const QString &toolId,
const QString &toolName,
const QString &result);
void onFinalized(const LLMQore::RequestID &id, const LLMQore::CompletionInfo &info);
void onFailed(const LLMQore::RequestID &id, const QString &err);
private:
void ensureAssistantOpen();
void resetTurnState();
QPointer<LLMQore::BaseClient> m_client;
QPointer<ConversationHistory> m_history;
LLMQore::RequestID m_activeId;
bool m_assistantOpen = false;
bool m_inToolResults = false;
};
} // namespace QodeAssist
-387
View File
@@ -1,387 +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 "Session.hpp"
#include <LLMQore/BaseClient.hpp>
#include <QDebug>
#include <QJsonObject>
#include <QUrl>
#include <algorithm>
#include "Agent.hpp"
#include "AgentConfig.hpp"
#include "ContextData.hpp"
#include "Message.hpp"
#include "PluginBlocks.hpp"
#include "PromptTemplate.hpp"
#include "Provider.hpp"
#include "ResponseRouter.hpp"
#include "SystemPromptBuilder.hpp"
namespace QodeAssist {
namespace {
QString roleToLegacyString(Message::Role role)
{
switch (role) {
case Message::Role::System: return QStringLiteral("system");
case Message::Role::User: return QStringLiteral("user");
case Message::Role::Assistant: return QStringLiteral("assistant");
}
return QStringLiteral("user");
}
} // namespace
Session::Session(QObject *parent)
: QObject(parent)
, m_history(new ConversationHistory(this))
, m_systemPrompt(new SystemPromptBuilder(this))
{
m_invalidReason = QStringLiteral("Session: no agent attached");
}
Session::Session(Agent *agent, QObject *parent)
: Session(agent, /*externalHistory=*/nullptr, parent)
{}
Session::Session(Agent *agent, ConversationHistory *externalHistory, QObject *parent)
: QObject(parent)
, m_agent(agent)
, m_history(externalHistory ? externalHistory : new ConversationHistory(this))
, m_systemPrompt(new SystemPromptBuilder(this))
{
if (!m_agent) {
m_invalidReason = QStringLiteral("Session: agent is null");
return;
}
m_agent->setParent(this);
if (!m_agent->isValid()) {
m_invalidReason = m_agent->invalidReason();
return;
}
auto *provider = m_agent->provider();
auto *client = provider ? provider->client() : nullptr;
if (!client) {
m_invalidReason = QStringLiteral("Session: provider has no live client");
return;
}
if (!m_agent->promptTemplate()) {
m_invalidReason
= QStringLiteral("Session: agent has no inline prompt template");
return;
}
m_router = new ResponseRouter(client, m_history, this);
connect(m_router, &ResponseRouter::event, this, &Session::onRouterEvent);
m_systemPrompt->setLayer(QStringLiteral("agent.role"), m_agent->config().role);
}
Session::~Session()
{
if (isInFlight())
cancel();
}
bool Session::isValid() const noexcept
{
return m_invalidReason.isEmpty();
}
QString Session::invalidReason() const
{
return m_invalidReason;
}
bool Session::isInFlight() const noexcept
{
return !m_inFlight.isEmpty();
}
void Session::setContentLoader(ContentLoader loader)
{
m_contentLoader = std::move(loader);
}
void Session::setContextBindings(Templates::ContextRenderer::Bindings bindings)
{
m_contextBindings = std::move(bindings);
}
QString Session::renderAgentContext() const
{
if (!m_agent)
return {};
const auto &cfg = m_agent->config();
if (cfg.context.isEmpty())
return {};
QString err;
QString rendered = Templates::ContextRenderer::render(cfg.context, m_contextBindings, &err);
if (!err.isEmpty())
qWarning("[QodeAssist] agent.context render failed: %s", qUtf8Printable(err));
return rendered;
}
LLMQore::RequestID Session::sendText(const QString &text)
{
std::vector<std::unique_ptr<LLMQore::ContentBlock>> blocks;
if (!text.isEmpty())
blocks.push_back(std::make_unique<LLMQore::TextContent>(text));
return send(std::move(blocks));
}
LLMQore::RequestID Session::send(
std::vector<std::unique_ptr<LLMQore::ContentBlock>> userBlocks,
std::optional<bool> toolsOverride)
{
if (!isValid() || userBlocks.empty())
return {};
if (!m_history)
return {};
if (isInFlight())
cancel();
Message msg(Message::Role::User);
for (auto &b : userBlocks)
msg.appendBlock(std::move(b));
m_history->append(std::move(msg));
return dispatch(toolsOverride);
}
void Session::cancel()
{
if (m_inFlight.isEmpty())
return;
const auto id = m_inFlight;
m_inFlight.clear();
if (m_router)
m_router->endRequest();
if (m_agent && m_agent->provider())
m_agent->provider()->cancelRequest(id);
emit failed(id, QStringLiteral("Cancelled by user"));
}
LLMQore::RequestID Session::sendCompletion(Templates::ContextData ctx)
{
if (!isValid())
return {};
if (isInFlight())
cancel();
if (m_history)
m_history->clear();
auto *provider = m_agent->provider();
auto *tmpl = m_agent->promptTemplate();
const auto &cfg = m_agent->config();
QJsonObject payload{{QStringLiteral("model"), cfg.model}};
if (!provider->prepareRequest(payload, tmpl, ctx, /*tools=*/false, /*thinking=*/false))
return {};
const auto id = provider->sendRequest(QUrl(provider->url()), payload, cfg.endpoint);
if (id.isEmpty())
return {};
m_inFlight = id;
if (m_router)
m_router->beginRequest(id);
emit started(id);
return id;
}
LLMQore::RequestID Session::dispatch(std::optional<bool> toolsOverride)
{
auto *provider = m_agent->provider();
auto *tmpl = m_agent->promptTemplate();
const auto &cfg = m_agent->config();
const QString renderedContext = renderAgentContext();
if (renderedContext.isEmpty())
m_systemPrompt->clearLayer(QStringLiteral("agent.context"));
else
m_systemPrompt->setLayer(QStringLiteral("agent.context"), renderedContext);
Templates::ContextData ctx = toLegacyContext();
QJsonObject payload{{QStringLiteral("model"), cfg.model}};
const bool tools = toolsOverride.value_or(cfg.enableTools);
if (!provider->prepareRequest(payload, tmpl, ctx, tools, cfg.enableThinking))
return {};
const auto id = provider->sendRequest(QUrl(provider->url()), payload, cfg.endpoint);
if (id.isEmpty())
return {};
m_inFlight = id;
if (m_router)
m_router->beginRequest(id);
emit started(id);
return id;
}
Templates::ContextData Session::toLegacyContext() const
{
if (!m_history)
return {};
return buildLegacyContext(m_history->messages(), m_systemPrompt->compose(), m_contentLoader);
}
Templates::ContextData Session::buildLegacyContext(
const std::vector<Message> &history,
const QString &systemPrompt,
const ContentLoader &loader)
{
using Templates::ContentBlockEntry;
using Templates::ContextData;
using LegacyMessage = Templates::Message;
ContextData ctx;
if (!systemPrompt.isEmpty())
ctx.systemPrompt = systemPrompt;
QSet<QString> resolvedToolUseIds;
QSet<QString> declaredToolUseIds;
for (const auto &m : history) {
for (const auto &blockPtr : m.blocks()) {
if (auto *tr = dynamic_cast<LLMQore::ToolResultContent *>(blockPtr.get()))
resolvedToolUseIds.insert(tr->toolUseId());
if (auto *tu = dynamic_cast<LLMQore::ToolUseContent *>(blockPtr.get()))
declaredToolUseIds.insert(tu->id());
}
}
QVector<LegacyMessage> hist;
for (const auto &m : history) {
QVector<ContentBlockEntry> blockEntries;
for (const auto &blockPtr : m.blocks()) {
auto *block = blockPtr.get();
if (!block)
continue;
if (auto *t = dynamic_cast<LLMQore::TextContent *>(block)) {
ContentBlockEntry e;
e.kind = ContentBlockEntry::Kind::Text;
e.text = t->text();
blockEntries.append(std::move(e));
} else if (auto *img = dynamic_cast<LLMQore::ImageContent *>(block)) {
ContentBlockEntry e;
e.kind = ContentBlockEntry::Kind::Image;
e.imageData = img->data();
e.mediaType = img->mediaType();
e.isImageUrl
= (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url);
blockEntries.append(std::move(e));
} else if (auto *si = dynamic_cast<StoredImageContent *>(block)) {
if (!loader)
continue;
const QString base64 = loader(si->storedPath());
if (base64.isEmpty())
continue;
ContentBlockEntry e;
e.kind = ContentBlockEntry::Kind::Image;
e.imageData = base64;
e.mediaType = si->mediaType();
e.isImageUrl = false;
blockEntries.append(std::move(e));
} else if (auto *sa = dynamic_cast<StoredAttachmentContent *>(block)) {
if (!loader)
continue;
const QString text = loader(sa->storedPath());
if (text.isEmpty())
continue;
ContentBlockEntry e;
e.kind = ContentBlockEntry::Kind::Text;
e.text = QStringLiteral("File: %1\n```\n%2\n```")
.arg(sa->fileName(), text);
blockEntries.append(std::move(e));
} else if (auto *th = dynamic_cast<LLMQore::ThinkingContent *>(block)) {
ContentBlockEntry e;
e.kind = ContentBlockEntry::Kind::Thinking;
e.thinking = th->thinking();
e.signature = th->signature();
blockEntries.append(std::move(e));
} else if (auto *rth = dynamic_cast<LLMQore::RedactedThinkingContent *>(block)) {
ContentBlockEntry e;
e.kind = ContentBlockEntry::Kind::RedactedThinking;
e.signature = rth->signature();
blockEntries.append(std::move(e));
} else if (auto *tu = dynamic_cast<LLMQore::ToolUseContent *>(block)) {
if (!resolvedToolUseIds.contains(tu->id()))
continue;
ContentBlockEntry e;
e.kind = ContentBlockEntry::Kind::ToolUse;
e.toolUseId = tu->id();
e.toolName = tu->name();
e.toolInput = tu->input();
blockEntries.append(std::move(e));
} else if (auto *tr = dynamic_cast<LLMQore::ToolResultContent *>(block)) {
if (!declaredToolUseIds.contains(tr->toolUseId()))
continue;
ContentBlockEntry e;
e.kind = ContentBlockEntry::Kind::ToolResult;
e.toolUseId = tr->toolUseId();
e.result = tr->result();
blockEntries.append(std::move(e));
}
}
if (blockEntries.isEmpty())
continue;
const bool hasNonThinking = std::any_of(
blockEntries.begin(), blockEntries.end(), [](const ContentBlockEntry &e) {
return e.kind != ContentBlockEntry::Kind::Thinking
&& e.kind != ContentBlockEntry::Kind::RedactedThinking;
});
if (!hasNonThinking)
continue;
LegacyMessage lm;
lm.role = roleToLegacyString(m.role());
lm.blocks = std::move(blockEntries);
hist.append(std::move(lm));
}
if (!hist.isEmpty())
ctx.history = std::move(hist);
return ctx;
}
void Session::onRouterEvent(const ResponseEvent &ev)
{
if (m_inFlight.isEmpty())
return; // stale events after cancel
emit event(ev);
if (ev.kind() == ResponseEvent::Kind::MessageStop) {
const auto *stop = ev.as<ResponseEvents::MessageStop>();
const QString reason = stop ? stop->stopReason : QString();
const auto id = m_inFlight;
m_inFlight.clear();
emit finished(id, reason);
} else if (ev.kind() == ResponseEvent::Kind::Error) {
const auto *err = ev.as<ResponseEvents::Error>();
const QString msg = err ? err->message : QStringLiteral("unknown error");
const auto id = m_inFlight;
m_inFlight.clear();
emit failed(id, msg);
}
}
} // namespace QodeAssist
-106
View File
@@ -1,106 +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 <LLMQore/BaseClient.hpp>
#include <LLMQore/ContentBlocks.hpp>
#include <ContextData.hpp>
#include <ContextRenderer.hpp>
#include <QObject>
#include <QPointer>
#include <QString>
#include <functional>
#include <memory>
#include <optional>
#include <vector>
#include "ConversationHistory.hpp"
#include "ResponseEvent.hpp"
namespace QodeAssist {
class Agent;
class ResponseRouter;
class SystemPromptBuilder;
class Session : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(Session)
public:
explicit Session(QObject *parent = nullptr);
Session(
Agent *agent,
ConversationHistory *externalHistory = nullptr,
QObject *parent = nullptr);
Session(Agent *agent, QObject *parent);
~Session() override;
bool isValid() const noexcept;
QString invalidReason() const;
bool isInFlight() const noexcept;
using ContentLoader = std::function<QString(const QString &storedPath)>;
void setContentLoader(ContentLoader loader);
Agent *agent() noexcept { return m_agent; }
ConversationHistory *history() const noexcept { return m_history; }
SystemPromptBuilder *systemPrompt() const noexcept { return m_systemPrompt; }
void setContextBindings(Templates::ContextRenderer::Bindings bindings);
QString renderAgentContext() const;
LLMQore::RequestID send(
std::vector<std::unique_ptr<LLMQore::ContentBlock>> userBlocks,
std::optional<bool> toolsOverride = std::nullopt);
LLMQore::RequestID sendText(const QString &text);
LLMQore::RequestID sendCompletion(Templates::ContextData ctx);
void cancel();
signals:
void event(const QodeAssist::ResponseEvent &ev);
void started(const LLMQore::RequestID &id);
void finished(const LLMQore::RequestID &id, const QString &stopReason);
void failed(const LLMQore::RequestID &id, const QString &error);
private slots:
void onRouterEvent(const QodeAssist::ResponseEvent &ev);
private:
LLMQore::RequestID dispatch(std::optional<bool> toolsOverride = std::nullopt);
Templates::ContextData toLegacyContext() const;
Agent *m_agent = nullptr; // child if non-null
QPointer<ConversationHistory> m_history; // child if internal, external otherwise
SystemPromptBuilder *m_systemPrompt = nullptr; // child
ResponseRouter *m_router = nullptr; // child, only when valid
LLMQore::RequestID m_inFlight;
QString m_invalidReason;
Templates::ContextRenderer::Bindings m_contextBindings;
public:
static Templates::ContextData buildLegacyContext(
const std::vector<Message> &history,
const QString &systemPrompt,
const ContentLoader &loader = ContentLoader{});
private:
ContentLoader m_contentLoader;
};
} // namespace QodeAssist
-106
View File
@@ -1,106 +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 "SessionManager.hpp"
#include "Agent.hpp"
#include "AgentFactory.hpp"
#include "Session.hpp"
namespace QodeAssist {
SessionManager::SessionManager(QObject *parent)
: QObject(parent)
{}
SessionManager::SessionManager(AgentFactory *agentFactory, QObject *parent)
: QObject(parent)
, m_agentFactory(agentFactory)
{}
SessionManager::~SessionManager() = default;
Session *SessionManager::createSession()
{
auto *session = new Session(this);
m_sessions.append(session);
emit sessionCreated(session);
return session;
}
Session *SessionManager::createSession(const QString &agentName, QString *errorOut)
{
return createSession(agentName, /*externalHistory=*/nullptr, errorOut);
}
Session *SessionManager::createSession(
const QString &agentName, ConversationHistory *externalHistory, QString *errorOut)
{
if (!m_agentFactory) {
if (errorOut)
*errorOut = QStringLiteral("SessionManager: no AgentFactory bound");
return nullptr;
}
QString agentErr;
Agent *agent = m_agentFactory->create(agentName, /*parent=*/nullptr, &agentErr);
if (!agent) {
if (errorOut)
*errorOut = agentErr.isEmpty()
? QStringLiteral("SessionManager: agent '%1' not found").arg(agentName)
: agentErr;
return nullptr;
}
auto *session = new Session(agent, externalHistory, this);
if (!session->isValid()) {
if (errorOut)
*errorOut = session->invalidReason();
delete session; // also deletes the reparented agent
return nullptr;
}
m_sessions.append(session);
emit sessionCreated(session);
return session;
}
void SessionManager::removeSession(Session *session)
{
if (!session)
return;
const int idx = m_sessions.indexOf(session);
if (idx < 0)
return;
if (session->isInFlight())
session->cancel();
m_sessions.removeAt(idx);
emit sessionRemoved(session);
session->deleteLater();
}
QList<Session *> SessionManager::sessions() const
{
QList<Session *> out;
out.reserve(m_sessions.size());
for (const auto &p : m_sessions) {
if (p)
out.append(p.data());
}
return out;
}
void SessionManager::cancelAll()
{
const auto snapshot = m_sessions;
for (const auto &p : snapshot) {
if (p && p->isInFlight())
p->cancel();
}
}
} // namespace QodeAssist
-53
View File
@@ -1,53 +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 <QList>
#include <QObject>
#include <QPointer>
#include <QString>
namespace QodeAssist {
class AgentFactory;
class ConversationHistory;
class Session;
class SessionManager : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(SessionManager)
public:
explicit SessionManager(QObject *parent = nullptr);
SessionManager(AgentFactory *agentFactory, QObject *parent = nullptr);
~SessionManager() override;
Session *createSession();
Session *createSession(const QString &agentName, QString *errorOut = nullptr);
Session *createSession(
const QString &agentName,
ConversationHistory *externalHistory,
QString *errorOut = nullptr);
void removeSession(Session *session);
QList<Session *> sessions() const;
void cancelAll();
signals:
void sessionCreated(Session *session);
void sessionRemoved(Session *session);
private:
QPointer<AgentFactory> m_agentFactory;
QList<QPointer<Session>> m_sessions;
};
} // namespace QodeAssist
-72
View File
@@ -1,72 +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 "SystemPromptBuilder.hpp"
namespace QodeAssist {
SystemPromptBuilder::SystemPromptBuilder(QObject *parent)
: QObject(parent)
{}
void SystemPromptBuilder::setLayer(const QString &name, const QString &text)
{
for (auto &pair : m_layers) {
if (pair.first == name) {
if (pair.second == text) return;
pair.second = text;
emit layersChanged();
return;
}
}
m_layers.append({name, text});
emit layersChanged();
}
void SystemPromptBuilder::clearLayer(const QString &name)
{
for (auto it = m_layers.begin(); it != m_layers.end(); ++it) {
if (it->first == name) {
m_layers.erase(it);
emit layersChanged();
return;
}
}
}
void SystemPromptBuilder::clear()
{
if (m_layers.isEmpty()) return;
m_layers.clear();
emit layersChanged();
}
QString SystemPromptBuilder::layer(const QString &name) const
{
for (const auto &pair : m_layers) {
if (pair.first == name) return pair.second;
}
return {};
}
QStringList SystemPromptBuilder::layerNames() const
{
QStringList out;
out.reserve(m_layers.size());
for (const auto &pair : m_layers) out.append(pair.first);
return out;
}
QString SystemPromptBuilder::compose(const QString &separator) const
{
QStringList parts;
parts.reserve(m_layers.size());
for (const auto &pair : m_layers) {
if (!pair.second.isEmpty())
parts.append(pair.second);
}
return parts.join(separator);
}
} // namespace QodeAssist
-37
View File
@@ -1,37 +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 <QString>
#include <QStringList>
#include <QVector>
namespace QodeAssist {
class SystemPromptBuilder : public QObject
{
Q_OBJECT
public:
explicit SystemPromptBuilder(QObject *parent = nullptr);
void setLayer(const QString &name, const QString &text);
void clearLayer(const QString &name);
void clear();
QString layer(const QString &name) const;
QStringList layerNames() const;
bool isEmpty() const { return m_layers.isEmpty(); }
QString compose(const QString &separator = QStringLiteral("\n\n")) const;
signals:
void layersChanged();
private:
QVector<QPair<QString, QString>> m_layers;
};
} // namespace QodeAssist
+7 -6
View File
@@ -5,12 +5,11 @@
#include "McpServerConnection.hpp"
#include <LLMQore/McpClient.hpp>
#include <LLMQore/McpExceptions.hpp>
#include <LLMQore/McpHttpTransport.hpp>
#include <LLMQore/McpRemoteTool.hpp>
#include <LLMQore/McpStdioTransport.hpp>
#include <LLMQore/McpTransport.hpp>
#include <LLMQore/McpTypes.hpp>
#include <LLMQore/RpcStdioTransport.hpp>
#include <LLMQore/RpcTransport.hpp>
#include <LLMQore/ToolsManager.hpp>
#include <LLMQore/Version.hpp>
@@ -22,6 +21,8 @@
#include <QJsonValue>
#include <QStandardPaths>
#include <utility>
#include <logger/Logger.hpp>
#include "providers/Provider.hpp"
#include <settings/McpSettings.hpp>
@@ -142,7 +143,7 @@ void McpServerConnection::setProviders(const QList<Providers::Provider *> &provi
}
}
::LLMQore::Mcp::McpTransport *McpServerConnection::createTransport()
::LLMQore::Rpc::Transport *McpServerConnection::createTransport()
{
if (m_config.transport == McpTransportKind::Http) {
if (!m_config.url.isValid()) {
@@ -166,7 +167,7 @@ void McpServerConnection::setProviders(const QList<Providers::Provider *> &provi
return nullptr;
}
::LLMQore::Mcp::StdioLaunchConfig cfg;
::LLMQore::Rpc::StdioLaunchConfig cfg;
cfg.arguments = m_config.args;
cfg.workingDirectory = m_config.workingDirectory;
@@ -211,7 +212,7 @@ void McpServerConnection::setProviders(const QList<Providers::Provider *> &provi
env.insert(it.key(), it.value());
cfg.environment = env;
return new ::LLMQore::Mcp::McpStdioClientTransport(cfg, this);
return new ::LLMQore::Rpc::StdioClientTransport(std::move(cfg), this);
}
void McpServerConnection::connectToServer()
+6 -3
View File
@@ -16,9 +16,12 @@
namespace LLMQore::Mcp {
class McpClient;
class McpTransport;
} // namespace LLMQore::Mcp
namespace LLMQore::Rpc {
class Transport;
} // namespace LLMQore::Rpc
namespace QodeAssist::Providers {
class Provider;
}
@@ -77,14 +80,14 @@ private:
void fetchAndRegisterTools();
void registerTools(const QList<::LLMQore::Mcp::McpClient *> & /*unused*/);
void unregisterTools();
::LLMQore::Mcp::McpTransport *createTransport();
::LLMQore::Rpc::Transport *createTransport();
McpServerConfig m_config;
McpConnectionState m_state = McpConnectionState::Disabled;
QString m_statusText;
QPointer<::LLMQore::Mcp::McpClient> m_client;
QPointer<::LLMQore::Mcp::McpTransport> m_transport;
QPointer<::LLMQore::Rpc::Transport> m_transport;
QPointer<QTimer> m_listToolsWatchdog;
QList<QPointer<Providers::Provider>> m_providers;
+8
View File
@@ -73,6 +73,10 @@
#include <texteditor/texteditor.h>
#include <texteditor/texteditorconstants.h>
#ifdef WITH_TESTS
#include "QodeAssistTest.hpp"
#endif
using namespace Utils;
using namespace Core;
using namespace ProjectExplorer;
@@ -305,6 +309,10 @@ public:
}
Chat::ChatFileManager::cleanupGlobalIntermediateStorage();
#ifdef WITH_TESTS
addTest<QodeAssistTest>();
#endif
}
void extensionsInitialized() final {}
+22
View File
@@ -0,0 +1,22 @@
add_library(QodeAssistSession STATIC
ContentBlock.hpp
Message.hpp
ConversationHistory.hpp ConversationHistory.cpp
FileEditPayload.hpp FileEditPayload.cpp
HistoryProjection.hpp HistoryProjection.cpp
HistorySerializer.hpp HistorySerializer.cpp
SessionEvent.hpp SessionEvent.cpp
TurnRequest.hpp
ChatBackend.hpp
Session.hpp Session.cpp
TurnContext.hpp TurnContext.cpp
TurnContextPorts.hpp
TurnContextBuilder.hpp TurnContextBuilder.cpp
)
target_link_libraries(QodeAssistSession
PUBLIC
Qt::Core
)
target_include_directories(QodeAssistSession PUBLIC ${CMAKE_SOURCE_DIR}/sources)
+28
View File
@@ -0,0 +1,28 @@
// 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 <QObject>
#include "session/SessionEvent.hpp"
#include "session/TurnRequest.hpp"
namespace QodeAssist::Session {
class ChatBackend : public QObject
{
Q_OBJECT
public:
using QObject::QObject;
virtual void sendTurn(const TurnRequest &request) = 0;
virtual void cancel() = 0;
signals:
void sessionEvent(const QodeAssist::Session::SessionEvent &event);
};
} // namespace QodeAssist::Session
+110
View File
@@ -0,0 +1,110 @@
// 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 <QDebug>
#include <QJsonObject>
#include <QString>
#include <variant>
namespace QodeAssist::Session {
struct TextBlock
{
QString text;
bool operator==(const TextBlock &other) const = default;
friend QDebug operator<<(QDebug debug, const TextBlock &block)
{
return debug.nospace() << "Text(" << block.text << ")";
}
};
struct ThinkingBlock
{
QString text;
QString signature;
bool redacted = false;
bool operator==(const ThinkingBlock &other) const = default;
friend QDebug operator<<(QDebug debug, const ThinkingBlock &block)
{
return debug.nospace() << "Thinking(" << block.text << ", sig=" << block.signature
<< ", redacted=" << block.redacted << ")";
}
};
struct ToolCallBlock
{
QString id;
QString name;
QJsonObject arguments;
QString result;
bool operator==(const ToolCallBlock &other) const = default;
friend QDebug operator<<(QDebug debug, const ToolCallBlock &block)
{
return debug.nospace() << "ToolCall(" << block.id << ", " << block.name
<< ", args=" << block.arguments << ", result=" << block.result
<< ")";
}
};
struct AttachmentBlock
{
QString fileName;
QString storedPath;
bool operator==(const AttachmentBlock &other) const = default;
friend QDebug operator<<(QDebug debug, const AttachmentBlock &block)
{
return debug.nospace() << "Attachment(" << block.fileName << ", " << block.storedPath
<< ")";
}
};
struct ImageBlock
{
QString fileName;
QString storedPath;
QString mediaType;
bool operator==(const ImageBlock &other) const = default;
friend QDebug operator<<(QDebug debug, const ImageBlock &block)
{
return debug.nospace() << "Image(" << block.fileName << ", " << block.storedPath << ", "
<< block.mediaType << ")";
}
};
struct FileEditBlock
{
QString id;
QString payload;
bool operator==(const FileEditBlock &other) const = default;
friend QDebug operator<<(QDebug debug, const FileEditBlock &block)
{
return debug.nospace() << "FileEdit(" << block.id << ", " << block.payload << ")";
}
};
using ContentBlock = std::
variant<TextBlock, ThinkingBlock, ToolCallBlock, AttachmentBlock, ImageBlock, FileEditBlock>;
inline QDebug operator<<(QDebug debug, const ContentBlock &block)
{
std::visit([&debug](const auto &alternative) { debug << alternative; }, block);
return debug;
}
} // namespace QodeAssist::Session
+47
View File
@@ -0,0 +1,47 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "session/ConversationHistory.hpp"
namespace QodeAssist::Session {
void ConversationHistory::append(const Message &message)
{
m_messages.append(message);
}
qsizetype ConversationHistory::size() const
{
return m_messages.size();
}
const QList<Message> &ConversationHistory::messages() const
{
return m_messages;
}
const Message &ConversationHistory::at(qsizetype index) const
{
return m_messages.at(index);
}
const Message &ConversationHistory::last() const
{
return m_messages.last();
}
Message *ConversationHistory::lastMessage()
{
return m_messages.isEmpty() ? nullptr : &m_messages.last();
}
void ConversationHistory::visitBlocks(const std::function<void(ContentBlock &)> &visit)
{
for (Message &message : m_messages) {
for (ContentBlock &block : message.blocks)
visit(block);
}
}
} // namespace QodeAssist::Session
+39
View File
@@ -0,0 +1,39 @@
// 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 <functional>
#include "session/Message.hpp"
namespace QodeAssist::Session {
class ConversationHistory
{
public:
void append(const Message &message);
qsizetype size() const;
const QList<Message> &messages() const;
const Message &at(qsizetype index) const;
const Message &last() const;
Message *lastMessage();
void visitBlocks(const std::function<void(ContentBlock &)> &visit);
bool operator==(const ConversationHistory &other) const = default;
friend QDebug operator<<(QDebug debug, const ConversationHistory &history)
{
return debug.nospace() << "History(" << history.m_messages << ")";
}
private:
QList<Message> m_messages;
};
} // namespace QodeAssist::Session
+49
View File
@@ -0,0 +1,49 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "session/FileEditPayload.hpp"
#include <QJsonDocument>
namespace QodeAssist::Session {
namespace {
QString fileEditMarker()
{
return QStringLiteral("QODEASSIST_FILE_EDIT:");
}
} // namespace
bool isFileEditPayload(const QString &text)
{
return text.startsWith(fileEditMarker());
}
std::optional<QJsonObject> parseFileEditPayload(const QString &text)
{
const QString marker = fileEditMarker();
const int markerPos = text.indexOf(marker);
if (markerPos < 0)
return std::nullopt;
const int jsonStart = markerPos + marker.length();
if (jsonStart >= text.length())
return std::nullopt;
const QJsonDocument document = QJsonDocument::fromJson(text.mid(jsonStart).toUtf8());
if (!document.isObject())
return std::nullopt;
return document.object();
}
QString encodeFileEditPayload(const QJsonObject &payload)
{
return fileEditMarker()
+ QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact));
}
} // namespace QodeAssist::Session
+18
View File
@@ -0,0 +1,18 @@
// 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 <QJsonObject>
#include <QString>
#include <optional>
namespace QodeAssist::Session {
bool isFileEditPayload(const QString &text);
std::optional<QJsonObject> parseFileEditPayload(const QString &text);
QString encodeFileEditPayload(const QJsonObject &payload);
} // namespace QodeAssist::Session
+244
View File
@@ -0,0 +1,244 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "session/HistoryProjection.hpp"
#include <optional>
#include <utility>
namespace QodeAssist::Session {
namespace {
QString signatureSuffix(const QString &signature)
{
return QString("\n[Signature: %1...]").arg(signature.left(40));
}
QString redactedThinkingText()
{
return QStringLiteral("[Thinking content redacted by safety systems]");
}
QString thinkingDisplayText(const ThinkingBlock &block)
{
const QString text = block.redacted && block.text.isEmpty() ? redactedThinkingText()
: block.text;
return block.signature.isEmpty() ? text : text + signatureSuffix(block.signature);
}
QString thinkingTextOf(const MessageRow &row)
{
QString text = row.content;
if (!row.signature.isEmpty()) {
const QString suffix = signatureSuffix(row.signature);
if (text.endsWith(suffix))
text.chop(suffix.size());
}
if (row.redacted && text == redactedThinkingText())
return {};
return text;
}
QString toolDisplayText(const ToolCallBlock &block)
{
if (block.name.isEmpty())
return block.result;
return block.result.isEmpty() ? block.name : block.name + "\n" + block.result;
}
ToolCallBlock toolCallOf(const MessageRow &row)
{
ToolCallBlock block{row.id, row.toolName, row.toolArguments, row.toolResult};
if (block.name.isEmpty() && block.result.isEmpty())
block.result = row.content;
return block;
}
RowKind textRowKind(MessageRole role)
{
switch (role) {
case MessageRole::System:
return RowKind::System;
case MessageRole::User:
return RowKind::User;
case MessageRole::Assistant:
return RowKind::Assistant;
}
return RowKind::Assistant;
}
} // namespace
std::optional<MessageRow> projectBlockToRow(const Message &message, const ContentBlock &block)
{
if (const auto *text = std::get_if<TextBlock>(&block)) {
MessageRow row;
row.kind = textRowKind(message.role);
row.id = message.id;
row.content = text->text;
return row;
}
if (const auto *thinking = std::get_if<ThinkingBlock>(&block)) {
MessageRow row;
row.kind = RowKind::Thinking;
row.id = message.id;
row.content = thinkingDisplayText(*thinking);
row.redacted = thinking->redacted;
row.signature = thinking->signature;
return row;
}
if (const auto *tool = std::get_if<ToolCallBlock>(&block)) {
MessageRow row;
row.kind = RowKind::Tool;
row.id = tool->id;
row.content = toolDisplayText(*tool);
row.toolName = tool->name;
row.toolArguments = tool->arguments;
row.toolResult = tool->result;
return row;
}
if (const auto *edit = std::get_if<FileEditBlock>(&block)) {
MessageRow row;
row.kind = RowKind::FileEdit;
row.id = edit->id;
row.content = edit->payload;
return row;
}
return std::nullopt;
}
QList<MessageRow> projectMessageToRows(const Message &message)
{
QList<MessageRow> rows;
bool usageAssigned = message.usage.isEmpty();
qsizetype textRowIndex = -1;
auto appendRow = [&](MessageRow row) {
if (!usageAssigned && row.id == message.id) {
row.usage = message.usage;
usageAssigned = true;
}
rows.append(std::move(row));
return rows.size() - 1;
};
auto ensureTextRow = [&] {
if (textRowIndex < 0) {
MessageRow row;
row.kind = textRowKind(message.role);
row.id = message.id;
textRowIndex = appendRow(row);
}
return textRowIndex;
};
for (const ContentBlock &block : message.blocks) {
if (auto row = projectBlockToRow(message, block)) {
const qsizetype index = appendRow(std::move(*row));
if (std::holds_alternative<TextBlock>(block))
textRowIndex = index;
} else if (const auto *attachment = std::get_if<AttachmentBlock>(&block)) {
rows[ensureTextRow()].attachments.append(*attachment);
} else if (const auto *image = std::get_if<ImageBlock>(&block)) {
rows[ensureTextRow()].images.append(*image);
}
}
return rows;
}
QList<MessageRow> projectToRows(const ConversationHistory &history)
{
QList<MessageRow> rows;
for (const Message &message : history.messages())
rows.append(projectMessageToRows(message));
return rows;
}
ConversationHistory buildFromRows(const QList<MessageRow> &rows)
{
ConversationHistory history;
std::optional<Message> assistant;
auto flush = [&] {
if (assistant) {
history.append(*assistant);
assistant.reset();
}
};
auto openAssistant = [&]() -> Message & {
if (!assistant) {
Message message;
message.role = MessageRole::Assistant;
assistant = message;
}
return *assistant;
};
for (const MessageRow &row : rows) {
switch (row.kind) {
case RowKind::User:
case RowKind::System: {
flush();
Message message;
message.role = row.kind == RowKind::User ? MessageRole::User : MessageRole::System;
message.id = row.id;
message.usage = row.usage;
message.blocks.append(TextBlock{row.content});
for (const AttachmentBlock &attachment : row.attachments)
message.blocks.append(attachment);
for (const ImageBlock &image : row.images)
message.blocks.append(image);
history.append(message);
break;
}
case RowKind::Assistant:
case RowKind::Thinking: {
if (assistant && !row.id.isEmpty() && !assistant->id.isEmpty()
&& assistant->id != row.id) {
flush();
}
Message &message = openAssistant();
if (message.id.isEmpty())
message.id = row.id;
if (row.kind == RowKind::Assistant) {
message.blocks.append(TextBlock{row.content});
} else {
message.blocks.append(
ThinkingBlock{thinkingTextOf(row), row.signature, row.redacted});
}
if (!row.usage.isEmpty())
message.usage = row.usage;
break;
}
case RowKind::Tool: {
Message &message = openAssistant();
message.blocks.append(toolCallOf(row));
break;
}
case RowKind::FileEdit: {
Message &message = openAssistant();
message.blocks.append(FileEditBlock{row.id, row.content});
break;
}
}
}
flush();
return history;
}
} // namespace QodeAssist::Session
+51
View File
@@ -0,0 +1,51 @@
// 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 <QJsonObject>
#include <QList>
#include <QString>
#include <optional>
#include "session/ConversationHistory.hpp"
namespace QodeAssist::Session {
enum class RowKind { User, Assistant, System, Tool, FileEdit, Thinking };
struct MessageRow
{
RowKind kind = RowKind::User;
QString id;
QString content;
bool redacted = false;
QString signature;
QString toolName;
QJsonObject toolArguments;
QString toolResult;
QList<AttachmentBlock> attachments;
QList<ImageBlock> images;
Usage usage;
bool operator==(const MessageRow &other) const = default;
friend QDebug operator<<(QDebug debug, const MessageRow &row)
{
return debug.nospace() << "Row(kind=" << int(row.kind) << ", id=" << row.id
<< ", content=" << row.content << ", sig=" << row.signature
<< ", redacted=" << row.redacted << ", tool=" << row.toolName
<< ", args=" << row.toolArguments << ", result=" << row.toolResult
<< ", attachments=" << row.attachments << ", images=" << row.images
<< ", " << row.usage << ")";
}
};
std::optional<MessageRow> projectBlockToRow(const Message &message, const ContentBlock &block);
QList<MessageRow> projectMessageToRows(const Message &message);
QList<MessageRow> projectToRows(const ConversationHistory &history);
ConversationHistory buildFromRows(const QList<MessageRow> &rows);
} // namespace QodeAssist::Session
+290
View File
@@ -0,0 +1,290 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "session/HistorySerializer.hpp"
#include <QJsonArray>
#include "session/HistoryProjection.hpp"
namespace QodeAssist::Session {
namespace {
QString roleToString(MessageRole role)
{
switch (role) {
case MessageRole::System:
return QStringLiteral("system");
case MessageRole::User:
return QStringLiteral("user");
case MessageRole::Assistant:
return QStringLiteral("assistant");
}
return QStringLiteral("assistant");
}
MessageRole roleFromString(const QString &role)
{
if (role == QLatin1String("system"))
return MessageRole::System;
if (role == QLatin1String("user"))
return MessageRole::User;
return MessageRole::Assistant;
}
RowKind legacyRowKind(int role)
{
switch (role) {
case 0:
return RowKind::System;
case 1:
return RowKind::User;
case 2:
return RowKind::Assistant;
case 3:
return RowKind::Tool;
case 4:
return RowKind::FileEdit;
case 5:
return RowKind::Thinking;
default:
return RowKind::Assistant;
}
}
QJsonObject usageToJson(const Usage &usage)
{
QJsonObject json;
json["promptTokens"] = usage.promptTokens;
json["completionTokens"] = usage.completionTokens;
if (usage.cachedPromptTokens > 0)
json["cachedPromptTokens"] = usage.cachedPromptTokens;
if (usage.reasoningTokens > 0)
json["reasoningTokens"] = usage.reasoningTokens;
return json;
}
Usage usageFromJson(const QJsonObject &json)
{
Usage usage;
usage.promptTokens = json["promptTokens"].toInt();
usage.completionTokens = json["completionTokens"].toInt();
usage.cachedPromptTokens = json["cachedPromptTokens"].toInt();
usage.reasoningTokens = json["reasoningTokens"].toInt();
return usage;
}
QJsonObject blockToJson(const ContentBlock &block)
{
QJsonObject json;
if (const auto *text = std::get_if<TextBlock>(&block)) {
json["type"] = "text";
json["text"] = text->text;
} else if (const auto *thinking = std::get_if<ThinkingBlock>(&block)) {
json["type"] = "thinking";
json["text"] = thinking->text;
if (!thinking->signature.isEmpty())
json["signature"] = thinking->signature;
if (thinking->redacted)
json["redacted"] = true;
} else if (const auto *tool = std::get_if<ToolCallBlock>(&block)) {
json["type"] = "tool_call";
json["id"] = tool->id;
json["name"] = tool->name;
if (!tool->arguments.isEmpty())
json["arguments"] = tool->arguments;
if (!tool->result.isEmpty())
json["result"] = tool->result;
} else if (const auto *attachment = std::get_if<AttachmentBlock>(&block)) {
json["type"] = "attachment";
json["fileName"] = attachment->fileName;
json["storedPath"] = attachment->storedPath;
} else if (const auto *image = std::get_if<ImageBlock>(&block)) {
json["type"] = "image";
json["fileName"] = image->fileName;
json["storedPath"] = image->storedPath;
json["mediaType"] = image->mediaType;
} else if (const auto *edit = std::get_if<FileEditBlock>(&block)) {
json["type"] = "file_edit";
json["id"] = edit->id;
json["payload"] = edit->payload;
}
return json;
}
std::optional<ContentBlock> blockFromJson(const QJsonObject &json)
{
const QString type = json["type"].toString();
if (type == QLatin1String("text"))
return ContentBlock{TextBlock{json["text"].toString()}};
if (type == QLatin1String("thinking")) {
return ContentBlock{ThinkingBlock{
json["text"].toString(), json["signature"].toString(), json["redacted"].toBool(false)}};
}
if (type == QLatin1String("tool_call")) {
return ContentBlock{ToolCallBlock{
json["id"].toString(),
json["name"].toString(),
json["arguments"].toObject(),
json["result"].toString()}};
}
if (type == QLatin1String("attachment"))
return ContentBlock{
AttachmentBlock{json["fileName"].toString(), json["storedPath"].toString()}};
if (type == QLatin1String("image")) {
return ContentBlock{ImageBlock{
json["fileName"].toString(),
json["storedPath"].toString(),
json["mediaType"].toString()}};
}
if (type == QLatin1String("file_edit"))
return ContentBlock{FileEditBlock{json["id"].toString(), json["payload"].toString()}};
return std::nullopt;
}
QJsonObject messageToJson(const Message &message)
{
QJsonArray blocks;
for (const ContentBlock &block : message.blocks)
blocks.append(blockToJson(block));
QJsonObject json;
json["role"] = roleToString(message.role);
if (!message.id.isEmpty())
json["id"] = message.id;
json["blocks"] = blocks;
if (!message.usage.isEmpty())
json["usage"] = usageToJson(message.usage);
return json;
}
Message messageFromJson(const QJsonObject &json, int &droppedBlocks)
{
Message message;
message.role = roleFromString(json["role"].toString());
message.id = json["id"].toString();
const QJsonArray blocks = json["blocks"].toArray();
for (const QJsonValue &value : blocks) {
if (auto block = blockFromJson(value.toObject()))
message.blocks.append(*block);
else
++droppedBlocks;
}
if (json.contains("usage"))
message.usage = usageFromJson(json["usage"].toObject());
return message;
}
MessageRow legacyRowFromJson(const QJsonObject &json)
{
MessageRow row;
row.kind = legacyRowKind(json["role"].toInt());
row.id = json["id"].toString();
row.content = json["content"].toString();
row.redacted = json["isRedacted"].toBool(false);
row.signature = json["signature"].toString();
row.toolName = json["toolName"].toString();
row.toolArguments = json["toolArguments"].toObject();
row.toolResult = json["toolResult"].toString();
const QJsonArray attachments = json["attachments"].toArray();
for (const QJsonValue &value : attachments) {
const QJsonObject attachment = value.toObject();
row.attachments.append(
AttachmentBlock{attachment["fileName"].toString(), attachment["storedPath"].toString()});
}
const QJsonArray images = json["images"].toArray();
for (const QJsonValue &value : images) {
const QJsonObject image = value.toObject();
row.images.append(
ImageBlock{
image["fileName"].toString(),
image["storedPath"].toString(),
image["mediaType"].toString()});
}
if (json.contains("usage"))
row.usage = usageFromJson(json["usage"].toObject());
return row;
}
ConversationHistory historyFromLegacyJson(const QJsonObject &root)
{
QList<MessageRow> rows;
const QJsonArray messages = root["messages"].toArray();
for (const QJsonValue &value : messages)
rows.append(legacyRowFromJson(value.toObject()));
return buildFromRows(rows);
}
} // namespace
QString HistorySerializer::currentVersion()
{
return QStringLiteral("0.4");
}
bool HistorySerializer::isSupportedVersion(const QString &version)
{
return version == currentVersion() || version == QLatin1String("0.2")
|| version == QLatin1String("0.1");
}
QJsonObject HistorySerializer::toJson(const ConversationHistory &history)
{
QJsonArray messages;
for (const Message &message : history.messages())
messages.append(messageToJson(message));
QJsonObject root;
root["version"] = currentVersion();
root["messages"] = messages;
return root;
}
std::optional<ConversationHistory> HistorySerializer::fromJson(
const QJsonObject &root, int *droppedBlocks)
{
if (droppedBlocks)
*droppedBlocks = 0;
const QString version = root["version"].toString();
if (!isSupportedVersion(version) || !root["messages"].isArray())
return std::nullopt;
if (version != currentVersion())
return historyFromLegacyJson(root);
int dropped = 0;
ConversationHistory history;
const QJsonArray messages = root["messages"].toArray();
for (const QJsonValue &value : messages)
history.append(messageFromJson(value.toObject(), dropped));
if (droppedBlocks)
*droppedBlocks = dropped;
return history;
}
} // namespace QodeAssist::Session
+27
View File
@@ -0,0 +1,27 @@
// 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 <QJsonObject>
#include <QString>
#include <optional>
#include "session/ConversationHistory.hpp"
namespace QodeAssist::Session {
class HistorySerializer
{
public:
static QString currentVersion();
static bool isSupportedVersion(const QString &version);
static QJsonObject toJson(const ConversationHistory &history);
static std::optional<ConversationHistory> fromJson(
const QJsonObject &root, int *droppedBlocks = nullptr);
};
} // namespace QodeAssist::Session
+55
View File
@@ -0,0 +1,55 @@
// 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 <QString>
#include "session/ContentBlock.hpp"
namespace QodeAssist::Session {
enum class MessageRole { User, Assistant, System };
struct Usage
{
int promptTokens = 0;
int completionTokens = 0;
int cachedPromptTokens = 0;
int reasoningTokens = 0;
bool isEmpty() const
{
return promptTokens == 0 && completionTokens == 0 && cachedPromptTokens == 0
&& reasoningTokens == 0;
}
bool operator==(const Usage &other) const = default;
friend QDebug operator<<(QDebug debug, const Usage &usage)
{
return debug.nospace() << "Usage(" << usage.promptTokens << ", " << usage.completionTokens
<< ", " << usage.cachedPromptTokens << ", " << usage.reasoningTokens
<< ")";
}
};
struct Message
{
MessageRole role = MessageRole::User;
QString id;
QList<ContentBlock> blocks;
Usage usage;
bool operator==(const Message &other) const = default;
friend QDebug operator<<(QDebug debug, const Message &message)
{
return debug.nospace() << "Message(role=" << int(message.role) << ", id=" << message.id
<< ", blocks=" << message.blocks << ", " << message.usage << ")";
}
};
} // namespace QodeAssist::Session
+427
View File
@@ -0,0 +1,427 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "session/Session.hpp"
#include <QUuid>
#include <algorithm>
#include "session/FileEditPayload.hpp"
namespace QodeAssist::Session {
namespace {
std::optional<FileEditBlock> fileEditFromToolResult(const QString &result)
{
if (!isFileEditPayload(result))
return std::nullopt;
auto payload = parseFileEditPayload(result);
if (!payload)
return std::nullopt;
const QString editId = payload->value("edit_id").toString();
if (!editId.isEmpty())
return FileEditBlock{.id = editId, .payload = result};
const QString generatedId = QString("edit_%1").arg(
QUuid::createUuid().toString(QUuid::WithoutBraces));
payload->insert("edit_id", generatedId);
return FileEditBlock{.id = generatedId, .payload = encodeFileEditPayload(*payload)};
}
Message truncateMessage(const Message &message, qsizetype rowsWanted)
{
Message truncated = message;
qsizetype keptBlocks = 0;
for (qsizetype i = 0; i < message.blocks.size(); ++i) {
Message probe = message;
probe.blocks = message.blocks.mid(0, i + 1);
if (projectMessageToRows(probe).size() > rowsWanted)
break;
keptBlocks = i + 1;
}
truncated.blocks = message.blocks.mid(0, keptBlocks);
return truncated;
}
bool continuesThinking(const ThinkingBlock &block, const ThinkingReceived &event)
{
return !block.redacted && !event.redacted && event.text.startsWith(block.text)
&& (block.signature.isEmpty() || block.signature == event.signature);
}
} // namespace
Session::Session(QObject *parent)
: QObject(parent)
{}
void Session::setBackend(ChatBackend *backend)
{
if (m_backend == backend)
return;
if (m_backend)
disconnect(m_backend, nullptr, this, nullptr);
m_backend = backend;
if (m_backend) {
connect(m_backend, &ChatBackend::sessionEvent, this, &Session::handleEvent);
}
}
const ConversationHistory &Session::history() const
{
return m_history;
}
const QList<MessageRow> &Session::rows() const
{
return m_rows;
}
void Session::setHistory(const ConversationHistory &history)
{
cancel();
m_history = history;
m_rows = projectToRows(m_history);
emit rowsReset(m_rows);
}
void Session::clear()
{
setHistory(ConversationHistory{});
}
void Session::truncateRows(int rowIndex)
{
if (rowIndex < 0 || rowIndex >= m_rows.size())
return;
cancel();
ConversationHistory kept;
qsizetype rowsKept = 0;
for (const Message &message : m_history.messages()) {
const qsizetype rows = projectMessageToRows(message).size();
if (rowsKept + rows > rowIndex) {
const qsizetype rowsWanted = rowIndex - rowsKept;
if (rowsWanted > 0)
kept.append(truncateMessage(message, rowsWanted));
break;
}
kept.append(message);
rowsKept += rows;
}
const int removed = static_cast<int>(m_rows.size()) - rowIndex;
m_rows.remove(rowIndex, removed);
m_history = kept;
emit rowsRemoved(rowIndex, removed);
}
void Session::sendTurn(
const QList<ContentBlock> &userBlocks,
const std::optional<TurnContext> &context,
const TurnOptions &options)
{
if (!m_backend)
return;
cancel();
Message message;
message.role = MessageRole::User;
message.blocks = userBlocks;
appendMessage(message);
TurnRequest request;
request.userBlocks = userBlocks;
request.history = &m_history;
request.context = context;
request.options = options;
m_backend->sendTurn(request);
}
void Session::cancel()
{
endTurn();
if (m_backend)
m_backend->cancel();
}
bool Session::updateFileEditStatus(
const QString &editId, const QString &status, const QString &statusMessage)
{
QStringList encodedPayloads;
m_history.visitBlocks([&](ContentBlock &block) {
auto *edit = std::get_if<FileEditBlock>(&block);
if (!edit || edit->id != editId)
return;
auto payload = parseFileEditPayload(edit->payload);
if (!payload)
return;
payload->insert("status", status);
if (!statusMessage.isEmpty())
payload->insert("status_message", statusMessage);
edit->payload = encodeFileEditPayload(*payload);
encodedPayloads.append(edit->payload);
});
if (encodedPayloads.isEmpty())
return false;
qsizetype matched = 0;
for (int i = 0; i < m_rows.size() && matched < encodedPayloads.size(); ++i) {
if (m_rows[i].kind != RowKind::FileEdit || m_rows[i].id != editId)
continue;
m_rows[i].content = encodedPayloads.at(matched++);
const MessageRow updated = m_rows.at(i);
emit rowUpdated(i, updated);
}
return true;
}
void Session::handleEvent(const SessionEvent &event)
{
static_assert(
std::variant_size_v<SessionEvent> == 8,
"SessionEvent gained an alternative; extend the dispatch below or it is silently dropped");
if (const auto *started = std::get_if<TurnStarted>(&event)) {
m_activeTurnId = started->turnId;
m_assistantRowStart = -1;
emit turnStarted(started->turnId);
return;
}
if (const auto *failure = std::get_if<TurnFailed>(&event)) {
if (!failure->turnId.isEmpty() && failure->turnId != m_activeTurnId)
return;
const QString error = failure->error;
endTurn();
emit turnFailed(error);
return;
}
if (m_activeTurnId.isEmpty() || turnIdOf(event) != m_activeTurnId)
return;
if (const auto *delta = std::get_if<TextDelta>(&event)) {
m_textSegment += delta->text;
if (!m_textSegment.isEmpty() && m_textSegment.at(0).isSpace()) {
qsizetype content = 0;
while (content < m_textSegment.size() && m_textSegment.at(content).isSpace())
++content;
m_textSegment.remove(0, content);
}
if (m_textSegment.isEmpty())
return;
const QString text = m_textSegment.trimmed();
mutateAssistantTail([&text](Message &message) {
if (!message.blocks.isEmpty()) {
if (auto *last = std::get_if<TextBlock>(&message.blocks.last())) {
last->text = text;
return;
}
}
message.blocks.append(TextBlock{text});
});
} else if (const auto *thinking = std::get_if<ThinkingReceived>(&event)) {
m_textSegment.clear();
mutateAssistantTail([thinking](Message &message) {
if (!message.blocks.isEmpty()) {
if (auto *last = std::get_if<ThinkingBlock>(&message.blocks.last());
last && continuesThinking(*last, *thinking)) {
last->text = thinking->text;
last->signature = thinking->signature;
return;
}
}
message.blocks.append(
ThinkingBlock{
.text = thinking->text,
.signature = thinking->signature,
.redacted = thinking->redacted});
});
} else if (const auto *toolStart = std::get_if<ToolCallStarted>(&event)) {
m_textSegment.clear();
mutateAssistant([toolStart](Message &message) {
if (toolStart->dropPrecedingText && !message.blocks.isEmpty()
&& std::holds_alternative<TextBlock>(message.blocks.last())) {
message.blocks.removeLast();
}
message.blocks.append(
ToolCallBlock{
.id = toolStart->toolId,
.name = toolStart->name,
.arguments = toolStart->arguments,
.result = {}});
});
} else if (const auto *toolEnd = std::get_if<ToolCallCompleted>(&event)) {
m_textSegment.clear();
mutateAssistant([toolEnd](Message &message) {
for (auto it = message.blocks.rbegin(); it != message.blocks.rend(); ++it) {
auto *tool = std::get_if<ToolCallBlock>(&*it);
if (!tool || tool->id != toolEnd->toolId)
continue;
tool->name = toolEnd->name;
tool->result = toolEnd->result;
break;
}
if (const auto edit = fileEditFromToolResult(toolEnd->result))
message.blocks.append(*edit);
});
} else if (const auto *usage = std::get_if<UsageReported>(&event)) {
if (Message *assistant = activeAssistantMessage()) {
assistant->usage = usage->usage;
syncAssistantRows();
}
emit usageReceived(usage->usage);
} else if (const auto *completed = std::get_if<TurnCompleted>(&event)) {
const QString turnId = completed->turnId;
endTurn();
emit turnFinished(turnId);
}
}
void Session::appendMessage(const Message &message)
{
m_history.append(message);
const QList<MessageRow> rows = projectMessageToRows(message);
if (rows.isEmpty())
return;
m_rows.append(rows);
emit rowsAppended(rows);
}
void Session::ensureAssistantMessage()
{
if (m_assistantRowStart >= 0)
return;
Message message;
message.role = MessageRole::Assistant;
message.id = m_activeTurnId;
m_assistantRowStart = static_cast<int>(m_rows.size());
m_history.append(message);
}
Message *Session::activeAssistantMessage()
{
return m_assistantRowStart < 0 ? nullptr : m_history.lastMessage();
}
void Session::mutateAssistant(const std::function<void(Message &)> &mutate)
{
ensureAssistantMessage();
Message *assistant = activeAssistantMessage();
if (!assistant)
return;
mutate(*assistant);
syncAssistantRows();
}
void Session::mutateAssistantTail(const std::function<void(Message &)> &mutate)
{
ensureAssistantMessage();
Message *assistant = activeAssistantMessage();
if (!assistant)
return;
const qsizetype blocksBefore = assistant->blocks.size();
mutate(*assistant);
if (assistant->blocks.size() == blocksBefore)
updateLastAssistantRow();
else
syncAssistantRows();
}
void Session::updateLastAssistantRow()
{
const Message *assistant = activeAssistantMessage();
const int index = static_cast<int>(m_rows.size()) - 1;
if (!assistant || index < m_assistantRowStart || assistant->blocks.isEmpty()) {
syncAssistantRows();
return;
}
auto fresh = projectBlockToRow(*assistant, assistant->blocks.last());
if (!fresh) {
syncAssistantRows();
return;
}
fresh->usage = m_rows.at(index).usage;
if (m_rows.at(index) == *fresh)
return;
const MessageRow updated = *fresh;
m_rows[index] = updated;
emit rowUpdated(index, updated);
}
void Session::syncAssistantRows()
{
const Message *assistant = activeAssistantMessage();
if (!assistant)
return;
const QList<MessageRow> fresh = projectMessageToRows(*assistant);
const int start = m_assistantRowStart;
const int previous = static_cast<int>(m_rows.size()) - start;
const int common = std::min(previous, static_cast<int>(fresh.size()));
for (int i = 0; i < common; ++i) {
if (m_rows.at(start + i) == fresh.at(i))
continue;
m_rows[start + i] = fresh[i];
emit rowUpdated(start + i, fresh[i]);
}
if (fresh.size() > previous) {
const QList<MessageRow> added = fresh.mid(previous);
m_rows.append(added);
emit rowsAppended(added);
} else if (fresh.size() < previous) {
const int removed = previous - static_cast<int>(fresh.size());
m_rows.remove(start + fresh.size(), removed);
emit rowsRemoved(start + static_cast<int>(fresh.size()), removed);
}
}
void Session::endTurn()
{
m_activeTurnId.clear();
m_textSegment.clear();
m_assistantRowStart = -1;
}
} // namespace QodeAssist::Session
+78
View File
@@ -0,0 +1,78 @@
// 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 <QString>
#include <functional>
#include <optional>
#include "session/ChatBackend.hpp"
#include "session/ConversationHistory.hpp"
#include "session/HistoryProjection.hpp"
#include "session/SessionEvent.hpp"
#include "session/TurnContext.hpp"
#include "session/TurnRequest.hpp"
namespace QodeAssist::Session {
class Session : public QObject
{
Q_OBJECT
public:
explicit Session(QObject *parent = nullptr);
void setBackend(ChatBackend *backend);
const ConversationHistory &history() const;
const QList<MessageRow> &rows() const;
void setHistory(const ConversationHistory &history);
void clear();
void truncateRows(int rowIndex);
void sendTurn(
const QList<ContentBlock> &userBlocks,
const std::optional<TurnContext> &context,
const TurnOptions &options);
void cancel();
bool updateFileEditStatus(
const QString &editId, const QString &status, const QString &statusMessage = {});
signals:
void rowsAppended(const QList<QodeAssist::Session::MessageRow> &rows);
void rowUpdated(int index, const QodeAssist::Session::MessageRow &row);
void rowsRemoved(int first, int count);
void rowsReset(const QList<QodeAssist::Session::MessageRow> &rows);
void turnStarted(const QString &turnId);
void turnFinished(const QString &turnId);
void turnFailed(const QString &error);
void usageReceived(const QodeAssist::Session::Usage &usage);
private:
void handleEvent(const SessionEvent &event);
void appendMessage(const Message &message);
Message *activeAssistantMessage();
void mutateAssistant(const std::function<void(Message &)> &mutate);
void mutateAssistantTail(const std::function<void(Message &)> &mutate);
void ensureAssistantMessage();
void updateLastAssistantRow();
void syncAssistantRows();
void endTurn();
ConversationHistory m_history;
QList<MessageRow> m_rows;
QPointer<ChatBackend> m_backend;
QString m_activeTurnId;
QString m_textSegment;
int m_assistantRowStart = -1;
};
} // namespace QodeAssist::Session
+14
View File
@@ -0,0 +1,14 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "session/SessionEvent.hpp"
namespace QodeAssist::Session {
QString turnIdOf(const SessionEvent &event)
{
return std::visit([](const auto &alternative) { return alternative.turnId; }, event);
}
} // namespace QodeAssist::Session
+100
View File
@@ -0,0 +1,100 @@
// 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 <QJsonObject>
#include <QMetaType>
#include <QString>
#include <variant>
#include "session/Message.hpp"
namespace QodeAssist::Session {
struct TurnStarted
{
QString turnId;
bool operator==(const TurnStarted &other) const = default;
};
struct TextDelta
{
QString turnId;
QString text;
bool operator==(const TextDelta &other) const = default;
};
struct ThinkingReceived
{
QString turnId;
QString text;
QString signature;
bool redacted = false;
bool operator==(const ThinkingReceived &other) const = default;
};
struct ToolCallStarted
{
QString turnId;
QString toolId;
QString name;
QJsonObject arguments;
bool dropPrecedingText = false;
bool operator==(const ToolCallStarted &other) const = default;
};
struct ToolCallCompleted
{
QString turnId;
QString toolId;
QString name;
QString result;
bool operator==(const ToolCallCompleted &other) const = default;
};
struct UsageReported
{
QString turnId;
Usage usage;
bool operator==(const UsageReported &other) const = default;
};
struct TurnCompleted
{
QString turnId;
bool operator==(const TurnCompleted &other) const = default;
};
struct TurnFailed
{
QString turnId;
QString error;
bool operator==(const TurnFailed &other) const = default;
};
using SessionEvent = std::variant<
TurnStarted,
TextDelta,
ThinkingReceived,
ToolCallStarted,
ToolCallCompleted,
UsageReported,
TurnCompleted,
TurnFailed>;
QString turnIdOf(const SessionEvent &event);
} // namespace QodeAssist::Session
Q_DECLARE_METATYPE(QodeAssist::Session::SessionEvent)
+56
View File
@@ -0,0 +1,56 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "session/TurnContext.hpp"
namespace QodeAssist::Session {
QString renderSystemPrompt(const TurnContext &context)
{
QString prompt = context.basePrompt;
if (context.rolePrompt)
prompt += "\n\n" + *context.rolePrompt;
if (context.project.available) {
prompt += QString("\n# Active project: %1").arg(context.project.displayName);
prompt += 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(context.project.sourceRoot);
if (context.project.buildDirectory) {
prompt += QString(
"\n# Build output directory (compiler artifacts only — do NOT "
"create or edit source files here): %1")
.arg(*context.project.buildDirectory);
}
if (!context.projectRules.isEmpty())
prompt += QString("\n# Project Rules\n\n") + context.projectRules;
} else {
prompt += QString("\n# No active project in IDE");
}
if (!context.alwaysOnSkills.isEmpty())
prompt += QString("\n\n") + context.alwaysOnSkills;
if (!context.skillsCatalog.isEmpty())
prompt += QString("\n\n") + context.skillsCatalog;
for (const InvokedSkill &skill : context.invokedSkills)
prompt += QString("\n\n# Invoked Skill: %1\n\n%2").arg(skill.name, skill.body);
if (!context.linkedFilePaths.isEmpty()) {
prompt += "\n\nLinked files for reference:\n";
for (const LinkedFile &file : context.linkedFiles)
prompt += QString("\nFile: %1\nContent:\n%2\n").arg(file.fileName, file.content);
}
return prompt;
}
} // namespace QodeAssist::Session
+57
View File
@@ -0,0 +1,57 @@
// 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 <QString>
#include <optional>
namespace QodeAssist::Session {
struct ProjectInfo
{
bool available = false;
QString displayName;
QString sourceRoot;
std::optional<QString> buildDirectory;
bool operator==(const ProjectInfo &other) const = default;
};
struct InvokedSkill
{
QString name;
QString body;
bool operator==(const InvokedSkill &other) const = default;
};
struct LinkedFile
{
QString fileName;
QString content;
bool operator==(const LinkedFile &other) const = default;
};
struct TurnContext
{
QString basePrompt;
std::optional<QString> rolePrompt;
ProjectInfo project;
QString projectRules;
QString alwaysOnSkills;
QString skillsCatalog;
QList<InvokedSkill> invokedSkills;
QList<QString> linkedFilePaths;
QList<LinkedFile> linkedFiles;
bool operator==(const TurnContext &other) const = default;
};
QString renderSystemPrompt(const TurnContext &context);
} // namespace QodeAssist::Session
+58
View File
@@ -0,0 +1,58 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "session/TurnContextBuilder.hpp"
#include <QRegularExpression>
#include <QStringList>
namespace QodeAssist::Session {
TurnContextBuilder::TurnContextBuilder(
const IProjectContextPort &project,
const ISkillsContextPort *skills,
const ILinkedFilesPort &linkedFiles)
: m_project(project)
, m_skills(skills)
, m_linkedFiles(linkedFiles)
{}
TurnContext TurnContextBuilder::build(const TurnContextRequest &request) const
{
TurnContext context;
context.basePrompt = request.basePrompt;
context.rolePrompt = request.rolePrompt;
context.project = m_project.projectInfo();
if (context.project.available)
context.projectRules = m_project.projectRules();
if (m_skills) {
context.alwaysOnSkills = m_skills->alwaysOnBodies();
context.skillsCatalog = m_skills->catalogText();
static const QRegularExpression skillCommand(
QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)"));
QStringList invokedSkillNames;
auto skillMatch = skillCommand.globalMatch(request.message);
while (skillMatch.hasNext()) {
const QString skillName = skillMatch.next().captured(1);
if (invokedSkillNames.contains(skillName))
continue;
const auto invokedSkill = m_skills->findSkill(skillName);
if (invokedSkill && !invokedSkill->body.isEmpty()) {
invokedSkillNames << skillName;
context.invokedSkills.append(*invokedSkill);
}
}
}
context.linkedFilePaths = request.linkedFilePaths;
if (!request.linkedFilePaths.isEmpty())
context.linkedFiles = m_linkedFiles.readFiles(request.linkedFilePaths);
return context;
}
} // namespace QodeAssist::Session
+46
View File
@@ -0,0 +1,46 @@
// 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 <QString>
#include <optional>
#include "session/TurnContext.hpp"
#include "session/TurnContextPorts.hpp"
namespace QodeAssist::Session {
struct TurnContextRequest
{
QString message;
QString basePrompt;
std::optional<QString> rolePrompt;
QList<QString> linkedFilePaths;
};
class TurnContextBuilder
{
public:
TurnContextBuilder(
const IProjectContextPort &project,
const ISkillsContextPort *skills,
const ILinkedFilesPort &linkedFiles);
TurnContextBuilder(IProjectContextPort &&, const ISkillsContextPort *, const ILinkedFilesPort &)
= delete;
TurnContextBuilder(const IProjectContextPort &, const ISkillsContextPort *, ILinkedFilesPort &&)
= delete;
TurnContext build(const TurnContextRequest &request) const;
private:
const IProjectContextPort &m_project;
const ISkillsContextPort *m_skills = nullptr;
const ILinkedFilesPort &m_linkedFiles;
};
} // namespace QodeAssist::Session
+53
View File
@@ -0,0 +1,53 @@
// 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 <QString>
#include <QtGlobal>
#include <optional>
#include "session/TurnContext.hpp"
namespace QodeAssist::Session {
class IProjectContextPort
{
Q_DISABLE_COPY_MOVE(IProjectContextPort)
public:
IProjectContextPort() = default;
virtual ~IProjectContextPort() = default;
virtual ProjectInfo projectInfo() const = 0;
virtual QString projectRules() const = 0;
};
class ISkillsContextPort
{
Q_DISABLE_COPY_MOVE(ISkillsContextPort)
public:
ISkillsContextPort() = default;
virtual ~ISkillsContextPort() = default;
virtual QString alwaysOnBodies() const = 0;
virtual QString catalogText() const = 0;
virtual std::optional<InvokedSkill> findSkill(const QString &name) const = 0;
};
class ILinkedFilesPort
{
Q_DISABLE_COPY_MOVE(ILinkedFilesPort)
public:
ILinkedFilesPort() = default;
virtual ~ILinkedFilesPort() = default;
virtual QList<LinkedFile> readFiles(const QList<QString> &paths) const = 0;
};
} // namespace QodeAssist::Session
+33
View File
@@ -0,0 +1,33 @@
// 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 <optional>
#include "session/ContentBlock.hpp"
#include "session/ConversationHistory.hpp"
#include "session/TurnContext.hpp"
namespace QodeAssist::Session {
struct TurnOptions
{
bool useTools = false;
bool useThinking = false;
bool operator==(const TurnOptions &other) const = default;
};
struct TurnRequest
{
QList<ContentBlock> userBlocks;
const ConversationHistory *history = nullptr;
std::optional<TurnContext> context;
TurnOptions options;
};
} // namespace QodeAssist::Session
+24 -15
View File
@@ -12,28 +12,30 @@
#include <QJsonObject>
#include <QtConcurrent>
#include "session/HistoryProjection.hpp"
#include "session/HistorySerializer.hpp"
namespace QodeAssist::Tools {
namespace {
QString roleName(int role)
QString roleName(Session::RowKind kind)
{
switch (role) {
case 0:
switch (kind) {
case Session::RowKind::System:
return QStringLiteral("system");
case 1:
case Session::RowKind::User:
return QStringLiteral("user");
case 2:
case Session::RowKind::Assistant:
return QStringLiteral("assistant");
case 3:
case Session::RowKind::Tool:
return QStringLiteral("tool");
case 4:
case Session::RowKind::FileEdit:
return QStringLiteral("file_edit");
case 5:
case Session::RowKind::Thinking:
return QStringLiteral("thinking");
default:
return QStringLiteral("unknown");
}
return QStringLiteral("unknown");
}
QJsonObject readJsonObject(const QString &path)
@@ -144,8 +146,15 @@ QFuture<LLMQore::ToolResult> ReadOriginalHistoryTool::executeAsync(const QJsonOb
"history.");
}
const QJsonObject root = readJsonObject(rootPath);
const QJsonArray messages = root.value("messages").toArray();
const auto history = Session::HistorySerializer::fromJson(readJsonObject(rootPath));
if (!history) {
return LLMQore::ToolResult::text(
QString("Cannot read the pre-compression history at %1: unsupported chat file "
"format.")
.arg(rootPath));
}
const QList<Session::MessageRow> messages = Session::projectToRows(*history);
const QString query = input.value("query").toString().trimmed();
const QString roleFilter = input.value("role").toString().trimmed().toLower();
@@ -155,9 +164,9 @@ QFuture<LLMQore::ToolResult> ReadOriginalHistoryTool::executeAsync(const QJsonOb
QStringList matched;
int matchCount = 0;
for (int i = 0; i < messages.size(); ++i) {
const QJsonObject msg = messages.at(i).toObject();
const QString role = roleName(msg.value("role").toInt());
const QString content = msg.value("content").toString();
const Session::MessageRow &msg = messages.at(i);
const QString role = roleName(msg.kind);
const QString content = msg.content;
if (!roleFilter.isEmpty() && role != roleFilter)
continue;
-30
View File
@@ -1,30 +0,0 @@
add_executable(QodeAssistTest
../sources/completion/CodeHandler.cpp
../sources/completion/LLMClientInterface.cpp
../sources/completion/LLMSuggestion.cpp
../sources/providers/Provider.cpp
CodeHandlerTest.cpp
ClaudeCacheControlTest.cpp
DocumentContextReaderTest.cpp
LLMSuggestionTest.cpp
# LLMClientInterfaceTests.cpp
unittest_main.cpp
)
target_link_libraries(QodeAssistTest PRIVATE
Qt::Core
Qt::Test
GTest::GTest
GTest::gmock
GTest::Main
QtCreator::LanguageClient
Context
QodeAssistLogger
LLMQore
)
target_include_directories(QodeAssistTest PRIVATE ${CMAKE_SOURCE_DIR}/sources)
target_compile_definitions(QodeAssistTest PRIVATE CMAKE_CURRENT_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}")
add_test(NAME QodeAssistTest COMMAND QodeAssistTest)
-182
View File
@@ -1,182 +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 <gtest/gtest.h>
#include <QJsonArray>
#include <QJsonObject>
#include "providers/ClaudeCacheControl.hpp"
using namespace QodeAssist::Providers::ClaudeCacheControl;
namespace {
QJsonObject expectedEphemeral(bool extendedTtl)
{
QJsonObject obj{{"type", "ephemeral"}};
if (extendedTtl)
obj["ttl"] = "1h";
return obj;
}
} // namespace
TEST(ClaudeCacheControlTest, BreakpointWithoutExtendedTTL)
{
const QJsonObject cc = buildBreakpoint(false);
EXPECT_EQ(cc.value("type").toString(), "ephemeral");
EXPECT_FALSE(cc.contains("ttl"));
}
TEST(ClaudeCacheControlTest, BreakpointWithExtendedTTL)
{
const QJsonObject cc = buildBreakpoint(true);
EXPECT_EQ(cc.value("type").toString(), "ephemeral");
EXPECT_EQ(cc.value("ttl").toString(), "1h");
}
TEST(ClaudeCacheControlTest, SystemAsStringWrappedIntoArray)
{
QJsonObject request;
request["system"] = "you are a helpful agent";
apply(request, false);
ASSERT_TRUE(request.value("system").isArray());
const QJsonArray sys = request.value("system").toArray();
ASSERT_EQ(sys.size(), 1);
const QJsonObject block = sys.first().toObject();
EXPECT_EQ(block.value("type").toString(), "text");
EXPECT_EQ(block.value("text").toString(), "you are a helpful agent");
EXPECT_EQ(block.value("cache_control").toObject(), expectedEphemeral(false));
}
TEST(ClaudeCacheControlTest, EmptySystemStringIsNotWrapped)
{
QJsonObject request;
request["system"] = "";
apply(request, false);
EXPECT_TRUE(request.value("system").isString());
}
TEST(ClaudeCacheControlTest, SystemAsArrayMarksLastBlock)
{
QJsonObject request;
request["system"] = QJsonArray{
QJsonObject{{"type", "text"}, {"text", "a"}},
QJsonObject{{"type", "text"}, {"text", "b"}}};
apply(request, false);
const QJsonArray sys = request.value("system").toArray();
ASSERT_EQ(sys.size(), 2);
EXPECT_FALSE(sys[0].toObject().contains("cache_control"));
EXPECT_EQ(sys[1].toObject().value("cache_control").toObject(), expectedEphemeral(false));
}
TEST(ClaudeCacheControlTest, ToolsLastEntryGetsCacheControl)
{
QJsonObject request;
request["tools"] = QJsonArray{
QJsonObject{{"name", "read_file"}},
QJsonObject{{"name", "edit_file"}},
QJsonObject{{"name", "search"}}};
apply(request, true);
const QJsonArray tools = request.value("tools").toArray();
ASSERT_EQ(tools.size(), 3);
EXPECT_FALSE(tools[0].toObject().contains("cache_control"));
EXPECT_FALSE(tools[1].toObject().contains("cache_control"));
EXPECT_EQ(tools[2].toObject().value("cache_control").toObject(), expectedEphemeral(true));
}
TEST(ClaudeCacheControlTest, SingleMessageHistorySkipped)
{
QJsonObject request;
request["messages"]
= QJsonArray{QJsonObject{{"role", "user"}, {"content", "first message"}}};
apply(request, false);
const QJsonArray msgs = request.value("messages").toArray();
ASSERT_EQ(msgs.size(), 1);
EXPECT_TRUE(msgs[0].toObject().value("content").isString());
}
TEST(ClaudeCacheControlTest, HistoryBreakpointOnSecondToLastMessage)
{
QJsonObject request;
request["messages"] = QJsonArray{
QJsonObject{{"role", "user"}, {"content", "u1"}},
QJsonObject{{"role", "assistant"}, {"content", "a1"}},
QJsonObject{{"role", "user"}, {"content", "u2-current"}}};
apply(request, false);
const QJsonArray msgs = request.value("messages").toArray();
ASSERT_EQ(msgs.size(), 3);
EXPECT_TRUE(msgs[0].toObject().value("content").isString());
const QJsonArray a1Content = msgs[1].toObject().value("content").toArray();
ASSERT_EQ(a1Content.size(), 1);
EXPECT_EQ(a1Content.first().toObject().value("text").toString(), "a1");
EXPECT_EQ(
a1Content.first().toObject().value("cache_control").toObject(),
expectedEphemeral(false));
EXPECT_TRUE(msgs[2].toObject().value("content").isString());
}
TEST(ClaudeCacheControlTest, HistoryArrayContentMarksLastBlock)
{
QJsonObject request;
request["messages"] = QJsonArray{
QJsonObject{
{"role", "user"},
{"content",
QJsonArray{
QJsonObject{{"type", "text"}, {"text", "describe this"}},
QJsonObject{{"type", "image"}}}}},
QJsonObject{{"role", "assistant"}, {"content", "ok"}}};
apply(request, false);
const QJsonArray msgs = request.value("messages").toArray();
const QJsonArray content = msgs[0].toObject().value("content").toArray();
ASSERT_EQ(content.size(), 2);
EXPECT_FALSE(content[0].toObject().contains("cache_control"));
EXPECT_EQ(content[1].toObject().value("cache_control").toObject(), expectedEphemeral(false));
}
TEST(ClaudeCacheControlTest, NoSystemNoToolsNoMessagesIsNoop)
{
QJsonObject request;
request["model"] = "claude-sonnet-4-5";
request["max_tokens"] = 1024;
apply(request, false);
EXPECT_EQ(request.value("model").toString(), "claude-sonnet-4-5");
EXPECT_EQ(request.value("max_tokens").toInt(), 1024);
EXPECT_FALSE(request.contains("system"));
EXPECT_FALSE(request.contains("tools"));
EXPECT_FALSE(request.contains("messages"));
}
TEST(ClaudeCacheControlTest, EmptyToolsArrayIsNoop)
{
QJsonObject request;
request["tools"] = QJsonArray{};
apply(request, false);
EXPECT_TRUE(request.value("tools").isArray());
EXPECT_TRUE(request.value("tools").toArray().isEmpty());
}
-218
View File
@@ -1,218 +0,0 @@
// Copyright (C) 2025 Povilas Kanapickas
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "completion/CodeHandler.hpp"
#include "TestUtils.hpp"
#include <gtest/gtest.h>
#include <QObject>
#include <QString>
using namespace QodeAssist;
class CodeHandlerTest : public QObject, public testing::Test
{
// Tests are written in the fixture format with the expectation that CodeHandler will
// be expanded.
Q_OBJECT
};
TEST_F(CodeHandlerTest, testProcessTextEmpty)
{
EXPECT_EQ(CodeHandler::processText("", "/file.py"), "\n\n");
}
TEST_F(CodeHandlerTest, testProcessTextWithLanguageCodeBlock)
{
QString input = "This is a comment\n"
"```python\nprint('Hello, world!')\n```\n"
"Another comment";
EXPECT_EQ(
CodeHandler::processText(input, "/file.py"),
"# This is a comment\n\nprint('Hello, world!')\n# Another comment\n\n");
}
TEST_F(CodeHandlerTest, testProcessTextWithPlainCodeBlockNoNewline)
{
QString input = "This is a comment\n"
"```print('Hello, world!')\n```\n"
"Another comment";
EXPECT_EQ(
CodeHandler::processText(input, "/file.py"),
"# This is a comment\n\nprint('Hello, world!')\n# Another comment\n\n");
}
TEST_F(CodeHandlerTest, testProcessTextWithPlainCodeBlockWithNewline)
{
QString input = "This is a comment\n"
"```\nprint('Hello, world!')\n```\n"
"Another comment";
EXPECT_EQ(
CodeHandler::processText(input, "/file.py"),
"# This is a comment\n\n\nprint('Hello, world!')\n# Another comment\n\n");
}
TEST_F(CodeHandlerTest, testProcessTextNoCommentsWithLanguageCodeBlock)
{
QString input = "```python\nprint('Hello, world!')\n```";
EXPECT_EQ(CodeHandler::processText(input, "/file.py"), "print('Hello, world!')\n");
}
TEST_F(CodeHandlerTest, testProcessTextNoCommentsWithPlainCodeBlockNoNewline)
{
QString input = "```print('Hello, world!')\n```";
EXPECT_EQ(CodeHandler::processText(input, "/file.py"), "print('Hello, world!')\n");
}
TEST_F(CodeHandlerTest, testProcessTextNoCommentsWithPlainCodeBlockWithNewline)
{
QString input = "```\nprint('Hello, world!')\n```";
EXPECT_EQ(CodeHandler::processText(input, "/file.py"), "\nprint('Hello, world!')\n");
}
TEST_F(CodeHandlerTest, testProcessTextWithMultipleCodeBlocksDifferentLanguages)
{
QString input = "First comment\n```python\nprint('Block 1')\n"
"```\nMiddle comment\n"
"```cpp\ncout << \"Block 2\";\n```\n"
"Last comment";
EXPECT_EQ(
CodeHandler::processText(input, "/file.py"),
"# First comment\n\n"
"print('Block 1')\n"
"// Middle comment\n\n"
"cout << \"Block 2\";\n"
"// Last comment\n\n");
}
TEST_F(CodeHandlerTest, testProcessTextWithMultipleCodeBlocksSameLanguage)
{
QString input = "First comment\n```python\nprint('Block 1')\n"
"```\nMiddle comment\n"
"```python\nprint('Block 2')\n```\n"
"Last comment";
EXPECT_EQ(
CodeHandler::processText(input, "/file.py"),
"# First comment\n\n"
"print('Block 1')\n"
"# Middle comment\n\n"
"print('Block 2')\n"
"# Last comment\n\n");
}
TEST_F(CodeHandlerTest, testProcessTextWithMultiplePlainCodeBlocksWithNewline)
{
QString input = "First comment\n```\nprint('Block 1')\n"
"```\nMiddle comment\n"
"```\ncout << \"Block 2\";\n```\n"
"Last comment";
EXPECT_EQ(
CodeHandler::processText(input, "/file.py"),
"# First comment\n\n\n"
"print('Block 1')\n"
"# Middle comment\n\n\n"
"cout << \"Block 2\";\n"
"# Last comment\n\n");
}
TEST_F(CodeHandlerTest, testProcessTextWithMultiplePlainCodeBlocksWithoutNewline)
{
QString input = "First comment\n```print('Block 1')\n"
"```\nMiddle comment\n"
"```cout << \"Block 2\";\n```\n"
"Last comment";
EXPECT_EQ(
CodeHandler::processText(input, "/file.py"),
"# First comment\n\n"
"print('Block 1')\n"
"# Middle comment\n\n"
"cout << \"Block 2\";\n"
"# Last comment\n\n");
}
TEST_F(CodeHandlerTest, testProcessTextWithEmptyLines)
{
QString input = "Comment with empty line\n\n```python\nprint('Hello')\n```\n\nAnother comment";
EXPECT_EQ(
CodeHandler::processText(input, "/file.py"),
"# Comment with empty line\n\n\n"
"print('Hello')\n\n"
"# Another comment\n\n");
}
TEST_F(CodeHandlerTest, testProcessTextPlainCodeBlockWithNewlineWithEmptyLines)
{
QString input = "Comment with empty line\n\n```\nprint('Hello')\n```\n\nAnother comment";
EXPECT_EQ(
CodeHandler::processText(input, "/file.py"),
"# Comment with empty line\n\n\n\n"
"print('Hello')\n\n"
"# Another comment\n\n");
}
TEST_F(CodeHandlerTest, testProcessTextWithoutCodeBlock)
{
QString input = "This is just a comment\nwith multiple lines";
EXPECT_EQ(
CodeHandler::processText(input, "/file.py"),
"# This is just a comment\n# with multiple lines\n\n");
}
TEST_F(CodeHandlerTest, testDetectLanguageFromLine)
{
EXPECT_EQ(CodeHandler::detectLanguageFromLine("```python"), "python");
EXPECT_EQ(CodeHandler::detectLanguageFromLine("```javascript"), "js");
EXPECT_EQ(CodeHandler::detectLanguageFromLine("```cpp"), "c-like");
EXPECT_EQ(CodeHandler::detectLanguageFromLine("``` ruby "), "ruby");
EXPECT_EQ(CodeHandler::detectLanguageFromLine("```"), "");
EXPECT_EQ(CodeHandler::detectLanguageFromLine("``` "), "");
}
TEST_F(CodeHandlerTest, testDetectLanguageFromExtension)
{
EXPECT_EQ(CodeHandler::detectLanguageFromExtension("py"), "python");
EXPECT_EQ(CodeHandler::detectLanguageFromExtension("js"), "js");
EXPECT_EQ(CodeHandler::detectLanguageFromExtension("cpp"), "c-like");
EXPECT_EQ(CodeHandler::detectLanguageFromExtension("hpp"), "c-like");
EXPECT_EQ(CodeHandler::detectLanguageFromExtension("rb"), "ruby");
EXPECT_EQ(CodeHandler::detectLanguageFromExtension("sh"), "shell");
EXPECT_EQ(CodeHandler::detectLanguageFromExtension("unknown"), "");
EXPECT_EQ(CodeHandler::detectLanguageFromExtension(""), "");
}
TEST_F(CodeHandlerTest, testCommentPrefixForDifferentLanguages)
{
struct TestCase
{
std::string language;
QString input;
QString expected;
};
std::vector<TestCase> testCases
= {{"python", "Comment\n```python\ncode\n```", "# Comment\n\ncode\n"},
{"cpp", "Comment\n```cpp\ncode\n```", "// Comment\n\ncode\n"},
{"ruby", "Comment\n```ruby\ncode\n```", "# Comment\n\ncode\n"},
{"lua", "Comment\n```lua\ncode\n```", "-- Comment\n\ncode\n"}};
for (const auto &testCase : testCases) {
EXPECT_EQ(CodeHandler::processText(testCase.input, ""), testCase.expected)
<< "Failed for language: " << testCase.language;
}
}
#include "CodeHandlerTest.moc"
-393
View File
@@ -1,393 +0,0 @@
// Copyright (C) 2025 Povilas Kanapickas
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "context/DocumentContextReader.hpp"
#include "TestUtils.hpp"
#include <gtest/gtest.h>
#include <QSharedPointer>
#include <QTextDocument>
namespace QodeAssist::LLMCore {
void PrintTo(const ContextData &data, std::ostream *os)
{
*os << "ContextData{prefix="
<< (data.prefix ? data.prefix->toStdString() : "<nullopt>")
<< ", suffix=" << (data.suffix ? data.suffix->toStdString() : "<nullopt>")
<< ", fileContext=" << (data.fileContext ? data.fileContext->toStdString() : "<nullopt>")
<< "}";
}
} // namespace QodeAssist::LLMCore
using namespace QodeAssist::Context;
using namespace QodeAssist::LLMCore;
using namespace QodeAssist::Settings;
class DocumentContextReaderTest : public QObject, public testing::Test
{
Q_OBJECT
protected:
QTextDocument *createTestDocument(const QString &text)
{
auto *doc = new QTextDocument(this);
doc->setPlainText(text);
return doc;
}
DocumentContextReader createTestReader(const QString &text)
{
return DocumentContextReader(createTestDocument(text), "text/python", "/path/to/file");
}
QSharedPointer<CodeCompletionSettings> createSettingsForWholeFile()
{
// CodeCompletionSettings is noncopyable
auto settings = QSharedPointer<CodeCompletionSettings>::create();
settings->readFullFile.setValue(true);
return settings;
}
QSharedPointer<CodeCompletionSettings> createSettingsForLines(int linesBefore, int linesAfter)
{
// CodeCompletionSettings is noncopyable
auto settings = QSharedPointer<CodeCompletionSettings>::create();
settings->readFullFile.setValue(false);
settings->readStringsBeforeCursor.setValue(linesBefore);
settings->readStringsAfterCursor.setValue(linesAfter);
return settings;
}
};
TEST_F(DocumentContextReaderTest, testGetLineText)
{
auto reader = createTestReader("Line 0\nLine 1\nLine 2");
EXPECT_EQ(reader.getLineText(0), "Line 0");
EXPECT_EQ(reader.getLineText(1), "Line 1");
EXPECT_EQ(reader.getLineText(2), "Line 2");
EXPECT_EQ(reader.getLineText(0, 4), "Line");
}
TEST_F(DocumentContextReaderTest, testGetContext)
{
auto reader = createTestReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4");
// Unknown cursor position
EXPECT_EQ(reader.getContextBefore(0, -1, 2), "Line 0");
EXPECT_EQ(reader.getContextAfter(0, -1, 2), "Line 0\nLine 1");
EXPECT_EQ(reader.getContextBefore(1, -1, 2), "Line 0\nLine 1");
EXPECT_EQ(reader.getContextAfter(1, -1, 2), "Line 1\nLine 2");
EXPECT_EQ(reader.getContextBefore(2, -1, 2), "Line 1\nLine 2");
EXPECT_EQ(reader.getContextAfter(2, -1, 2), "Line 2\nLine 3");
EXPECT_EQ(reader.getContextBefore(3, -1, 2), "Line 2\nLine 3");
EXPECT_EQ(reader.getContextAfter(3, -1, 2), "Line 3\nLine 4");
// Known cursor position
EXPECT_EQ(reader.getContextBefore(0, 1, 2), "L");
EXPECT_EQ(reader.getContextAfter(0, 1, 2), "ine 0\nLine 1");
EXPECT_EQ(reader.getContextBefore(1, 1, 2), "Line 0\nL");
EXPECT_EQ(reader.getContextAfter(1, 1, 2), "ine 1\nLine 2");
EXPECT_EQ(reader.getContextBefore(2, 1, 2), "Line 1\nL");
EXPECT_EQ(reader.getContextAfter(2, 1, 2), "ine 2\nLine 3");
EXPECT_EQ(reader.getContextBefore(3, 1, 2), "Line 2\nL");
EXPECT_EQ(reader.getContextAfter(3, 1, 2), "ine 3\nLine 4");
}
TEST_F(DocumentContextReaderTest, testGetContextWithCopyright)
{
auto reader = createTestReader("/* Copyright (C) 2024 */\nLine 0\nLine 1\nLine 2\nLine 3");
// Unknown cursor position
EXPECT_EQ(reader.getContextBefore(0, -1, 2), "");
EXPECT_EQ(reader.getContextAfter(0, -1, 2), "Line 0");
EXPECT_EQ(reader.getContextBefore(1, -1, 2), "Line 0");
EXPECT_EQ(reader.getContextAfter(1, -1, 2), "Line 0\nLine 1");
EXPECT_EQ(reader.getContextBefore(2, -1, 2), "Line 0\nLine 1");
EXPECT_EQ(reader.getContextAfter(2, -1, 2), "Line 1\nLine 2");
EXPECT_EQ(reader.getContextBefore(3, -1, 2), "Line 1\nLine 2");
EXPECT_EQ(reader.getContextAfter(3, -1, 2), "Line 2\nLine 3");
// Known cursor position
EXPECT_EQ(reader.getContextBefore(0, 1, 2), "");
EXPECT_EQ(reader.getContextAfter(0, 1, 2), "Line 0");
EXPECT_EQ(reader.getContextBefore(1, 1, 2), "L");
EXPECT_EQ(reader.getContextAfter(1, 1, 2), "ine 0\nLine 1");
EXPECT_EQ(reader.getContextBefore(2, 1, 2), "Line 0\nL");
EXPECT_EQ(reader.getContextAfter(2, 1, 2), "ine 1\nLine 2");
EXPECT_EQ(reader.getContextBefore(3, 1, 2), "Line 1\nL");
EXPECT_EQ(reader.getContextAfter(3, 1, 2), "ine 2\nLine 3");
}
TEST_F(DocumentContextReaderTest, testReadWholeFile)
{
auto reader = createTestReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4");
// Unknown cursor position
EXPECT_EQ(reader.readWholeFileBefore(0, -1), "Line 0");
EXPECT_EQ(reader.readWholeFileAfter(0, -1), "Line 0\nLine 1\nLine 2\nLine 3\nLine 4");
EXPECT_EQ(reader.readWholeFileBefore(1, -1), "Line 0\nLine 1");
EXPECT_EQ(reader.readWholeFileAfter(1, -1), "Line 1\nLine 2\nLine 3\nLine 4");
EXPECT_EQ(reader.readWholeFileBefore(2, -1), "Line 0\nLine 1\nLine 2");
EXPECT_EQ(reader.readWholeFileAfter(2, -1), "Line 2\nLine 3\nLine 4");
EXPECT_EQ(reader.readWholeFileBefore(3, -1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileAfter(3, -1), "Line 3\nLine 4");
EXPECT_EQ(reader.readWholeFileBefore(4, -1), "Line 0\nLine 1\nLine 2\nLine 3\nLine 4");
EXPECT_EQ(reader.readWholeFileAfter(4, -1), "Line 4");
// Known cursor position
EXPECT_EQ(reader.readWholeFileBefore(0, 1), "L");
EXPECT_EQ(reader.readWholeFileAfter(0, 1), "ine 0\nLine 1\nLine 2\nLine 3\nLine 4");
EXPECT_EQ(reader.readWholeFileBefore(1, 1), "Line 0\nL");
EXPECT_EQ(reader.readWholeFileAfter(1, 1), "ine 1\nLine 2\nLine 3\nLine 4");
EXPECT_EQ(reader.readWholeFileBefore(2, 1), "Line 0\nLine 1\nL");
EXPECT_EQ(reader.readWholeFileAfter(2, 1), "ine 2\nLine 3\nLine 4");
EXPECT_EQ(reader.readWholeFileBefore(3, 1), "Line 0\nLine 1\nLine 2\nL");
EXPECT_EQ(reader.readWholeFileAfter(3, 1), "ine 3\nLine 4");
EXPECT_EQ(reader.readWholeFileBefore(4, 1), "Line 0\nLine 1\nLine 2\nLine 3\nL");
EXPECT_EQ(reader.readWholeFileAfter(4, 1), "ine 4");
}
TEST_F(DocumentContextReaderTest, testReadWholeFileWithCopyright)
{
auto reader = createTestReader("/* Copyright (C) 2024 */\nLine 0\nLine 1\nLine 2\nLine 3");
// Unknown cursor position
EXPECT_EQ(reader.readWholeFileBefore(0, -1), "");
EXPECT_EQ(reader.readWholeFileAfter(0, -1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(1, -1), "Line 0");
EXPECT_EQ(reader.readWholeFileAfter(1, -1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(2, -1), "Line 0\nLine 1");
EXPECT_EQ(reader.readWholeFileAfter(2, -1), "Line 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(3, -1), "Line 0\nLine 1\nLine 2");
EXPECT_EQ(reader.readWholeFileAfter(3, -1), "Line 2\nLine 3");
// Known cursor position
EXPECT_EQ(reader.readWholeFileBefore(0, 0), "");
EXPECT_EQ(reader.readWholeFileAfter(0, 0), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(1, 0), "");
EXPECT_EQ(reader.readWholeFileAfter(1, 0), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(2, 0), "Line 0\n");
EXPECT_EQ(reader.readWholeFileAfter(2, 0), "Line 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(3, 0), "Line 0\nLine 1\n");
EXPECT_EQ(reader.readWholeFileAfter(3, 0), "Line 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(0, 1), "");
EXPECT_EQ(reader.readWholeFileAfter(0, 1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(1, 1), "L");
EXPECT_EQ(reader.readWholeFileAfter(1, 1), "ine 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(2, 1), "Line 0\nL");
EXPECT_EQ(reader.readWholeFileAfter(2, 1), "ine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(3, 1), "Line 0\nLine 1\nL");
EXPECT_EQ(reader.readWholeFileAfter(3, 1), "ine 2\nLine 3");
}
TEST_F(DocumentContextReaderTest, testReadWholeFileWithMultilineCopyright)
{
auto reader = createTestReader(
"/*\n * Copyright (C) 2024\n * \n * This file is part of QodeAssist.\n */\n"
"Line 0\nLine 1\nLine 2\nLine 3");
// Unknown cursor position
EXPECT_EQ(reader.readWholeFileBefore(0, -1), "");
EXPECT_EQ(reader.readWholeFileAfter(0, -1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(1, -1), "");
EXPECT_EQ(reader.readWholeFileAfter(1, -1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(2, -1), "");
EXPECT_EQ(reader.readWholeFileAfter(2, -1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(3, -1), "");
EXPECT_EQ(reader.readWholeFileAfter(3, -1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(4, -1), "");
EXPECT_EQ(reader.readWholeFileAfter(4, -1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(5, -1), "Line 0");
EXPECT_EQ(reader.readWholeFileAfter(5, -1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(6, -1), "Line 0\nLine 1");
EXPECT_EQ(reader.readWholeFileAfter(6, -1), "Line 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(7, -1), "Line 0\nLine 1\nLine 2");
EXPECT_EQ(reader.readWholeFileAfter(7, -1), "Line 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(8, -1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileAfter(8, -1), "Line 3");
// Known cursor position
EXPECT_EQ(reader.readWholeFileBefore(0, 0), "");
EXPECT_EQ(reader.readWholeFileAfter(0, 0), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(1, 0), "");
EXPECT_EQ(reader.readWholeFileAfter(1, 0), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(2, 0), "");
EXPECT_EQ(reader.readWholeFileAfter(2, 0), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(3, 0), "");
EXPECT_EQ(reader.readWholeFileAfter(3, 0), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(4, 0), "");
EXPECT_EQ(reader.readWholeFileAfter(4, 0), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(5, 0), "");
EXPECT_EQ(reader.readWholeFileAfter(5, 0), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(6, 0), "Line 0\n");
EXPECT_EQ(reader.readWholeFileAfter(6, 0), "Line 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(7, 0), "Line 0\nLine 1\n");
EXPECT_EQ(reader.readWholeFileAfter(7, 0), "Line 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(8, 0), "Line 0\nLine 1\nLine 2\n");
EXPECT_EQ(reader.readWholeFileAfter(8, 0), "Line 3");
EXPECT_EQ(reader.readWholeFileBefore(0, 1), "");
EXPECT_EQ(reader.readWholeFileAfter(0, 1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(1, 1), "");
EXPECT_EQ(reader.readWholeFileAfter(1, 1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(2, 1), "");
EXPECT_EQ(reader.readWholeFileAfter(2, 1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(3, 1), "");
EXPECT_EQ(reader.readWholeFileAfter(3, 1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(4, 1), "");
EXPECT_EQ(reader.readWholeFileAfter(4, 1), "Line 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(5, 1), "L");
EXPECT_EQ(reader.readWholeFileAfter(5, 1), "ine 0\nLine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(6, 1), "Line 0\nL");
EXPECT_EQ(reader.readWholeFileAfter(6, 1), "ine 1\nLine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(7, 1), "Line 0\nLine 1\nL");
EXPECT_EQ(reader.readWholeFileAfter(7, 1), "ine 2\nLine 3");
EXPECT_EQ(reader.readWholeFileBefore(8, 1), "Line 0\nLine 1\nLine 2\nL");
EXPECT_EQ(reader.readWholeFileAfter(8, 1), "ine 3");
}
TEST_F(DocumentContextReaderTest, testFindCopyrightSingleLine)
{
auto reader = createTestReader("/* Copyright (C) 2024 */\nCode line 0\nCode line 1");
auto info = reader.findCopyright();
ASSERT_TRUE(info.found);
EXPECT_EQ(info.startLine, 0);
EXPECT_EQ(info.endLine, 0);
}
TEST_F(DocumentContextReaderTest, testFindCopyrightMultiLine)
{
auto reader = createTestReader(
"/*\n * Copyright (C) 2024\n * \n * This file is part of QodeAssist.\n */\nCode line 0");
auto info = reader.findCopyright();
ASSERT_TRUE(info.found);
EXPECT_EQ(info.startLine, 0);
EXPECT_EQ(info.endLine, 4);
}
TEST_F(DocumentContextReaderTest, testFindCopyrightMultipleBlocks)
{
auto reader = createTestReader("/* Copyright 2023 */\n\n/* Copyright 2024 */\nCode");
auto info = reader.findCopyright();
ASSERT_TRUE(info.found);
EXPECT_EQ(info.startLine, 0);
EXPECT_EQ(info.endLine, 0);
}
TEST_F(DocumentContextReaderTest, testFindCopyrightNoCopyright)
{
auto reader = createTestReader("/* Just a comment */\nCode line 0");
auto info = reader.findCopyright();
ASSERT_TRUE(!info.found);
EXPECT_EQ(info.startLine, -1);
EXPECT_EQ(info.endLine, -1);
}
TEST_F(DocumentContextReaderTest, testGetContextBetween)
{
auto reader = createTestReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4");
EXPECT_EQ(reader.getContextBetween(2, -1, 0, -1), "");
EXPECT_EQ(reader.getContextBetween(0, -1, 0, -1), "Line 0");
EXPECT_EQ(reader.getContextBetween(1, -1, 1, -1), "Line 1");
EXPECT_EQ(reader.getContextBetween(1, 3, 1, 1), "");
EXPECT_EQ(reader.getContextBetween(1, 3, 1, 3), "");
EXPECT_EQ(reader.getContextBetween(1, 3, 1, 4), "e");
EXPECT_EQ(reader.getContextBetween(1, -1, 3, -1), "Line 1\nLine 2\nLine 3");
EXPECT_EQ(reader.getContextBetween(1, 2, 3, -1), "ne 1\nLine 2\nLine 3");
EXPECT_EQ(reader.getContextBetween(1, -1, 3, 2), "Line 1\nLine 2\nLi");
EXPECT_EQ(reader.getContextBetween(1, 2, 3, 2), "ne 1\nLine 2\nLi");
}
TEST_F(DocumentContextReaderTest, testPrepareContext)
{
auto reader = createTestReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4");
EXPECT_EQ(
reader.prepareContext(2, 3, *createSettingsForWholeFile()),
(QodeAssist::LLMCore::ContextData{
.prefix = "Line 0\nLine 1\nLin",
.suffix = "e 2\nLine 3\nLine 4",
.fileContext = "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n"
"Recent Project Changes Context:\n "}));
EXPECT_EQ(
reader.prepareContext(2, 3, *createSettingsForLines(1, 1)),
(QodeAssist::LLMCore::ContextData{
.prefix = "Line 1\nLin",
.suffix = "e 2\nLine 3",
.fileContext = "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n"
"Recent Project Changes Context:\n "}));
EXPECT_EQ(
reader.prepareContext(2, 3, *createSettingsForLines(2, 2)),
(QodeAssist::LLMCore::ContextData{
.prefix = "Line 0\nLine 1\nLin",
.suffix = "e 2\nLine 3\nLine 4",
.fileContext = "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n"
"Recent Project Changes Context:\n "}));
}
#include "DocumentContextReaderTest.moc"
-112
View File
@@ -1,112 +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 "completion/LLMSuggestion.hpp"
#include "TestUtils.hpp"
#include <gtest/gtest.h>
#include <QObject>
#include <QString>
using namespace QodeAssist;
class LLMSuggestionTest : public QObject, public testing::Test
{
Q_OBJECT
};
// Degenerate / no-op cases
TEST_F(LLMSuggestionTest, emptyRight)
{
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("foo", ""), 0);
}
TEST_F(LLMSuggestionTest, noOverlap)
{
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("foo", "bar"), 0);
}
TEST_F(LLMSuggestionTest, singleCharNoOverlap)
{
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("a", "b"), 0);
}
// LCP: model echoed the existing right side as its own prefix
TEST_F(LLMSuggestionTest, lcpExtendsRight)
{
// cursor: int |myVariable ; suggestion echoes "myVar" then adds nothing.
// LCP=5, remainder "iable" not a closing tail -> replace 5.
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("myVar", "myVariable"), 5);
}
TEST_F(LLMSuggestionTest, lcpPartialEngineCall)
{
// cursor: |engine.rootContext() ; suggestion: engine.load()
// LCP="engine." (7); remainder "rootContext()" is not closing-only -> keep LCP
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("engine.load()", "engine.rootContext()"), 7);
}
// LCP + closing-only tail: extend to full right
TEST_F(LLMSuggestionTest, lcpThenClosingTailExtendsFull)
{
// cursor: QStringList |colors{}; ; suggestion: colors << "red"
// LCP=6 ("colors"); remainder "{};" is punctuation -> replace all (9)
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};"), 9);
}
// LCP=0, right is closing-only, suggestion ends with a matching closer -> replace full right
TEST_F(LLMSuggestionTest, closingTailReplaceSemicolon)
{
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("x;", ";"), 1);
}
TEST_F(LLMSuggestionTest, closingTailReplaceParen)
{
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("arg1, arg2)", ")"), 1);
}
TEST_F(LLMSuggestionTest, closingTailReplaceBrackets)
{
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("[0]", "];"), 2);
}
TEST_F(LLMSuggestionTest, closingTailReplaceBracesAndSemi)
{
// cursor: colors|{}; ; suggestion: = {"red"}
// LCP=0; right "{};" is closing-only; suggestion ends on "}" which is in right -> replace 3
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("= {\"red\"}", "{};"), 3);
}
TEST_F(LLMSuggestionTest, closingTailWithLeadingSpace)
{
EXPECT_EQ(LLMSuggestion::calculateReplaceLength(" = {\"red\"}", "{};"), 3);
}
TEST_F(LLMSuggestionTest, closingTailEqualsSemi)
{
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("= 5;", ";"), 1);
}
// LCP=0, right is closing-only but suggestion does not end on any of its chars -> leave right alone
TEST_F(LLMSuggestionTest, closingTailNoMatchingClose)
{
// right "};" closes; suggestion ends on '"' (not in close set) -> 0
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("\"red\", \"green\"", "};"), 0);
}
// Right side has real code (not closing-only) and no LCP -> leave it alone
TEST_F(LLMSuggestionTest, realCodeRightNoLcp)
{
// cursor: int |myVar = 5; ; suggestion: myVar
// LCP=0, right " = 5;" is not closing-only (has '5') -> 0
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("myVar", " = 5;"), 0);
}
// Trailing whitespace is not treated as a closer, suggestion has no closer -> leave alone
TEST_F(LLMSuggestionTest, trailingWhitespaceOnlyLeftAlone)
{
EXPECT_EQ(LLMSuggestion::calculateReplaceLength("code", " "), 0);
}
#include "LLMSuggestionTest.moc"
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
// Copyright (C) 2026 Petr Mironychev
// Copyright (C) 2025 Povilas Kanapickas <[email protected]>
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QJsonObject>
#include <QObject>
#include <QSharedPointer>
#include "context/DocumentContextReader.hpp"
#include "settings/CodeCompletionSettings.hpp"
QT_BEGIN_NAMESPACE
class QTextDocument;
QT_END_NAMESPACE
namespace QodeAssist {
class QodeAssistTest final : public QObject
{
Q_OBJECT
private slots:
void testProcessTextEmpty();
void testProcessTextCommentsAroundCodeBlock();
void testProcessTextWithPlainCodeBlockNoNewline();
void testProcessTextWithPlainCodeBlockWithNewline();
void testProcessTextNoCommentsWithLanguageCodeBlock();
void testProcessTextNoCommentsWithPlainCodeBlockNoNewline();
void testProcessTextNoCommentsWithPlainCodeBlockWithNewline();
void testProcessTextWithMultipleCodeBlocksDifferentLanguages();
void testProcessTextWithMultipleCodeBlocksSameLanguage();
void testProcessTextWithMultiplePlainCodeBlocksWithNewline();
void testProcessTextWithMultiplePlainCodeBlocksWithoutNewline();
void testProcessTextWithEmptyLines();
void testProcessTextPlainCodeBlockWithNewlineWithEmptyLines();
void testProcessTextWithoutCodeBlock();
void testDetectLanguageFromLine();
void testDetectLanguageFromExtension();
void testCommentPrefixForDifferentLanguages();
void testHasCodeBlocks();
void testReplaceLengthEmptyRight();
void testReplaceLengthNoOverlap();
void testReplaceLengthSingleCharNoOverlap();
void testReplaceLengthLcpExtendsRight();
void testReplaceLengthLcpPartialEngineCall();
void testReplaceLengthLcpThenClosingTailExtendsFull();
void testReplaceLengthClosingTailReplaceSemicolon();
void testReplaceLengthClosingTailReplaceParen();
void testReplaceLengthClosingTailReplaceBrackets();
void testReplaceLengthClosingTailReplaceBracesAndSemi();
void testReplaceLengthClosingTailWithLeadingSpace();
void testReplaceLengthClosingTailEqualsSemi();
void testReplaceLengthClosingTailNoMatchingClose();
void testReplaceLengthRealCodeRightNoLcp();
void testReplaceLengthTrailingWhitespaceOnlyLeftAlone();
void testCacheControlBreakpointWithoutExtendedTtl();
void testCacheControlBreakpointWithExtendedTtl();
void testCacheControlSystemAsStringWrappedIntoArray();
void testCacheControlEmptySystemStringIsNotWrapped();
void testCacheControlSystemAsArrayMarksLastBlock();
void testCacheControlToolsLastEntryGetsCacheControl();
void testCacheControlSingleMessageHistorySkipped();
void testCacheControlHistoryBreakpointOnSecondToLastMessage();
void testCacheControlHistoryArrayContentMarksLastBlock();
void testCacheControlNoSystemNoToolsNoMessagesIsNoop();
void testCacheControlEmptyToolsArrayIsNoop();
void testGetLineText();
void testGetContext();
void testGetContextWithCopyright();
void testReadWholeFile();
void testReadWholeFileWithCopyright();
void testReadWholeFileWithMultilineCopyright();
void testFindCopyrightSingleLine();
void testFindCopyrightMultiLine();
void testFindCopyrightMultipleBlocks();
void testFindCopyrightNoCopyright();
void testGetContextBetween();
void testPrepareContext();
void testHistorySnapshotUsesCurrentVersion();
void testHistorySnapshotRoundTrip();
void testUnsupportedChatVersionIsRejected();
void testChatFileWithoutMessagesArrayIsRejected();
void testLegacyChatFileConvertsToHistory();
void testLegacyToolRowWithoutMetadataKeepsItsText();
void testChatRowsRoundTripThroughHistory();
void testCompressedChatShapeReloadsAsOneAssistantRow();
void testHistoryAppliesToChatModel();
void testAdjacentThinkingBlocksSurviveReload();
void testSessionAppendsUserTurnBeforeSending();
void testSessionStreamsTextThinkingAndTools();
void testSessionTrimsStreamedText();
void testSessionAbandoningTheTurnCancelsTheBackend();
void testSessionProjectionMatchesHistory();
void testSessionAccumulatesStreamedThinking();
void testSessionKeepsThinkingAfterToolContinuation();
void testSessionDropsPreToolTextWhenAsked();
void testSessionToolResultAppendsFileEditRow();
void testSessionIgnoresEchoedFileEditMarker();
void testSessionTruncationKeepsBlockOrder();
void testHistorySnapshotReportsUnreadableBlocks();
void testSessionUsageLandsOnTheTurn();
void testSessionIgnoresEventsFromOtherTurns();
void testSessionReportsFailureWithoutStartedTurn();
void testSessionIgnoresFailureFromAbandonedTurn();
void testSessionTruncateRowsRewritesHistory();
void testTurnContextCollectsPartsFromPorts();
void testTurnContextSkillCommandScanning();
void testTurnContextWithoutSkillsPort();
void testLinkedFilesHeaderSurvivesUnreadablePaths();
void testSystemPromptRenderingWithProject();
void testSystemPromptRenderingWithoutProject();
private:
Context::DocumentContextReader createReader(const QString &text);
QSharedPointer<Settings::CodeCompletionSettings> createSettingsForWholeFile();
QSharedPointer<Settings::CodeCompletionSettings> createSettingsForLines(
int linesBefore, int linesAfter);
static QJsonObject expectedEphemeral(bool extendedTtl);
};
} // namespace QodeAssist
-65
View File
@@ -1,65 +0,0 @@
// Copyright (C) 2025 Povilas Kanapickas
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include <iostream>
#include "llmcore/ContextData.hpp"
#include <QString>
QT_BEGIN_NAMESPACE
// gtest can't pick pretty printer when comparing QString
inline void PrintTo(const QString &value, ::std::ostream *out)
{
*out << '"' << value.toStdString() << '"';
}
QT_END_NAMESPACE
inline std::ostream &operator<<(std::ostream &out, const QString &value)
{
out << '"' << value.toStdString() << '"';
return out;
}
template<class T>
std::ostream &operator<<(std::ostream &out, const QVector<T> &value)
{
out << "[";
for (const auto &el : value) {
out << value << ", ";
}
out << "]";
return out;
}
template<class T>
std::ostream &operator<<(std::ostream &out, const std::optional<T> &value)
{
if (value.has_value()) {
out << value.value();
} else {
out << "(no value)";
}
return out;
}
namespace QodeAssist::LLMCore {
inline std::ostream &operator<<(std::ostream &out, const Message &value)
{
out << "Message{"
<< "role=" << value.role << "content=" << value.content << "}";
return out;
}
inline std::ostream &operator<<(std::ostream &out, const ContextData &value)
{
out << "ContextData{"
<< "\n systemPrompt=" << value.systemPrompt << "\n prefix=" << value.prefix
<< "\n suffix=" << value.suffix << "\n fileContext=" << value.fileContext
<< "\n history=" << value.history << "\n}";
return out;
}
} // namespace QodeAssist::LLMCore
-23
View File
@@ -1,23 +0,0 @@
// Copyright (C) 2025 Povilas Kanapickas
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include <gtest/gtest.h>
#include <QGuiApplication>
#include <QtGlobal>
void silenceQtWarningNoise(QtMsgType type, const QMessageLogContext &context, const QString &msg)
{
if (msg.startsWith("SOFT ASSERT") || msg.startsWith("Accessing MimeDatabase")) {
return;
}
qt_message_output(type, context, msg);
}
int main(int argc, char *argv[])
{
qInstallMessageHandler(silenceQtWarningNoise);
QGuiApplication application(argc, argv);
testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}