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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,16 @@
#include "MessagePart.hpp" #include "MessagePart.hpp"
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QHash>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QPointer>
#include <QVector>
#include <QtQmlIntegration> #include <QtQmlIntegration>
#include "context/ContentFile.hpp" namespace QodeAssist {
class ConversationHistory;
}
namespace QodeAssist::Chat { namespace QodeAssist::Chat {
@@ -43,80 +48,19 @@ public:
}; };
Q_ENUM(Roles) 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); explicit ChatModel(QObject *parent = nullptr);
void setHistory(ConversationHistory *history);
int rowCount(const QModelIndex &parent = QModelIndex()) const override; int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() 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 void clear();
Q_INVOKABLE QList<MessagePart> processMessageContent(const QString &content) const; 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 void resetModelTo(int index);
Q_INVOKABLE QVariantList userMessagePreviews(int maxLength = 80) const; 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( void setMessageUsage(
const QString &messageId, const QString &messageId,
int promptTokens, int promptTokens,
@@ -128,10 +72,7 @@ public:
int sessionCompletionTokens() const; int sessionCompletionTokens() const;
int sessionCachedPromptTokens() const; int sessionCachedPromptTokens() const;
int sessionTotalTokens() const; int sessionTotalTokens() const;
void setLoadingFromHistory(bool loading);
bool isLoadingFromHistory() const;
void setChatFilePath(const QString &filePath); void setChatFilePath(const QString &filePath);
QString chatFilePath() const; QString chatFilePath() const;
@@ -140,18 +81,60 @@ signals:
void sessionUsageChanged(); void sessionUsageChanged();
private slots: private slots:
void onFileEditApplied(const QString &editId); void onHistoryMessageAdded(int index);
void onFileEditRejected(const QString &editId); void onHistoryMessageUpdated(int index);
void onFileEditArchived(const QString &editId); void onHistoryCleared();
void onHistoryReset();
void onFileEditStatusChanged(const QString &editId);
private: private:
void updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage); struct AttachmentRef
{
QVector<Message> m_messages; QString fileName;
bool m_loadingFromHistory = false; 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;
};
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; QString m_chatFilePath;
}; };
} // namespace QodeAssist::Chat } // namespace QodeAssist::Chat
Q_DECLARE_METATYPE(QodeAssist::Chat::ChatModel::Message)
Q_DECLARE_METATYPE(QodeAssist::Chat::MessagePart) Q_DECLARE_METATYPE(QodeAssist::Chat::MessagePart)

View File

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

View File

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

View File

