From abadc2262c5add5db55c30dbcd1f0a502585e0cf Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Tue, 26 May 2026 18:02:44 +0200 Subject: [PATCH] feat: Add session layout --- sources/Session/CMakeLists.txt | 23 ++ sources/Session/ConversationHistory.cpp | 82 +++++ sources/Session/ConversationHistory.hpp | 49 +++ sources/Session/LLMRequest.hpp | 32 ++ sources/Session/Message.cpp | 30 ++ sources/Session/Message.hpp | 73 +++++ sources/Session/MessageSerializer.cpp | 212 +++++++++++++ sources/Session/MessageSerializer.hpp | 20 ++ sources/Session/PluginBlocks.hpp | 114 +++++++ sources/Session/ResponseEvent.hpp | 169 +++++++++++ sources/Session/ResponseRouter.cpp | 163 ++++++++++ sources/Session/ResponseRouter.hpp | 64 ++++ sources/Session/Session.cpp | 386 ++++++++++++++++++++++++ sources/Session/Session.hpp | 105 +++++++ sources/Session/SessionManager.cpp | 105 +++++++ sources/Session/SessionManager.hpp | 52 ++++ sources/Session/SystemPromptBuilder.cpp | 71 +++++ sources/Session/SystemPromptBuilder.hpp | 36 +++ 18 files changed, 1786 insertions(+) create mode 100644 sources/Session/CMakeLists.txt create mode 100644 sources/Session/ConversationHistory.cpp create mode 100644 sources/Session/ConversationHistory.hpp create mode 100644 sources/Session/LLMRequest.hpp create mode 100644 sources/Session/Message.cpp create mode 100644 sources/Session/Message.hpp create mode 100644 sources/Session/MessageSerializer.cpp create mode 100644 sources/Session/MessageSerializer.hpp create mode 100644 sources/Session/PluginBlocks.hpp create mode 100644 sources/Session/ResponseEvent.hpp create mode 100644 sources/Session/ResponseRouter.cpp create mode 100644 sources/Session/ResponseRouter.hpp create mode 100644 sources/Session/Session.cpp create mode 100644 sources/Session/Session.hpp create mode 100644 sources/Session/SessionManager.cpp create mode 100644 sources/Session/SessionManager.hpp create mode 100644 sources/Session/SystemPromptBuilder.cpp create mode 100644 sources/Session/SystemPromptBuilder.hpp diff --git a/sources/Session/CMakeLists.txt b/sources/Session/CMakeLists.txt new file mode 100644 index 0000000..0d4329b --- /dev/null +++ b/sources/Session/CMakeLists.txt @@ -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} +) diff --git a/sources/Session/ConversationHistory.cpp b/sources/Session/ConversationHistory.cpp new file mode 100644 index 0000000..7955152 --- /dev/null +++ b/sources/Session/ConversationHistory.cpp @@ -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(m_messages.size()) - 1); +} + +void ConversationHistory::appendBlockToLast(std::unique_ptr block) +{ + if (m_messages.empty() || !block) + return; + + m_messages.back().appendBlock(std::move(block)); + emit messageUpdated(static_cast(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()) { + text->appendText(delta); + } else { + last.appendBlock(std::make_unique(delta)); + } + emit messageUpdated(static_cast(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(); + if (!thinking) { + auto fresh = std::make_unique(delta, signature); + last.appendBlock(std::move(fresh)); + } else { + if (!delta.isEmpty()) + thinking->appendThinking(delta); + if (!signature.isEmpty()) + thinking->setSignature(signature); + } + emit messageUpdated(static_cast(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(index) >= m_messages.size()) + return; + + m_messages.resize(static_cast(index)); + emit reset(); +} + +} // namespace QodeAssist diff --git a/sources/Session/ConversationHistory.hpp b/sources/Session/ConversationHistory.hpp new file mode 100644 index 0000000..a62cc9c --- /dev/null +++ b/sources/Session/ConversationHistory.hpp @@ -0,0 +1,49 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include + +#include +#include + +#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 &messages() const noexcept { return m_messages; } + int size() const noexcept { return static_cast(m_messages.size()); } + bool isEmpty() const noexcept { return m_messages.empty(); } + + void append(Message message); + + void appendBlockToLast(std::unique_ptr 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 m_messages; +}; + +} // namespace QodeAssist diff --git a/sources/Session/LLMRequest.hpp b/sources/Session/LLMRequest.hpp new file mode 100644 index 0000000..313fd87 --- /dev/null +++ b/sources/Session/LLMRequest.hpp @@ -0,0 +1,32 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include + +#include "Message.hpp" + +namespace QodeAssist { + +struct LLMRequest +{ + QString systemPrompt; + std::vector history; + bool toolsEnabled = false; + bool thinkingEnabled = false; + + std::optional fimPrefix; + std::optional fimSuffix; + + LLMRequest() = default; + LLMRequest(const LLMRequest &) = delete; + LLMRequest &operator=(const LLMRequest &) = delete; + LLMRequest(LLMRequest &&) noexcept = default; + LLMRequest &operator=(LLMRequest &&) noexcept = default; +}; + +} // namespace QodeAssist diff --git a/sources/Session/Message.cpp b/sources/Session/Message.cpp new file mode 100644 index 0000000..125aa86 --- /dev/null +++ b/sources/Session/Message.cpp @@ -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(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(block.get())) + return true; + } + return false; +} + +} // namespace QodeAssist diff --git a/sources/Session/Message.hpp b/sources/Session/Message.hpp new file mode 100644 index 0000000..2d1b771 --- /dev/null +++ b/sources/Session/Message.hpp @@ -0,0 +1,73 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include + +#include +#include + +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> &blocks() const noexcept + { + return m_blocks; + } + + void appendBlock(std::unique_ptr block) + { + if (block) + m_blocks.push_back(std::move(block)); + } + + template + T *lastBlockOfType() + { + for (auto it = m_blocks.rbegin(); it != m_blocks.rend(); ++it) { + if (auto *p = dynamic_cast(it->get())) + return p; + } + return nullptr; + } + + template + const T *lastBlockOfType() const + { + return const_cast(this)->lastBlockOfType(); + } + + QString text() const; + + bool hasToolUse() const; + +private: + Role m_role = Role::User; + QString m_id; + std::vector> m_blocks; +}; + +} // namespace QodeAssist diff --git a/sources/Session/MessageSerializer.cpp b/sources/Session/MessageSerializer.cpp new file mode 100644 index 0000000..7f2b074 --- /dev/null +++ b/sources/Session/MessageSerializer.cpp @@ -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 + +#include +#include + +#include + +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(&block)) { + obj["type"] = kKindText; + obj["text"] = t->text(); + } else if (auto *th = dynamic_cast(&block)) { + obj["type"] = kKindThinking; + obj["thinking"] = th->thinking(); + obj["signature"] = th->signature(); + } else if (auto *rth = dynamic_cast(&block)) { + obj["type"] = kKindRedactedThinking; + obj["signature"] = rth->signature(); + } else if (auto *img = dynamic_cast(&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(&block)) { + obj["type"] = kKindToolUse; + obj["id"] = tu->id(); + obj["name"] = tu->name(); + obj["input"] = tu->input(); + } else if (auto *tr = dynamic_cast(&block)) { + obj["type"] = kKindToolResult; + obj["toolUseId"] = tr->toolUseId(); + obj["result"] = tr->result(); + } else if (auto *si = dynamic_cast(&block)) { + obj["type"] = kKindStoredImage; + obj["fileName"] = si->fileName(); + obj["storedPath"] = si->storedPath(); + obj["mediaType"] = si->mediaType(); + } else if (auto *sa = dynamic_cast(&block)) { + obj["type"] = kKindStoredAttachment; + obj["fileName"] = sa->fileName(); + obj["storedPath"] = sa->storedPath(); + } else if (auto *fe = dynamic_cast(&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 blockFromJson(const QJsonObject &obj) +{ + const QString type = obj.value("type").toString(); + if (type == kKindText) { + return std::make_unique(obj.value("text").toString()); + } + if (type == kKindThinking) { + return std::make_unique( + obj.value("thinking").toString(), obj.value("signature").toString()); + } + if (type == kKindRedactedThinking) { + return std::make_unique( + 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( + obj.value("data").toString(), obj.value("mediaType").toString(), sourceType); + } + if (type == kKindToolUse) { + return std::make_unique( + obj.value("id").toString(), + obj.value("name").toString(), + obj.value("input").toObject()); + } + if (type == kKindToolResult) { + return std::make_unique( + obj.value("toolUseId").toString(), obj.value("result").toString()); + } + if (type == kKindStoredImage) { + return std::make_unique( + obj.value("fileName").toString(), + obj.value("storedPath").toString(), + obj.value("mediaType").toString()); + } + if (type == kKindStoredAttachment) { + return std::make_unique( + obj.value("fileName").toString(), obj.value("storedPath").toString()); + } + if (type == kKindFileEdit) { + return std::make_unique( + 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 diff --git a/sources/Session/MessageSerializer.hpp b/sources/Session/MessageSerializer.hpp new file mode 100644 index 0000000..7f95c2b --- /dev/null +++ b/sources/Session/MessageSerializer.hpp @@ -0,0 +1,20 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#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 diff --git a/sources/Session/PluginBlocks.hpp b/sources/Session/PluginBlocks.hpp new file mode 100644 index 0000000..5461074 --- /dev/null +++ b/sources/Session/PluginBlocks.hpp @@ -0,0 +1,114 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include + +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 diff --git a/sources/Session/ResponseEvent.hpp b/sources/Session/ResponseEvent.hpp new file mode 100644 index 0000000..105222c --- /dev/null +++ b/sources/Session/ResponseEvent.hpp @@ -0,0 +1,169 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include + +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 + const T *as() const noexcept + { + return std::get_if(&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 diff --git a/sources/Session/ResponseRouter.cpp b/sources/Session/ResponseRouter.cpp new file mode 100644 index 0000000..160293b --- /dev/null +++ b/sources/Session/ResponseRouter.cpp @@ -0,0 +1,163 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ResponseRouter.hpp" + +#include + +#include + +#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(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(toolId, result)); + } else { + Message m(Message::Role::User); + m.appendBlock(std::make_unique(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 diff --git a/sources/Session/ResponseRouter.hpp b/sources/Session/ResponseRouter.hpp new file mode 100644 index 0000000..f401622 --- /dev/null +++ b/sources/Session/ResponseRouter.hpp @@ -0,0 +1,64 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include + +#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 m_client; + QPointer m_history; + + LLMQore::RequestID m_activeId; + bool m_assistantOpen = false; + bool m_inToolResults = false; +}; + +} // namespace QodeAssist diff --git a/sources/Session/Session.cpp b/sources/Session/Session.cpp new file mode 100644 index 0000000..b368754 --- /dev/null +++ b/sources/Session/Session.cpp @@ -0,0 +1,386 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "Session.hpp" + +#include + +#include +#include +#include + +#include + +#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> blocks; + if (!text.isEmpty()) + blocks.push_back(std::make_unique(text)); + return send(std::move(blocks)); +} + +LLMQore::RequestID Session::send( + std::vector> userBlocks, + std::optional 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 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 &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 resolvedToolUseIds; + QSet declaredToolUseIds; + for (const auto &m : history) { + for (const auto &blockPtr : m.blocks()) { + if (auto *tr = dynamic_cast(blockPtr.get())) + resolvedToolUseIds.insert(tr->toolUseId()); + if (auto *tu = dynamic_cast(blockPtr.get())) + declaredToolUseIds.insert(tu->id()); + } + } + + QVector hist; + + for (const auto &m : history) { + QVector blockEntries; + + for (const auto &blockPtr : m.blocks()) { + auto *block = blockPtr.get(); + if (!block) + continue; + + if (auto *t = dynamic_cast(block)) { + ContentBlockEntry e; + e.kind = ContentBlockEntry::Kind::Text; + e.text = t->text(); + blockEntries.append(std::move(e)); + } else if (auto *img = dynamic_cast(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(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(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(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(block)) { + ContentBlockEntry e; + e.kind = ContentBlockEntry::Kind::RedactedThinking; + e.signature = rth->signature(); + blockEntries.append(std::move(e)); + } else if (auto *tu = dynamic_cast(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(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(); + 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(); + const QString msg = err ? err->message : QStringLiteral("unknown error"); + const auto id = m_inFlight; + m_inFlight.clear(); + emit failed(id, msg); + } +} + +} // namespace QodeAssist diff --git a/sources/Session/Session.hpp b/sources/Session/Session.hpp new file mode 100644 index 0000000..dfcdd4d --- /dev/null +++ b/sources/Session/Session.hpp @@ -0,0 +1,105 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#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; + 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> userBlocks, + std::optional 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 toolsOverride = std::nullopt); + Templates::ContextData toLegacyContext() const; + + Agent *m_agent = nullptr; // child if non-null + QPointer 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 &history, + const QString &systemPrompt, + const ContentLoader &loader = ContentLoader{}); + +private: + ContentLoader m_contentLoader; +}; + +} // namespace QodeAssist diff --git a/sources/Session/SessionManager.cpp b/sources/Session/SessionManager.cpp new file mode 100644 index 0000000..d66fa72 --- /dev/null +++ b/sources/Session/SessionManager.cpp @@ -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 SessionManager::sessions() const +{ + QList 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 diff --git a/sources/Session/SessionManager.hpp b/sources/Session/SessionManager.hpp new file mode 100644 index 0000000..a5f60ef --- /dev/null +++ b/sources/Session/SessionManager.hpp @@ -0,0 +1,52 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +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 sessions() const; + + void cancelAll(); + +signals: + void sessionCreated(Session *session); + void sessionRemoved(Session *session); + +private: + QPointer m_agentFactory; + QList> m_sessions; +}; + +} // namespace QodeAssist diff --git a/sources/Session/SystemPromptBuilder.cpp b/sources/Session/SystemPromptBuilder.cpp new file mode 100644 index 0000000..f632cc6 --- /dev/null +++ b/sources/Session/SystemPromptBuilder.cpp @@ -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 diff --git a/sources/Session/SystemPromptBuilder.hpp b/sources/Session/SystemPromptBuilder.hpp new file mode 100644 index 0000000..07d6cb0 --- /dev/null +++ b/sources/Session/SystemPromptBuilder.hpp @@ -0,0 +1,36 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +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> m_layers; +}; + +} // namespace QodeAssist