mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-09-11 19:57:03 -04:00
refactor: Decoupling chat architecture (#367)
* refactor: Migrate tests to Qt Creator plugin framework and update llmqore transports * refactor: Move conversation history into IDE-free session library * refactor: Extract turn context assembly behind injected ports * build: Add RunQodeAssistTests target for the plugin test suite * refactor: Route the chat through a Session and ChatBackend seam
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
+1
-1
Submodule sources/external/llmqore updated: 48e6dfb30d...3f9c8f5253
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user