feat: Add session layout

This commit is contained in:
Petr Mironychev
2026-05-26 18:02:44 +02:00
parent 31ad99af61
commit abadc2262c
18 changed files with 1786 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
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}
)

View File

@@ -0,0 +1,82 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,49 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,32 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,73 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,212 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,20 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,114 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,169 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,163 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,64 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

386
sources/Session/Session.cpp Normal file
View File

@@ -0,0 +1,386 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

105
sources/Session/Session.hpp Normal file
View File

@@ -0,0 +1,105 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,105 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,52 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,71 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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

View File

@@ -0,0 +1,36 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#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