refactor: Change to chat conversation

This commit is contained in:
Petr Mironychev
2026-06-11 14:06:19 +02:00
parent 05fe38e289
commit 7bfe9d6f0e
17 changed files with 940 additions and 1243 deletions

View File

@@ -9,7 +9,6 @@
#include <LLMQore/BaseClient.hpp>
#include <LLMQore/ContentBlocks.hpp>
#include "ChatModel.hpp"
#include "GeneralSettings.hpp"
#include "logger/Logger.hpp"
@@ -43,7 +42,8 @@ void ChatCompressor::setActiveAgent(const QString &agentName)
m_activeAgent = agentName;
}
void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *chatModel)
void ChatCompressor::startCompression(
const QString &chatFilePath, ConversationHistory *sourceHistory)
{
if (m_isCompressing) {
emit compressionFailed(tr("Compression already in progress"));
@@ -55,7 +55,7 @@ void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *ch
return;
}
if (!chatModel || chatModel->rowCount() == 0) {
if (!sourceHistory || sourceHistory->isEmpty()) {
emit compressionFailed(tr("Chat is empty, nothing to compress"));
return;
}
@@ -81,9 +81,7 @@ void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *ch
}
m_isCompressing = true;
m_chatModel = chatModel;
m_originalChatPath = chatFilePath;
m_accumulatedSummary.clear();
m_session = session;
emit compressionStarted();
@@ -96,28 +94,26 @@ void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *ch
"discussion."));
auto *history = session->history();
for (const auto &msg : m_chatModel->getChatHistory()) {
if (msg.role == ChatModel::ChatRole::Tool || msg.role == ChatModel::ChatRole::FileEdit
|| msg.role == ChatModel::ChatRole::Thinking)
for (const auto &msg : sourceHistory->messages()) {
if (msg.role() != Message::Role::User && msg.role() != Message::Role::Assistant)
continue;
if (msg.content.trimmed().isEmpty())
const QString text = msg.text();
if (text.trimmed().isEmpty())
continue;
Message apiMessage(
msg.role == ChatModel::ChatRole::User ? Message::Role::User : Message::Role::Assistant);
apiMessage.appendBlock(std::make_unique<LLMQore::TextContent>(msg.content));
Message apiMessage(msg.role());
apiMessage.appendBlock(std::make_unique<LLMQore::TextContent>(text));
history->append(std::move(apiMessage));
}
m_connections.append(connect(
client, &::LLMQore::BaseClient::chunkReceived,
this, &ChatCompressor::onPartialResponseReceived, Qt::UniqueConnection));
m_connections.append(connect(
client, &::LLMQore::BaseClient::requestCompleted,
this, &ChatCompressor::onFullResponseReceived, Qt::UniqueConnection));
m_connections.append(connect(
client, &::LLMQore::BaseClient::requestFailed,
this, &ChatCompressor::onRequestFailed, Qt::UniqueConnection));
connect(
session, &Session::finished, this,
[this](const LLMQore::RequestID &id, const QString &) { onCompressionFinished(id); });
connect(
session, &Session::failed, this,
[this](const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error) {
onCompressionFailed(id, error.message);
});
client->setTransferTimeout(
static_cast<int>(Settings::generalSettings().requestTimeout() * 1000));
@@ -149,26 +145,20 @@ void ChatCompressor::cancelCompression()
emit compressionFailed(tr("Compression cancelled"));
}
void ChatCompressor::onPartialResponseReceived(const QString &requestId, const QString &partialText)
void ChatCompressor::onCompressionFinished(const QString &requestId)
{
if (!m_isCompressing || requestId != m_currentRequestId)
return;
m_accumulatedSummary += partialText;
QString summary;
if (m_session) {
if (auto *history = m_session->history(); history && !history->isEmpty())
summary = history->messages().back().text();
}
void ChatCompressor::onFullResponseReceived(const QString &requestId, const QString &fullText)
{
Q_UNUSED(fullText)
if (!m_isCompressing || requestId != m_currentRequestId)
return;
LOG_MESSAGE(
QString("Received summary, length: %1 characters").arg(m_accumulatedSummary.length()));
LOG_MESSAGE(QString("Received summary, length: %1 characters").arg(summary.length()));
const QString compressedPath = createCompressedChatPath(m_originalChatPath);
const QString summary = m_accumulatedSummary;
const QString sourcePath = m_originalChatPath;
cleanupState();
@@ -182,7 +172,7 @@ void ChatCompressor::onFullResponseReceived(const QString &requestId, const QStr
emit compressionCompleted(compressedPath);
}
void ChatCompressor::onRequestFailed(const QString &requestId, const QString &error)
void ChatCompressor::onCompressionFailed(const QString &requestId, const QString &error)
{
if (!m_isCompressing || requestId != m_currentRequestId)
return;
@@ -242,11 +232,11 @@ bool ChatCompressor::createCompressedChatFile(
QJsonObject summaryMessage;
summaryMessage["role"] = "assistant";
summaryMessage["content"] = QString("# Chat Summary\n\n%1").arg(summary);
summaryMessage["id"] = QUuid::createUuid().toString(QUuid::WithoutBraces);
summaryMessage["isRedacted"] = false;
summaryMessage["attachments"] = QJsonArray();
summaryMessage["images"] = QJsonArray();
QJsonObject textBlock;
textBlock["type"] = "text";
textBlock["text"] = QString("# Chat Summary\n\n%1").arg(summary);
summaryMessage["blocks"] = QJsonArray{textBlock};
root["messages"] = QJsonArray{summaryMessage};
root["compressedFrom"] = sourcePath;
@@ -265,24 +255,13 @@ bool ChatCompressor::createCompressedChatFile(
return true;
}
void ChatCompressor::disconnectAllSignals()
{
for (const auto &connection : std::as_const(m_connections))
disconnect(connection);
m_connections.clear();
}
void ChatCompressor::cleanupState()
{
disconnectAllSignals();
Session *session = m_session;
m_isCompressing = false;
m_currentRequestId.clear();
m_originalChatPath.clear();
m_accumulatedSummary.clear();
m_chatModel = nullptr;
m_session = nullptr;
if (session && m_sessionManager)

View File

@@ -12,12 +12,11 @@
namespace QodeAssist {
class SessionManager;
class Session;
class ConversationHistory;
}
namespace QodeAssist::Chat {
class ChatModel;
class ChatCompressor : public QObject
{
Q_OBJECT
@@ -28,7 +27,7 @@ public:
void setSessionManager(SessionManager *sessionManager);
void setActiveAgent(const QString &agentName);
void startCompression(const QString &chatFilePath, ChatModel *chatModel);
void startCompression(const QString &chatFilePath, ConversationHistory *sourceHistory);
bool isCompressing() const;
void cancelCompression();
@@ -38,30 +37,23 @@ signals:
void compressionCompleted(const QString &compressedChatPath);
void compressionFailed(const QString &error);
private slots:
void onPartialResponseReceived(const QString &requestId, const QString &partialText);
void onFullResponseReceived(const QString &requestId, const QString &fullText);
void onRequestFailed(const QString &requestId, const QString &error);
private:
void onCompressionFinished(const QString &requestId);
void onCompressionFailed(const QString &requestId, const QString &error);
QString createCompressedChatPath(const QString &originalPath) const;
QString buildCompressionPrompt() const;
bool createCompressedChatFile(
const QString &sourcePath, const QString &destPath, const QString &summary);
void disconnectAllSignals();
void cleanupState();
void handleCompressionError(const QString &error);
bool m_isCompressing = false;
QString m_currentRequestId;
QString m_originalChatPath;
QString m_accumulatedSummary;
QPointer<SessionManager> m_sessionManager;
QString m_activeAgent;
QPointer<Session> m_session;
ChatModel *m_chatModel = nullptr;
QList<QMetaObject::Connection> m_connections;
};
} // namespace QodeAssist::Chat

View File

@@ -16,15 +16,20 @@
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
#include "ChatModel.hpp"
#include <ConversationHistory.hpp>
#include <Message.hpp>
#include <PluginBlocks.hpp>
#include <LLMQore/ContentBlocks.hpp>
#include "Logger.hpp"
#include "ProjectSettings.hpp"
namespace QodeAssist::Chat {
ChatHistoryStore::ChatHistoryStore(ChatModel *chatModel, QObject *parent)
ChatHistoryStore::ChatHistoryStore(ConversationHistory *history, QObject *parent)
: QObject(parent)
, m_chatModel(chatModel)
, m_history(history)
{}
QString ChatHistoryStore::historyDir() const
@@ -52,19 +57,25 @@ QString ChatHistoryStore::suggestedFileName() const
{
QString shortMessage;
if (m_chatModel->rowCount() > 0) {
QString firstMessage
= m_chatModel->data(m_chatModel->index(0), ChatModel::Content).toString();
shortMessage = firstMessage.split('\n').first().simplified().left(30);
if (m_history) {
for (const auto &message : m_history->messages()) {
if (message.role() != Message::Role::User)
continue;
if (shortMessage.isEmpty()) {
QVariantList images
= m_chatModel->data(m_chatModel->index(0), ChatModel::Images).toList();
if (!images.isEmpty()) {
const QString text = message.text();
if (!text.trimmed().isEmpty()) {
shortMessage = text.split('\n').first().simplified().left(30);
} else {
for (const auto &block : message.blocks()) {
if (dynamic_cast<StoredImageContent *>(block.get())) {
shortMessage = "image_chat";
break;
}
}
}
break;
}
}
return generateChatFileName(shortMessage, historyDir());
}
@@ -107,12 +118,12 @@ QString ChatHistoryStore::autosaveFilePath(
SerializationResult ChatHistoryStore::save(const QString &filePath) const
{
return ChatSerializer::saveToFile(m_chatModel, filePath);
return ChatSerializer::saveToFile(m_history, filePath);
}
SerializationResult ChatHistoryStore::load(const QString &filePath) const
{
return ChatSerializer::loadFromFile(m_chatModel, filePath);
return ChatSerializer::loadFromFile(m_history, filePath);
}
void ChatHistoryStore::showSaveDialog()

View File

@@ -9,16 +9,18 @@
#include "ChatSerializer.hpp"
namespace QodeAssist::Chat {
namespace QodeAssist {
class ConversationHistory;
}
class ChatModel;
namespace QodeAssist::Chat {
class ChatHistoryStore : public QObject
{
Q_OBJECT
public:
explicit ChatHistoryStore(ChatModel *chatModel, QObject *parent = nullptr);
explicit ChatHistoryStore(ConversationHistory *history, QObject *parent = nullptr);
QString historyDir() const;
QString suggestedFileName() const;
@@ -42,7 +44,7 @@ signals:
private:
QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
ChatModel *m_chatModel;
ConversationHistory *m_history;
};
} // namespace QodeAssist::Chat

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,16 @@
#include "MessagePart.hpp"
#include <QAbstractListModel>
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QPointer>
#include <QVector>
#include <QtQmlIntegration>
#include "context/ContentFile.hpp"
namespace QodeAssist {
class ConversationHistory;
}
namespace QodeAssist::Chat {
@@ -43,80 +48,19 @@ public:
};
Q_ENUM(Roles)
struct ImageAttachment
{
QString fileName; // Original filename
QString storedPath; // Path to stored image file (relative to chat folder)
QString mediaType; // MIME type
};
struct Message
{
ChatRole role;
QString content;
QString id;
bool isRedacted = false;
QString signature = QString();
QList<Context::ContentFile> attachments;
QList<ImageAttachment> images;
QString toolName;
QJsonObject toolArguments;
QString toolResult;
int promptTokens = 0;
int completionTokens = 0;
int cachedPromptTokens = 0;
int reasoningTokens = 0;
};
explicit ChatModel(QObject *parent = nullptr);
void setHistory(ConversationHistory *history);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE void addMessage(
const QString &content,
ChatRole role,
const QString &id,
const QList<Context::ContentFile> &attachments = {},
const QList<ImageAttachment> &images = {},
bool isRedacted = false,
const QString &signature = QString());
Q_INVOKABLE void clear();
Q_INVOKABLE QList<MessagePart> processMessageContent(const QString &content) const;
QVector<Message> getChatHistory() const;
QJsonArray prepareMessagesForRequest(const QString &systemPrompt) const;
QString currentModel() const;
QString lastMessageId() const;
Q_INVOKABLE void resetModelTo(int index);
Q_INVOKABLE QVariantList userMessagePreviews(int maxLength = 80) const;
void addToolExecutionStatus(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments);
void setToolMessageData(
const QString &toolId,
const QString &toolName,
const QJsonObject &toolArguments,
const QString &toolResult);
void updateToolResult(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QString &result);
void addThinkingBlock(
const QString &requestId, const QString &thinking, const QString &signature);
void addRedactedThinkingBlock(const QString &requestId, const QString &signature);
void updateMessageContent(const QString &messageId, const QString &newContent);
void setMessageUsage(
const QString &messageId,
int promptTokens,
@@ -129,9 +73,6 @@ public:
int sessionCachedPromptTokens() const;
int sessionTotalTokens() const;
void setLoadingFromHistory(bool loading);
bool isLoadingFromHistory() const;
void setChatFilePath(const QString &filePath);
QString chatFilePath() const;
@@ -140,18 +81,60 @@ signals:
void sessionUsageChanged();
private slots:
void onFileEditApplied(const QString &editId);
void onFileEditRejected(const QString &editId);
void onFileEditArchived(const QString &editId);
void onHistoryMessageAdded(int index);
void onHistoryMessageUpdated(int index);
void onHistoryCleared();
void onHistoryReset();
void onFileEditStatusChanged(const QString &editId);
private:
void updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage);
struct AttachmentRef
{
QString fileName;
QString storedPath;
};
struct ImageRef
{
QString fileName;
QString storedPath;
QString mediaType;
};
struct Row
{
ChatRole kind = ChatRole::Assistant;
int messageIndex = -1;
QString messageId;
QString content;
bool isRedacted = false;
QString editId;
QVector<AttachmentRef> attachments;
QVector<ImageRef> images;
};
struct Usage
{
int prompt = 0;
int completion = 0;
int cached = 0;
int reasoning = 0;
};
QVector<Message> m_messages;
bool m_loadingFromHistory = false;
void rebuildAll();
void reprojectTail(int startMessageIndex);
int startMessageIndexFor(int messageIndex) const;
int firstRowForMessage(int messageIndex) const;
QHash<QString, QString> buildToolResultMap() const;
void appendRowsForMessage(
int messageIndex, const QHash<QString, QString> &toolResults, QVector<Row> &out) const;
QString overlayFileEditStatus(const QString &content, const QString &editId) const;
QVariantList buildAttachmentList(const QVector<AttachmentRef> &attachments) const;
QVariantList buildImageList(const QVector<ImageRef> &images) const;
QPointer<ConversationHistory> m_history;
QVector<Row> m_rows;
QHash<QString, Usage> m_usageByMessageId;
QString m_chatFilePath;
};
} // namespace QodeAssist::Chat
Q_DECLARE_METATYPE(QodeAssist::Chat::ChatModel::Message)
Q_DECLARE_METATYPE(QodeAssist::Chat::MessagePart)

View File

@@ -29,6 +29,8 @@
#include "QodeAssistConstants.hpp"
#include <AgentFactory.hpp>
#include <ConversationHistory.hpp>
#include <Message.hpp>
#include <SessionManager.hpp>
#include "ChatAgentController.hpp"
@@ -74,17 +76,20 @@ QKeySequence sendMessageKeySequence()
ChatRootView::ChatRootView(QQuickItem *parent)
: QQuickItem(parent)
, m_history(new QodeAssist::ConversationHistory(this))
, m_chatModel(new ChatModel(this))
, m_clientInterface(new ClientInterface(m_chatModel, this))
, m_fileManager(new ChatFileManager(this))
, m_isRequestInProgress(false)
, m_chatCompressor(new ChatCompressor(this))
, m_agentController(new ChatAgentController(this))
, m_fileEditController(new FileEditController(m_chatModel, this))
, m_tokenCounter(
new InputTokenCounter(m_chatModel, m_clientInterface->contextManager(), this))
, m_historyStore(new ChatHistoryStore(m_chatModel, this))
, m_fileEditController(new FileEditController(this))
, m_tokenCounter(new InputTokenCounter(m_history, m_clientInterface->contextManager(), this))
, m_historyStore(new ChatHistoryStore(m_history, this))
{
m_chatModel->setHistory(m_history);
m_clientInterface->setHistory(m_history);
m_isSyncOpenFiles = Settings::chatAssistantSettings().linkOpenFiles();
connect(
&Settings::chatAssistantSettings().linkOpenFiles,
@@ -923,13 +928,12 @@ QString ChatRootView::chatTitle() const
QString ChatRootView::computeChatTitle() const
{
if (!m_chatModel)
if (!m_history)
return {};
const auto history = m_chatModel->getChatHistory();
for (const auto &msg : history) {
if (msg.role != ChatModel::User)
for (const auto &msg : m_history->messages()) {
if (msg.role() != Message::Role::User)
continue;
const QString content = msg.content.trimmed();
const QString content = msg.text().trimmed();
if (content.isEmpty())
continue;
const QString firstLine = content.section(QChar('\n'), 0, 0).trimmed();
@@ -1266,7 +1270,7 @@ void ChatRootView::compressCurrentChat()
loadAvailableChatAgents();
m_chatCompressor->setSessionManager(sessionManager());
m_chatCompressor->setActiveAgent(currentChatAgent());
m_chatCompressor->startCompression(m_recentFilePath, m_chatModel);
m_chatCompressor->startCompression(m_recentFilePath, m_history);
}
void ChatRootView::cancelCompression()

View File

@@ -20,6 +20,7 @@ class SkillsManager;
namespace QodeAssist {
class AgentFactory;
class SessionManager;
class ConversationHistory;
}
namespace QodeAssist::Chat {
@@ -248,6 +249,7 @@ private:
AgentFactory *agentFactory() const;
SessionManager *sessionManager() const;
QodeAssist::ConversationHistory *m_history;
ChatModel *m_chatModel;
ClientInterface *m_clientInterface;
ChatFileManager *m_fileManager;

View File

@@ -5,7 +5,8 @@
#include "ChatSerializer.hpp"
#include "Logger.hpp"
#include <QBuffer>
#include <memory>
#include <QDir>
#include <QFile>
#include <QFileInfo>
@@ -13,12 +14,57 @@
#include <QJsonDocument>
#include <QUuid>
#include <LLMQore/ContentBlocks.hpp>
#include <ConversationHistory.hpp>
#include <Message.hpp>
#include <MessageSerializer.hpp>
#include <PluginBlocks.hpp>
#include "context/ChangesManager.h"
namespace QodeAssist::Chat {
const QString ChatSerializer::VERSION = "0.2";
namespace {
SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QString &filePath)
const QString kFileEditMarker = QStringLiteral("QODEASSIST_FILE_EDIT:");
// Legacy (<= 0.2) per-row ChatRole values, kept only for importing old chat files.
enum class LegacyRole { System = 0, User = 1, Assistant = 2, Tool = 3, FileEdit = 4, Thinking = 5 };
void registerEditFromResult(const QString &result)
{
const int pos = result.indexOf(kFileEditMarker);
if (pos < 0)
return;
const QString jsonStr = result.mid(pos + kFileEditMarker.length());
const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
if (!doc.isObject())
return;
const QJsonObject obj = doc.object();
const QString editId = obj.value("edit_id").toString();
const QString filePath = obj.value("file").toString();
if (editId.isEmpty() || filePath.isEmpty())
return;
Context::ChangesManager::instance().addFileEdit(
editId,
filePath,
obj.value("old_content").toString(),
obj.value("new_content").toString(),
/*autoApply=*/false,
/*isFromHistory=*/true);
}
} // namespace
const QString ChatSerializer::VERSION = "0.3";
SerializationResult ChatSerializer::saveToFile(
const ConversationHistory *history, const QString &filePath)
{
if (!history)
return {false, "No conversation history"};
if (!ensureDirectoryExists(filePath)) {
return {false, "Failed to create directory structure"};
}
@@ -28,9 +74,7 @@ SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QSt
return {false, QString("Failed to open file for writing: %1").arg(filePath)};
}
QJsonObject root = serializeChat(model, filePath);
QJsonDocument doc(root);
QJsonDocument doc(serializeChat(history));
if (file.write(doc.toJson(QJsonDocument::Indented)) == -1) {
return {false, QString("Failed to write to file: %1").arg(file.errorString())};
}
@@ -38,8 +82,12 @@ SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QSt
return {true, QString()};
}
SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString &filePath)
SerializationResult ChatSerializer::loadFromFile(
ConversationHistory *history, const QString &filePath)
{
if (!history)
return {false, "No conversation history"};
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) {
return {false, QString("Failed to open file for reading: %1").arg(filePath)};
@@ -51,180 +99,140 @@ SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString
return {false, QString("JSON parse error: %1").arg(error.errorString())};
}
QJsonObject root = doc.object();
QString version = root["version"].toString();
const QJsonObject root = doc.object();
const QString version = root["version"].toString();
if (!validateVersion(version)) {
return {false, QString("Unsupported version: %1").arg(version)};
}
if (!deserializeChat(model, root, filePath)) {
return {false, "Failed to deserialize chat data"};
if (version == VERSION)
return loadCurrent(history, root);
return loadLegacy(history, root);
}
return {true, QString()};
}
QJsonObject ChatSerializer::serializeMessage(
const ChatModel::Message &message, const QString &chatFilePath)
{
QJsonObject messageObj;
messageObj["role"] = static_cast<int>(message.role);
messageObj["content"] = message.content;
messageObj["id"] = message.id;
if (message.isRedacted) {
messageObj["isRedacted"] = true;
}
if (!message.signature.isEmpty()) {
messageObj["signature"] = message.signature;
}
if (message.role == ChatModel::ChatRole::Tool) {
if (!message.toolName.isEmpty())
messageObj["toolName"] = message.toolName;
if (!message.toolArguments.isEmpty())
messageObj["toolArguments"] = message.toolArguments;
if (!message.toolResult.isEmpty())
messageObj["toolResult"] = message.toolResult;
}
if (!message.attachments.isEmpty()) {
QJsonArray attachmentsArray;
for (const auto &attachment : message.attachments) {
QJsonObject attachmentObj;
attachmentObj["fileName"] = attachment.filename;
attachmentObj["storedPath"] = attachment.content;
attachmentsArray.append(attachmentObj);
}
messageObj["attachments"] = attachmentsArray;
}
if (!message.images.isEmpty()) {
QJsonArray imagesArray;
for (const auto &image : message.images) {
QJsonObject imageObj;
imageObj["fileName"] = image.fileName;
imageObj["storedPath"] = image.storedPath;
imageObj["mediaType"] = image.mediaType;
imagesArray.append(imageObj);
}
messageObj["images"] = imagesArray;
}
if (message.promptTokens > 0 || message.completionTokens > 0) {
QJsonObject usageObj;
usageObj["promptTokens"] = message.promptTokens;
usageObj["completionTokens"] = message.completionTokens;
if (message.cachedPromptTokens > 0)
usageObj["cachedPromptTokens"] = message.cachedPromptTokens;
if (message.reasoningTokens > 0)
usageObj["reasoningTokens"] = message.reasoningTokens;
messageObj["usage"] = usageObj;
}
return messageObj;
}
ChatModel::Message ChatSerializer::deserializeMessage(
const QJsonObject &json, const QString &chatFilePath)
{
ChatModel::Message message;
message.role = static_cast<ChatModel::ChatRole>(json["role"].toInt());
message.content = json["content"].toString();
message.id = json["id"].toString();
message.isRedacted = json["isRedacted"].toBool(false);
message.signature = json["signature"].toString();
message.toolName = json["toolName"].toString();
message.toolArguments = json["toolArguments"].toObject();
message.toolResult = json["toolResult"].toString();
if (json.contains("attachments")) {
QJsonArray attachmentsArray = json["attachments"].toArray();
for (const auto &attachmentValue : attachmentsArray) {
QJsonObject attachmentObj = attachmentValue.toObject();
Context::ContentFile attachment;
attachment.filename = attachmentObj["fileName"].toString();
attachment.content = attachmentObj["storedPath"].toString();
message.attachments.append(attachment);
}
}
if (json.contains("images")) {
QJsonArray imagesArray = json["images"].toArray();
for (const auto &imageValue : imagesArray) {
QJsonObject imageObj = imageValue.toObject();
ChatModel::ImageAttachment image;
image.fileName = imageObj["fileName"].toString();
image.storedPath = imageObj["storedPath"].toString();
image.mediaType = imageObj["mediaType"].toString();
message.images.append(image);
}
}
if (json.contains("usage")) {
const QJsonObject usageObj = json["usage"].toObject();
message.promptTokens = usageObj["promptTokens"].toInt();
message.completionTokens = usageObj["completionTokens"].toInt();
message.cachedPromptTokens = usageObj["cachedPromptTokens"].toInt();
message.reasoningTokens = usageObj["reasoningTokens"].toInt();
}
return message;
}
QJsonObject ChatSerializer::serializeChat(const ChatModel *model, const QString &chatFilePath)
QJsonObject ChatSerializer::serializeChat(const ConversationHistory *history)
{
QJsonArray messagesArray;
for (const auto &message : model->getChatHistory()) {
messagesArray.append(serializeMessage(message, chatFilePath));
}
for (const auto &message : history->messages())
messagesArray.append(MessageSerializer::toJson(message));
QJsonObject root;
root["version"] = VERSION;
root["messages"] = messagesArray;
return root;
}
bool ChatSerializer::deserializeChat(
ChatModel *model, const QJsonObject &json, const QString &chatFilePath)
SerializationResult ChatSerializer::loadCurrent(ConversationHistory *history, const QJsonObject &root)
{
QJsonArray messagesArray = json["messages"].toArray();
QVector<ChatModel::Message> messages;
messages.reserve(messagesArray.size());
history->clear();
for (const auto &messageValue : messagesArray) {
messages.append(deserializeMessage(messageValue.toObject(), chatFilePath));
const QJsonArray messagesArray = root["messages"].toArray();
for (const auto &value : messagesArray) {
bool ok = false;
Message message = MessageSerializer::fromJson(value.toObject(), &ok);
if (ok)
history->append(std::move(message));
}
model->clear();
model->setLoadingFromHistory(true);
for (const auto &message : messages) {
model->addMessage(
message.content,
message.role,
message.id,
message.attachments,
message.images,
message.isRedacted,
message.signature);
if (message.role == ChatModel::ChatRole::Tool) {
model->setToolMessageData(
message.id, message.toolName, message.toolArguments, message.toolResult);
}
LOG_MESSAGE(QString("Loaded message with %1 image(s), isRedacted=%2, signature length=%3")
.arg(message.images.size())
.arg(message.isRedacted)
.arg(message.signature.length()));
registerHistoricalFileEdits(history);
return {true, QString()};
}
model->setLoadingFromHistory(false);
SerializationResult ChatSerializer::loadLegacy(ConversationHistory *history, const QJsonObject &root)
{
history->clear();
return true;
const QJsonArray arr = root["messages"].toArray();
int i = 0;
while (i < arr.size()) {
const QJsonObject mj = arr[i].toObject();
const auto role = static_cast<LegacyRole>(mj["role"].toInt());
if (role == LegacyRole::Tool) {
Message assistant(Message::Role::Assistant);
Message toolResults(Message::Role::User);
while (i < arr.size()
&& static_cast<LegacyRole>(arr[i].toObject()["role"].toInt()) == LegacyRole::Tool) {
const QJsonObject tj = arr[i].toObject();
const QString toolName = tj["toolName"].toString();
const QString id = tj["id"].toString();
if (!toolName.isEmpty()) {
assistant.appendBlock(std::make_unique<LLMQore::ToolUseContent>(
id, toolName, tj["toolArguments"].toObject()));
toolResults.appendBlock(std::make_unique<LLMQore::ToolResultContent>(
id, tj["toolResult"].toString()));
}
++i;
}
if (!assistant.blocks().empty()) {
history->append(std::move(assistant));
history->append(std::move(toolResults));
}
continue;
}
++i;
if (role == LegacyRole::FileEdit)
continue; // derived from the tool result in the new model
if (role == LegacyRole::Thinking) {
const QString content = mj["content"].toString();
const QString signature = mj["signature"].toString();
Message assistant(Message::Role::Assistant);
if (mj["isRedacted"].toBool(false)) {
assistant.appendBlock(
std::make_unique<LLMQore::RedactedThinkingContent>(signature));
} else {
const int sigPos = content.indexOf(QStringLiteral("\n[Signature:"));
const QString thinking = sigPos >= 0 ? content.left(sigPos) : content;
assistant.appendBlock(
std::make_unique<LLMQore::ThinkingContent>(thinking, signature));
}
history->append(std::move(assistant));
continue;
}
if (role == LegacyRole::User) {
Message user(Message::Role::User, mj["id"].toString());
user.appendBlock(std::make_unique<LLMQore::TextContent>(mj["content"].toString()));
for (const auto &a : mj["attachments"].toArray()) {
const QJsonObject ao = a.toObject();
user.appendBlock(std::make_unique<StoredAttachmentContent>(
ao["fileName"].toString(), ao["storedPath"].toString()));
}
for (const auto &im : mj["images"].toArray()) {
const QJsonObject io = im.toObject();
user.appendBlock(std::make_unique<StoredImageContent>(
io["fileName"].toString(),
io["storedPath"].toString(),
io["mediaType"].toString()));
}
history->append(std::move(user));
} else {
const QString content = mj["content"].toString();
if (content.trimmed().isEmpty())
continue;
const Message::Role mapped
= role == LegacyRole::System ? Message::Role::System : Message::Role::Assistant;
Message message(mapped, mj["id"].toString());
message.appendBlock(std::make_unique<LLMQore::TextContent>(content));
history->append(std::move(message));
}
}
registerHistoricalFileEdits(history);
return {true, QString()};
}
void ChatSerializer::registerHistoricalFileEdits(const ConversationHistory *history)
{
for (const auto &message : history->messages()) {
for (const auto &block : message.blocks()) {
if (auto *tr = dynamic_cast<LLMQore::ToolResultContent *>(block.get()))
registerEditFromResult(tr->result());
}
}
}
bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
@@ -236,18 +244,7 @@ bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
bool ChatSerializer::validateVersion(const QString &version)
{
if (version == VERSION) {
return true;
}
if (version == "0.1") {
LOG_MESSAGE(
"Loading chat from old format 0.1 - images folder structure has changed from _images "
"to _content");
return true;
}
return false;
return version == VERSION || version == "0.2" || version == "0.1";
}
QString ChatSerializer::getChatContentFolder(const QString &chatFilePath)

View File

@@ -4,11 +4,12 @@
#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include "ChatModel.hpp"
namespace QodeAssist {
class ConversationHistory;
}
namespace QodeAssist::Chat {
@@ -21,18 +22,14 @@ struct SerializationResult
class ChatSerializer
{
public:
static SerializationResult saveToFile(const ChatModel *model, const QString &filePath);
static SerializationResult loadFromFile(ChatModel *model, const QString &filePath);
// Public for testing purposes
static QJsonObject serializeMessage(const ChatModel::Message &message, const QString &chatFilePath);
static ChatModel::Message deserializeMessage(const QJsonObject &json, const QString &chatFilePath);
static QJsonObject serializeChat(const ChatModel *model, const QString &chatFilePath);
static bool deserializeChat(ChatModel *model, const QJsonObject &json, const QString &chatFilePath);
static SerializationResult saveToFile(
const ConversationHistory *history, const QString &filePath);
static SerializationResult loadFromFile(ConversationHistory *history, const QString &filePath);
// Content management (images and text files)
static QString getChatContentFolder(const QString &chatFilePath);
static bool saveContentToStorage(const QString &chatFilePath,
static bool saveContentToStorage(
const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath);
@@ -40,7 +37,11 @@ public:
private:
static const QString VERSION;
static constexpr int CURRENT_VERSION = 1;
static QJsonObject serializeChat(const ConversationHistory *history);
static SerializationResult loadCurrent(ConversationHistory *history, const QJsonObject &root);
static SerializationResult loadLegacy(ConversationHistory *history, const QJsonObject &root);
static void registerHistoricalFileEdits(const ConversationHistory *history);
static bool ensureDirectoryExists(const QString &filePath);
static bool validateVersion(const QString &version);

View File

@@ -5,6 +5,7 @@
#include "ClientInterface.hpp"
#include <memory>
#include <vector>
#include <LLMQore/BaseClient.hpp>
#include <LLMQore/ContentBlocks.hpp>
@@ -20,6 +21,7 @@
#include <texteditor/textdocument.h>
#include <texteditor/texteditor.h>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
@@ -29,11 +31,10 @@
#include <QUuid>
#include <ConversationHistory.hpp>
#include <Message.hpp>
#include <ContextRenderer.hpp>
#include <Message.hpp>
#include <PluginBlocks.hpp>
#include <Session.hpp>
#include <QDir>
#include <SessionManager.hpp>
#include <SystemPromptBuilder.hpp>
@@ -52,6 +53,15 @@
namespace QodeAssist::Chat {
namespace {
struct StoredImage
{
QString fileName;
QString storedPath;
QString mediaType;
};
} // namespace
ClientInterface::ClientInterface(ChatModel *chatModel, QObject *parent)
: QObject(parent)
, m_chatModel(chatModel)
@@ -73,6 +83,11 @@ void ClientInterface::setSessionManager(SessionManager *sessionManager)
m_sessionManager = sessionManager;
}
void ClientInterface::setHistory(ConversationHistory *history)
{
m_history = history;
}
void ClientInterface::setActiveAgent(const QString &agentName)
{
m_activeAgent = agentName;
@@ -94,7 +109,6 @@ void ClientInterface::sendMessage(
}
cancelRequest();
m_accumulatedResponses.clear();
Context::ChangesManager::instance().archiveAllNonArchivedEdits();
@@ -126,7 +140,7 @@ void ClientInterface::sendMessage(
.arg(textFiles.size()));
}
QList<ChatModel::ImageAttachment> imageAttachments;
QList<StoredImage> storedImages;
if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
for (const QString &imagePath : imageFiles) {
QString base64Data = encodeImageToBase64(imagePath);
@@ -137,11 +151,8 @@ void ClientInterface::sendMessage(
QFileInfo fileInfo(imagePath);
if (ChatSerializer::saveContentToStorage(
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
ChatModel::ImageAttachment imageAttachment;
imageAttachment.fileName = fileInfo.fileName();
imageAttachment.storedPath = storedPath;
imageAttachment.mediaType = getMediaTypeForImage(imagePath);
imageAttachments.append(imageAttachment);
storedImages.append(
{fileInfo.fileName(), storedPath, getMediaTypeForImage(imagePath)});
LOG_MESSAGE(QString("Stored image %1 as %2").arg(fileInfo.fileName(), storedPath));
}
}
@@ -156,15 +167,15 @@ void ClientInterface::sendMessage(
emit errorOccurred(error);
return;
}
// Snapshot prior turns BEFORE the new user message is appended to the model.
const QVector<ChatModel::Message> priorHistory = m_chatModel->getChatHistory();
m_chatModel
->addMessage(message, ChatModel::ChatRole::User, "", storedAttachments, imageAttachments);
if (!m_history) {
const QString error = QStringLiteral("Chat history is not available");
LOG_MESSAGE(error);
emit errorOccurred(error);
return;
}
QString sessionError;
Session *session = m_sessionManager->createSession(m_activeAgent, &sessionError);
Session *session = m_sessionManager->createSession(m_activeAgent, m_history, &sessionError);
if (!session) {
const QString error = sessionError.isEmpty()
? QStringLiteral("No chat agent selected")
@@ -190,7 +201,11 @@ void ClientInterface::sendMessage(
bindings.roleId = m_activeRoleId;
session->setContextBindings(bindings);
if (m_sessionManager)
const QString chatFilePath = m_chatFilePath;
session->setContentLoader([chatFilePath](const QString &storedPath) {
return ChatSerializer::loadContentFromStorage(chatFilePath, storedPath);
});
m_sessionManager->toolContributors().contribute(client->tools());
client->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations());
client->setTransferTimeout(
@@ -200,61 +215,25 @@ void ClientInterface::sendMessage(
if (!chatContext.isEmpty())
session->systemPrompt()->setLayer(QStringLiteral("chat.context"), chatContext);
seedHistory(*session->history(), priorHistory);
QString userText = message;
if (!storedAttachments.isEmpty() && !m_chatFilePath.isEmpty()) {
userText += "\n\nAttached files:";
for (const auto &attachment : storedAttachments) {
QString fileContent
= ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.content);
if (!fileContent.isEmpty()) {
QString decoded = QString::fromUtf8(QByteArray::fromBase64(fileContent.toUtf8()));
userText += QString("\n\nFile: %1\n```\n%2\n```").arg(attachment.filename, decoded);
}
}
}
std::vector<std::unique_ptr<LLMQore::ContentBlock>> blocks;
blocks.push_back(std::make_unique<LLMQore::TextContent>(userText));
blocks.push_back(std::make_unique<LLMQore::TextContent>(message));
if (!imageAttachments.isEmpty() && session->supportsImages() && !m_chatFilePath.isEmpty()) {
for (const auto &image : imageAttachments) {
QString base64
= ChatSerializer::loadContentFromStorage(m_chatFilePath, image.storedPath);
if (base64.isEmpty())
continue;
blocks.push_back(std::make_unique<LLMQore::ImageContent>(
base64, image.mediaType, LLMQore::ImageContent::ImageSourceType::Base64));
for (const auto &attachment : storedAttachments) {
blocks.push_back(
std::make_unique<StoredAttachmentContent>(attachment.filename, attachment.content));
}
} else if (!imageAttachments.isEmpty() && !session->supportsImages()) {
if (!storedImages.isEmpty() && session->supportsImages()) {
for (const auto &image : storedImages) {
blocks.push_back(std::make_unique<StoredImageContent>(
image.fileName, image.storedPath, image.mediaType));
}
} else if (!storedImages.isEmpty() && !session->supportsImages()) {
LOG_MESSAGE(QString("Agent '%1' doesn't support images, %2 ignored")
.arg(m_activeAgent)
.arg(imageAttachments.size()));
.arg(storedImages.size()));
}
connect(
client, &::LLMQore::BaseClient::chunkReceived,
this, &ClientInterface::handlePartialResponse, Qt::UniqueConnection);
connect(
client, &::LLMQore::BaseClient::requestCompleted,
this, &ClientInterface::handleFullResponse, Qt::UniqueConnection);
connect(
client, &::LLMQore::BaseClient::requestFinalized,
this, &ClientInterface::handleRequestFinalized, Qt::UniqueConnection);
connect(
client, &::LLMQore::BaseClient::requestFailed,
this, &ClientInterface::handleRequestFailed, Qt::UniqueConnection);
connect(
client, &::LLMQore::BaseClient::toolStarted,
this, &ClientInterface::handleToolExecutionStarted, Qt::UniqueConnection);
connect(
client, &::LLMQore::BaseClient::toolResultReady,
this, &ClientInterface::handleToolExecutionCompleted, Qt::UniqueConnection);
connect(
client, &::LLMQore::BaseClient::thinkingBlockReceived,
this, &ClientInterface::handleThinkingBlockReceived, Qt::UniqueConnection);
if (!m_chatFilePath.isEmpty()) {
if (auto *todoTool
= qobject_cast<QodeAssist::Tools::TodoTool *>(client->tools()->tool("todo_tool"))) {
@@ -266,6 +245,18 @@ void ClientInterface::sendMessage(
}
}
connect(session, &Session::event, this, [this, session](const QodeAssist::ResponseEvent &ev) {
onSessionEvent(session, ev);
});
connect(
session, &Session::finished, this,
[this](const LLMQore::RequestID &id, const QString &) { onSessionFinished(id); });
connect(
session, &Session::failed, this,
[this](const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error) {
onSessionFailed(id, error);
});
const LLMQore::RequestID requestId = session->send(std::move(blocks));
if (requestId.isEmpty()) {
const QString error = QStringLiteral("Failed to start chat request for agent '%1': %2")
@@ -276,83 +267,87 @@ void ClientInterface::sendMessage(
return;
}
QJsonObject request{{"id", requestId}};
m_activeRequests[requestId] = {request, session};
m_activeRequests[requestId] = {QJsonObject{{"id", requestId}}, session};
emit requestStarted(requestId);
}
void ClientInterface::seedHistory(
ConversationHistory &history, const QVector<ChatModel::Message> &messages) const
QString ClientInterface::requestIdForSession(Session *session) const
{
int i = 0;
while (i < messages.size()) {
const ChatModel::Message &msg = messages[i];
if (msg.role == ChatModel::ChatRole::Tool) {
Message assistant(Message::Role::Assistant);
Message toolResults(Message::Role::User);
while (i < messages.size() && messages[i].role == ChatModel::ChatRole::Tool) {
const ChatModel::Message &toolMsg = messages[i];
if (!toolMsg.toolName.isEmpty()) {
assistant.appendBlock(std::make_unique<LLMQore::ToolUseContent>(
toolMsg.id, toolMsg.toolName, toolMsg.toolArguments));
toolResults.appendBlock(
std::make_unique<LLMQore::ToolResultContent>(toolMsg.id, toolMsg.toolResult));
for (auto it = m_activeRequests.cbegin(); it != m_activeRequests.cend(); ++it) {
if (it.value().session == session)
return it.key();
}
++i;
}
if (!assistant.blocks().empty()) {
history.append(std::move(assistant));
history.append(std::move(toolResults));
}
continue;
return {};
}
++i;
void ClientInterface::onSessionEvent(Session *session, const QodeAssist::ResponseEvent &ev)
{
if (ev.kind() != ResponseEvent::Kind::Usage)
return;
if (msg.role == ChatModel::ChatRole::FileEdit
|| msg.role == ChatModel::ChatRole::Thinking) {
continue;
const auto *usage = ev.as<ResponseEvents::Usage>();
if (!usage)
return;
const QString requestId = requestIdForSession(session);
if (!requestId.isEmpty()) {
m_chatModel->setMessageUsage(
requestId,
usage->inputTokens,
usage->outputTokens,
usage->cachedTokens,
usage->reasoningTokens);
}
if (msg.role == ChatModel::ChatRole::User) {
Message userMessage(Message::Role::User);
QString content = msg.content;
if (!msg.attachments.isEmpty() && !m_chatFilePath.isEmpty()) {
content += "\n\nAttached files:";
for (const auto &attachment : msg.attachments) {
QString fileContent = ChatSerializer::loadContentFromStorage(
m_chatFilePath, attachment.content);
if (!fileContent.isEmpty()) {
QString decoded
= QString::fromUtf8(QByteArray::fromBase64(fileContent.toUtf8()));
content
+= QString("\n\nFile: %1\n```\n%2\n```").arg(attachment.filename, decoded);
}
}
}
userMessage.appendBlock(std::make_unique<LLMQore::TextContent>(content));
emit messageUsageReceived(
usage->inputTokens, usage->outputTokens, usage->cachedTokens, usage->reasoningTokens);
if (!msg.images.isEmpty() && !m_chatFilePath.isEmpty()) {
for (const auto &image : msg.images) {
QString base64 = ChatSerializer::loadContentFromStorage(
m_chatFilePath, image.storedPath);
if (base64.isEmpty())
continue;
userMessage.appendBlock(std::make_unique<LLMQore::ImageContent>(
base64, image.mediaType, LLMQore::ImageContent::ImageSourceType::Base64));
LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
.arg(requestId)
.arg(usage->inputTokens)
.arg(usage->outputTokens)
.arg(usage->cachedTokens)
.arg(usage->reasoningTokens));
}
void ClientInterface::onSessionFinished(const QString &requestId)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
Session *session = it.value().session;
QString applyError;
if (!Context::ChangesManager::instance().applyPendingEditsForRequest(requestId, &applyError)) {
LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2")
.arg(requestId, applyError));
}
history.append(std::move(userMessage));
} else { // Assistant
if (msg.content.trimmed().isEmpty())
continue;
Message assistant(Message::Role::Assistant);
assistant.appendBlock(std::make_unique<LLMQore::TextContent>(msg.content));
history.append(std::move(assistant));
}
emit messageReceivedCompletely();
m_activeRequests.erase(it);
if (session && m_sessionManager)
m_sessionManager->removeSession(session);
}
void ClientInterface::onSessionFailed(const QString &requestId, const QodeAssist::ErrorInfo &error)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
Session *session = it.value().session;
LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error.message));
emit errorOccurred(error.message);
m_activeRequests.erase(it);
if (session && m_sessionManager)
m_sessionManager->removeSession(session);
}
QString ClientInterface::buildChatContextLayer(
@@ -431,39 +426,24 @@ QString ClientInterface::buildChatContextLayer(
void ClientInterface::clearMessages()
{
m_chatModel->clear();
if (m_history)
m_history->clear();
}
void ClientInterface::cancelRequest()
{
const auto requests = m_activeRequests;
m_activeRequests.clear();
m_accumulatedResponses.clear();
m_awaitingContinuation.clear();
for (auto it = requests.begin(); it != requests.end(); ++it) {
Session *session = it.value().session;
if (!session)
continue;
if (auto *client = session->client())
disconnect(client, nullptr, this, nullptr);
if (m_sessionManager)
if (session && m_sessionManager)
m_sessionManager->removeSession(session);
}
LOG_MESSAGE("All chat requests cancelled and state cleared");
}
void ClientInterface::handleLLMResponse(const QString &response, const QJsonObject &request)
{
const auto message = response.trimmed();
if (!message.isEmpty()) {
QString messageId = request["id"].toString();
m_chatModel->addMessage(message, ChatModel::ChatRole::Assistant, messageId);
}
}
QString ClientInterface::getCurrentFileContext() const
{
auto currentEditor = Core::EditorManager::currentEditor();
@@ -493,149 +473,6 @@ Context::ContextManager *ClientInterface::contextManager() const
return m_contextManager;
}
void ClientInterface::handlePartialResponse(const QString &requestId, const QString &partialText)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
if (m_awaitingContinuation.remove(requestId)) {
m_accumulatedResponses[requestId].clear();
LOG_MESSAGE(
QString("Cleared accumulated responses for continuation request %1").arg(requestId));
}
m_accumulatedResponses[requestId] += partialText;
const RequestContext &ctx = it.value();
handleLLMResponse(m_accumulatedResponses[requestId], ctx.originalRequest);
}
void ClientInterface::handleFullResponse(const QString &requestId, const QString &fullText)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
const QJsonObject originalRequest = it.value().originalRequest;
Session *session = it.value().session;
QString finalText = !fullText.isEmpty() ? fullText : m_accumulatedResponses[requestId];
QString applyError;
bool applySuccess
= Context::ChangesManager::instance().applyPendingEditsForRequest(requestId, &applyError);
if (!applySuccess) {
LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2")
.arg(requestId, applyError));
}
LOG_MESSAGE(
"Message completed. Final response for message " + originalRequest["id"].toString() + ": "
+ finalText);
emit messageReceivedCompletely();
m_activeRequests.erase(it);
m_accumulatedResponses.remove(requestId);
m_awaitingContinuation.remove(requestId);
if (session && m_sessionManager)
m_sessionManager->removeSession(session);
}
void ClientInterface::handleRequestFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
{
if (!m_activeRequests.contains(requestId))
return;
if (!info.usage)
return;
const auto &u = *info.usage;
m_chatModel->setMessageUsage(
requestId, u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens);
emit messageUsageReceived(
u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens);
LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
.arg(requestId)
.arg(u.promptTokens)
.arg(u.completionTokens)
.arg(u.cachedPromptTokens)
.arg(u.reasoningTokens));
}
void ClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
{
auto it = m_activeRequests.find(requestId);
if (it == m_activeRequests.end())
return;
Session *session = it.value().session;
LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error));
emit errorOccurred(error);
m_activeRequests.erase(it);
m_accumulatedResponses.remove(requestId);
m_awaitingContinuation.remove(requestId);
if (session && m_sessionManager)
m_sessionManager->removeSession(session);
}
void ClientInterface::handleThinkingBlockReceived(
const QString &requestId, const QString &thinking, const QString &signature)
{
if (!m_activeRequests.contains(requestId)) {
LOG_MESSAGE(QString("Ignoring thinking block for non-chat request: %1").arg(requestId));
return;
}
if (m_awaitingContinuation.remove(requestId)) {
m_accumulatedResponses[requestId].clear();
LOG_MESSAGE(
QString("Cleared accumulated responses for continuation request %1").arg(requestId));
}
if (thinking.isEmpty()) {
m_chatModel->addRedactedThinkingBlock(requestId, signature);
} else {
m_chatModel->addThinkingBlock(requestId, thinking, signature);
}
}
void ClientInterface::handleToolExecutionStarted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &arguments)
{
if (!m_activeRequests.contains(requestId)) {
LOG_MESSAGE(QString("Ignoring tool execution start for non-chat request: %1").arg(requestId));
return;
}
m_chatModel->addToolExecutionStatus(requestId, toolId, toolName, arguments);
m_awaitingContinuation.insert(requestId);
}
void ClientInterface::handleToolExecutionCompleted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QString &toolOutput)
{
if (!m_activeRequests.contains(requestId)) {
LOG_MESSAGE(QString("Ignoring tool execution result for non-chat request: %1").arg(requestId));
return;
}
m_chatModel->updateToolResult(requestId, toolId, toolName, toolOutput);
}
bool ClientInterface::isImageFile(const QString &filePath) const
{
static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"};

View File

@@ -6,12 +6,12 @@
#include <QObject>
#include <QPointer>
#include <QSet>
#include <QString>
#include <QVector>
#include "ChatModel.hpp"
#include <ErrorInfo.hpp>
#include <LLMQore/BaseClient.hpp>
#include <ResponseEvent.hpp>
#include <context/ContextManager.hpp>
namespace QodeAssist {
@@ -36,6 +36,7 @@ public:
void setSkillsManager(Skills::SkillsManager *skillsManager);
void setSessionManager(SessionManager *sessionManager);
void setHistory(ConversationHistory *history);
void setActiveAgent(const QString &agentName);
void setActiveRole(const QString &roleId);
@@ -58,31 +59,15 @@ signals:
void messageUsageReceived(
int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens);
private slots:
void handlePartialResponse(const QString &requestId, const QString &partialText);
void handleFullResponse(const QString &requestId, const QString &fullText);
void handleRequestFinalized(const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info);
void handleRequestFailed(const QString &requestId, const QString &error);
void handleThinkingBlockReceived(
const QString &requestId, const QString &thinking, const QString &signature);
void handleToolExecutionStarted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QJsonObject &arguments);
void handleToolExecutionCompleted(
const QString &requestId,
const QString &toolId,
const QString &toolName,
const QString &toolOutput);
private:
void handleLLMResponse(const QString &response, const QJsonObject &request);
void onSessionEvent(Session *session, const QodeAssist::ResponseEvent &ev);
void onSessionFinished(const QString &requestId);
void onSessionFailed(const QString &requestId, const QodeAssist::ErrorInfo &error);
QString getCurrentFileContext() const;
QString buildChatContextLayer(
const QString &message, const QList<QString> &linkedFiles) const;
void seedHistory(
ConversationHistory &history, const QVector<ChatModel::Message> &messages) const;
QString requestIdForSession(Session *session) const;
bool isImageFile(const QString &filePath) const;
QString getMediaTypeForImage(const QString &filePath) const;
QString encodeImageToBase64(const QString &filePath) const;
@@ -95,6 +80,7 @@ private:
ChatModel *m_chatModel;
Context::ContextManager *m_contextManager;
QPointer<ConversationHistory> m_history;
Skills::SkillsManager *m_skillsManager = nullptr;
QPointer<SessionManager> m_sessionManager;
QString m_activeAgent;
@@ -102,8 +88,6 @@ private:
QString m_chatFilePath;
QHash<QString, RequestContext> m_activeRequests;
QHash<QString, QString> m_accumulatedResponses;
QSet<QString> m_awaitingContinuation;
};
} // namespace QodeAssist::Chat

View File

@@ -10,15 +10,13 @@
#include <coreplugin/editormanager/editormanager.h>
#include <texteditor/texteditor.h>
#include "ChatModel.hpp"
#include "Logger.hpp"
#include "context/ChangesManager.h"
namespace QodeAssist::Chat {
FileEditController::FileEditController(ChatModel *chatModel, QObject *parent)
FileEditController::FileEditController(QObject *parent)
: QObject(parent)
, m_chatModel(chatModel)
{
auto &changes = Context::ChangesManager::instance();
connect(&changes, &Context::ChangesManager::fileEditAdded, this, [this](const QString &) {
@@ -80,7 +78,6 @@ void FileEditController::applyFileEdit(const QString &editId)
LOG_MESSAGE(QString("Applying file edit: %1").arg(editId));
if (Context::ChangesManager::instance().applyFileEdit(editId)) {
emit infoMessage(QString("File edit applied successfully"));
updateFileEditStatus(editId, "applied");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred(
@@ -95,7 +92,6 @@ void FileEditController::rejectFileEdit(const QString &editId)
LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId));
if (Context::ChangesManager::instance().rejectFileEdit(editId)) {
emit infoMessage(QString("File edit rejected"));
updateFileEditStatus(editId, "rejected");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred(
@@ -110,7 +106,6 @@ void FileEditController::undoFileEdit(const QString &editId)
LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId));
if (Context::ChangesManager::instance().undoFileEdit(editId)) {
emit infoMessage(QString("File edit undone successfully"));
updateFileEditStatus(editId, "rejected");
} else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred(
@@ -163,44 +158,6 @@ void FileEditController::openFileEditInEditor(const QString &editId)
LOG_MESSAGE(QString("Opened file in editor: %1").arg(edit.filePath));
}
void FileEditController::updateFileEditStatus(const QString &editId, const QString &status)
{
auto messages = m_chatModel->getChatHistory();
for (int i = 0; i < messages.size(); ++i) {
if (messages[i].role == Chat::ChatModel::FileEdit && messages[i].id == editId) {
QString content = messages[i].content;
const QString marker = "QODEASSIST_FILE_EDIT:";
int markerPos = content.indexOf(marker);
QString jsonStr = content;
if (markerPos >= 0) {
jsonStr = content.mid(markerPos + marker.length());
}
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
if (doc.isObject()) {
QJsonObject obj = doc.object();
obj["status"] = status;
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
if (!edit.statusMessage.isEmpty()) {
obj["status_message"] = edit.statusMessage;
}
QString updatedContent = marker
+ QString::fromUtf8(
QJsonDocument(obj).toJson(QJsonDocument::Compact));
m_chatModel->updateMessageContent(editId, updatedContent);
LOG_MESSAGE(QString("Updated file edit status to: %1").arg(status));
}
break;
}
}
updateStats();
}
void FileEditController::applyAllForCurrentMessage()
{
if (m_currentRequestId.isEmpty()) {
@@ -223,13 +180,6 @@ void FileEditController::applyAllForCurrentMessage()
: QString("Failed to apply some file edits:\n%1").arg(errorMsg));
}
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Applied) {
updateFileEditStatus(edit.editId, "applied");
}
}
updateStats();
}
@@ -255,13 +205,6 @@ void FileEditController::undoAllForCurrentMessage()
: QString("Failed to undo some file edits:\n%1").arg(errorMsg));
}
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
for (const auto &edit : edits) {
if (edit.status == Context::ChangesManager::Rejected) {
updateFileEditStatus(edit.editId, "rejected");
}
}
updateStats();
}

View File

@@ -9,14 +9,12 @@
namespace QodeAssist::Chat {
class ChatModel;
class FileEditController : public QObject
{
Q_OBJECT
public:
explicit FileEditController(ChatModel *chatModel, QObject *parent = nullptr);
explicit FileEditController(QObject *parent = nullptr);
void setCurrentRequestId(const QString &requestId);
void clearCurrentRequestId();
@@ -41,9 +39,6 @@ signals:
void errorOccurred(const QString &error);
private:
void updateFileEditStatus(const QString &editId, const QString &status);
ChatModel *m_chatModel;
QString m_currentRequestId;
int m_totalEdits{0};
int m_appliedEdits{0};

View File

@@ -9,17 +9,19 @@
#include <utils/aspects.h>
#include "ChatAssistantSettings.hpp"
#include "ChatModel.hpp"
#include "Logger.hpp"
#include "context/ContextManager.hpp"
#include "context/TokenUtils.hpp"
#include <ConversationHistory.hpp>
#include <Message.hpp>
namespace QodeAssist::Chat {
InputTokenCounter::InputTokenCounter(
ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent)
ConversationHistory *history, Context::ContextManager *contextManager, QObject *parent)
: QObject(parent)
, m_chatModel(chatModel)
, m_history(history)
, m_contextManager(contextManager)
{
auto &settings = Settings::chatAssistantSettings();
@@ -100,11 +102,12 @@ void InputTokenCounter::recompute()
}
}
const auto &history = m_chatModel->getChatHistory();
for (const auto &message : history) {
inputTokens += Context::TokenUtils::estimateTokens(message.content);
if (m_history) {
for (const auto &message : m_history->messages()) {
inputTokens += Context::TokenUtils::estimateTokens(message.text());
inputTokens += 4; // + role
}
}
m_inputTokens = static_cast<int>(inputTokens * m_calibrationFactor);
emit inputTokensChanged();

View File

@@ -7,21 +7,25 @@
#include <QObject>
#include <QStringList>
namespace QodeAssist {
class ConversationHistory;
}
namespace QodeAssist::Context {
class ContextManager;
}
namespace QodeAssist::Chat {
class ChatModel;
class InputTokenCounter : public QObject
{
Q_OBJECT
public:
InputTokenCounter(
ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent = nullptr);
ConversationHistory *history,
Context::ContextManager *contextManager,
QObject *parent = nullptr);
int inputTokens() const;
@@ -37,7 +41,7 @@ signals:
void inputTokensChanged();
private:
ChatModel *m_chatModel;
ConversationHistory *m_history;
Context::ContextManager *m_contextManager;
QStringList m_attachments;

View File

@@ -325,9 +325,10 @@ Templates::ContextData Session::buildLegacyContext(
} else if (auto *sa = dynamic_cast<StoredAttachmentContent *>(block)) {
if (!loader)
continue;
const QString text = loader(sa->storedPath());
if (text.isEmpty())
const QString stored = loader(sa->storedPath());
if (stored.isEmpty())
continue;
const QString text = QString::fromUtf8(QByteArray::fromBase64(stored.toUtf8()));
ContentBlockEntry e;
e.kind = ContentBlockEntry::Kind::Text;
e.text = QStringLiteral("File: %1\n```\n%2\n```")