@@ -5,7 +5,8 @@
#include "ChatSerializer.hpp" #include "ChatSerializer.hpp"
#include "Logger.hpp" #include "Logger.hpp"
#include <QBuffer> #include <memory>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
@@ -13,12 +14,57 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QUuid> #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 { 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)) { if (!ensureDirectoryExists(filePath)) {
return {false, "Failed to create directory structure"}; 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)}; return {false, QString("Failed to open file for writing: %1").arg(filePath)};
} }
QJsonObject root = serializeChat(model, filePath); QJsonDocument doc(serializeChat(history));
QJsonDocument doc(root);
if (file.write(doc.toJson(QJsonDocument::Indented)) == -1) { if (file.write(doc.toJson(QJsonDocument::Indented)) == -1) {
return {false, QString("Failed to write to file: %1").arg(file.errorString())}; 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()}; 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); QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) { if (!file.open(QIODevice::ReadOnly)) {
return {false, QString("Failed to open file for reading: %1").arg(filePath)}; 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())}; return {false, QString("JSON parse error: %1").arg(error.errorString())};
} }
QJsonObject root = doc.object(); const QJsonObject root = doc.object();
QString version = root["version"].toString(); const QString version = root["version"].toString();
if (!validateVersion(version)) { if (!validateVersion(version)) {
return {false, QString("Unsupported version: %1").arg(version)}; return {false, QString("Unsupported version: %1").arg(version)};
} }
if (!deserializeChat(model, root, filePath)) { if (version == VERSION)
return {false, "Failed to deserialize chat data"}; return loadCurrent(history, root);
} return loadLegacy(history, root);
return {true, QString()};
} }
QJsonObject ChatSerializer::serializeMessage( QJsonObject ChatSerializer::serializeChat(const ConversationHistory *history)
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)
{ {
QJsonArray messagesArray; QJsonArray messagesArray;
for (const auto &message : model->getChatHistory()) { for (const auto &message : history->messages())
messagesArray.append(serializeMessage(message, chatFilePath)); messagesArray.append(MessageSerializer::toJson(message));
}
QJsonObject root; QJsonObject root;
root["version"] = VERSION; root["version"] = VERSION;
root["messages"] = messagesArray; root["messages"] = messagesArray;
return root; return root;
} }
bool ChatSerializer::deserializeChat( SerializationResult ChatSerializer::loadCurrent(ConversationHistory *history, const QJsonObject &root)
ChatModel *model, const QJsonObject &json, const QString &chatFilePath)
{ {
QJsonArray messagesArray = json["messages"].toArray(); history->clear();
QVector<ChatModel::Message> messages;
messages.reserve(messagesArray.size());
for (const auto &messageValue : messagesArray) { const QJsonArray messagesArray = root["messages"].toArray();
messages.append(deserializeMessage(messageValue.toObject(), chatFilePath)); for (const auto &value : messagesArray) {
bool ok = false;
Message message = MessageSerializer::fromJson(value.toObject(), &ok);
if (ok)
history->append(std::move(message));
} }
model->clear(); registerHistoricalFileEdits(history);
return {true, QString()};
}
model->setLoadingFromHistory(true); SerializationResult ChatSerializer::loadLegacy(ConversationHistory *history, const QJsonObject &root)
{
history->clear();
for (const auto &message : messages) { const QJsonArray arr = root["messages"].toArray();
model->addMessage( int i = 0;
message.content, while (i < arr.size()) {
message.role, const QJsonObject mj = arr[i].toObject();
message.id, const auto role = static_cast<LegacyRole>(mj["role"].toInt());
message.attachments,
message.images, if (role == LegacyRole::Tool) {
message.isRedacted, Message assistant(Message::Role::Assistant);
message.signature); Message toolResults(Message::Role::User);
if (message.role == ChatModel::ChatRole::Tool) { while (i < arr.size()
model->setToolMessageData( && static_cast<LegacyRole>(arr[i].toObject()["role"].toInt()) == LegacyRole::Tool) {
message.id, message.toolName, message.toolArguments, message.toolResult); 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));
} }
LOG_MESSAGE(QString("Loaded message with %1 image(s), isRedacted=%2, signature length=%3")
.arg(message.images.size())
.arg(message.isRedacted)
.arg(message.signature.length()));
} }
model->setLoadingFromHistory(false); registerHistoricalFileEdits(history);
return {true, QString()};
}
return true; 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) bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
@@ -236,18 +244,7 @@ bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
bool ChatSerializer::validateVersion(const QString &version) bool ChatSerializer::validateVersion(const QString &version)
{ {
if (version == VERSION) { return version == VERSION || version == "0.2" || version == "0.1";
return true;
}
if (version == "0.1") {
LOG_MESSAGE(
"Loading chat from old format 0.1 - images folder structure has changed from _images "
"to _content");
return true;
}
return false;
} }
QString ChatSerializer::getChatContentFolder(const QString &chatFilePath) QString ChatSerializer::getChatContentFolder(const QString &chatFilePath)

View File

