mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-07-24 20:20:59 -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:
22
sources/session/CMakeLists.txt
Normal file
22
sources/session/CMakeLists.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
add_library(QodeAssistSession STATIC
|
||||
ContentBlock.hpp
|
||||
Message.hpp
|
||||
ConversationHistory.hpp ConversationHistory.cpp
|
||||
FileEditPayload.hpp FileEditPayload.cpp
|
||||
HistoryProjection.hpp HistoryProjection.cpp
|
||||
HistorySerializer.hpp HistorySerializer.cpp
|
||||
SessionEvent.hpp SessionEvent.cpp
|
||||
TurnRequest.hpp
|
||||
ChatBackend.hpp
|
||||
Session.hpp Session.cpp
|
||||
TurnContext.hpp TurnContext.cpp
|
||||
TurnContextPorts.hpp
|
||||
TurnContextBuilder.hpp TurnContextBuilder.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(QodeAssistSession
|
||||
PUBLIC
|
||||
Qt::Core
|
||||
)
|
||||
|
||||
target_include_directories(QodeAssistSession PUBLIC ${CMAKE_SOURCE_DIR}/sources)
|
||||
28
sources/session/ChatBackend.hpp
Normal file
28
sources/session/ChatBackend.hpp
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
|
||||
#include "session/SessionEvent.hpp"
|
||||
#include "session/TurnRequest.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
class ChatBackend : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using QObject::QObject;
|
||||
|
||||
virtual void sendTurn(const TurnRequest &request) = 0;
|
||||
virtual void cancel() = 0;
|
||||
|
||||
signals:
|
||||
void sessionEvent(const QodeAssist::Session::SessionEvent &event);
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
110
sources/session/ContentBlock.hpp
Normal file
110
sources/session/ContentBlock.hpp
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDebug>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#include <variant>
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
struct TextBlock
|
||||
{
|
||||
QString text;
|
||||
|
||||
bool operator==(const TextBlock &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const TextBlock &block)
|
||||
{
|
||||
return debug.nospace() << "Text(" << block.text << ")";
|
||||
}
|
||||
};
|
||||
|
||||
struct ThinkingBlock
|
||||
{
|
||||
QString text;
|
||||
QString signature;
|
||||
bool redacted = false;
|
||||
|
||||
bool operator==(const ThinkingBlock &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const ThinkingBlock &block)
|
||||
{
|
||||
return debug.nospace() << "Thinking(" << block.text << ", sig=" << block.signature
|
||||
<< ", redacted=" << block.redacted << ")";
|
||||
}
|
||||
};
|
||||
|
||||
struct ToolCallBlock
|
||||
{
|
||||
QString id;
|
||||
QString name;
|
||||
QJsonObject arguments;
|
||||
QString result;
|
||||
|
||||
bool operator==(const ToolCallBlock &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const ToolCallBlock &block)
|
||||
{
|
||||
return debug.nospace() << "ToolCall(" << block.id << ", " << block.name
|
||||
<< ", args=" << block.arguments << ", result=" << block.result
|
||||
<< ")";
|
||||
}
|
||||
};
|
||||
|
||||
struct AttachmentBlock
|
||||
{
|
||||
QString fileName;
|
||||
QString storedPath;
|
||||
|
||||
bool operator==(const AttachmentBlock &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const AttachmentBlock &block)
|
||||
{
|
||||
return debug.nospace() << "Attachment(" << block.fileName << ", " << block.storedPath
|
||||
<< ")";
|
||||
}
|
||||
};
|
||||
|
||||
struct ImageBlock
|
||||
{
|
||||
QString fileName;
|
||||
QString storedPath;
|
||||
QString mediaType;
|
||||
|
||||
bool operator==(const ImageBlock &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const ImageBlock &block)
|
||||
{
|
||||
return debug.nospace() << "Image(" << block.fileName << ", " << block.storedPath << ", "
|
||||
<< block.mediaType << ")";
|
||||
}
|
||||
};
|
||||
|
||||
struct FileEditBlock
|
||||
{
|
||||
QString id;
|
||||
QString payload;
|
||||
|
||||
bool operator==(const FileEditBlock &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const FileEditBlock &block)
|
||||
{
|
||||
return debug.nospace() << "FileEdit(" << block.id << ", " << block.payload << ")";
|
||||
}
|
||||
};
|
||||
|
||||
using ContentBlock = std::
|
||||
variant<TextBlock, ThinkingBlock, ToolCallBlock, AttachmentBlock, ImageBlock, FileEditBlock>;
|
||||
|
||||
inline QDebug operator<<(QDebug debug, const ContentBlock &block)
|
||||
{
|
||||
std::visit([&debug](const auto &alternative) { debug << alternative; }, block);
|
||||
return debug;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
47
sources/session/ConversationHistory.cpp
Normal file
47
sources/session/ConversationHistory.cpp
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "session/ConversationHistory.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
void ConversationHistory::append(const Message &message)
|
||||
{
|
||||
m_messages.append(message);
|
||||
}
|
||||
|
||||
qsizetype ConversationHistory::size() const
|
||||
{
|
||||
return m_messages.size();
|
||||
}
|
||||
|
||||
const QList<Message> &ConversationHistory::messages() const
|
||||
{
|
||||
return m_messages;
|
||||
}
|
||||
|
||||
const Message &ConversationHistory::at(qsizetype index) const
|
||||
{
|
||||
return m_messages.at(index);
|
||||
}
|
||||
|
||||
const Message &ConversationHistory::last() const
|
||||
{
|
||||
return m_messages.last();
|
||||
}
|
||||
|
||||
Message *ConversationHistory::lastMessage()
|
||||
{
|
||||
return m_messages.isEmpty() ? nullptr : &m_messages.last();
|
||||
}
|
||||
|
||||
void ConversationHistory::visitBlocks(const std::function<void(ContentBlock &)> &visit)
|
||||
{
|
||||
for (Message &message : m_messages) {
|
||||
for (ContentBlock &block : message.blocks)
|
||||
visit(block);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
39
sources/session/ConversationHistory.hpp
Normal file
39
sources/session/ConversationHistory.hpp
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "session/Message.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
class ConversationHistory
|
||||
{
|
||||
public:
|
||||
void append(const Message &message);
|
||||
|
||||
qsizetype size() const;
|
||||
const QList<Message> &messages() const;
|
||||
const Message &at(qsizetype index) const;
|
||||
const Message &last() const;
|
||||
Message *lastMessage();
|
||||
|
||||
void visitBlocks(const std::function<void(ContentBlock &)> &visit);
|
||||
|
||||
bool operator==(const ConversationHistory &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const ConversationHistory &history)
|
||||
{
|
||||
return debug.nospace() << "History(" << history.m_messages << ")";
|
||||
}
|
||||
|
||||
private:
|
||||
QList<Message> m_messages;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
49
sources/session/FileEditPayload.cpp
Normal file
49
sources/session/FileEditPayload.cpp
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "session/FileEditPayload.hpp"
|
||||
|
||||
#include <QJsonDocument>
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
namespace {
|
||||
|
||||
QString fileEditMarker()
|
||||
{
|
||||
return QStringLiteral("QODEASSIST_FILE_EDIT:");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool isFileEditPayload(const QString &text)
|
||||
{
|
||||
return text.startsWith(fileEditMarker());
|
||||
}
|
||||
|
||||
std::optional<QJsonObject> parseFileEditPayload(const QString &text)
|
||||
{
|
||||
const QString marker = fileEditMarker();
|
||||
const int markerPos = text.indexOf(marker);
|
||||
if (markerPos < 0)
|
||||
return std::nullopt;
|
||||
|
||||
const int jsonStart = markerPos + marker.length();
|
||||
if (jsonStart >= text.length())
|
||||
return std::nullopt;
|
||||
|
||||
const QJsonDocument document = QJsonDocument::fromJson(text.mid(jsonStart).toUtf8());
|
||||
if (!document.isObject())
|
||||
return std::nullopt;
|
||||
|
||||
return document.object();
|
||||
}
|
||||
|
||||
QString encodeFileEditPayload(const QJsonObject &payload)
|
||||
{
|
||||
return fileEditMarker()
|
||||
+ QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
18
sources/session/FileEditPayload.hpp
Normal file
18
sources/session/FileEditPayload.hpp
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#include <optional>
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
bool isFileEditPayload(const QString &text);
|
||||
std::optional<QJsonObject> parseFileEditPayload(const QString &text);
|
||||
QString encodeFileEditPayload(const QJsonObject &payload);
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
244
sources/session/HistoryProjection.cpp
Normal file
244
sources/session/HistoryProjection.cpp
Normal file
@@ -0,0 +1,244 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "session/HistoryProjection.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
namespace {
|
||||
|
||||
QString signatureSuffix(const QString &signature)
|
||||
{
|
||||
return QString("\n[Signature: %1...]").arg(signature.left(40));
|
||||
}
|
||||
|
||||
QString redactedThinkingText()
|
||||
{
|
||||
return QStringLiteral("[Thinking content redacted by safety systems]");
|
||||
}
|
||||
|
||||
QString thinkingDisplayText(const ThinkingBlock &block)
|
||||
{
|
||||
const QString text = block.redacted && block.text.isEmpty() ? redactedThinkingText()
|
||||
: block.text;
|
||||
return block.signature.isEmpty() ? text : text + signatureSuffix(block.signature);
|
||||
}
|
||||
|
||||
QString thinkingTextOf(const MessageRow &row)
|
||||
{
|
||||
QString text = row.content;
|
||||
if (!row.signature.isEmpty()) {
|
||||
const QString suffix = signatureSuffix(row.signature);
|
||||
if (text.endsWith(suffix))
|
||||
text.chop(suffix.size());
|
||||
}
|
||||
|
||||
if (row.redacted && text == redactedThinkingText())
|
||||
return {};
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
QString toolDisplayText(const ToolCallBlock &block)
|
||||
{
|
||||
if (block.name.isEmpty())
|
||||
return block.result;
|
||||
return block.result.isEmpty() ? block.name : block.name + "\n" + block.result;
|
||||
}
|
||||
|
||||
ToolCallBlock toolCallOf(const MessageRow &row)
|
||||
{
|
||||
ToolCallBlock block{row.id, row.toolName, row.toolArguments, row.toolResult};
|
||||
if (block.name.isEmpty() && block.result.isEmpty())
|
||||
block.result = row.content;
|
||||
return block;
|
||||
}
|
||||
|
||||
RowKind textRowKind(MessageRole role)
|
||||
{
|
||||
switch (role) {
|
||||
case MessageRole::System:
|
||||
return RowKind::System;
|
||||
case MessageRole::User:
|
||||
return RowKind::User;
|
||||
case MessageRole::Assistant:
|
||||
return RowKind::Assistant;
|
||||
}
|
||||
return RowKind::Assistant;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<MessageRow> projectBlockToRow(const Message &message, const ContentBlock &block)
|
||||
{
|
||||
if (const auto *text = std::get_if<TextBlock>(&block)) {
|
||||
MessageRow row;
|
||||
row.kind = textRowKind(message.role);
|
||||
row.id = message.id;
|
||||
row.content = text->text;
|
||||
return row;
|
||||
}
|
||||
|
||||
if (const auto *thinking = std::get_if<ThinkingBlock>(&block)) {
|
||||
MessageRow row;
|
||||
row.kind = RowKind::Thinking;
|
||||
row.id = message.id;
|
||||
row.content = thinkingDisplayText(*thinking);
|
||||
row.redacted = thinking->redacted;
|
||||
row.signature = thinking->signature;
|
||||
return row;
|
||||
}
|
||||
|
||||
if (const auto *tool = std::get_if<ToolCallBlock>(&block)) {
|
||||
MessageRow row;
|
||||
row.kind = RowKind::Tool;
|
||||
row.id = tool->id;
|
||||
row.content = toolDisplayText(*tool);
|
||||
row.toolName = tool->name;
|
||||
row.toolArguments = tool->arguments;
|
||||
row.toolResult = tool->result;
|
||||
return row;
|
||||
}
|
||||
|
||||
if (const auto *edit = std::get_if<FileEditBlock>(&block)) {
|
||||
MessageRow row;
|
||||
row.kind = RowKind::FileEdit;
|
||||
row.id = edit->id;
|
||||
row.content = edit->payload;
|
||||
return row;
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QList<MessageRow> projectMessageToRows(const Message &message)
|
||||
{
|
||||
QList<MessageRow> rows;
|
||||
|
||||
bool usageAssigned = message.usage.isEmpty();
|
||||
qsizetype textRowIndex = -1;
|
||||
|
||||
auto appendRow = [&](MessageRow row) {
|
||||
if (!usageAssigned && row.id == message.id) {
|
||||
row.usage = message.usage;
|
||||
usageAssigned = true;
|
||||
}
|
||||
rows.append(std::move(row));
|
||||
return rows.size() - 1;
|
||||
};
|
||||
|
||||
auto ensureTextRow = [&] {
|
||||
if (textRowIndex < 0) {
|
||||
MessageRow row;
|
||||
row.kind = textRowKind(message.role);
|
||||
row.id = message.id;
|
||||
textRowIndex = appendRow(row);
|
||||
}
|
||||
return textRowIndex;
|
||||
};
|
||||
|
||||
for (const ContentBlock &block : message.blocks) {
|
||||
if (auto row = projectBlockToRow(message, block)) {
|
||||
const qsizetype index = appendRow(std::move(*row));
|
||||
if (std::holds_alternative<TextBlock>(block))
|
||||
textRowIndex = index;
|
||||
} else if (const auto *attachment = std::get_if<AttachmentBlock>(&block)) {
|
||||
rows[ensureTextRow()].attachments.append(*attachment);
|
||||
} else if (const auto *image = std::get_if<ImageBlock>(&block)) {
|
||||
rows[ensureTextRow()].images.append(*image);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
QList<MessageRow> projectToRows(const ConversationHistory &history)
|
||||
{
|
||||
QList<MessageRow> rows;
|
||||
|
||||
for (const Message &message : history.messages())
|
||||
rows.append(projectMessageToRows(message));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
ConversationHistory buildFromRows(const QList<MessageRow> &rows)
|
||||
{
|
||||
ConversationHistory history;
|
||||
std::optional<Message> assistant;
|
||||
|
||||
auto flush = [&] {
|
||||
if (assistant) {
|
||||
history.append(*assistant);
|
||||
assistant.reset();
|
||||
}
|
||||
};
|
||||
|
||||
auto openAssistant = [&]() -> Message & {
|
||||
if (!assistant) {
|
||||
Message message;
|
||||
message.role = MessageRole::Assistant;
|
||||
assistant = message;
|
||||
}
|
||||
return *assistant;
|
||||
};
|
||||
|
||||
for (const MessageRow &row : rows) {
|
||||
switch (row.kind) {
|
||||
case RowKind::User:
|
||||
case RowKind::System: {
|
||||
flush();
|
||||
Message message;
|
||||
message.role = row.kind == RowKind::User ? MessageRole::User : MessageRole::System;
|
||||
message.id = row.id;
|
||||
message.usage = row.usage;
|
||||
message.blocks.append(TextBlock{row.content});
|
||||
for (const AttachmentBlock &attachment : row.attachments)
|
||||
message.blocks.append(attachment);
|
||||
for (const ImageBlock &image : row.images)
|
||||
message.blocks.append(image);
|
||||
history.append(message);
|
||||
break;
|
||||
}
|
||||
case RowKind::Assistant:
|
||||
case RowKind::Thinking: {
|
||||
if (assistant && !row.id.isEmpty() && !assistant->id.isEmpty()
|
||||
&& assistant->id != row.id) {
|
||||
flush();
|
||||
}
|
||||
Message &message = openAssistant();
|
||||
if (message.id.isEmpty())
|
||||
message.id = row.id;
|
||||
if (row.kind == RowKind::Assistant) {
|
||||
message.blocks.append(TextBlock{row.content});
|
||||
} else {
|
||||
message.blocks.append(
|
||||
ThinkingBlock{thinkingTextOf(row), row.signature, row.redacted});
|
||||
}
|
||||
if (!row.usage.isEmpty())
|
||||
message.usage = row.usage;
|
||||
break;
|
||||
}
|
||||
case RowKind::Tool: {
|
||||
Message &message = openAssistant();
|
||||
message.blocks.append(toolCallOf(row));
|
||||
break;
|
||||
}
|
||||
case RowKind::FileEdit: {
|
||||
Message &message = openAssistant();
|
||||
message.blocks.append(FileEditBlock{row.id, row.content});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
51
sources/session/HistoryProjection.hpp
Normal file
51
sources/session/HistoryProjection.hpp
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "session/ConversationHistory.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
enum class RowKind { User, Assistant, System, Tool, FileEdit, Thinking };
|
||||
|
||||
struct MessageRow
|
||||
{
|
||||
RowKind kind = RowKind::User;
|
||||
QString id;
|
||||
QString content;
|
||||
bool redacted = false;
|
||||
QString signature;
|
||||
QString toolName;
|
||||
QJsonObject toolArguments;
|
||||
QString toolResult;
|
||||
QList<AttachmentBlock> attachments;
|
||||
QList<ImageBlock> images;
|
||||
Usage usage;
|
||||
|
||||
bool operator==(const MessageRow &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const MessageRow &row)
|
||||
{
|
||||
return debug.nospace() << "Row(kind=" << int(row.kind) << ", id=" << row.id
|
||||
<< ", content=" << row.content << ", sig=" << row.signature
|
||||
<< ", redacted=" << row.redacted << ", tool=" << row.toolName
|
||||
<< ", args=" << row.toolArguments << ", result=" << row.toolResult
|
||||
<< ", attachments=" << row.attachments << ", images=" << row.images
|
||||
<< ", " << row.usage << ")";
|
||||
}
|
||||
};
|
||||
|
||||
std::optional<MessageRow> projectBlockToRow(const Message &message, const ContentBlock &block);
|
||||
QList<MessageRow> projectMessageToRows(const Message &message);
|
||||
QList<MessageRow> projectToRows(const ConversationHistory &history);
|
||||
ConversationHistory buildFromRows(const QList<MessageRow> &rows);
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
290
sources/session/HistorySerializer.cpp
Normal file
290
sources/session/HistorySerializer.cpp
Normal file
@@ -0,0 +1,290 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "session/HistorySerializer.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
|
||||
#include "session/HistoryProjection.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
namespace {
|
||||
|
||||
QString roleToString(MessageRole role)
|
||||
{
|
||||
switch (role) {
|
||||
case MessageRole::System:
|
||||
return QStringLiteral("system");
|
||||
case MessageRole::User:
|
||||
return QStringLiteral("user");
|
||||
case MessageRole::Assistant:
|
||||
return QStringLiteral("assistant");
|
||||
}
|
||||
return QStringLiteral("assistant");
|
||||
}
|
||||
|
||||
MessageRole roleFromString(const QString &role)
|
||||
{
|
||||
if (role == QLatin1String("system"))
|
||||
return MessageRole::System;
|
||||
if (role == QLatin1String("user"))
|
||||
return MessageRole::User;
|
||||
return MessageRole::Assistant;
|
||||
}
|
||||
|
||||
RowKind legacyRowKind(int role)
|
||||
{
|
||||
switch (role) {
|
||||
case 0:
|
||||
return RowKind::System;
|
||||
case 1:
|
||||
return RowKind::User;
|
||||
case 2:
|
||||
return RowKind::Assistant;
|
||||
case 3:
|
||||
return RowKind::Tool;
|
||||
case 4:
|
||||
return RowKind::FileEdit;
|
||||
case 5:
|
||||
return RowKind::Thinking;
|
||||
default:
|
||||
return RowKind::Assistant;
|
||||
}
|
||||
}
|
||||
|
||||
QJsonObject usageToJson(const Usage &usage)
|
||||
{
|
||||
QJsonObject json;
|
||||
json["promptTokens"] = usage.promptTokens;
|
||||
json["completionTokens"] = usage.completionTokens;
|
||||
if (usage.cachedPromptTokens > 0)
|
||||
json["cachedPromptTokens"] = usage.cachedPromptTokens;
|
||||
if (usage.reasoningTokens > 0)
|
||||
json["reasoningTokens"] = usage.reasoningTokens;
|
||||
return json;
|
||||
}
|
||||
|
||||
Usage usageFromJson(const QJsonObject &json)
|
||||
{
|
||||
Usage usage;
|
||||
usage.promptTokens = json["promptTokens"].toInt();
|
||||
usage.completionTokens = json["completionTokens"].toInt();
|
||||
usage.cachedPromptTokens = json["cachedPromptTokens"].toInt();
|
||||
usage.reasoningTokens = json["reasoningTokens"].toInt();
|
||||
return usage;
|
||||
}
|
||||
|
||||
QJsonObject blockToJson(const ContentBlock &block)
|
||||
{
|
||||
QJsonObject json;
|
||||
|
||||
if (const auto *text = std::get_if<TextBlock>(&block)) {
|
||||
json["type"] = "text";
|
||||
json["text"] = text->text;
|
||||
} else if (const auto *thinking = std::get_if<ThinkingBlock>(&block)) {
|
||||
json["type"] = "thinking";
|
||||
json["text"] = thinking->text;
|
||||
if (!thinking->signature.isEmpty())
|
||||
json["signature"] = thinking->signature;
|
||||
if (thinking->redacted)
|
||||
json["redacted"] = true;
|
||||
} else if (const auto *tool = std::get_if<ToolCallBlock>(&block)) {
|
||||
json["type"] = "tool_call";
|
||||
json["id"] = tool->id;
|
||||
json["name"] = tool->name;
|
||||
if (!tool->arguments.isEmpty())
|
||||
json["arguments"] = tool->arguments;
|
||||
if (!tool->result.isEmpty())
|
||||
json["result"] = tool->result;
|
||||
} else if (const auto *attachment = std::get_if<AttachmentBlock>(&block)) {
|
||||
json["type"] = "attachment";
|
||||
json["fileName"] = attachment->fileName;
|
||||
json["storedPath"] = attachment->storedPath;
|
||||
} else if (const auto *image = std::get_if<ImageBlock>(&block)) {
|
||||
json["type"] = "image";
|
||||
json["fileName"] = image->fileName;
|
||||
json["storedPath"] = image->storedPath;
|
||||
json["mediaType"] = image->mediaType;
|
||||
} else if (const auto *edit = std::get_if<FileEditBlock>(&block)) {
|
||||
json["type"] = "file_edit";
|
||||
json["id"] = edit->id;
|
||||
json["payload"] = edit->payload;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
std::optional<ContentBlock> blockFromJson(const QJsonObject &json)
|
||||
{
|
||||
const QString type = json["type"].toString();
|
||||
|
||||
if (type == QLatin1String("text"))
|
||||
return ContentBlock{TextBlock{json["text"].toString()}};
|
||||
|
||||
if (type == QLatin1String("thinking")) {
|
||||
return ContentBlock{ThinkingBlock{
|
||||
json["text"].toString(), json["signature"].toString(), json["redacted"].toBool(false)}};
|
||||
}
|
||||
|
||||
if (type == QLatin1String("tool_call")) {
|
||||
return ContentBlock{ToolCallBlock{
|
||||
json["id"].toString(),
|
||||
json["name"].toString(),
|
||||
json["arguments"].toObject(),
|
||||
json["result"].toString()}};
|
||||
}
|
||||
|
||||
if (type == QLatin1String("attachment"))
|
||||
return ContentBlock{
|
||||
AttachmentBlock{json["fileName"].toString(), json["storedPath"].toString()}};
|
||||
|
||||
if (type == QLatin1String("image")) {
|
||||
return ContentBlock{ImageBlock{
|
||||
json["fileName"].toString(),
|
||||
json["storedPath"].toString(),
|
||||
json["mediaType"].toString()}};
|
||||
}
|
||||
|
||||
if (type == QLatin1String("file_edit"))
|
||||
return ContentBlock{FileEditBlock{json["id"].toString(), json["payload"].toString()}};
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QJsonObject messageToJson(const Message &message)
|
||||
{
|
||||
QJsonArray blocks;
|
||||
for (const ContentBlock &block : message.blocks)
|
||||
blocks.append(blockToJson(block));
|
||||
|
||||
QJsonObject json;
|
||||
json["role"] = roleToString(message.role);
|
||||
if (!message.id.isEmpty())
|
||||
json["id"] = message.id;
|
||||
json["blocks"] = blocks;
|
||||
if (!message.usage.isEmpty())
|
||||
json["usage"] = usageToJson(message.usage);
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
Message messageFromJson(const QJsonObject &json, int &droppedBlocks)
|
||||
{
|
||||
Message message;
|
||||
message.role = roleFromString(json["role"].toString());
|
||||
message.id = json["id"].toString();
|
||||
|
||||
const QJsonArray blocks = json["blocks"].toArray();
|
||||
for (const QJsonValue &value : blocks) {
|
||||
if (auto block = blockFromJson(value.toObject()))
|
||||
message.blocks.append(*block);
|
||||
else
|
||||
++droppedBlocks;
|
||||
}
|
||||
|
||||
if (json.contains("usage"))
|
||||
message.usage = usageFromJson(json["usage"].toObject());
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
MessageRow legacyRowFromJson(const QJsonObject &json)
|
||||
{
|
||||
MessageRow row;
|
||||
row.kind = legacyRowKind(json["role"].toInt());
|
||||
row.id = json["id"].toString();
|
||||
row.content = json["content"].toString();
|
||||
row.redacted = json["isRedacted"].toBool(false);
|
||||
row.signature = json["signature"].toString();
|
||||
row.toolName = json["toolName"].toString();
|
||||
row.toolArguments = json["toolArguments"].toObject();
|
||||
row.toolResult = json["toolResult"].toString();
|
||||
|
||||
const QJsonArray attachments = json["attachments"].toArray();
|
||||
for (const QJsonValue &value : attachments) {
|
||||
const QJsonObject attachment = value.toObject();
|
||||
row.attachments.append(
|
||||
AttachmentBlock{attachment["fileName"].toString(), attachment["storedPath"].toString()});
|
||||
}
|
||||
|
||||
const QJsonArray images = json["images"].toArray();
|
||||
for (const QJsonValue &value : images) {
|
||||
const QJsonObject image = value.toObject();
|
||||
row.images.append(
|
||||
ImageBlock{
|
||||
image["fileName"].toString(),
|
||||
image["storedPath"].toString(),
|
||||
image["mediaType"].toString()});
|
||||
}
|
||||
|
||||
if (json.contains("usage"))
|
||||
row.usage = usageFromJson(json["usage"].toObject());
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
ConversationHistory historyFromLegacyJson(const QJsonObject &root)
|
||||
{
|
||||
QList<MessageRow> rows;
|
||||
const QJsonArray messages = root["messages"].toArray();
|
||||
for (const QJsonValue &value : messages)
|
||||
rows.append(legacyRowFromJson(value.toObject()));
|
||||
|
||||
return buildFromRows(rows);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString HistorySerializer::currentVersion()
|
||||
{
|
||||
return QStringLiteral("0.4");
|
||||
}
|
||||
|
||||
bool HistorySerializer::isSupportedVersion(const QString &version)
|
||||
{
|
||||
return version == currentVersion() || version == QLatin1String("0.2")
|
||||
|| version == QLatin1String("0.1");
|
||||
}
|
||||
|
||||
QJsonObject HistorySerializer::toJson(const ConversationHistory &history)
|
||||
{
|
||||
QJsonArray messages;
|
||||
for (const Message &message : history.messages())
|
||||
messages.append(messageToJson(message));
|
||||
|
||||
QJsonObject root;
|
||||
root["version"] = currentVersion();
|
||||
root["messages"] = messages;
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
std::optional<ConversationHistory> HistorySerializer::fromJson(
|
||||
const QJsonObject &root, int *droppedBlocks)
|
||||
{
|
||||
if (droppedBlocks)
|
||||
*droppedBlocks = 0;
|
||||
|
||||
const QString version = root["version"].toString();
|
||||
|
||||
if (!isSupportedVersion(version) || !root["messages"].isArray())
|
||||
return std::nullopt;
|
||||
|
||||
if (version != currentVersion())
|
||||
return historyFromLegacyJson(root);
|
||||
|
||||
int dropped = 0;
|
||||
ConversationHistory history;
|
||||
const QJsonArray messages = root["messages"].toArray();
|
||||
for (const QJsonValue &value : messages)
|
||||
history.append(messageFromJson(value.toObject(), dropped));
|
||||
|
||||
if (droppedBlocks)
|
||||
*droppedBlocks = dropped;
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
27
sources/session/HistorySerializer.hpp
Normal file
27
sources/session/HistorySerializer.hpp
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "session/ConversationHistory.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
class HistorySerializer
|
||||
{
|
||||
public:
|
||||
static QString currentVersion();
|
||||
static bool isSupportedVersion(const QString &version);
|
||||
|
||||
static QJsonObject toJson(const ConversationHistory &history);
|
||||
static std::optional<ConversationHistory> fromJson(
|
||||
const QJsonObject &root, int *droppedBlocks = nullptr);
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
55
sources/session/Message.hpp
Normal file
55
sources/session/Message.hpp
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
#include "session/ContentBlock.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
enum class MessageRole { User, Assistant, System };
|
||||
|
||||
struct Usage
|
||||
{
|
||||
int promptTokens = 0;
|
||||
int completionTokens = 0;
|
||||
int cachedPromptTokens = 0;
|
||||
int reasoningTokens = 0;
|
||||
|
||||
bool isEmpty() const
|
||||
{
|
||||
return promptTokens == 0 && completionTokens == 0 && cachedPromptTokens == 0
|
||||
&& reasoningTokens == 0;
|
||||
}
|
||||
|
||||
bool operator==(const Usage &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const Usage &usage)
|
||||
{
|
||||
return debug.nospace() << "Usage(" << usage.promptTokens << ", " << usage.completionTokens
|
||||
<< ", " << usage.cachedPromptTokens << ", " << usage.reasoningTokens
|
||||
<< ")";
|
||||
}
|
||||
};
|
||||
|
||||
struct Message
|
||||
{
|
||||
MessageRole role = MessageRole::User;
|
||||
QString id;
|
||||
QList<ContentBlock> blocks;
|
||||
Usage usage;
|
||||
|
||||
bool operator==(const Message &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const Message &message)
|
||||
{
|
||||
return debug.nospace() << "Message(role=" << int(message.role) << ", id=" << message.id
|
||||
<< ", blocks=" << message.blocks << ", " << message.usage << ")";
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
427
sources/session/Session.cpp
Normal file
427
sources/session/Session.cpp
Normal file
@@ -0,0 +1,427 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "session/Session.hpp"
|
||||
|
||||
#include <QUuid>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "session/FileEditPayload.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
namespace {
|
||||
|
||||
std::optional<FileEditBlock> fileEditFromToolResult(const QString &result)
|
||||
{
|
||||
if (!isFileEditPayload(result))
|
||||
return std::nullopt;
|
||||
|
||||
auto payload = parseFileEditPayload(result);
|
||||
if (!payload)
|
||||
return std::nullopt;
|
||||
|
||||
const QString editId = payload->value("edit_id").toString();
|
||||
if (!editId.isEmpty())
|
||||
return FileEditBlock{.id = editId, .payload = result};
|
||||
|
||||
const QString generatedId = QString("edit_%1").arg(
|
||||
QUuid::createUuid().toString(QUuid::WithoutBraces));
|
||||
payload->insert("edit_id", generatedId);
|
||||
|
||||
return FileEditBlock{.id = generatedId, .payload = encodeFileEditPayload(*payload)};
|
||||
}
|
||||
|
||||
Message truncateMessage(const Message &message, qsizetype rowsWanted)
|
||||
{
|
||||
Message truncated = message;
|
||||
qsizetype keptBlocks = 0;
|
||||
|
||||
for (qsizetype i = 0; i < message.blocks.size(); ++i) {
|
||||
Message probe = message;
|
||||
probe.blocks = message.blocks.mid(0, i + 1);
|
||||
if (projectMessageToRows(probe).size() > rowsWanted)
|
||||
break;
|
||||
keptBlocks = i + 1;
|
||||
}
|
||||
|
||||
truncated.blocks = message.blocks.mid(0, keptBlocks);
|
||||
return truncated;
|
||||
}
|
||||
|
||||
bool continuesThinking(const ThinkingBlock &block, const ThinkingReceived &event)
|
||||
{
|
||||
return !block.redacted && !event.redacted && event.text.startsWith(block.text)
|
||||
&& (block.signature.isEmpty() || block.signature == event.signature);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Session::Session(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
void Session::setBackend(ChatBackend *backend)
|
||||
{
|
||||
if (m_backend == backend)
|
||||
return;
|
||||
|
||||
if (m_backend)
|
||||
disconnect(m_backend, nullptr, this, nullptr);
|
||||
|
||||
m_backend = backend;
|
||||
|
||||
if (m_backend) {
|
||||
connect(m_backend, &ChatBackend::sessionEvent, this, &Session::handleEvent);
|
||||
}
|
||||
}
|
||||
|
||||
const ConversationHistory &Session::history() const
|
||||
{
|
||||
return m_history;
|
||||
}
|
||||
|
||||
const QList<MessageRow> &Session::rows() const
|
||||
{
|
||||
return m_rows;
|
||||
}
|
||||
|
||||
void Session::setHistory(const ConversationHistory &history)
|
||||
{
|
||||
cancel();
|
||||
m_history = history;
|
||||
m_rows = projectToRows(m_history);
|
||||
emit rowsReset(m_rows);
|
||||
}
|
||||
|
||||
void Session::clear()
|
||||
{
|
||||
setHistory(ConversationHistory{});
|
||||
}
|
||||
|
||||
void Session::truncateRows(int rowIndex)
|
||||
{
|
||||
if (rowIndex < 0 || rowIndex >= m_rows.size())
|
||||
return;
|
||||
|
||||
cancel();
|
||||
|
||||
ConversationHistory kept;
|
||||
qsizetype rowsKept = 0;
|
||||
|
||||
for (const Message &message : m_history.messages()) {
|
||||
const qsizetype rows = projectMessageToRows(message).size();
|
||||
if (rowsKept + rows > rowIndex) {
|
||||
const qsizetype rowsWanted = rowIndex - rowsKept;
|
||||
if (rowsWanted > 0)
|
||||
kept.append(truncateMessage(message, rowsWanted));
|
||||
break;
|
||||
}
|
||||
kept.append(message);
|
||||
rowsKept += rows;
|
||||
}
|
||||
|
||||
const int removed = static_cast<int>(m_rows.size()) - rowIndex;
|
||||
m_rows.remove(rowIndex, removed);
|
||||
m_history = kept;
|
||||
|
||||
emit rowsRemoved(rowIndex, removed);
|
||||
}
|
||||
|
||||
void Session::sendTurn(
|
||||
const QList<ContentBlock> &userBlocks,
|
||||
const std::optional<TurnContext> &context,
|
||||
const TurnOptions &options)
|
||||
{
|
||||
if (!m_backend)
|
||||
return;
|
||||
|
||||
cancel();
|
||||
|
||||
Message message;
|
||||
message.role = MessageRole::User;
|
||||
message.blocks = userBlocks;
|
||||
appendMessage(message);
|
||||
|
||||
TurnRequest request;
|
||||
request.userBlocks = userBlocks;
|
||||
request.history = &m_history;
|
||||
request.context = context;
|
||||
request.options = options;
|
||||
|
||||
m_backend->sendTurn(request);
|
||||
}
|
||||
|
||||
void Session::cancel()
|
||||
{
|
||||
endTurn();
|
||||
|
||||
if (m_backend)
|
||||
m_backend->cancel();
|
||||
}
|
||||
|
||||
bool Session::updateFileEditStatus(
|
||||
const QString &editId, const QString &status, const QString &statusMessage)
|
||||
{
|
||||
QStringList encodedPayloads;
|
||||
|
||||
m_history.visitBlocks([&](ContentBlock &block) {
|
||||
auto *edit = std::get_if<FileEditBlock>(&block);
|
||||
if (!edit || edit->id != editId)
|
||||
return;
|
||||
|
||||
auto payload = parseFileEditPayload(edit->payload);
|
||||
if (!payload)
|
||||
return;
|
||||
|
||||
payload->insert("status", status);
|
||||
if (!statusMessage.isEmpty())
|
||||
payload->insert("status_message", statusMessage);
|
||||
|
||||
edit->payload = encodeFileEditPayload(*payload);
|
||||
encodedPayloads.append(edit->payload);
|
||||
});
|
||||
|
||||
if (encodedPayloads.isEmpty())
|
||||
return false;
|
||||
|
||||
qsizetype matched = 0;
|
||||
for (int i = 0; i < m_rows.size() && matched < encodedPayloads.size(); ++i) {
|
||||
if (m_rows[i].kind != RowKind::FileEdit || m_rows[i].id != editId)
|
||||
continue;
|
||||
m_rows[i].content = encodedPayloads.at(matched++);
|
||||
const MessageRow updated = m_rows.at(i);
|
||||
emit rowUpdated(i, updated);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void Session::handleEvent(const SessionEvent &event)
|
||||
{
|
||||
static_assert(
|
||||
std::variant_size_v<SessionEvent> == 8,
|
||||
"SessionEvent gained an alternative; extend the dispatch below or it is silently dropped");
|
||||
|
||||
if (const auto *started = std::get_if<TurnStarted>(&event)) {
|
||||
m_activeTurnId = started->turnId;
|
||||
m_assistantRowStart = -1;
|
||||
emit turnStarted(started->turnId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (const auto *failure = std::get_if<TurnFailed>(&event)) {
|
||||
if (!failure->turnId.isEmpty() && failure->turnId != m_activeTurnId)
|
||||
return;
|
||||
const QString error = failure->error;
|
||||
endTurn();
|
||||
emit turnFailed(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_activeTurnId.isEmpty() || turnIdOf(event) != m_activeTurnId)
|
||||
return;
|
||||
|
||||
if (const auto *delta = std::get_if<TextDelta>(&event)) {
|
||||
m_textSegment += delta->text;
|
||||
|
||||
if (!m_textSegment.isEmpty() && m_textSegment.at(0).isSpace()) {
|
||||
qsizetype content = 0;
|
||||
while (content < m_textSegment.size() && m_textSegment.at(content).isSpace())
|
||||
++content;
|
||||
m_textSegment.remove(0, content);
|
||||
}
|
||||
|
||||
if (m_textSegment.isEmpty())
|
||||
return;
|
||||
|
||||
const QString text = m_textSegment.trimmed();
|
||||
|
||||
mutateAssistantTail([&text](Message &message) {
|
||||
if (!message.blocks.isEmpty()) {
|
||||
if (auto *last = std::get_if<TextBlock>(&message.blocks.last())) {
|
||||
last->text = text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
message.blocks.append(TextBlock{text});
|
||||
});
|
||||
} else if (const auto *thinking = std::get_if<ThinkingReceived>(&event)) {
|
||||
m_textSegment.clear();
|
||||
mutateAssistantTail([thinking](Message &message) {
|
||||
if (!message.blocks.isEmpty()) {
|
||||
if (auto *last = std::get_if<ThinkingBlock>(&message.blocks.last());
|
||||
last && continuesThinking(*last, *thinking)) {
|
||||
last->text = thinking->text;
|
||||
last->signature = thinking->signature;
|
||||
return;
|
||||
}
|
||||
}
|
||||
message.blocks.append(
|
||||
ThinkingBlock{
|
||||
.text = thinking->text,
|
||||
.signature = thinking->signature,
|
||||
.redacted = thinking->redacted});
|
||||
});
|
||||
} else if (const auto *toolStart = std::get_if<ToolCallStarted>(&event)) {
|
||||
m_textSegment.clear();
|
||||
mutateAssistant([toolStart](Message &message) {
|
||||
if (toolStart->dropPrecedingText && !message.blocks.isEmpty()
|
||||
&& std::holds_alternative<TextBlock>(message.blocks.last())) {
|
||||
message.blocks.removeLast();
|
||||
}
|
||||
message.blocks.append(
|
||||
ToolCallBlock{
|
||||
.id = toolStart->toolId,
|
||||
.name = toolStart->name,
|
||||
.arguments = toolStart->arguments,
|
||||
.result = {}});
|
||||
});
|
||||
} else if (const auto *toolEnd = std::get_if<ToolCallCompleted>(&event)) {
|
||||
m_textSegment.clear();
|
||||
mutateAssistant([toolEnd](Message &message) {
|
||||
for (auto it = message.blocks.rbegin(); it != message.blocks.rend(); ++it) {
|
||||
auto *tool = std::get_if<ToolCallBlock>(&*it);
|
||||
if (!tool || tool->id != toolEnd->toolId)
|
||||
continue;
|
||||
tool->name = toolEnd->name;
|
||||
tool->result = toolEnd->result;
|
||||
break;
|
||||
}
|
||||
if (const auto edit = fileEditFromToolResult(toolEnd->result))
|
||||
message.blocks.append(*edit);
|
||||
});
|
||||
} else if (const auto *usage = std::get_if<UsageReported>(&event)) {
|
||||
if (Message *assistant = activeAssistantMessage()) {
|
||||
assistant->usage = usage->usage;
|
||||
syncAssistantRows();
|
||||
}
|
||||
emit usageReceived(usage->usage);
|
||||
} else if (const auto *completed = std::get_if<TurnCompleted>(&event)) {
|
||||
const QString turnId = completed->turnId;
|
||||
endTurn();
|
||||
emit turnFinished(turnId);
|
||||
}
|
||||
}
|
||||
|
||||
void Session::appendMessage(const Message &message)
|
||||
{
|
||||
m_history.append(message);
|
||||
|
||||
const QList<MessageRow> rows = projectMessageToRows(message);
|
||||
if (rows.isEmpty())
|
||||
return;
|
||||
|
||||
m_rows.append(rows);
|
||||
emit rowsAppended(rows);
|
||||
}
|
||||
|
||||
void Session::ensureAssistantMessage()
|
||||
{
|
||||
if (m_assistantRowStart >= 0)
|
||||
return;
|
||||
|
||||
Message message;
|
||||
message.role = MessageRole::Assistant;
|
||||
message.id = m_activeTurnId;
|
||||
m_assistantRowStart = static_cast<int>(m_rows.size());
|
||||
m_history.append(message);
|
||||
}
|
||||
|
||||
Message *Session::activeAssistantMessage()
|
||||
{
|
||||
return m_assistantRowStart < 0 ? nullptr : m_history.lastMessage();
|
||||
}
|
||||
|
||||
void Session::mutateAssistant(const std::function<void(Message &)> &mutate)
|
||||
{
|
||||
ensureAssistantMessage();
|
||||
|
||||
Message *assistant = activeAssistantMessage();
|
||||
if (!assistant)
|
||||
return;
|
||||
|
||||
mutate(*assistant);
|
||||
syncAssistantRows();
|
||||
}
|
||||
|
||||
void Session::mutateAssistantTail(const std::function<void(Message &)> &mutate)
|
||||
{
|
||||
ensureAssistantMessage();
|
||||
|
||||
Message *assistant = activeAssistantMessage();
|
||||
if (!assistant)
|
||||
return;
|
||||
|
||||
const qsizetype blocksBefore = assistant->blocks.size();
|
||||
mutate(*assistant);
|
||||
|
||||
if (assistant->blocks.size() == blocksBefore)
|
||||
updateLastAssistantRow();
|
||||
else
|
||||
syncAssistantRows();
|
||||
}
|
||||
|
||||
void Session::updateLastAssistantRow()
|
||||
{
|
||||
const Message *assistant = activeAssistantMessage();
|
||||
const int index = static_cast<int>(m_rows.size()) - 1;
|
||||
if (!assistant || index < m_assistantRowStart || assistant->blocks.isEmpty()) {
|
||||
syncAssistantRows();
|
||||
return;
|
||||
}
|
||||
|
||||
auto fresh = projectBlockToRow(*assistant, assistant->blocks.last());
|
||||
if (!fresh) {
|
||||
syncAssistantRows();
|
||||
return;
|
||||
}
|
||||
|
||||
fresh->usage = m_rows.at(index).usage;
|
||||
if (m_rows.at(index) == *fresh)
|
||||
return;
|
||||
|
||||
const MessageRow updated = *fresh;
|
||||
m_rows[index] = updated;
|
||||
emit rowUpdated(index, updated);
|
||||
}
|
||||
|
||||
void Session::syncAssistantRows()
|
||||
{
|
||||
const Message *assistant = activeAssistantMessage();
|
||||
if (!assistant)
|
||||
return;
|
||||
|
||||
const QList<MessageRow> fresh = projectMessageToRows(*assistant);
|
||||
const int start = m_assistantRowStart;
|
||||
const int previous = static_cast<int>(m_rows.size()) - start;
|
||||
|
||||
const int common = std::min(previous, static_cast<int>(fresh.size()));
|
||||
for (int i = 0; i < common; ++i) {
|
||||
if (m_rows.at(start + i) == fresh.at(i))
|
||||
continue;
|
||||
m_rows[start + i] = fresh[i];
|
||||
emit rowUpdated(start + i, fresh[i]);
|
||||
}
|
||||
|
||||
if (fresh.size() > previous) {
|
||||
const QList<MessageRow> added = fresh.mid(previous);
|
||||
m_rows.append(added);
|
||||
emit rowsAppended(added);
|
||||
} else if (fresh.size() < previous) {
|
||||
const int removed = previous - static_cast<int>(fresh.size());
|
||||
m_rows.remove(start + fresh.size(), removed);
|
||||
emit rowsRemoved(start + static_cast<int>(fresh.size()), removed);
|
||||
}
|
||||
}
|
||||
|
||||
void Session::endTurn()
|
||||
{
|
||||
m_activeTurnId.clear();
|
||||
m_textSegment.clear();
|
||||
m_assistantRowStart = -1;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
78
sources/session/Session.hpp
Normal file
78
sources/session/Session.hpp
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
#include "session/ChatBackend.hpp"
|
||||
#include "session/ConversationHistory.hpp"
|
||||
#include "session/HistoryProjection.hpp"
|
||||
#include "session/SessionEvent.hpp"
|
||||
#include "session/TurnContext.hpp"
|
||||
#include "session/TurnRequest.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
class Session : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit Session(QObject *parent = nullptr);
|
||||
|
||||
void setBackend(ChatBackend *backend);
|
||||
|
||||
const ConversationHistory &history() const;
|
||||
const QList<MessageRow> &rows() const;
|
||||
|
||||
void setHistory(const ConversationHistory &history);
|
||||
void clear();
|
||||
void truncateRows(int rowIndex);
|
||||
|
||||
void sendTurn(
|
||||
const QList<ContentBlock> &userBlocks,
|
||||
const std::optional<TurnContext> &context,
|
||||
const TurnOptions &options);
|
||||
void cancel();
|
||||
|
||||
bool updateFileEditStatus(
|
||||
const QString &editId, const QString &status, const QString &statusMessage = {});
|
||||
|
||||
signals:
|
||||
void rowsAppended(const QList<QodeAssist::Session::MessageRow> &rows);
|
||||
void rowUpdated(int index, const QodeAssist::Session::MessageRow &row);
|
||||
void rowsRemoved(int first, int count);
|
||||
void rowsReset(const QList<QodeAssist::Session::MessageRow> &rows);
|
||||
void turnStarted(const QString &turnId);
|
||||
void turnFinished(const QString &turnId);
|
||||
void turnFailed(const QString &error);
|
||||
void usageReceived(const QodeAssist::Session::Usage &usage);
|
||||
|
||||
private:
|
||||
void handleEvent(const SessionEvent &event);
|
||||
void appendMessage(const Message &message);
|
||||
Message *activeAssistantMessage();
|
||||
void mutateAssistant(const std::function<void(Message &)> &mutate);
|
||||
void mutateAssistantTail(const std::function<void(Message &)> &mutate);
|
||||
void ensureAssistantMessage();
|
||||
void updateLastAssistantRow();
|
||||
void syncAssistantRows();
|
||||
void endTurn();
|
||||
|
||||
ConversationHistory m_history;
|
||||
QList<MessageRow> m_rows;
|
||||
QPointer<ChatBackend> m_backend;
|
||||
QString m_activeTurnId;
|
||||
QString m_textSegment;
|
||||
int m_assistantRowStart = -1;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
14
sources/session/SessionEvent.cpp
Normal file
14
sources/session/SessionEvent.cpp
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "session/SessionEvent.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
QString turnIdOf(const SessionEvent &event)
|
||||
{
|
||||
return std::visit([](const auto &alternative) { return alternative.turnId; }, event);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
100
sources/session/SessionEvent.hpp
Normal file
100
sources/session/SessionEvent.hpp
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QMetaType>
|
||||
#include <QString>
|
||||
|
||||
#include <variant>
|
||||
|
||||
#include "session/Message.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
struct TurnStarted
|
||||
{
|
||||
QString turnId;
|
||||
|
||||
bool operator==(const TurnStarted &other) const = default;
|
||||
};
|
||||
|
||||
struct TextDelta
|
||||
{
|
||||
QString turnId;
|
||||
QString text;
|
||||
|
||||
bool operator==(const TextDelta &other) const = default;
|
||||
};
|
||||
|
||||
struct ThinkingReceived
|
||||
{
|
||||
QString turnId;
|
||||
QString text;
|
||||
QString signature;
|
||||
bool redacted = false;
|
||||
|
||||
bool operator==(const ThinkingReceived &other) const = default;
|
||||
};
|
||||
|
||||
struct ToolCallStarted
|
||||
{
|
||||
QString turnId;
|
||||
QString toolId;
|
||||
QString name;
|
||||
QJsonObject arguments;
|
||||
bool dropPrecedingText = false;
|
||||
|
||||
bool operator==(const ToolCallStarted &other) const = default;
|
||||
};
|
||||
|
||||
struct ToolCallCompleted
|
||||
{
|
||||
QString turnId;
|
||||
QString toolId;
|
||||
QString name;
|
||||
QString result;
|
||||
|
||||
bool operator==(const ToolCallCompleted &other) const = default;
|
||||
};
|
||||
|
||||
struct UsageReported
|
||||
{
|
||||
QString turnId;
|
||||
Usage usage;
|
||||
|
||||
bool operator==(const UsageReported &other) const = default;
|
||||
};
|
||||
|
||||
struct TurnCompleted
|
||||
{
|
||||
QString turnId;
|
||||
|
||||
bool operator==(const TurnCompleted &other) const = default;
|
||||
};
|
||||
|
||||
struct TurnFailed
|
||||
{
|
||||
QString turnId;
|
||||
QString error;
|
||||
|
||||
bool operator==(const TurnFailed &other) const = default;
|
||||
};
|
||||
|
||||
using SessionEvent = std::variant<
|
||||
TurnStarted,
|
||||
TextDelta,
|
||||
ThinkingReceived,
|
||||
ToolCallStarted,
|
||||
ToolCallCompleted,
|
||||
UsageReported,
|
||||
TurnCompleted,
|
||||
TurnFailed>;
|
||||
|
||||
QString turnIdOf(const SessionEvent &event);
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
|
||||
Q_DECLARE_METATYPE(QodeAssist::Session::SessionEvent)
|
||||
56
sources/session/TurnContext.cpp
Normal file
56
sources/session/TurnContext.cpp
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "session/TurnContext.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
QString renderSystemPrompt(const TurnContext &context)
|
||||
{
|
||||
QString prompt = context.basePrompt;
|
||||
|
||||
if (context.rolePrompt)
|
||||
prompt += "\n\n" + *context.rolePrompt;
|
||||
|
||||
if (context.project.available) {
|
||||
prompt += QString("\n# Active project: %1").arg(context.project.displayName);
|
||||
prompt += QString(
|
||||
"\n# Project source root: %1"
|
||||
"\n# All new source files, headers, QML and CMake edits MUST be "
|
||||
"created or modified under this directory. Use absolute paths "
|
||||
"rooted here, or project-relative paths.")
|
||||
.arg(context.project.sourceRoot);
|
||||
|
||||
if (context.project.buildDirectory) {
|
||||
prompt += QString(
|
||||
"\n# Build output directory (compiler artifacts only — do NOT "
|
||||
"create or edit source files here): %1")
|
||||
.arg(*context.project.buildDirectory);
|
||||
}
|
||||
|
||||
if (!context.projectRules.isEmpty())
|
||||
prompt += QString("\n# Project Rules\n\n") + context.projectRules;
|
||||
} else {
|
||||
prompt += QString("\n# No active project in IDE");
|
||||
}
|
||||
|
||||
if (!context.alwaysOnSkills.isEmpty())
|
||||
prompt += QString("\n\n") + context.alwaysOnSkills;
|
||||
|
||||
if (!context.skillsCatalog.isEmpty())
|
||||
prompt += QString("\n\n") + context.skillsCatalog;
|
||||
|
||||
for (const InvokedSkill &skill : context.invokedSkills)
|
||||
prompt += QString("\n\n# Invoked Skill: %1\n\n%2").arg(skill.name, skill.body);
|
||||
|
||||
if (!context.linkedFilePaths.isEmpty()) {
|
||||
prompt += "\n\nLinked files for reference:\n";
|
||||
for (const LinkedFile &file : context.linkedFiles)
|
||||
prompt += QString("\nFile: %1\nContent:\n%2\n").arg(file.fileName, file.content);
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
57
sources/session/TurnContext.hpp
Normal file
57
sources/session/TurnContext.hpp
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
#include <optional>
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
struct ProjectInfo
|
||||
{
|
||||
bool available = false;
|
||||
QString displayName;
|
||||
QString sourceRoot;
|
||||
std::optional<QString> buildDirectory;
|
||||
|
||||
bool operator==(const ProjectInfo &other) const = default;
|
||||
};
|
||||
|
||||
struct InvokedSkill
|
||||
{
|
||||
QString name;
|
||||
QString body;
|
||||
|
||||
bool operator==(const InvokedSkill &other) const = default;
|
||||
};
|
||||
|
||||
struct LinkedFile
|
||||
{
|
||||
QString fileName;
|
||||
QString content;
|
||||
|
||||
bool operator==(const LinkedFile &other) const = default;
|
||||
};
|
||||
|
||||
struct TurnContext
|
||||
{
|
||||
QString basePrompt;
|
||||
std::optional<QString> rolePrompt;
|
||||
ProjectInfo project;
|
||||
QString projectRules;
|
||||
QString alwaysOnSkills;
|
||||
QString skillsCatalog;
|
||||
QList<InvokedSkill> invokedSkills;
|
||||
QList<QString> linkedFilePaths;
|
||||
QList<LinkedFile> linkedFiles;
|
||||
|
||||
bool operator==(const TurnContext &other) const = default;
|
||||
};
|
||||
|
||||
QString renderSystemPrompt(const TurnContext &context);
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
58
sources/session/TurnContextBuilder.cpp
Normal file
58
sources/session/TurnContextBuilder.cpp
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "session/TurnContextBuilder.hpp"
|
||||
|
||||
#include <QRegularExpression>
|
||||
#include <QStringList>
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
TurnContextBuilder::TurnContextBuilder(
|
||||
const IProjectContextPort &project,
|
||||
const ISkillsContextPort *skills,
|
||||
const ILinkedFilesPort &linkedFiles)
|
||||
: m_project(project)
|
||||
, m_skills(skills)
|
||||
, m_linkedFiles(linkedFiles)
|
||||
{}
|
||||
|
||||
TurnContext TurnContextBuilder::build(const TurnContextRequest &request) const
|
||||
{
|
||||
TurnContext context;
|
||||
context.basePrompt = request.basePrompt;
|
||||
context.rolePrompt = request.rolePrompt;
|
||||
|
||||
context.project = m_project.projectInfo();
|
||||
if (context.project.available)
|
||||
context.projectRules = m_project.projectRules();
|
||||
|
||||
if (m_skills) {
|
||||
context.alwaysOnSkills = m_skills->alwaysOnBodies();
|
||||
context.skillsCatalog = m_skills->catalogText();
|
||||
|
||||
static const QRegularExpression skillCommand(
|
||||
QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)"));
|
||||
QStringList invokedSkillNames;
|
||||
auto skillMatch = skillCommand.globalMatch(request.message);
|
||||
while (skillMatch.hasNext()) {
|
||||
const QString skillName = skillMatch.next().captured(1);
|
||||
if (invokedSkillNames.contains(skillName))
|
||||
continue;
|
||||
const auto invokedSkill = m_skills->findSkill(skillName);
|
||||
if (invokedSkill && !invokedSkill->body.isEmpty()) {
|
||||
invokedSkillNames << skillName;
|
||||
context.invokedSkills.append(*invokedSkill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.linkedFilePaths = request.linkedFilePaths;
|
||||
if (!request.linkedFilePaths.isEmpty())
|
||||
context.linkedFiles = m_linkedFiles.readFiles(request.linkedFilePaths);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
46
sources/session/TurnContextBuilder.hpp
Normal file
46
sources/session/TurnContextBuilder.hpp
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "session/TurnContext.hpp"
|
||||
#include "session/TurnContextPorts.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
struct TurnContextRequest
|
||||
{
|
||||
QString message;
|
||||
QString basePrompt;
|
||||
std::optional<QString> rolePrompt;
|
||||
QList<QString> linkedFilePaths;
|
||||
};
|
||||
|
||||
class TurnContextBuilder
|
||||
{
|
||||
public:
|
||||
TurnContextBuilder(
|
||||
const IProjectContextPort &project,
|
||||
const ISkillsContextPort *skills,
|
||||
const ILinkedFilesPort &linkedFiles);
|
||||
|
||||
TurnContextBuilder(IProjectContextPort &&, const ISkillsContextPort *, const ILinkedFilesPort &)
|
||||
= delete;
|
||||
TurnContextBuilder(const IProjectContextPort &, const ISkillsContextPort *, ILinkedFilesPort &&)
|
||||
= delete;
|
||||
|
||||
TurnContext build(const TurnContextRequest &request) const;
|
||||
|
||||
private:
|
||||
const IProjectContextPort &m_project;
|
||||
const ISkillsContextPort *m_skills = nullptr;
|
||||
const ILinkedFilesPort &m_linkedFiles;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
53
sources/session/TurnContextPorts.hpp
Normal file
53
sources/session/TurnContextPorts.hpp
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "session/TurnContext.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
class IProjectContextPort
|
||||
{
|
||||
Q_DISABLE_COPY_MOVE(IProjectContextPort)
|
||||
|
||||
public:
|
||||
IProjectContextPort() = default;
|
||||
virtual ~IProjectContextPort() = default;
|
||||
|
||||
virtual ProjectInfo projectInfo() const = 0;
|
||||
virtual QString projectRules() const = 0;
|
||||
};
|
||||
|
||||
class ISkillsContextPort
|
||||
{
|
||||
Q_DISABLE_COPY_MOVE(ISkillsContextPort)
|
||||
|
||||
public:
|
||||
ISkillsContextPort() = default;
|
||||
virtual ~ISkillsContextPort() = default;
|
||||
|
||||
virtual QString alwaysOnBodies() const = 0;
|
||||
virtual QString catalogText() const = 0;
|
||||
virtual std::optional<InvokedSkill> findSkill(const QString &name) const = 0;
|
||||
};
|
||||
|
||||
class ILinkedFilesPort
|
||||
{
|
||||
Q_DISABLE_COPY_MOVE(ILinkedFilesPort)
|
||||
|
||||
public:
|
||||
ILinkedFilesPort() = default;
|
||||
virtual ~ILinkedFilesPort() = default;
|
||||
|
||||
virtual QList<LinkedFile> readFiles(const QList<QString> &paths) const = 0;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
33
sources/session/TurnRequest.hpp
Normal file
33
sources/session/TurnRequest.hpp
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "session/ContentBlock.hpp"
|
||||
#include "session/ConversationHistory.hpp"
|
||||
#include "session/TurnContext.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
struct TurnOptions
|
||||
{
|
||||
bool useTools = false;
|
||||
bool useThinking = false;
|
||||
|
||||
bool operator==(const TurnOptions &other) const = default;
|
||||
};
|
||||
|
||||
struct TurnRequest
|
||||
{
|
||||
QList<ContentBlock> userBlocks;
|
||||
const ConversationHistory *history = nullptr;
|
||||
std::optional<TurnContext> context;
|
||||
TurnOptions options;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
Reference in New Issue
Block a user