diff --git a/ChatView/ChatCompressor.cpp b/ChatView/ChatCompressor.cpp index 115c5a7..624f56a 100644 --- a/ChatView/ChatCompressor.cpp +++ b/ChatView/ChatCompressor.cpp @@ -9,7 +9,6 @@ #include #include -#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(msg.content)); + Message apiMessage(msg.role()); + apiMessage.appendBlock(std::make_unique(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(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) diff --git a/ChatView/ChatCompressor.hpp b/ChatView/ChatCompressor.hpp index 8eb3641..f29824e 100644 --- a/ChatView/ChatCompressor.hpp +++ b/ChatView/ChatCompressor.hpp @@ -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 m_sessionManager; QString m_activeAgent; QPointer m_session; - ChatModel *m_chatModel = nullptr; - - QList m_connections; }; } // namespace QodeAssist::Chat diff --git a/ChatView/ChatHistoryStore.cpp b/ChatView/ChatHistoryStore.cpp index 6c5332c..c23909e 100644 --- a/ChatView/ChatHistoryStore.cpp +++ b/ChatView/ChatHistoryStore.cpp @@ -16,15 +16,20 @@ #include #include -#include "ChatModel.hpp" +#include +#include +#include + +#include + #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,17 +57,23 @@ 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()) { - shortMessage = "image_chat"; + 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(block.get())) { + shortMessage = "image_chat"; + break; + } + } } + break; } } @@ -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() diff --git a/ChatView/ChatHistoryStore.hpp b/ChatView/ChatHistoryStore.hpp index 0de31ed..8e00a92 100644 --- a/ChatView/ChatHistoryStore.hpp +++ b/ChatView/ChatHistoryStore.hpp @@ -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 diff --git a/ChatView/ChatModel.cpp b/ChatView/ChatModel.cpp index 125e536..c6abd23 100644 --- a/ChatView/ChatModel.cpp +++ b/ChatView/ChatModel.cpp @@ -3,115 +3,159 @@ // Additional attribution terms under GPLv3 §7(b) apply — see LICENSE #include "ChatModel.hpp" -#include -#include + #include #include #include -#include +#include #include -#include -#include "Logger.hpp" +#include + +#include +#include +#include + #include "context/ChangesManager.h" namespace QodeAssist::Chat { +namespace { + +const QString kFileEditMarker = QStringLiteral("QODEASSIST_FILE_EDIT:"); + +QString changesStatusToString(Context::ChangesManager::FileEditStatus status) +{ + switch (status) { + case Context::ChangesManager::Pending: return QStringLiteral("pending"); + case Context::ChangesManager::Applied: return QStringLiteral("applied"); + case Context::ChangesManager::Rejected: return QStringLiteral("rejected"); + case Context::ChangesManager::Archived: return QStringLiteral("archived"); + } + return QStringLiteral("pending"); +} + +QString parseEditId(const QString &markerContent) +{ + const int pos = markerContent.indexOf(kFileEditMarker); + if (pos < 0) + return {}; + const QString jsonStr = markerContent.mid(pos + kFileEditMarker.length()); + const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); + if (!doc.isObject()) + return {}; + return doc.object().value(QStringLiteral("edit_id")).toString(); +} + +QString collectText(const Message &m) +{ + QString text; + for (const auto &block : m.blocks()) { + if (auto *t = dynamic_cast(block.get())) { + if (!text.isEmpty()) + text += QLatin1Char('\n'); + text += t->text(); + } + } + return text; +} + +bool messageIsToolResultsOnly(const Message &m) +{ + bool hasToolResult = false; + for (const auto &block : m.blocks()) { + if (dynamic_cast(block.get())) + hasToolResult = true; + else + return false; + } + return hasToolResult; +} + +} // namespace + ChatModel::ChatModel(QObject *parent) : QAbstractListModel(parent) { - connect(&Context::ChangesManager::instance(), - &Context::ChangesManager::fileEditApplied, - this, - &ChatModel::onFileEditApplied); - - connect(&Context::ChangesManager::instance(), - &Context::ChangesManager::fileEditRejected, - this, - &ChatModel::onFileEditRejected); - - connect(&Context::ChangesManager::instance(), - &Context::ChangesManager::fileEditArchived, - this, - &ChatModel::onFileEditArchived); + auto &changes = Context::ChangesManager::instance(); + connect( + &changes, &Context::ChangesManager::fileEditApplied, + this, &ChatModel::onFileEditStatusChanged); + connect( + &changes, &Context::ChangesManager::fileEditRejected, + this, &ChatModel::onFileEditStatusChanged); + connect( + &changes, &Context::ChangesManager::fileEditUndone, + this, &ChatModel::onFileEditStatusChanged); + connect( + &changes, &Context::ChangesManager::fileEditArchived, + this, &ChatModel::onFileEditStatusChanged); +} + +void ChatModel::setHistory(ConversationHistory *history) +{ + if (m_history == history) + return; + + if (m_history) + m_history->disconnect(this); + + m_history = history; + + if (m_history) { + connect( + m_history, &ConversationHistory::messageAdded, + this, &ChatModel::onHistoryMessageAdded); + connect( + m_history, &ConversationHistory::messageUpdated, + this, &ChatModel::onHistoryMessageUpdated); + connect(m_history, &ConversationHistory::cleared, this, &ChatModel::onHistoryCleared); + connect(m_history, &ConversationHistory::reset, this, &ChatModel::onHistoryReset); + } + + beginResetModel(); + rebuildAll(); + endResetModel(); + emit sessionUsageChanged(); } int ChatModel::rowCount(const QModelIndex &parent) const { - return m_messages.size(); + if (parent.isValid()) + return 0; + return m_rows.size(); } QVariant ChatModel::data(const QModelIndex &index, int role) const { - if (!index.isValid() || index.row() >= m_messages.size()) + if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) return QVariant(); - const Message &message = m_messages[index.row()]; + const Row &row = m_rows[index.row()]; switch (static_cast(role)) { case Roles::RoleType: - return QVariant::fromValue(message.role); - case Roles::Content: { - return message.content; - } - case Roles::Attachments: { - QVariantList attachmentsList; - for (const auto &attachment : message.attachments) { - QVariantMap attachmentMap; - attachmentMap["fileName"] = attachment.filename; - attachmentMap["storedPath"] = attachment.content; - - if (!m_chatFilePath.isEmpty()) { - QFileInfo fileInfo(m_chatFilePath); - QString baseName = fileInfo.completeBaseName(); - QString dirPath = fileInfo.absolutePath(); - QString contentFolder = QDir(dirPath).filePath(baseName + "_content"); - QString fullPath = QDir(contentFolder).filePath(attachment.content); - attachmentMap["filePath"] = fullPath; - } else { - attachmentMap["filePath"] = QString(); - } - - attachmentsList.append(attachmentMap); - } - return attachmentsList; - } - case Roles::IsRedacted: { - return message.isRedacted; - } + return QVariant::fromValue(row.kind); + case Roles::Content: + if (row.kind == ChatRole::FileEdit) + return overlayFileEditStatus(row.content, row.editId); + return row.content; + case Roles::Attachments: + return buildAttachmentList(row.attachments); + case Roles::Images: + return buildImageList(row.images); + case Roles::IsRedacted: + return row.isRedacted; case Roles::PromptTokens: - return message.promptTokens; + return m_usageByMessageId.value(row.messageId).prompt; case Roles::CompletionTokens: - return message.completionTokens; + return m_usageByMessageId.value(row.messageId).completion; case Roles::CachedPromptTokens: - return message.cachedPromptTokens; + return m_usageByMessageId.value(row.messageId).cached; case Roles::ReasoningTokens: - return message.reasoningTokens; - case Roles::TotalTokens: - return message.promptTokens + message.completionTokens; - case Roles::Images: { - QVariantList imagesList; - for (const auto &image : message.images) { - QVariantMap imageMap; - imageMap["fileName"] = image.fileName; - imageMap["storedPath"] = image.storedPath; - imageMap["mediaType"] = image.mediaType; - - if (!m_chatFilePath.isEmpty()) { - QFileInfo fileInfo(m_chatFilePath); - QString baseName = fileInfo.completeBaseName(); - QString dirPath = fileInfo.absolutePath(); - QString contentFolder = QDir(dirPath).filePath(baseName + "_content"); - QString fullPath = QDir(contentFolder).filePath(image.storedPath); - imageMap["imageUrl"] = QUrl::fromLocalFile(fullPath).toString(); - imageMap["filePath"] = fullPath; - } else { - imageMap["imageUrl"] = QString(); - imageMap["filePath"] = QString(); - } - - imagesList.append(imageMap); - } - return imagesList; + return m_usageByMessageId.value(row.messageId).reasoning; + case Roles::TotalTokens: { + const Usage u = m_usageByMessageId.value(row.messageId); + return u.prompt + u.completion; } default: return QVariant(); @@ -134,87 +178,311 @@ QHash ChatModel::roleNames() const return roles; } -void ChatModel::addMessage( - const QString &content, - ChatRole role, - const QString &id, - const QList &attachments, - const QList &images, - bool isRedacted, - const QString &signature) +QVariantList ChatModel::buildAttachmentList(const QVector &attachments) const { - if (!m_messages.isEmpty() && !id.isEmpty() && m_messages.last().id == id - && m_messages.last().role == role) { - Message &lastMessage = m_messages.last(); - lastMessage.content = content; - lastMessage.attachments = attachments; - lastMessage.images = images; - lastMessage.isRedacted = isRedacted; - lastMessage.signature = signature; - emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1)); - } else { - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message newMessage{role, content, id}; - newMessage.attachments = attachments; - newMessage.images = images; - newMessage.isRedacted = isRedacted; - newMessage.signature = signature; - m_messages.append(newMessage); - endInsertRows(); - - if (m_loadingFromHistory && role == ChatRole::FileEdit) { - const QString marker = "QODEASSIST_FILE_EDIT:"; - if (content.contains(marker)) { - int markerPos = content.indexOf(marker); - int jsonStart = markerPos + marker.length(); - - if (jsonStart < content.length()) { - QString jsonStr = content.mid(jsonStart); - QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); - - if (doc.isObject()) { - QJsonObject editData = doc.object(); - QString editId = editData.value("edit_id").toString(); - QString filePath = editData.value("file").toString(); - QString oldContent = editData.value("old_content").toString(); - QString newContent = editData.value("new_content").toString(); - QString originalStatus = editData.value("status").toString(); - - if (!editId.isEmpty() && !filePath.isEmpty()) { - Context::ChangesManager::instance().addFileEdit( - editId, filePath, oldContent, newContent, false, true); - - editData["status"] = "archived"; - editData["status_message"] = "Loaded from chat history"; - - QString updatedContent = marker - + QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact)); - m_messages.last().content = updatedContent; - - emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1)); - - LOG_MESSAGE(QString("Registered historical file edit: %1 (original status: %2, now: archived)") - .arg(editId, originalStatus)); - } - } + QVariantList list; + for (const auto &attachment : attachments) { + QVariantMap map; + map["fileName"] = attachment.fileName; + map["storedPath"] = attachment.storedPath; + if (!m_chatFilePath.isEmpty()) { + QFileInfo fileInfo(m_chatFilePath); + const QString contentFolder + = QDir(fileInfo.absolutePath()).filePath(fileInfo.completeBaseName() + "_content"); + map["filePath"] = QDir(contentFolder).filePath(attachment.storedPath); + } else { + map["filePath"] = QString(); + } + list.append(map); + } + return list; +} + +QVariantList ChatModel::buildImageList(const QVector &images) const +{ + QVariantList list; + for (const auto &image : images) { + QVariantMap map; + map["fileName"] = image.fileName; + map["storedPath"] = image.storedPath; + map["mediaType"] = image.mediaType; + if (!m_chatFilePath.isEmpty()) { + QFileInfo fileInfo(m_chatFilePath); + const QString contentFolder + = QDir(fileInfo.absolutePath()).filePath(fileInfo.completeBaseName() + "_content"); + const QString fullPath = QDir(contentFolder).filePath(image.storedPath); + map["imageUrl"] = QUrl::fromLocalFile(fullPath).toString(); + map["filePath"] = fullPath; + } else { + map["imageUrl"] = QString(); + map["filePath"] = QString(); + } + list.append(map); + } + return list; +} + +QString ChatModel::overlayFileEditStatus(const QString &content, const QString &editId) const +{ + const int pos = content.indexOf(kFileEditMarker); + if (pos < 0) + return content; + + const QString jsonStr = content.mid(pos + kFileEditMarker.length()); + const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); + if (!doc.isObject()) + return content; + + QJsonObject obj = doc.object(); + if (!editId.isEmpty()) { + const auto edit = Context::ChangesManager::instance().getFileEdit(editId); + if (!edit.editId.isEmpty()) { + obj["status"] = changesStatusToString(edit.status); + if (!edit.statusMessage.isEmpty()) + obj["status_message"] = edit.statusMessage; + } + } + return kFileEditMarker + + QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)); +} + +QHash ChatModel::buildToolResultMap() const +{ + QHash results; + if (!m_history) + return results; + for (const auto &m : m_history->messages()) { + for (const auto &block : m.blocks()) { + if (auto *tr = dynamic_cast(block.get())) + results.insert(tr->toolUseId(), tr->result()); + } + } + return results; +} + +void ChatModel::appendRowsForMessage( + int messageIndex, const QHash &toolResults, QVector &out) const +{ + if (!m_history || messageIndex < 0 || messageIndex >= m_history->size()) + return; + + const Message &m = m_history->messages()[static_cast(messageIndex)]; + const QString id = m.id(); + + switch (m.role()) { + case Message::Role::System: { + const QString text = collectText(m); + if (!text.trimmed().isEmpty()) { + Row row; + row.kind = ChatRole::System; + row.messageIndex = messageIndex; + row.messageId = id; + row.content = text; + out.append(std::move(row)); + } + break; + } + case Message::Role::User: { + QString text; + QVector attachments; + QVector images; + bool hasDisplayable = false; + for (const auto &block : m.blocks()) { + if (auto *t = dynamic_cast(block.get())) { + if (!text.isEmpty()) + text += QLatin1Char('\n'); + text += t->text(); + hasDisplayable = true; + } else if (auto *sa = dynamic_cast(block.get())) { + attachments.append({sa->fileName(), sa->storedPath()}); + hasDisplayable = true; + } else if (auto *si = dynamic_cast(block.get())) { + images.append({si->fileName(), si->storedPath(), si->mediaType()}); + hasDisplayable = true; + } + } + if (hasDisplayable) { + Row row; + row.kind = ChatRole::User; + row.messageIndex = messageIndex; + row.messageId = id; + row.content = text; + row.attachments = std::move(attachments); + row.images = std::move(images); + out.append(std::move(row)); + } + break; + } + case Message::Role::Assistant: { + for (const auto &block : m.blocks()) { + if (auto *th = dynamic_cast(block.get())) { + QString content = th->thinking(); + if (!th->signature().isEmpty()) + content += QStringLiteral("\n[Signature: ") + th->signature().left(40) + + QStringLiteral("...]"); + Row row; + row.kind = ChatRole::Thinking; + row.messageIndex = messageIndex; + row.messageId = id; + row.content = content; + out.append(std::move(row)); + } else if ( + auto *rth = dynamic_cast(block.get())) { + QString content = QStringLiteral("[Thinking content redacted by safety systems]"); + if (!rth->signature().isEmpty()) + content += QStringLiteral("\n[Signature: ") + rth->signature().left(40) + + QStringLiteral("...]"); + Row row; + row.kind = ChatRole::Thinking; + row.messageIndex = messageIndex; + row.messageId = id; + row.content = content; + row.isRedacted = true; + out.append(std::move(row)); + } else if (auto *t = dynamic_cast(block.get())) { + if (!t->text().trimmed().isEmpty()) { + Row row; + row.kind = ChatRole::Assistant; + row.messageIndex = messageIndex; + row.messageId = id; + row.content = t->text(); + out.append(std::move(row)); + } + } else if (auto *tu = dynamic_cast(block.get())) { + const QString result = toolResults.value(tu->id()); + Row toolRow; + toolRow.kind = ChatRole::Tool; + toolRow.messageIndex = messageIndex; + toolRow.messageId = id; + toolRow.content = tu->name() + QLatin1Char('\n') + result; + out.append(std::move(toolRow)); + + if (result.contains(kFileEditMarker)) { + Row editRow; + editRow.kind = ChatRole::FileEdit; + editRow.messageIndex = messageIndex; + editRow.messageId = id; + editRow.content = result; + editRow.editId = parseEditId(result); + out.append(std::move(editRow)); } } } + break; + } } } -QVector ChatModel::getChatHistory() const +void ChatModel::rebuildAll() { - return m_messages; + m_rows.clear(); + if (!m_history) + return; + const QHash toolResults = buildToolResultMap(); + for (int mi = 0; mi < m_history->size(); ++mi) + appendRowsForMessage(mi, toolResults, m_rows); +} + +int ChatModel::firstRowForMessage(int messageIndex) const +{ + for (int i = 0; i < m_rows.size(); ++i) { + if (m_rows[i].messageIndex >= messageIndex) + return i; + } + return m_rows.size(); +} + +int ChatModel::startMessageIndexFor(int messageIndex) const +{ + if (!m_history || messageIndex < 0 || messageIndex >= m_history->size()) + return messageIndex; + + const auto &msgs = m_history->messages(); + const Message &m = msgs[static_cast(messageIndex)]; + if (m.role() == Message::Role::User && messageIsToolResultsOnly(m)) { + for (int j = messageIndex - 1; j >= 0; --j) { + if (msgs[static_cast(j)].role() == Message::Role::Assistant) + return j; + } + } + return messageIndex; +} + +void ChatModel::reprojectTail(int startMessageIndex) +{ + if (!m_history) + return; + + const int oldStart = firstRowForMessage(startMessageIndex); + const QHash toolResults = buildToolResultMap(); + + QVector newTail; + for (int mi = startMessageIndex; mi < m_history->size(); ++mi) + appendRowsForMessage(mi, toolResults, newTail); + + const int oldCount = m_rows.size() - oldStart; + const int newCount = newTail.size(); + const int common = qMin(oldCount, newCount); + + for (int i = 0; i < common; ++i) + m_rows[oldStart + i] = newTail[i]; + if (common > 0) + emit dataChanged(index(oldStart), index(oldStart + common - 1)); + + if (newCount > oldCount) { + beginInsertRows(QModelIndex(), oldStart + oldCount, oldStart + newCount - 1); + for (int i = oldCount; i < newCount; ++i) + m_rows.append(newTail[i]); + endInsertRows(); + } else if (newCount < oldCount) { + beginRemoveRows(QModelIndex(), oldStart + newCount, oldStart + oldCount - 1); + m_rows.remove(oldStart + newCount, oldCount - newCount); + endRemoveRows(); + } +} + +void ChatModel::onHistoryMessageAdded(int index) +{ + reprojectTail(startMessageIndexFor(index)); +} + +void ChatModel::onHistoryMessageUpdated(int index) +{ + reprojectTail(startMessageIndexFor(index)); +} + +void ChatModel::onHistoryCleared() +{ + beginResetModel(); + m_rows.clear(); + m_usageByMessageId.clear(); + endResetModel(); + emit modelReseted(); + emit sessionUsageChanged(); +} + +void ChatModel::onHistoryReset() +{ + beginResetModel(); + rebuildAll(); + endResetModel(); + emit sessionUsageChanged(); +} + +void ChatModel::onFileEditStatusChanged(const QString &editId) +{ + for (int i = 0; i < m_rows.size(); ++i) { + if (m_rows[i].kind == ChatRole::FileEdit && m_rows[i].editId == editId) + emit dataChanged(index(i), index(i), {Roles::Content}); + } } void ChatModel::clear() { - beginResetModel(); - m_messages.clear(); - endResetModel(); - emit modelReseted(); - emit sessionUsageChanged(); + if (m_history) + m_history->clear(); + else + onHistoryCleared(); } QList ChatModel::processMessageContent(const QString &content) const @@ -277,73 +545,21 @@ QList ChatModel::processMessageContent(const QString &content) cons return parts; } -QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) const -{ - QJsonArray messages; - messages.append(QJsonObject{{"role", "system"}, {"content", systemPrompt}}); - - for (const auto &message : m_messages) { - QString role; - switch (message.role) { - case ChatRole::User: - role = "user"; - break; - case ChatRole::Assistant: - role = "assistant"; - break; - case ChatRole::Tool: - case ChatRole::FileEdit: - continue; - default: - continue; - } - - QString content - = message.attachments.isEmpty() - ? message.content - : message.content + "\n\nAttached files list:" - + std::accumulate( - message.attachments.begin(), - message.attachments.end(), - QString(), - [](QString acc, const Context::ContentFile &attachment) { - return acc - + QString("\nname: %1\nfile content:\n%2") - .arg(attachment.filename, attachment.content); - }); - - messages.append(QJsonObject{{"role", role}, {"content", content}}); - } - - return messages; -} - -QString ChatModel::lastMessageId() const -{ - return !m_messages.isEmpty() ? m_messages.last().id : ""; -} - void ChatModel::resetModelTo(int index) { - if (index < 0 || index >= m_messages.size()) + if (!m_history || index < 0 || index >= m_rows.size()) return; - - if (index < m_messages.size()) { - beginRemoveRows(QModelIndex(), index, m_messages.size() - 1); - m_messages.remove(index, m_messages.size() - index); - endRemoveRows(); - emit sessionUsageChanged(); - } + m_history->resetTo(m_rows[index].messageIndex); } QVariantList ChatModel::userMessagePreviews(int maxLength) const { QVariantList result; const int limit = maxLength > 4 ? maxLength : 80; - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role != ChatRole::User) + for (int i = 0; i < m_rows.size(); ++i) { + if (m_rows[i].kind != ChatRole::User) continue; - QString preview = m_messages[i].content; + QString preview = m_rows[i].content; preview.replace(QLatin1Char('\n'), QLatin1Char(' ')); preview.replace(QLatin1Char('\r'), QLatin1Char(' ')); preview.replace(QLatin1Char('\t'), QLatin1Char(' ')); @@ -358,205 +574,6 @@ QVariantList ChatModel::userMessagePreviews(int maxLength) const return result; } -void ChatModel::addToolExecutionStatus( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QJsonObject &toolArguments) -{ - QString content = toolName; - - LOG_MESSAGE(QString("Adding tool execution status: requestId=%1, toolId=%2, toolName=%3") - .arg(requestId, toolId, toolName)); - - if (!m_messages.isEmpty() && !toolId.isEmpty() && m_messages.last().id == toolId - && m_messages.last().role == ChatRole::Tool) { - Message &lastMessage = m_messages.last(); - lastMessage.content = content; - lastMessage.toolName = toolName; - lastMessage.toolArguments = toolArguments; - LOG_MESSAGE(QString("Updated existing tool message at index %1").arg(m_messages.size() - 1)); - emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1)); - } else { - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message newMessage{ChatRole::Tool, content, toolId}; - newMessage.toolName = toolName; - newMessage.toolArguments = toolArguments; - m_messages.append(newMessage); - endInsertRows(); - LOG_MESSAGE(QString("Created new tool message at index %1 with toolId=%2") - .arg(m_messages.size() - 1) - .arg(toolId)); - } -} - -void ChatModel::setToolMessageData( - const QString &toolId, - const QString &toolName, - const QJsonObject &toolArguments, - const QString &toolResult) -{ - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role == ChatRole::Tool && m_messages[i].id == toolId) { - m_messages[i].toolName = toolName; - m_messages[i].toolArguments = toolArguments; - m_messages[i].toolResult = toolResult; - return; - } - } -} - -void ChatModel::updateToolResult( - const QString &requestId, const QString &toolId, const QString &toolName, const QString &result) -{ - if (m_messages.isEmpty() || toolId.isEmpty()) { - LOG_MESSAGE(QString("Cannot update tool result: messages empty=%1, toolId empty=%2") - .arg(m_messages.isEmpty()) - .arg(toolId.isEmpty())); - return; - } - - LOG_MESSAGE( - QString("Updating tool result: requestId=%1, toolId=%2, toolName=%3, result length=%4") - .arg(requestId, toolId, toolName) - .arg(result.length())); - - bool toolMessageFound = false; - for (int i = m_messages.size() - 1; i >= 0; --i) { - if (m_messages[i].id == toolId && m_messages[i].role == ChatRole::Tool) { - m_messages[i].content = toolName + "\n" + result; - m_messages[i].toolName = toolName; - m_messages[i].toolResult = result; - emit dataChanged(index(i), index(i)); - toolMessageFound = true; - LOG_MESSAGE(QString("Updated tool result at index %1").arg(i)); - break; - } - } - - if (!toolMessageFound) { - LOG_MESSAGE(QString("WARNING: Tool message with requestId=%1 toolId=%2 not found!") - .arg(requestId, toolId)); - } - - const QString marker = "QODEASSIST_FILE_EDIT:"; - if (result.contains(marker)) { - LOG_MESSAGE(QString("File edit marker detected in tool result")); - - int markerPos = result.indexOf(marker); - int jsonStart = markerPos + marker.length(); - - if (jsonStart < result.length()) { - QString jsonStr = result.mid(jsonStart); - - QJsonParseError parseError; - QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8(), &parseError); - - if (parseError.error != QJsonParseError::NoError) { - LOG_MESSAGE(QString("ERROR: Failed to parse file edit JSON at offset %1: %2") - .arg(parseError.offset) - .arg(parseError.errorString())); - } else if (!doc.isObject()) { - LOG_MESSAGE( - QString("ERROR: Parsed JSON is not an object, is array=%1").arg(doc.isArray())); - } else { - QJsonObject editData = doc.object(); - - QString editId = editData.value("edit_id").toString(); - - if (editId.isEmpty()) { - editId = QString("edit_%1").arg(QDateTime::currentMSecsSinceEpoch()); - } - - LOG_MESSAGE(QString("Adding FileEdit message, editId=%1").arg(editId)); - - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message fileEditMsg; - fileEditMsg.role = ChatRole::FileEdit; - fileEditMsg.content = result; - fileEditMsg.id = editId; - m_messages.append(fileEditMsg); - endInsertRows(); - - LOG_MESSAGE(QString("Added FileEdit message with editId=%1").arg(editId)); - } - } - } -} - -void ChatModel::addThinkingBlock( - const QString &requestId, const QString &thinking, const QString &signature) -{ - LOG_MESSAGE(QString("Adding thinking block: requestId=%1, thinking length=%2, signature length=%3") - .arg(requestId) - .arg(thinking.length()) - .arg(signature.length())); - - QString displayContent = thinking; - if (!signature.isEmpty()) { - displayContent += "\n[Signature: " + signature.left(40) + "...]"; - } - - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role == ChatRole::Thinking && m_messages[i].id == requestId) { - m_messages[i].content = displayContent; - m_messages[i].signature = signature; - emit dataChanged(index(i), index(i)); - LOG_MESSAGE(QString("Updated existing thinking message at index %1").arg(i)); - return; - } - } - - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message thinkingMessage; - thinkingMessage.role = ChatRole::Thinking; - thinkingMessage.content = displayContent; - thinkingMessage.id = requestId; - thinkingMessage.isRedacted = false; - thinkingMessage.signature = signature; - m_messages.append(thinkingMessage); - endInsertRows(); - LOG_MESSAGE(QString("Added thinking message at index %1 with signature length=%2") - .arg(m_messages.size() - 1).arg(signature.length())); -} - -void ChatModel::addRedactedThinkingBlock(const QString &requestId, const QString &signature) -{ - LOG_MESSAGE( - QString("Adding redacted thinking block: requestId=%1, signature length=%2") - .arg(requestId) - .arg(signature.length())); - - QString displayContent = "[Thinking content redacted by safety systems]"; - if (!signature.isEmpty()) { - displayContent += "\n[Signature: " + signature.left(40) + "...]"; - } - - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message thinkingMessage; - thinkingMessage.role = ChatRole::Thinking; - thinkingMessage.content = displayContent; - thinkingMessage.id = requestId; - thinkingMessage.isRedacted = true; - thinkingMessage.signature = signature; - m_messages.append(thinkingMessage); - endInsertRows(); - LOG_MESSAGE(QString("Added redacted thinking message at index %1 with signature length=%2") - .arg(m_messages.size() - 1).arg(signature.length())); -} - -void ChatModel::updateMessageContent(const QString &messageId, const QString &newContent) -{ - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].id == messageId) { - m_messages[i].content = newContent; - emit dataChanged(index(i), index(i)); - LOG_MESSAGE(QString("Updated message content for id: %1").arg(messageId)); - break; - } - } -} - void ChatModel::setMessageUsage( const QString &messageId, int promptTokens, @@ -564,47 +581,54 @@ void ChatModel::setMessageUsage( int cachedPromptTokens, int reasoningTokens) { - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].id != messageId) - continue; - m_messages[i].promptTokens = promptTokens; - m_messages[i].completionTokens = completionTokens; - m_messages[i].cachedPromptTokens = cachedPromptTokens; - m_messages[i].reasoningTokens = reasoningTokens; - emit dataChanged( - index(i), - index(i), - {Roles::PromptTokens, - Roles::CompletionTokens, - Roles::CachedPromptTokens, - Roles::ReasoningTokens, - Roles::TotalTokens}); - emit sessionUsageChanged(); + if (messageId.isEmpty()) return; + + m_usageByMessageId[messageId] + = Usage{promptTokens, completionTokens, cachedPromptTokens, reasoningTokens}; + + for (int i = 0; i < m_rows.size(); ++i) { + if (m_rows[i].messageId == messageId) { + emit dataChanged( + index(i), + index(i), + {Roles::PromptTokens, + Roles::CompletionTokens, + Roles::CachedPromptTokens, + Roles::ReasoningTokens, + Roles::TotalTokens}); + } } + emit sessionUsageChanged(); } int ChatModel::sessionPromptTokens() const { int total = 0; - for (const auto &m : m_messages) - total += m.promptTokens; + if (m_history) { + for (const auto &m : m_history->messages()) + total += m_usageByMessageId.value(m.id()).prompt; + } return total; } int ChatModel::sessionCompletionTokens() const { int total = 0; - for (const auto &m : m_messages) - total += m.completionTokens; + if (m_history) { + for (const auto &m : m_history->messages()) + total += m_usageByMessageId.value(m.id()).completion; + } return total; } int ChatModel::sessionCachedPromptTokens() const { int total = 0; - for (const auto &m : m_messages) - total += m.cachedPromptTokens; + if (m_history) { + for (const auto &m : m_history->messages()) + total += m_usageByMessageId.value(m.id()).cached; + } return total; } @@ -613,71 +637,6 @@ int ChatModel::sessionTotalTokens() const return sessionPromptTokens() + sessionCompletionTokens(); } -void ChatModel::setLoadingFromHistory(bool loading) -{ - m_loadingFromHistory = loading; - LOG_MESSAGE(QString("ChatModel loading from history: %1").arg(loading ? "true" : "false")); -} - -bool ChatModel::isLoadingFromHistory() const -{ - return m_loadingFromHistory; -} - -void ChatModel::onFileEditApplied(const QString &editId) -{ - updateFileEditStatus(editId, "applied", "Successfully applied"); -} - -void ChatModel::onFileEditRejected(const QString &editId) -{ - updateFileEditStatus(editId, "rejected", "Rejected by user"); -} - -void ChatModel::onFileEditArchived(const QString &editId) -{ - updateFileEditStatus(editId, "archived", "Archived (from previous conversation turn)"); -} - -void ChatModel::updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage) -{ - const QString marker = "QODEASSIST_FILE_EDIT:"; - - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role == ChatRole::FileEdit && m_messages[i].id == editId) { - const QString &content = m_messages[i].content; - - if (content.contains(marker)) { - int markerPos = content.indexOf(marker); - int jsonStart = markerPos + marker.length(); - - if (jsonStart < content.length()) { - QString jsonStr = content.mid(jsonStart); - QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); - - if (doc.isObject()) { - QJsonObject editData = doc.object(); - - editData["status"] = status; - editData["status_message"] = statusMessage; - - QString updatedContent = marker - + QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact)); - - m_messages[i].content = updatedContent; - - emit dataChanged(index(i), index(i)); - - LOG_MESSAGE(QString("Updated FileEdit message status: editId=%1, status=%2") - .arg(editId, status)); - break; - } - } - } - } - } -} - void ChatModel::setChatFilePath(const QString &filePath) { m_chatFilePath = filePath; diff --git a/ChatView/ChatModel.hpp b/ChatView/ChatModel.hpp index c06c198..6bbd2a1 100644 --- a/ChatView/ChatModel.hpp +++ b/ChatView/ChatModel.hpp @@ -8,11 +8,16 @@ #include "MessagePart.hpp" #include +#include #include #include +#include +#include #include -#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 attachments; - QList 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 roleNames() const override; - Q_INVOKABLE void addMessage( - const QString &content, - ChatRole role, - const QString &id, - const QList &attachments = {}, - const QList &images = {}, - bool isRedacted = false, - const QString &signature = QString()); Q_INVOKABLE void clear(); Q_INVOKABLE QList processMessageContent(const QString &content) const; - - QVector 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, @@ -128,10 +72,7 @@ public: int sessionCompletionTokens() const; 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); - - QVector m_messages; - bool m_loadingFromHistory = false; + 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 attachments; + QVector 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 buildToolResultMap() const; + void appendRowsForMessage( + int messageIndex, const QHash &toolResults, QVector &out) const; + QString overlayFileEditStatus(const QString &content, const QString &editId) const; + QVariantList buildAttachmentList(const QVector &attachments) const; + QVariantList buildImageList(const QVector &images) const; + + QPointer m_history; + QVector m_rows; + QHash m_usageByMessageId; QString m_chatFilePath; }; } // namespace QodeAssist::Chat -Q_DECLARE_METATYPE(QodeAssist::Chat::ChatModel::Message) + Q_DECLARE_METATYPE(QodeAssist::Chat::MessagePart) diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 95aa34f..716d6df 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -29,6 +29,8 @@ #include "QodeAssistConstants.hpp" #include +#include +#include #include #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() diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index cad0245..fb17dc1 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -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; diff --git a/ChatView/ChatSerializer.cpp b/ChatView/ChatSerializer.cpp index 120a4c5..dc8f802 100644 --- a/ChatView/ChatSerializer.cpp +++ b/ChatView/ChatSerializer.cpp @@ -5,7 +5,8 @@ #include "ChatSerializer.hpp" #include "Logger.hpp" -#include +#include + #include #include #include @@ -13,12 +14,57 @@ #include #include +#include + +#include +#include +#include +#include + +#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"}; - } - - return {true, QString()}; + if (version == VERSION) + return loadCurrent(history, root); + return loadLegacy(history, root); } -QJsonObject ChatSerializer::serializeMessage( - const ChatModel::Message &message, const QString &chatFilePath) -{ - QJsonObject messageObj; - messageObj["role"] = static_cast(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(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 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(); + registerHistoricalFileEdits(history); + return {true, QString()}; +} - model->setLoadingFromHistory(true); +SerializationResult ChatSerializer::loadLegacy(ConversationHistory *history, const QJsonObject &root) +{ + history->clear(); - 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); + const QJsonArray arr = root["messages"].toArray(); + int i = 0; + while (i < arr.size()) { + const QJsonObject mj = arr[i].toObject(); + const auto role = static_cast(mj["role"].toInt()); + + if (role == LegacyRole::Tool) { + Message assistant(Message::Role::Assistant); + Message toolResults(Message::Role::User); + while (i < arr.size() + && static_cast(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( + id, toolName, tj["toolArguments"].toObject())); + toolResults.appendBlock(std::make_unique( + 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(signature)); + } else { + const int sigPos = content.indexOf(QStringLiteral("\n[Signature:")); + const QString thinking = sigPos >= 0 ? content.left(sigPos) : content; + assistant.appendBlock( + std::make_unique(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(mj["content"].toString())); + for (const auto &a : mj["attachments"].toArray()) { + const QJsonObject ao = a.toObject(); + user.appendBlock(std::make_unique( + ao["fileName"].toString(), ao["storedPath"].toString())); + } + for (const auto &im : mj["images"].toArray()) { + const QJsonObject io = im.toObject(); + user.appendBlock(std::make_unique( + 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(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(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) diff --git a/ChatView/ChatSerializer.hpp b/ChatView/ChatSerializer.hpp index 82c1075..af6dd2f 100644 --- a/ChatView/ChatSerializer.hpp +++ b/ChatView/ChatSerializer.hpp @@ -4,11 +4,12 @@ #pragma once -#include #include #include -#include "ChatModel.hpp" +namespace QodeAssist { +class ConversationHistory; +} namespace QodeAssist::Chat { @@ -21,26 +22,26 @@ 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, - const QString &fileName, - const QString &base64Data, - QString &storedPath); + static bool saveContentToStorage( + const QString &chatFilePath, + const QString &fileName, + const QString &base64Data, + QString &storedPath); static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath); 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); diff --git a/ChatView/ClientInterface.cpp b/ChatView/ClientInterface.cpp index 47550ca..18d4868 100644 --- a/ChatView/ClientInterface.cpp +++ b/ChatView/ClientInterface.cpp @@ -5,6 +5,7 @@ #include "ClientInterface.hpp" #include +#include #include #include @@ -20,6 +21,7 @@ #include #include +#include #include #include #include @@ -29,11 +31,10 @@ #include #include -#include #include +#include +#include #include - -#include #include #include @@ -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 imageAttachments; + QList 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 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,8 +201,12 @@ void ClientInterface::sendMessage( bindings.roleId = m_activeRoleId; session->setContextBindings(bindings); - if (m_sessionManager) - m_sessionManager->toolContributors().contribute(client->tools()); + 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( static_cast(Settings::generalSettings().requestTimeout() * 1000)); @@ -200,61 +215,25 @@ void ClientInterface::sendMessage( if (!chatContext.isEmpty()) session->systemPrompt()->setLayer(QStringLiteral("chat.context"), chatContext); - seedHistory(*session->history(), priorHistory); + std::vector> blocks; + blocks.push_back(std::make_unique(message)); - 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); - } - } + for (const auto &attachment : storedAttachments) { + blocks.push_back( + std::make_unique(attachment.filename, attachment.content)); } - std::vector> blocks; - blocks.push_back(std::make_unique(userText)); - - 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( - base64, image.mediaType, LLMQore::ImageContent::ImageSourceType::Base64)); + if (!storedImages.isEmpty() && session->supportsImages()) { + for (const auto &image : storedImages) { + blocks.push_back(std::make_unique( + image.fileName, image.storedPath, image.mediaType)); } - } else if (!imageAttachments.isEmpty() && !session->supportsImages()) { + } 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(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 &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( - toolMsg.id, toolMsg.toolName, toolMsg.toolArguments)); - toolResults.appendBlock( - std::make_unique(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(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( - 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(msg.content)); - history.append(std::move(assistant)); - } + for (auto it = m_activeRequests.cbegin(); it != m_activeRequests.cend(); ++it) { + if (it.value().session == session) + return it.key(); } + return {}; +} + +void ClientInterface::onSessionEvent(Session *session, const QodeAssist::ResponseEvent &ev) +{ + if (ev.kind() != ResponseEvent::Kind::Usage) + return; + + const auto *usage = ev.as(); + 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( @@ -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 imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"}; diff --git a/ChatView/ClientInterface.hpp b/ChatView/ClientInterface.hpp index 06ee6bb..bc21dc7 100644 --- a/ChatView/ClientInterface.hpp +++ b/ChatView/ClientInterface.hpp @@ -6,12 +6,12 @@ #include #include -#include #include -#include #include "ChatModel.hpp" +#include #include +#include #include 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 &linkedFiles) const; - void seedHistory( - ConversationHistory &history, const QVector &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 m_history; Skills::SkillsManager *m_skillsManager = nullptr; QPointer m_sessionManager; QString m_activeAgent; @@ -102,8 +88,6 @@ private: QString m_chatFilePath; QHash m_activeRequests; - QHash m_accumulatedResponses; - QSet m_awaitingContinuation; }; } // namespace QodeAssist::Chat diff --git a/ChatView/FileEditController.cpp b/ChatView/FileEditController.cpp index 689c457..8273871 100644 --- a/ChatView/FileEditController.cpp +++ b/ChatView/FileEditController.cpp @@ -10,15 +10,13 @@ #include #include -#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(); } diff --git a/ChatView/FileEditController.hpp b/ChatView/FileEditController.hpp index 6e7e261..691278a 100644 --- a/ChatView/FileEditController.hpp +++ b/ChatView/FileEditController.hpp @@ -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}; diff --git a/ChatView/InputTokenCounter.cpp b/ChatView/InputTokenCounter.cpp index b59a1ea..f68ed3b 100644 --- a/ChatView/InputTokenCounter.cpp +++ b/ChatView/InputTokenCounter.cpp @@ -9,17 +9,19 @@ #include #include "ChatAssistantSettings.hpp" -#include "ChatModel.hpp" #include "Logger.hpp" #include "context/ContextManager.hpp" #include "context/TokenUtils.hpp" +#include +#include + 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,10 +102,11 @@ void InputTokenCounter::recompute() } } - const auto &history = m_chatModel->getChatHistory(); - for (const auto &message : history) { - inputTokens += Context::TokenUtils::estimateTokens(message.content); - inputTokens += 4; // + role + if (m_history) { + for (const auto &message : m_history->messages()) { + inputTokens += Context::TokenUtils::estimateTokens(message.text()); + inputTokens += 4; // + role + } } m_inputTokens = static_cast(inputTokens * m_calibrationFactor); diff --git a/ChatView/InputTokenCounter.hpp b/ChatView/InputTokenCounter.hpp index 86260f1..9f79982 100644 --- a/ChatView/InputTokenCounter.hpp +++ b/ChatView/InputTokenCounter.hpp @@ -7,21 +7,25 @@ #include #include +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; diff --git a/sources/Session/Session.cpp b/sources/Session/Session.cpp index 03c72f7..aaac52c 100644 --- a/sources/Session/Session.cpp +++ b/sources/Session/Session.cpp @@ -325,9 +325,10 @@ Templates::ContextData Session::buildLegacyContext( } else if (auto *sa = dynamic_cast(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```")