@@ -4,11 +4,12 @@
#pragma once #pragma once
#include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QString> #include <QString>
#include "ChatModel.hpp" namespace QodeAssist {
class ConversationHistory;
}
namespace QodeAssist::Chat { namespace QodeAssist::Chat {
@@ -21,26 +22,26 @@ struct SerializationResult
class ChatSerializer class ChatSerializer
{ {
public: public:
static SerializationResult saveToFile(const ChatModel *model, const QString &filePath); static SerializationResult saveToFile(
static SerializationResult loadFromFile(ChatModel *model, const QString &filePath); const ConversationHistory *history, const QString &filePath);
static SerializationResult loadFromFile(ConversationHistory *history, 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);
// Content management (images and text files) // Content management (images and text files)
static QString getChatContentFolder(const QString &chatFilePath); static QString getChatContentFolder(const QString &chatFilePath);
static bool saveContentToStorage(const QString &chatFilePath, static bool saveContentToStorage(
const QString &fileName, const QString &chatFilePath,
const QString &base64Data, const QString &fileName,
QString &storedPath); const QString &base64Data,
QString &storedPath);
static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath); static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath);
private: private:
static const QString VERSION; 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 ensureDirectoryExists(const QString &filePath);
static bool validateVersion(const QString &version); static bool validateVersion(const QString &version);

View File

@@ -5,6 +5,7 @@
#include "ClientInterface.hpp" #include "ClientInterface.hpp"
#include <memory> #include <memory>
#include <vector>
#include <LLMQore/BaseClient.hpp> #include <LLMQore/BaseClient.hpp>
#include <LLMQore/ContentBlocks.hpp> #include <LLMQore/ContentBlocks.hpp>
@@ -20,6 +21,7 @@
#include <texteditor/textdocument.h> #include <texteditor/textdocument.h>
#include <texteditor/texteditor.h> #include <texteditor/texteditor.h>
#include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonArray> #include <QJsonArray>
@@ -29,11 +31,10 @@
#include <QUuid> #include <QUuid>
#include <ConversationHistory.hpp> #include <ConversationHistory.hpp>
#include <Message.hpp>
#include <ContextRenderer.hpp> #include <ContextRenderer.hpp>
#include <Message.hpp>
#include <PluginBlocks.hpp>
#include <Session.hpp> #include <Session.hpp>
#include <QDir>
#include <SessionManager.hpp> #include <SessionManager.hpp>
#include <SystemPromptBuilder.hpp> #include <SystemPromptBuilder.hpp>
@@ -52,6 +53,15 @@
namespace QodeAssist::Chat { namespace QodeAssist::Chat {
namespace {
struct StoredImage
{
QString fileName;
QString storedPath;
QString mediaType;
};
} // namespace
ClientInterface::ClientInterface(ChatModel *chatModel, QObject *parent) ClientInterface::ClientInterface(ChatModel *chatModel, QObject *parent)
: QObject(parent) : QObject(parent)
, m_chatModel(chatModel) , m_chatModel(chatModel)
@@ -73,6 +83,11 @@ void ClientInterface::setSessionManager(SessionManager *sessionManager)
m_sessionManager = sessionManager; m_sessionManager = sessionManager;
} }
void ClientInterface::setHistory(ConversationHistory *history)
{
m_history = history;
}
void ClientInterface::setActiveAgent(const QString &agentName) void ClientInterface::setActiveAgent(const QString &agentName)
{ {
m_activeAgent = agentName; m_activeAgent = agentName;
@@ -94,7 +109,6 @@ void ClientInterface::sendMessage(
} }
cancelRequest(); cancelRequest();
m_accumulatedResponses.clear();
Context::ChangesManager::instance().archiveAllNonArchivedEdits(); Context::ChangesManager::instance().archiveAllNonArchivedEdits();
@@ -126,7 +140,7 @@ void ClientInterface::sendMessage(
.arg(textFiles.size())); .arg(textFiles.size()));
} }
QList<ChatModel::ImageAttachment> imageAttachments; QList<StoredImage> storedImages;
if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) { if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
for (const QString &imagePath : imageFiles) { for (const QString &imagePath : imageFiles) {
QString base64Data = encodeImageToBase64(imagePath); QString base64Data = encodeImageToBase64(imagePath);
@@ -137,11 +151,8 @@ void ClientInterface::sendMessage(
QFileInfo fileInfo(imagePath); QFileInfo fileInfo(imagePath);
if (ChatSerializer::saveContentToStorage( if (ChatSerializer::saveContentToStorage(
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) { m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
ChatModel::ImageAttachment imageAttachment; storedImages.append(
imageAttachment.fileName = fileInfo.fileName(); {fileInfo.fileName(), storedPath, getMediaTypeForImage(imagePath)});
imageAttachment.storedPath = storedPath;
imageAttachment.mediaType = getMediaTypeForImage(imagePath);
imageAttachments.append(imageAttachment);
LOG_MESSAGE(QString("Stored image %1 as %2").arg(fileInfo.fileName(), storedPath)); LOG_MESSAGE(QString("Stored image %1 as %2").arg(fileInfo.fileName(), storedPath));
} }
} }
@@ -156,15 +167,15 @@ void ClientInterface::sendMessage(
emit errorOccurred(error); emit errorOccurred(error);
return; return;
} }
if (!m_history) {
// Snapshot prior turns BEFORE the new user message is appended to the model. const QString error = QStringLiteral("Chat history is not available");
const QVector<ChatModel::Message> priorHistory = m_chatModel->getChatHistory(); LOG_MESSAGE(error);
emit errorOccurred(error);
m_chatModel return;
->addMessage(message, ChatModel::ChatRole::User, "", storedAttachments, imageAttachments); }
QString sessionError; QString sessionError;
Session *session = m_sessionManager->createSession(m_activeAgent, &sessionError); Session *session = m_sessionManager->createSession(m_activeAgent, m_history, &sessionError);
if (!session) { if (!session) {
const QString error = sessionError.isEmpty() const QString error = sessionError.isEmpty()
? QStringLiteral("No chat agent selected") ? QStringLiteral("No chat agent selected")
@@ -190,8 +201,12 @@ void ClientInterface::sendMessage(
bindings.roleId = m_activeRoleId; bindings.roleId = m_activeRoleId;
session->setContextBindings(bindings); session->setContextBindings(bindings);
if (m_sessionManager) const QString chatFilePath = m_chatFilePath;
m_sessionManager->toolContributors().contribute(client->tools()); session->setContentLoader([chatFilePath](const QString &storedPath) {
return ChatSerializer::loadContentFromStorage(chatFilePath, storedPath);
});
m_sessionManager->toolContributors().contribute(client->tools());
client->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations()); client->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations());
client->setTransferTimeout( client->setTransferTimeout(
static_cast<int>(Settings::generalSettings().requestTimeout() * 1000)); static_cast<int>(Settings::generalSettings().requestTimeout() * 1000));
@@ -200,61 +215,25 @@ void ClientInterface::sendMessage(
if (!chatContext.isEmpty()) if (!chatContext.isEmpty())
session->systemPrompt()->setLayer(QStringLiteral("chat.context"), chatContext); session->systemPrompt()->setLayer(QStringLiteral("chat.context"), chatContext);
seedHistory(*session->history(), priorHistory); std::vector<std::unique_ptr<LLMQore::ContentBlock>> blocks;
blocks.push_back(std::make_unique<LLMQore::TextContent>(message));
QString userText = message; for (const auto &attachment : storedAttachments) {
if (!storedAttachments.isEmpty() && !m_chatFilePath.isEmpty()) { blocks.push_back(
userText += "\n\nAttached files:"; std::make_unique<StoredAttachmentContent>(attachment.filename, attachment.content));
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; if (!storedImages.isEmpty() && session->supportsImages()) {
blocks.push_back(std::make_unique<LLMQore::TextContent>(userText)); for (const auto &image : storedImages) {
blocks.push_back(std::make_unique<StoredImageContent>(
if (!imageAttachments.isEmpty() && session->supportsImages() && !m_chatFilePath.isEmpty()) { image.fileName, image.storedPath, image.mediaType));
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));
} }
} else if (!imageAttachments.isEmpty() && !session->supportsImages()) { } else if (!storedImages.isEmpty() && !session->supportsImages()) {
LOG_MESSAGE(QString("Agent '%1' doesn't support images, %2 ignored") LOG_MESSAGE(QString("Agent '%1' doesn't support images, %2 ignored")
.arg(m_activeAgent) .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 (!m_chatFilePath.isEmpty()) {
if (auto *todoTool if (auto *todoTool
= qobject_cast<QodeAssist::Tools::TodoTool *>(client->tools()->tool("todo_tool"))) { = 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)); const LLMQore::RequestID requestId = session->send(std::move(blocks));
if (requestId.isEmpty()) { if (requestId.isEmpty()) {
const QString error = QStringLiteral("Failed to start chat request for agent '%1': %2") const QString error = QStringLiteral("Failed to start chat request for agent '%1': %2")
@@ -276,83 +267,87 @@ void ClientInterface::sendMessage(
return; return;
} }
QJsonObject request{{"id", requestId}}; m_activeRequests[requestId] = {QJsonObject{{"id", requestId}}, session};
m_activeRequests[requestId] = {request, session};
emit requestStarted(requestId); emit requestStarted(requestId);
} }
void ClientInterface::seedHistory( QString ClientInterface::requestIdForSession(Session *session) const
ConversationHistory &history, const QVector<ChatModel::Message> &messages) const
{ {
int i = 0; for (auto it = m_activeRequests.cbegin(); it != m_activeRequests.cend(); ++it) {
while (i < messages.size()) { if (it.value().session == session)
const ChatModel::Message &msg = messages[i]; return it.key();
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));
}
++i;
}
if (!assistant.blocks().empty()) {
history.append(std::move(assistant));
history.append(std::move(toolResults));
}
continue;
}
++i;
if (msg.role == ChatModel::ChatRole::FileEdit
|| msg.role == ChatModel::ChatRole::Thinking) {
continue;
}
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));
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));
}
}
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));
}
} }
return {};
}
void ClientInterface::onSessionEvent(Session *session, const QodeAssist::ResponseEvent &ev)
{
if (ev.kind() != ResponseEvent::Kind::Usage)
return;
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);
}
emit messageUsageReceived(
usage->inputTokens, usage->outputTokens, usage->cachedTokens, usage->reasoningTokens);
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));
}
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( QString ClientInterface::buildChatContextLayer(
@@ -431,39 +426,24 @@ QString ClientInterface::buildChatContextLayer(
void ClientInterface::clearMessages() void ClientInterface::clearMessages()
{ {
m_chatModel->clear(); if (m_history)
m_history->clear();
} }
void ClientInterface::cancelRequest() void ClientInterface::cancelRequest()
{ {
const auto requests = m_activeRequests; const auto requests = m_activeRequests;
m_activeRequests.clear(); m_activeRequests.clear();
m_accumulatedResponses.clear();
m_awaitingContinuation.clear();
for (auto it = requests.begin(); it != requests.end(); ++it) { for (auto it = requests.begin(); it != requests.end(); ++it) {
Session *session = it.value().session; Session *session = it.value().session;
if (!session) if (session && m_sessionManager)
continue;
if (auto *client = session->client())
disconnect(client, nullptr, this, nullptr);
if (m_sessionManager)
m_sessionManager->removeSession(session); m_sessionManager->removeSession(session);
} }
LOG_MESSAGE("All chat requests cancelled and state cleared"); 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 QString ClientInterface::getCurrentFileContext() const
{ {
auto currentEditor = Core::EditorManager::currentEditor(); auto currentEditor = Core::EditorManager::currentEditor();
@@ -493,149 +473,6 @@ Context::ContextManager *ClientInterface::contextManager() const
return m_contextManager; 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 bool ClientInterface::isImageFile(const QString &filePath) const
{ {
static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"}; static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"};

View File

@@ -6,12 +6,12 @@
#include <QObject> #include <QObject>
#include <QPointer> #include <QPointer>
#include <QSet>
#include <QString> #include <QString>
#include <QVector>
#include "ChatModel.hpp" #include "ChatModel.hpp"
#include <ErrorInfo.hpp>
#include <LLMQore/BaseClient.hpp> #include <LLMQore/BaseClient.hpp>
#include <ResponseEvent.hpp>
#include <context/ContextManager.hpp> #include <context/ContextManager.hpp>
namespace QodeAssist { namespace QodeAssist {
@@ -36,6 +36,7 @@ public:
void setSkillsManager(Skills::SkillsManager *skillsManager); void setSkillsManager(Skills::SkillsManager *skillsManager);
void setSessionManager(SessionManager *sessionManager); void setSessionManager(SessionManager *sessionManager);
void setHistory(ConversationHistory *history);
void setActiveAgent(const QString &agentName); void setActiveAgent(const QString &agentName);
void setActiveRole(const QString &roleId); void setActiveRole(const QString &roleId);
@@ -58,31 +59,15 @@ signals:
void messageUsageReceived( void messageUsageReceived(
int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens); 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: 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 getCurrentFileContext() const;
QString buildChatContextLayer( QString buildChatContextLayer(
const QString &message, const QList<QString> &linkedFiles) const; const QString &message, const QList<QString> &linkedFiles) const;
void seedHistory( QString requestIdForSession(Session *session) const;
ConversationHistory &history, const QVector<ChatModel::Message> &messages) const;
bool isImageFile(const QString &filePath) const; bool isImageFile(const QString &filePath) const;
QString getMediaTypeForImage(const QString &filePath) const; QString getMediaTypeForImage(const QString &filePath) const;
QString encodeImageToBase64(const QString &filePath) const; QString encodeImageToBase64(const QString &filePath) const;
@@ -95,6 +80,7 @@ private:
ChatModel *m_chatModel; ChatModel *m_chatModel;
Context::ContextManager *m_contextManager; Context::ContextManager *m_contextManager;
QPointer<ConversationHistory> m_history;
Skills::SkillsManager *m_skillsManager = nullptr; Skills::SkillsManager *m_skillsManager = nullptr;
QPointer<SessionManager> m_sessionManager; QPointer<SessionManager> m_sessionManager;
QString m_activeAgent; QString m_activeAgent;
@@ -102,8 +88,6 @@ private:
QString m_chatFilePath; QString m_chatFilePath;
QHash<QString, RequestContext> m_activeRequests; QHash<QString, RequestContext> m_activeRequests;
QHash<QString, QString> m_accumulatedResponses;
QSet<QString> m_awaitingContinuation;
}; };
} // namespace QodeAssist::Chat } // namespace QodeAssist::Chat

View File

@@ -10,15 +10,13 @@
#include <coreplugin/editormanager/editormanager.h> #include <coreplugin/editormanager/editormanager.h>
#include <texteditor/texteditor.h> #include <texteditor/texteditor.h>
#include "ChatModel.hpp"
#include "Logger.hpp" #include "Logger.hpp"
#include "context/ChangesManager.h" #include "context/ChangesManager.h"
namespace QodeAssist::Chat { namespace QodeAssist::Chat {
FileEditController::FileEditController(ChatModel *chatModel, QObject *parent) FileEditController::FileEditController(QObject *parent)
: QObject(parent) : QObject(parent)
, m_chatModel(chatModel)
{ {
auto &changes = Context::ChangesManager::instance(); auto &changes = Context::ChangesManager::instance();
connect(&changes, &Context::ChangesManager::fileEditAdded, this, [this](const QString &) { 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)); LOG_MESSAGE(QString("Applying file edit: %1").arg(editId));
if (Context::ChangesManager::instance().applyFileEdit(editId)) { if (Context::ChangesManager::instance().applyFileEdit(editId)) {
emit infoMessage(QString("File edit applied successfully")); emit infoMessage(QString("File edit applied successfully"));
updateFileEditStatus(editId, "applied");
} else { } else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId); auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred( emit errorOccurred(
@@ -95,7 +92,6 @@ void FileEditController::rejectFileEdit(const QString &editId)
LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId)); LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId));
if (Context::ChangesManager::instance().rejectFileEdit(editId)) { if (Context::ChangesManager::instance().rejectFileEdit(editId)) {
emit infoMessage(QString("File edit rejected")); emit infoMessage(QString("File edit rejected"));
updateFileEditStatus(editId, "rejected");
} else { } else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId); auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred( emit errorOccurred(
@@ -110,7 +106,6 @@ void FileEditController::undoFileEdit(const QString &editId)
LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId)); LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId));
if (Context::ChangesManager::instance().undoFileEdit(editId)) { if (Context::ChangesManager::instance().undoFileEdit(editId)) {
emit infoMessage(QString("File edit undone successfully")); emit infoMessage(QString("File edit undone successfully"));
updateFileEditStatus(editId, "rejected");
} else { } else {
auto edit = Context::ChangesManager::instance().getFileEdit(editId); auto edit = Context::ChangesManager::instance().getFileEdit(editId);
emit errorOccurred( emit errorOccurred(
@@ -163,44 +158,6 @@ void FileEditController::openFileEditInEditor(const QString &editId)
LOG_MESSAGE(QString("Opened file in editor: %1").arg(edit.filePath)); 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() void FileEditController::applyAllForCurrentMessage()
{ {
if (m_currentRequestId.isEmpty()) { if (m_currentRequestId.isEmpty()) {
@@ -223,13 +180,6 @@ void FileEditController::applyAllForCurrentMessage()
: QString("Failed to apply some file edits:\n%1").arg(errorMsg)); : 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(); updateStats();
} }
@@ -255,13 +205,6 @@ void FileEditController::undoAllForCurrentMessage()
: QString("Failed to undo some file edits:\n%1").arg(errorMsg)); : 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(); updateStats();
} }

View File

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

View File

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

View File

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

View File

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