mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-09-10 19:27:05 -04:00
feat: add support acp in common chat (#369)
This commit is contained in:
@@ -4,5 +4,6 @@ add_subdirectory(logger)
|
||||
add_subdirectory(settings)
|
||||
add_subdirectory(context)
|
||||
add_subdirectory(session)
|
||||
add_subdirectory(acp)
|
||||
add_subdirectory(UIControls)
|
||||
add_subdirectory(ChatView)
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "AgentRoleController.hpp"
|
||||
|
||||
#include <utils/aspects.h>
|
||||
|
||||
#include "AgentRole.hpp"
|
||||
#include "ChatAssistantSettings.hpp"
|
||||
#include "GeneralSettings.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
AgentRoleController::AgentRoleController(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
connect(
|
||||
&Settings::chatAssistantSettings().systemPrompt,
|
||||
&Utils::BaseAspect::changed,
|
||||
this,
|
||||
&AgentRoleController::baseSystemPromptChanged);
|
||||
|
||||
loadAvailableRoles();
|
||||
}
|
||||
|
||||
QStringList AgentRoleController::availableRoles() const
|
||||
{
|
||||
return m_availableRoles;
|
||||
}
|
||||
|
||||
QString AgentRoleController::currentRole() const
|
||||
{
|
||||
return m_currentRole;
|
||||
}
|
||||
|
||||
QString AgentRoleController::baseSystemPrompt() const
|
||||
{
|
||||
return Settings::chatAssistantSettings().systemPrompt();
|
||||
}
|
||||
|
||||
QString AgentRoleController::currentRoleDescription() const
|
||||
{
|
||||
const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
|
||||
if (lastRoleId.isEmpty())
|
||||
return Settings::AgentRolesManager::getNoRole().description;
|
||||
|
||||
const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
|
||||
if (role.id.isEmpty())
|
||||
return Settings::AgentRolesManager::getNoRole().description;
|
||||
|
||||
return role.description;
|
||||
}
|
||||
|
||||
QString AgentRoleController::currentRoleSystemPrompt() const
|
||||
{
|
||||
const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
|
||||
if (lastRoleId.isEmpty())
|
||||
return QString();
|
||||
|
||||
const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
|
||||
if (role.id.isEmpty())
|
||||
return QString();
|
||||
|
||||
return role.systemPrompt;
|
||||
}
|
||||
|
||||
void AgentRoleController::loadAvailableRoles()
|
||||
{
|
||||
const QList<Settings::AgentRole> roles = Settings::AgentRolesManager::loadAllRoles();
|
||||
|
||||
m_availableRoles.clear();
|
||||
m_availableRoles.append(Settings::AgentRolesManager::getNoRole().name);
|
||||
|
||||
for (const auto &role : roles)
|
||||
m_availableRoles.append(role.name);
|
||||
|
||||
const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
|
||||
m_currentRole = Settings::AgentRolesManager::getNoRole().name;
|
||||
|
||||
if (!lastRoleId.isEmpty()) {
|
||||
for (const auto &role : roles) {
|
||||
if (role.id == lastRoleId) {
|
||||
m_currentRole = role.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit availableRolesChanged();
|
||||
emit currentRoleChanged();
|
||||
}
|
||||
|
||||
void AgentRoleController::applyRole(const QString &roleName)
|
||||
{
|
||||
auto &settings = Settings::chatAssistantSettings();
|
||||
|
||||
if (roleName == Settings::AgentRolesManager::getNoRole().name) {
|
||||
settings.lastUsedRoleId.setValue("");
|
||||
settings.writeSettings();
|
||||
m_currentRole = roleName;
|
||||
emit currentRoleChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<Settings::AgentRole> roles = Settings::AgentRolesManager::loadAllRoles();
|
||||
|
||||
for (const auto &role : roles) {
|
||||
if (role.name == roleName) {
|
||||
settings.lastUsedRoleId.setValue(role.id);
|
||||
settings.writeSettings();
|
||||
m_currentRole = role.name;
|
||||
emit currentRoleChanged();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AgentRoleController::openSettings()
|
||||
{
|
||||
Settings::showSettings(Utils::Id("QodeAssist.AgentRoles"));
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class AgentRoleController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit AgentRoleController(QObject *parent = nullptr);
|
||||
|
||||
QStringList availableRoles() const;
|
||||
QString currentRole() const;
|
||||
QString baseSystemPrompt() const;
|
||||
QString currentRoleDescription() const;
|
||||
QString currentRoleSystemPrompt() const;
|
||||
|
||||
void loadAvailableRoles();
|
||||
void applyRole(const QString &roleName);
|
||||
void openSettings();
|
||||
|
||||
signals:
|
||||
void availableRolesChanged();
|
||||
void currentRoleChanged();
|
||||
void baseSystemPromptChanged();
|
||||
|
||||
private:
|
||||
QStringList m_availableRoles;
|
||||
QString m_currentRole;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -2,13 +2,12 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatFileManager.hpp"
|
||||
#include "AttachmentStaging.hpp"
|
||||
#include "Logger.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QStandardPaths>
|
||||
#include <QUuid>
|
||||
#include <QDateTime>
|
||||
#include <QRegularExpression>
|
||||
@@ -17,14 +16,14 @@
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatFileManager::ChatFileManager(QObject *parent)
|
||||
AttachmentStaging::AttachmentStaging(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_intermediateStorageDir(getIntermediateStorageDir())
|
||||
{}
|
||||
|
||||
ChatFileManager::~ChatFileManager() = default;
|
||||
AttachmentStaging::~AttachmentStaging() = default;
|
||||
|
||||
QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths)
|
||||
QStringList AttachmentStaging::processDroppedFiles(const QStringList &filePaths)
|
||||
{
|
||||
QStringList processedPaths;
|
||||
processedPaths.reserve(filePaths.size());
|
||||
@@ -52,17 +51,17 @@ QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths)
|
||||
return processedPaths;
|
||||
}
|
||||
|
||||
void ChatFileManager::setChatFilePath(const QString &chatFilePath)
|
||||
void AttachmentStaging::setChatFilePath(const QString &chatFilePath)
|
||||
{
|
||||
m_chatFilePath = chatFilePath;
|
||||
}
|
||||
|
||||
QString ChatFileManager::chatFilePath() const
|
||||
QString AttachmentStaging::chatFilePath() const
|
||||
{
|
||||
return m_chatFilePath;
|
||||
}
|
||||
|
||||
void ChatFileManager::clearIntermediateStorage()
|
||||
void AttachmentStaging::clearIntermediateStorage()
|
||||
{
|
||||
QDir dir(m_intermediateStorageDir);
|
||||
if (!dir.exists()) {
|
||||
@@ -82,13 +81,13 @@ void ChatFileManager::clearIntermediateStorage()
|
||||
}
|
||||
}
|
||||
|
||||
bool ChatFileManager::isFileAccessible(const QString &filePath)
|
||||
bool AttachmentStaging::isFileAccessible(const QString &filePath)
|
||||
{
|
||||
QFileInfo fileInfo(filePath);
|
||||
return fileInfo.exists() && fileInfo.isFile() && fileInfo.isReadable();
|
||||
}
|
||||
|
||||
void ChatFileManager::cleanupGlobalIntermediateStorage()
|
||||
void AttachmentStaging::cleanupGlobalIntermediateStorage()
|
||||
{
|
||||
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
|
||||
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
|
||||
@@ -113,13 +112,13 @@ void ChatFileManager::cleanupGlobalIntermediateStorage()
|
||||
}
|
||||
|
||||
if (removedCount > 0 || failedCount > 0) {
|
||||
LOG_MESSAGE(QString("ChatFileManager global cleanup: removed=%1, failed=%2")
|
||||
LOG_MESSAGE(QString("AttachmentStaging global cleanup: removed=%1, failed=%2")
|
||||
.arg(removedCount)
|
||||
.arg(failedCount));
|
||||
}
|
||||
}
|
||||
|
||||
QString ChatFileManager::copyToIntermediateStorage(const QString &filePath)
|
||||
QString AttachmentStaging::copyToIntermediateStorage(const QString &filePath)
|
||||
{
|
||||
QFileInfo fileInfo(filePath);
|
||||
if (!fileInfo.exists() || !fileInfo.isFile()) {
|
||||
@@ -154,7 +153,7 @@ QString ChatFileManager::copyToIntermediateStorage(const QString &filePath)
|
||||
return destinationPath;
|
||||
}
|
||||
|
||||
QString ChatFileManager::getIntermediateStorageDir()
|
||||
QString AttachmentStaging::getIntermediateStorageDir()
|
||||
{
|
||||
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
|
||||
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
|
||||
@@ -168,7 +167,7 @@ QString ChatFileManager::getIntermediateStorageDir()
|
||||
return intermediatePath;
|
||||
}
|
||||
|
||||
QString ChatFileManager::generateIntermediateFileName(const QString &originalPath)
|
||||
QString AttachmentStaging::generateIntermediateFileName(const QString &originalPath)
|
||||
{
|
||||
const QFileInfo fileInfo(originalPath);
|
||||
const QString extension = fileInfo.suffix();
|
||||
@@ -7,17 +7,16 @@
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QMap>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatFileManager : public QObject
|
||||
class AttachmentStaging : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatFileManager(QObject *parent = nullptr);
|
||||
~ChatFileManager();
|
||||
explicit AttachmentStaging(QObject *parent = nullptr);
|
||||
~AttachmentStaging();
|
||||
|
||||
QStringList processDroppedFiles(const QStringList &filePaths);
|
||||
void setChatFilePath(const QString &chatFilePath);
|
||||
@@ -16,7 +16,10 @@ qt_add_qml_module(QodeAssistChatView
|
||||
qml/chatparts/TextBlock.qml
|
||||
qml/chatparts/ThinkingBlock.qml
|
||||
qml/chatparts/ToolBlock.qml
|
||||
qml/chatparts/PermissionBlock.qml
|
||||
qml/chatparts/PlanBlock.qml
|
||||
qml/chatparts/ChatItem.qml
|
||||
qml/chatparts/BlockPayload.js
|
||||
|
||||
qml/controls/AttachedFilesPlace.qml
|
||||
qml/controls/BottomBar.qml
|
||||
@@ -26,7 +29,7 @@ qt_add_qml_module(QodeAssistChatView
|
||||
qml/controls/SkillCommandPopup.qml
|
||||
qml/controls/Toast.qml
|
||||
qml/controls/TopBar.qml
|
||||
qml/controls/SplitDropZone.qml
|
||||
qml/controls/DropZone.qml
|
||||
qml/controls/MessageNavigator.qml
|
||||
|
||||
RESOURCES
|
||||
@@ -34,8 +37,6 @@ qt_add_qml_module(QodeAssistChatView
|
||||
icons/attach-file-dark.svg
|
||||
icons/close-dark.svg
|
||||
icons/close-light.svg
|
||||
icons/link-file-light.svg
|
||||
icons/link-file-dark.svg
|
||||
icons/image-dark.svg
|
||||
icons/load-chat-dark.svg
|
||||
icons/save-chat-dark.svg
|
||||
@@ -67,22 +68,21 @@ qt_add_qml_module(QodeAssistChatView
|
||||
ChatModel.hpp ChatModel.cpp
|
||||
ChatRootView.hpp ChatRootView.cpp
|
||||
ChatController.hpp ChatController.cpp
|
||||
ConversationPorts.hpp
|
||||
ConversationCoordinator.hpp ConversationCoordinator.cpp
|
||||
LlmChatBackend.hpp LlmChatBackend.cpp
|
||||
MessagePart.hpp
|
||||
ChatUtils.h ChatUtils.cpp
|
||||
ChatSerializer.hpp ChatSerializer.cpp
|
||||
ChatHistoryBridge.hpp ChatHistoryBridge.cpp
|
||||
ChatFileStore.hpp ChatFileStore.cpp
|
||||
TurnContextAdapters.hpp TurnContextAdapters.cpp
|
||||
ChatView.hpp ChatView.cpp
|
||||
ChatData.hpp
|
||||
FileItem.hpp FileItem.cpp
|
||||
ChatFileManager.hpp ChatFileManager.cpp
|
||||
AttachmentStaging.hpp AttachmentStaging.cpp
|
||||
ChatCompressor.hpp ChatCompressor.cpp
|
||||
AgentRoleController.hpp AgentRoleController.cpp
|
||||
ChatConfigurationController.hpp ChatConfigurationController.cpp
|
||||
FileEditController.hpp FileEditController.cpp
|
||||
InputTokenCounter.hpp InputTokenCounter.cpp
|
||||
ChatHistoryStore.hpp ChatHistoryStore.cpp
|
||||
FileMentionItem.hpp FileMentionItem.cpp
|
||||
SessionFileRegistry.hpp SessionFileRegistry.cpp
|
||||
)
|
||||
@@ -98,6 +98,7 @@ target_link_libraries(QodeAssistChatView
|
||||
QodeAssistSettings
|
||||
Context
|
||||
QodeAssistSession
|
||||
QodeAssistAcp
|
||||
QodeAssistUIControlsplugin
|
||||
QodeAssistLogger
|
||||
LLMQore
|
||||
|
||||
@@ -25,15 +25,48 @@ ChatCompressor::ChatCompressor(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
QString ChatCompressor::configurationIssue()
|
||||
{
|
||||
auto &settings = Settings::generalSettings();
|
||||
|
||||
if (settings.caProvider().isEmpty())
|
||||
return tr("no provider is assigned to the chat feature");
|
||||
if (settings.caModel().isEmpty())
|
||||
return tr("no model is assigned to the chat feature");
|
||||
if (settings.caTemplate().isEmpty())
|
||||
return tr("no prompt template is assigned to the chat feature");
|
||||
if (settings.caUrl().isEmpty())
|
||||
return tr("the chat feature has no URL configured");
|
||||
|
||||
if (!Providers::ProvidersManager::instance().getProviderByName(settings.caProvider()))
|
||||
return tr("the provider \"%1\" is not available").arg(settings.caProvider());
|
||||
|
||||
if (!Templates::PromptTemplateManager::instance().getChatTemplateByName(settings.caTemplate()))
|
||||
return tr("the prompt template \"%1\" is not available").arg(settings.caTemplate());
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
void ChatCompressor::startSummary(const Session::ConversationHistory &history)
|
||||
{
|
||||
beginCompression(QString(), history, /*summaryOnly*/ true);
|
||||
}
|
||||
|
||||
void ChatCompressor::startCompression(
|
||||
const QString &chatFilePath, const Session::ConversationHistory &history)
|
||||
{
|
||||
beginCompression(chatFilePath, history, /*summaryOnly*/ false);
|
||||
}
|
||||
|
||||
void ChatCompressor::beginCompression(
|
||||
const QString &chatFilePath, const Session::ConversationHistory &history, bool summaryOnly)
|
||||
{
|
||||
if (m_isCompressing) {
|
||||
emit compressionFailed(tr("Compression already in progress"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (chatFilePath.isEmpty()) {
|
||||
if (!summaryOnly && chatFilePath.isEmpty()) {
|
||||
emit compressionFailed(tr("No chat file to compress"));
|
||||
return;
|
||||
}
|
||||
@@ -62,11 +95,13 @@ void ChatCompressor::startCompression(
|
||||
}
|
||||
|
||||
m_isCompressing = true;
|
||||
m_summaryOnly = summaryOnly;
|
||||
m_rows = rows;
|
||||
m_originalChatPath = chatFilePath;
|
||||
m_accumulatedSummary.clear();
|
||||
|
||||
emit compressionStarted();
|
||||
emit compressingChanged();
|
||||
|
||||
connectProviderSignals();
|
||||
|
||||
@@ -122,6 +157,19 @@ void ChatCompressor::onFullResponseReceived(const QString &requestId, const QStr
|
||||
LOG_MESSAGE(
|
||||
QString("Received summary, length: %1 characters").arg(m_accumulatedSummary.length()));
|
||||
|
||||
if (m_summaryOnly) {
|
||||
const QString summary = m_accumulatedSummary.trimmed();
|
||||
cleanupState();
|
||||
|
||||
if (summary.isEmpty()) {
|
||||
emit compressionFailed(tr("The summary came back empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
emit summaryReady(summary);
|
||||
return;
|
||||
}
|
||||
|
||||
QString compressedPath = createCompressedChatPath(m_originalChatPath);
|
||||
if (!createCompressedChatFile(m_originalChatPath, compressedPath, m_accumulatedSummary)) {
|
||||
handleCompressionError(tr("Failed to save compressed chat"));
|
||||
@@ -181,12 +229,13 @@ void ChatCompressor::buildRequestPayload(
|
||||
|
||||
QVector<LLMCore::Message> messages;
|
||||
for (const Session::MessageRow &row : std::as_const(m_rows)) {
|
||||
if (row.kind == Session::RowKind::Tool || row.kind == Session::RowKind::FileEdit
|
||||
|| row.kind == Session::RowKind::Thinking)
|
||||
const Session::RowTreatment treatment
|
||||
= Session::rowTreatmentFor(Session::RowAudience::Compression, row.kind);
|
||||
if (treatment == Session::RowTreatment::Omit)
|
||||
continue;
|
||||
|
||||
LLMCore::Message apiMessage;
|
||||
apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant";
|
||||
apiMessage.role = treatment == Session::RowTreatment::UserText ? "user" : "assistant";
|
||||
apiMessage.content = row.content;
|
||||
messages.append(apiMessage);
|
||||
}
|
||||
@@ -282,12 +331,18 @@ void ChatCompressor::cleanupState()
|
||||
{
|
||||
disconnectAllSignals();
|
||||
|
||||
const bool wasCompressing = m_isCompressing;
|
||||
|
||||
m_isCompressing = false;
|
||||
m_summaryOnly = false;
|
||||
m_currentRequestId.clear();
|
||||
m_originalChatPath.clear();
|
||||
m_accumulatedSummary.clear();
|
||||
m_rows.clear();
|
||||
m_provider = nullptr;
|
||||
|
||||
if (wasCompressing)
|
||||
emit compressingChanged();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -30,13 +30,22 @@ public:
|
||||
|
||||
void startCompression(
|
||||
const QString &chatFilePath, const Session::ConversationHistory &history);
|
||||
void startSummary(const Session::ConversationHistory &history);
|
||||
|
||||
signals:
|
||||
void compressingChanged();
|
||||
|
||||
public:
|
||||
|
||||
bool isCompressing() const;
|
||||
void cancelCompression();
|
||||
|
||||
static QString configurationIssue();
|
||||
|
||||
signals:
|
||||
void compressionStarted();
|
||||
void compressionCompleted(const QString &compressedChatPath);
|
||||
void summaryReady(const QString &summary);
|
||||
void compressionFailed(const QString &error);
|
||||
|
||||
private slots:
|
||||
@@ -54,8 +63,13 @@ private:
|
||||
void cleanupState();
|
||||
void handleCompressionError(const QString &error);
|
||||
void buildRequestPayload(QJsonObject &payload, Templates::PromptTemplate *promptTemplate);
|
||||
void beginCompression(
|
||||
const QString &chatFilePath,
|
||||
const Session::ConversationHistory &history,
|
||||
bool summaryOnly);
|
||||
|
||||
bool m_isCompressing = false;
|
||||
bool m_summaryOnly = false;
|
||||
QString m_currentRequestId;
|
||||
QString m_originalChatPath;
|
||||
QString m_accumulatedSummary;
|
||||
|
||||
@@ -8,9 +8,19 @@
|
||||
|
||||
#include "ConfigurationManager.hpp"
|
||||
#include "GeneralSettings.hpp"
|
||||
#include "acp/AgentCatalogStore.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
namespace {
|
||||
|
||||
QString agentEntry(const QString &agentName)
|
||||
{
|
||||
return ChatConfigurationController::tr("Agent: %1").arg(agentName);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ChatConfigurationController::ChatConfigurationController(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
@@ -29,6 +39,26 @@ ChatConfigurationController::ChatConfigurationController(QObject *parent)
|
||||
loadAvailableConfigurations();
|
||||
}
|
||||
|
||||
void ChatConfigurationController::setAgentCatalog(Acp::AgentCatalogStore *store)
|
||||
{
|
||||
if (m_agents == store)
|
||||
return;
|
||||
|
||||
if (m_agents)
|
||||
disconnect(m_agents, nullptr, this, nullptr);
|
||||
|
||||
m_agents = store;
|
||||
if (m_agents) {
|
||||
connect(
|
||||
m_agents,
|
||||
&Acp::AgentCatalogStore::catalogChanged,
|
||||
this,
|
||||
&ChatConfigurationController::loadAvailableConfigurations);
|
||||
}
|
||||
|
||||
loadAvailableConfigurations();
|
||||
}
|
||||
|
||||
QStringList ChatConfigurationController::availableConfigurations() const
|
||||
{
|
||||
return m_availableConfigurations;
|
||||
@@ -41,12 +71,33 @@ QString ChatConfigurationController::currentConfiguration() const
|
||||
|
||||
void ChatConfigurationController::updateCurrentConfiguration()
|
||||
{
|
||||
if (!m_boundAgentName.isEmpty()) {
|
||||
m_currentConfiguration = agentEntry(m_boundAgentName);
|
||||
emit currentConfigurationChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
auto &settings = Settings::generalSettings();
|
||||
m_currentConfiguration
|
||||
= QString("%1 - %2").arg(settings.caProvider.value(), settings.caModel.value());
|
||||
emit currentConfigurationChanged();
|
||||
}
|
||||
|
||||
void ChatConfigurationController::setBoundAgent(const Acp::AgentDefinition &agent)
|
||||
{
|
||||
m_boundAgentName = agent.name;
|
||||
updateCurrentConfiguration();
|
||||
}
|
||||
|
||||
void ChatConfigurationController::clearBoundAgent()
|
||||
{
|
||||
if (m_boundAgentName.isEmpty())
|
||||
return;
|
||||
|
||||
m_boundAgentName.clear();
|
||||
updateCurrentConfiguration();
|
||||
}
|
||||
|
||||
void ChatConfigurationController::loadAvailableConfigurations()
|
||||
{
|
||||
auto &manager = Settings::ConfigurationManager::instance();
|
||||
@@ -56,23 +107,56 @@ void ChatConfigurationController::loadAvailableConfigurations()
|
||||
Settings::ConfigurationType::Chat);
|
||||
|
||||
m_availableConfigurations.clear();
|
||||
m_agentIdByEntry.clear();
|
||||
m_availableConfigurations.append(QObject::tr("Current Settings"));
|
||||
|
||||
for (const Settings::AIConfiguration &config : configs) {
|
||||
m_availableConfigurations.append(config.name);
|
||||
}
|
||||
|
||||
if (m_agents) {
|
||||
for (const Acp::AgentDefinition &agent : m_agents->catalog().launchableAgents()) {
|
||||
const QString entry = agentEntry(agent.name);
|
||||
m_agentIdByEntry.insert(entry, agent.id);
|
||||
m_availableConfigurations.append(entry);
|
||||
}
|
||||
}
|
||||
|
||||
updateCurrentConfiguration();
|
||||
|
||||
emit availableConfigurationsChanged();
|
||||
}
|
||||
|
||||
std::optional<Acp::AgentDefinition> ChatConfigurationController::agentById(const QString &agentId)
|
||||
{
|
||||
if (agentId.isEmpty() || !m_agents)
|
||||
return std::nullopt;
|
||||
|
||||
if (auto agent = m_agents->catalog().agent(agentId))
|
||||
return agent;
|
||||
|
||||
m_agents->reload();
|
||||
return m_agents->catalog().agent(agentId);
|
||||
}
|
||||
|
||||
void ChatConfigurationController::applyConfiguration(const QString &configName)
|
||||
{
|
||||
if (configName == QObject::tr("Current Settings")) {
|
||||
const QString agentId = m_agentIdByEntry.value(configName);
|
||||
if (!agentId.isEmpty()) {
|
||||
if (!m_agents)
|
||||
return;
|
||||
if (const auto agent = m_agents->catalog().agent(agentId))
|
||||
emit agentRequested(*agent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (configName == QObject::tr("Current Settings")) {
|
||||
emit llmRequested();
|
||||
return;
|
||||
}
|
||||
|
||||
emit llmRequested();
|
||||
|
||||
auto &manager = Settings::ConfigurationManager::instance();
|
||||
QVector<Settings::AIConfiguration> configs = manager.configurations(
|
||||
Settings::ConfigurationType::Chat);
|
||||
|
||||
@@ -4,9 +4,18 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
#include "acp/AgentDefinition.hpp"
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
class AgentCatalogStore;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatConfigurationController : public QObject
|
||||
@@ -19,18 +28,30 @@ public:
|
||||
QStringList availableConfigurations() const;
|
||||
QString currentConfiguration() const;
|
||||
|
||||
void setAgentCatalog(Acp::AgentCatalogStore *store);
|
||||
|
||||
void loadAvailableConfigurations();
|
||||
void applyConfiguration(const QString &configName);
|
||||
|
||||
void setBoundAgent(const Acp::AgentDefinition &agent);
|
||||
void clearBoundAgent();
|
||||
|
||||
std::optional<Acp::AgentDefinition> agentById(const QString &agentId);
|
||||
|
||||
signals:
|
||||
void availableConfigurationsChanged();
|
||||
void currentConfigurationChanged();
|
||||
void agentRequested(const QodeAssist::Acp::AgentDefinition &agent);
|
||||
void llmRequested();
|
||||
|
||||
private:
|
||||
void updateCurrentConfiguration();
|
||||
|
||||
Acp::AgentCatalogStore *m_agents = nullptr;
|
||||
QStringList m_availableConfigurations;
|
||||
QHash<QString, QString> m_agentIdByEntry;
|
||||
QString m_currentConfiguration;
|
||||
QString m_boundAgentName;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -8,15 +8,19 @@
|
||||
#include <QFileInfo>
|
||||
#include <QMimeDatabase>
|
||||
|
||||
#include "ChatHistoryBridge.hpp"
|
||||
#include "ChatSerializer.hpp"
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
#include <utils/filepath.h>
|
||||
|
||||
#include "ChatFileStore.hpp"
|
||||
#include "LlmChatBackend.hpp"
|
||||
#include "TurnContextAdapters.hpp"
|
||||
#include "context/ChangesManager.h"
|
||||
#include "context/RulesLoader.hpp"
|
||||
#include "context/FileEditManager.hpp"
|
||||
#include "logger/Logger.hpp"
|
||||
#include "session/FileEditPayload.hpp"
|
||||
#include "session/TurnContextBuilder.hpp"
|
||||
#include "acp/AcpChatBackend.hpp"
|
||||
#include "mcp/AgentKnowledgeServer.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
@@ -28,9 +32,42 @@ ChatController::ChatController(
|
||||
, m_chatModel(chatModel)
|
||||
, m_contextManager(new Context::ContextManager(this))
|
||||
, m_session(new Session::Session(this))
|
||||
, m_backend(new LlmChatBackend(promptProvider, this))
|
||||
, m_llmBackend(new LlmChatBackend(promptProvider, this))
|
||||
, m_acpBackend(new Acp::AcpChatBackend(this))
|
||||
, m_agentKnowledge(new Mcp::AgentKnowledgeServer(this))
|
||||
, m_backend(m_llmBackend)
|
||||
{
|
||||
new ChatHistoryBridge(m_session, chatModel, this);
|
||||
connect(m_session, &Session::Session::rowsReset, m_chatModel, &ChatModel::resetMessages);
|
||||
connect(m_session, &Session::Session::rowsAppended, m_chatModel, &ChatModel::appendMessages);
|
||||
connect(m_session, &Session::Session::rowUpdated, m_chatModel, &ChatModel::updateMessage);
|
||||
connect(m_session, &Session::Session::rowsRemoved, m_chatModel, &ChatModel::removeMessages);
|
||||
m_chatModel->resetMessages(m_session->rows());
|
||||
|
||||
m_acpBackend->setStoredContentLoader(&ChatFileStore::loadRawContentFromStorage);
|
||||
m_agentKnowledge->setIgnorePredicate([this](const QString &filePath) {
|
||||
auto *project = ProjectExplorer::ProjectManager::projectForFile(
|
||||
Utils::FilePath::fromString(filePath));
|
||||
return m_contextManager->ignoreManager()->shouldIgnore(filePath, project);
|
||||
});
|
||||
m_acpBackend->setKnowledgeService(m_agentKnowledge);
|
||||
|
||||
connect(
|
||||
m_session,
|
||||
&Session::Session::sessionInfoReceived,
|
||||
this,
|
||||
&ChatController::sessionInfoReceived);
|
||||
|
||||
connect(
|
||||
m_acpBackend,
|
||||
&Acp::AcpChatBackend::agentSessionUnavailable,
|
||||
this,
|
||||
&ChatController::agentSessionUnavailable);
|
||||
|
||||
connect(
|
||||
m_acpBackend,
|
||||
&Acp::AcpChatBackend::availableCommandsChanged,
|
||||
this,
|
||||
&ChatController::agentCommandsChanged);
|
||||
|
||||
m_session->setBackend(m_backend);
|
||||
|
||||
@@ -39,7 +76,7 @@ ChatController::ChatController(
|
||||
|
||||
connect(m_session, &Session::Session::turnFinished, this, [this](const QString &turnId) {
|
||||
QString applyError;
|
||||
if (!Context::ChangesManager::instance().applyPendingEditsForRequest(turnId, &applyError)) {
|
||||
if (!Context::FileEditManager::instance().applyPendingEditsForRequest(turnId, &applyError)) {
|
||||
LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2")
|
||||
.arg(turnId, applyError));
|
||||
}
|
||||
@@ -56,14 +93,53 @@ ChatController::ChatController(
|
||||
|
||||
connect(m_session, &Session::Session::rowsReset, this, [this] { registerHistoricalEdits(); });
|
||||
|
||||
auto &changes = Context::ChangesManager::instance();
|
||||
connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &id) {
|
||||
connect(
|
||||
m_session,
|
||||
&Session::Session::agentFileEditRecorded,
|
||||
this,
|
||||
[](const QString &turnId,
|
||||
const QString &editId,
|
||||
const QString &filePath,
|
||||
const QString &oldContent,
|
||||
const QString &newContent) {
|
||||
const QFileInfo info(filePath);
|
||||
const QString canonical = info.canonicalFilePath();
|
||||
const Utils::FilePath target = Utils::FilePath::fromString(
|
||||
canonical.isEmpty() ? info.absoluteFilePath() : canonical);
|
||||
|
||||
bool insideProject = false;
|
||||
const QList<ProjectExplorer::Project *> projects
|
||||
= ProjectExplorer::ProjectManager::projects();
|
||||
for (const ProjectExplorer::Project *project : projects) {
|
||||
if (target.isChildOf(project->projectDirectory())) {
|
||||
insideProject = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!insideProject) {
|
||||
LOG_MESSAGE(
|
||||
QString("Agent edit %1 targets %2 outside every open project; recorded "
|
||||
"in the transcript only, without apply/undo actions")
|
||||
.arg(editId, filePath));
|
||||
return;
|
||||
}
|
||||
|
||||
Context::FileEditManager::instance().registerAppliedFileEdit(
|
||||
editId, target.toUrlishString(), oldContent, newContent, turnId);
|
||||
});
|
||||
|
||||
auto &changes = Context::FileEditManager::instance();
|
||||
connect(&changes, &Context::FileEditManager::fileEditApplied, this, [this](const QString &id) {
|
||||
m_session->updateFileEditStatus(id, "applied", "Successfully applied");
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &id) {
|
||||
connect(&changes, &Context::FileEditManager::fileEditRejected, this, [this](const QString &id) {
|
||||
m_session->updateFileEditStatus(id, "rejected", "Rejected by user");
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &id) {
|
||||
connect(&changes, &Context::FileEditManager::fileEditUndone, this, [this](const QString &id) {
|
||||
recordFileEditStatus(id, "rejected", "Successfully undone");
|
||||
});
|
||||
connect(&changes, &Context::FileEditManager::fileEditArchived, this, [this](const QString &id) {
|
||||
m_session->updateFileEditStatus(id, "archived", "Archived (from previous conversation turn)");
|
||||
});
|
||||
}
|
||||
@@ -73,32 +149,107 @@ void ChatController::setSkillsManager(Skills::SkillsManager *skillsManager)
|
||||
m_skillsManager = skillsManager;
|
||||
}
|
||||
|
||||
void ChatController::bindAgent(const Acp::AgentDefinition &agent)
|
||||
{
|
||||
m_acpBackend->bindAgent(agent);
|
||||
activateBackend(m_acpBackend);
|
||||
}
|
||||
|
||||
void ChatController::bindLlm()
|
||||
{
|
||||
activateBackend(m_llmBackend);
|
||||
}
|
||||
|
||||
QString ChatController::boundAgentId() const
|
||||
{
|
||||
return m_backend == m_acpBackend ? m_acpBackend->boundAgentId() : QString();
|
||||
}
|
||||
|
||||
QString ChatController::boundAgentName() const
|
||||
{
|
||||
return m_backend == m_acpBackend ? m_acpBackend->boundAgentName() : QString();
|
||||
}
|
||||
|
||||
QList<LLMQore::Acp::AvailableCommand> ChatController::agentCommands() const
|
||||
{
|
||||
if (m_backend != m_acpBackend)
|
||||
return {};
|
||||
return m_acpBackend->availableCommands();
|
||||
}
|
||||
|
||||
bool ChatController::transcriptEmpty() const
|
||||
{
|
||||
return m_session->rows().isEmpty();
|
||||
}
|
||||
|
||||
Acp::AgentBinding ChatController::agentBinding() const
|
||||
{
|
||||
if (m_backend != m_acpBackend)
|
||||
return {};
|
||||
|
||||
return Acp::AgentBinding{m_acpBackend->boundAgentId(), m_acpBackend->bindingSessionId()};
|
||||
}
|
||||
|
||||
void ChatController::resumeAgentSession(const QString &sessionId)
|
||||
{
|
||||
m_acpBackend->resumeSession(sessionId);
|
||||
}
|
||||
|
||||
void ChatController::startFreshAgentSession()
|
||||
{
|
||||
m_acpBackend->startFreshSession();
|
||||
}
|
||||
|
||||
void ChatController::startFreshAgentSession(const QString &handoverSummary)
|
||||
{
|
||||
m_acpBackend->clearToolSession(m_chatFilePath);
|
||||
m_acpBackend->startFreshSession();
|
||||
m_acpBackend->setHandoverSummary(handoverSummary);
|
||||
}
|
||||
|
||||
void ChatController::releaseAgentSession()
|
||||
{
|
||||
m_acpBackend->clearToolSession(m_chatFilePath);
|
||||
}
|
||||
|
||||
bool ChatController::conversationStarted() const
|
||||
{
|
||||
return !m_session->history().messages().isEmpty();
|
||||
}
|
||||
|
||||
void ChatController::activateBackend(Session::ChatBackend *backend)
|
||||
{
|
||||
if (m_backend == backend)
|
||||
return;
|
||||
|
||||
m_session->cancel();
|
||||
|
||||
if (m_backend)
|
||||
m_backend->clearToolSession(m_chatFilePath);
|
||||
|
||||
m_backend = backend;
|
||||
m_session->setBackend(backend);
|
||||
backend->setChatFilePath(m_chatFilePath);
|
||||
}
|
||||
|
||||
Session::Session *ChatController::session() const
|
||||
{
|
||||
return m_session;
|
||||
}
|
||||
|
||||
void ChatController::sendMessage(
|
||||
const QString &message,
|
||||
const QList<QString> &attachments,
|
||||
const QList<QString> &linkedFiles,
|
||||
bool useTools,
|
||||
bool useThinking)
|
||||
void ChatController::sendMessage(const QString &message, const QList<QString> &attachments)
|
||||
{
|
||||
if (message.trimmed().isEmpty() && attachments.isEmpty()) {
|
||||
LOG_MESSAGE("Ignoring empty chat message");
|
||||
return;
|
||||
}
|
||||
|
||||
Context::ChangesManager::instance().archiveAllNonArchivedEdits();
|
||||
Context::FileEditManager::instance().archiveAllNonArchivedEdits();
|
||||
|
||||
m_session->sendTurn(
|
||||
composeUserBlocks(message, attachments),
|
||||
buildTurnContext(message, linkedFiles),
|
||||
Session::TurnOptions{useTools, useThinking});
|
||||
m_session->sendTurn(composeUserBlocks(message, attachments), buildTurnContext(message));
|
||||
}
|
||||
|
||||
void ChatController::clearMessages()
|
||||
void ChatController::clearConversation()
|
||||
{
|
||||
m_backend->clearToolSession(m_chatFilePath);
|
||||
m_session->clear();
|
||||
@@ -114,6 +265,11 @@ void ChatController::resetToRow(int rowIndex)
|
||||
m_session->truncateRows(rowIndex);
|
||||
}
|
||||
|
||||
void ChatController::respondToPermission(const QString &requestId, const QString &optionId)
|
||||
{
|
||||
m_session->respondPermission(requestId, optionId);
|
||||
}
|
||||
|
||||
QList<Session::ContentBlock> ChatController::composeUserBlocks(
|
||||
const QString &message, const QList<QString> &attachments)
|
||||
{
|
||||
@@ -132,7 +288,7 @@ QList<Session::ContentBlock> ChatController::composeUserBlocks(
|
||||
if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
|
||||
for (const auto &file : m_contextManager->getContentFiles(textFiles)) {
|
||||
QString storedPath;
|
||||
if (!ChatSerializer::saveContentToStorage(
|
||||
if (!ChatFileStore::saveContentToStorage(
|
||||
m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) {
|
||||
continue;
|
||||
}
|
||||
@@ -152,7 +308,7 @@ QList<Session::ContentBlock> ChatController::composeUserBlocks(
|
||||
|
||||
const QFileInfo fileInfo(imagePath);
|
||||
QString storedPath;
|
||||
if (!ChatSerializer::saveContentToStorage(
|
||||
if (!ChatFileStore::saveContentToStorage(
|
||||
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
|
||||
continue;
|
||||
}
|
||||
@@ -169,32 +325,23 @@ QList<Session::ContentBlock> ChatController::composeUserBlocks(
|
||||
return blocks;
|
||||
}
|
||||
|
||||
std::optional<Session::TurnContext> ChatController::buildTurnContext(
|
||||
const QString &message, const QList<QString> &linkedFiles) const
|
||||
Session::TurnContext ChatController::buildTurnContext(const QString &message) const
|
||||
{
|
||||
auto &chatAssistantSettings = Settings::chatAssistantSettings();
|
||||
if (!chatAssistantSettings.useSystemPrompt())
|
||||
return std::nullopt;
|
||||
|
||||
Session::TurnContextRequest contextRequest;
|
||||
contextRequest.message = message;
|
||||
contextRequest.basePrompt = chatAssistantSettings.systemPrompt();
|
||||
contextRequest.linkedFilePaths = linkedFiles;
|
||||
contextRequest.needs = m_backend->contextNeeds();
|
||||
|
||||
const QString lastRoleId = chatAssistantSettings.lastUsedRoleId();
|
||||
if (!lastRoleId.isEmpty()) {
|
||||
const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
|
||||
if (!role.id.isEmpty())
|
||||
contextRequest.rolePrompt = role.systemPrompt;
|
||||
}
|
||||
if (contextRequest.needs.systemPrompt)
|
||||
contextRequest.basePrompt = chatAssistantSettings.systemPrompt();
|
||||
|
||||
auto *project = Context::RulesLoader::getActiveProject();
|
||||
auto *project = activeProject();
|
||||
|
||||
ProjectContextQtCreator projectPort(project);
|
||||
LinkedFilesQtCreator linkedFilesPort(m_contextManager);
|
||||
auto skillsPort = makeSkillsContext(m_skillsManager, project);
|
||||
|
||||
const Session::TurnContextBuilder builder(projectPort, skillsPort.get(), linkedFilesPort);
|
||||
const Session::TurnContextBuilder builder(projectPort, skillsPort.get());
|
||||
|
||||
return builder.build(contextRequest);
|
||||
}
|
||||
@@ -202,7 +349,7 @@ std::optional<Session::TurnContext> ChatController::buildTurnContext(
|
||||
void ChatController::recordFileEditStatus(
|
||||
const QString &editId, const QString &status, const QString &fallbackMessage)
|
||||
{
|
||||
const auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
const auto edit = Context::FileEditManager::instance().getFileEdit(editId);
|
||||
const QString message = edit.statusMessage.isEmpty() ? fallbackMessage : edit.statusMessage;
|
||||
m_session->updateFileEditStatus(editId, status, message);
|
||||
}
|
||||
@@ -232,7 +379,7 @@ void ChatController::registerHistoricalEdits()
|
||||
continue;
|
||||
}
|
||||
|
||||
Context::ChangesManager::instance().addFileEdit(
|
||||
Context::FileEditManager::instance().addFileEdit(
|
||||
editId,
|
||||
filePath,
|
||||
payload->value("old_content").toString(),
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
#include <LLMQore/AcpTypes.hpp>
|
||||
|
||||
#include "ChatModel.hpp"
|
||||
#include "ConversationPorts.hpp"
|
||||
#include "acp/AgentBinding.hpp"
|
||||
#include "acp/AgentDefinition.hpp"
|
||||
#include "session/Session.hpp"
|
||||
#include "templates/IPromptProvider.hpp"
|
||||
#include <context/ContextManager.hpp>
|
||||
@@ -16,12 +21,19 @@ namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
class AcpChatBackend;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Mcp {
|
||||
class AgentKnowledgeServer;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatHistoryBridge;
|
||||
class LlmChatBackend;
|
||||
|
||||
class ChatController : public QObject
|
||||
class ChatController : public QObject, public IConversationPort
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
@@ -31,15 +43,10 @@ public:
|
||||
|
||||
void setSkillsManager(Skills::SkillsManager *skillsManager);
|
||||
|
||||
void sendMessage(
|
||||
const QString &message,
|
||||
const QList<QString> &attachments = {},
|
||||
const QList<QString> &linkedFiles = {},
|
||||
bool useTools = false,
|
||||
bool useThinking = false);
|
||||
void clearMessages();
|
||||
void sendMessage(const QString &message, const QList<QString> &attachments = {});
|
||||
void cancelRequest();
|
||||
void resetToRow(int rowIndex);
|
||||
void respondToPermission(const QString &requestId, const QString &optionId);
|
||||
|
||||
Session::Session *session() const;
|
||||
Context::ContextManager *contextManager() const;
|
||||
@@ -47,7 +54,26 @@ public:
|
||||
void setChatFilePath(const QString &filePath);
|
||||
QString chatFilePath() const;
|
||||
|
||||
QString boundAgentId() const override;
|
||||
QString boundAgentName() const;
|
||||
QList<LLMQore::Acp::AvailableCommand> agentCommands() const;
|
||||
bool conversationStarted() const override;
|
||||
bool transcriptEmpty() const override;
|
||||
Acp::AgentBinding agentBinding() const override;
|
||||
|
||||
void bindAgent(const Acp::AgentDefinition &agent) override;
|
||||
void bindLlm() override;
|
||||
void clearConversation() override;
|
||||
|
||||
void resumeAgentSession(const QString &sessionId) override;
|
||||
void startFreshAgentSession() override;
|
||||
void startFreshAgentSession(const QString &handoverSummary) override;
|
||||
void releaseAgentSession() override;
|
||||
|
||||
signals:
|
||||
void sessionInfoReceived(const QString &title);
|
||||
void agentCommandsChanged();
|
||||
void agentSessionUnavailable(const QString &reason);
|
||||
void errorOccurred(const QString &error);
|
||||
void messageReceivedCompletely();
|
||||
void requestStarted(const QString &requestId);
|
||||
@@ -57,11 +83,11 @@ signals:
|
||||
private:
|
||||
QList<Session::ContentBlock> composeUserBlocks(
|
||||
const QString &message, const QList<QString> &attachments);
|
||||
std::optional<Session::TurnContext> buildTurnContext(
|
||||
const QString &message, const QList<QString> &linkedFiles) const;
|
||||
Session::TurnContext buildTurnContext(const QString &message) const;
|
||||
void recordFileEditStatus(
|
||||
const QString &editId, const QString &status, const QString &fallbackMessage);
|
||||
void registerHistoricalEdits();
|
||||
void activateBackend(Session::ChatBackend *backend);
|
||||
|
||||
bool isImageFile(const QString &filePath) const;
|
||||
QString getMediaTypeForImage(const QString &filePath) const;
|
||||
@@ -72,7 +98,10 @@ private:
|
||||
Context::ContextManager *m_contextManager = nullptr;
|
||||
Skills::SkillsManager *m_skillsManager = nullptr;
|
||||
Session::Session *m_session = nullptr;
|
||||
LlmChatBackend *m_backend = nullptr;
|
||||
LlmChatBackend *m_llmBackend = nullptr;
|
||||
Acp::AcpChatBackend *m_acpBackend = nullptr;
|
||||
Mcp::AgentKnowledgeServer *m_agentKnowledge = nullptr;
|
||||
Session::ChatBackend *m_backend = nullptr;
|
||||
QString m_chatFilePath;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatFileStore.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QRegularExpression>
|
||||
#include <QSaveFile>
|
||||
#include <QUrl>
|
||||
#include <QUuid>
|
||||
|
||||
#include <coreplugin/icore.h>
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
|
||||
#include "Logger.hpp"
|
||||
#include "ProjectSettings.hpp"
|
||||
#include "session/HistorySerializer.hpp"
|
||||
#include "session/Session.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatFileStore::ChatFileStore(Session::Session *session, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_session(session)
|
||||
{}
|
||||
|
||||
QString ChatFileStore::historyDir() const
|
||||
{
|
||||
QString path;
|
||||
|
||||
if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
|
||||
Settings::ProjectSettings projectSettings(project);
|
||||
path = projectSettings.chatHistoryPath().toFSPathString();
|
||||
} else {
|
||||
QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
|
||||
path = baseDir.filePath("qodeassist/chat_history");
|
||||
}
|
||||
|
||||
QDir dir(path);
|
||||
if (!dir.exists() && !dir.mkpath(".")) {
|
||||
LOG_MESSAGE(QString("Failed to create directory: %1").arg(path));
|
||||
return QString();
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
QString ChatFileStore::suggestedFileName() const
|
||||
{
|
||||
QString shortMessage;
|
||||
|
||||
if (!m_session)
|
||||
return generateChatFileName(shortMessage, historyDir());
|
||||
|
||||
const QList<Session::MessageRow> &rows = m_session->rows();
|
||||
if (!rows.isEmpty()) {
|
||||
shortMessage = rows.first().content.split('\n').first().simplified().left(30);
|
||||
|
||||
if (shortMessage.isEmpty() && !rows.first().images.isEmpty())
|
||||
shortMessage = "image_chat";
|
||||
}
|
||||
|
||||
return generateChatFileName(shortMessage, historyDir());
|
||||
}
|
||||
|
||||
QString ChatFileStore::autosaveFilePath(const QString &recentFilePath) const
|
||||
{
|
||||
if (!recentFilePath.isEmpty()) {
|
||||
return recentFilePath;
|
||||
}
|
||||
|
||||
QString dir = historyDir();
|
||||
if (dir.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
return QDir(dir).filePath(suggestedFileName() + ".json");
|
||||
}
|
||||
|
||||
QString ChatFileStore::autosaveFilePath(
|
||||
const QString &recentFilePath, const QString &firstMessage, bool hasImageAttachments) const
|
||||
{
|
||||
if (!recentFilePath.isEmpty()) {
|
||||
return recentFilePath;
|
||||
}
|
||||
|
||||
QString dir = historyDir();
|
||||
if (dir.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString shortMessage = firstMessage.split('\n').first().simplified().left(30);
|
||||
|
||||
if (shortMessage.isEmpty() && hasImageAttachments) {
|
||||
shortMessage = "image_chat";
|
||||
}
|
||||
|
||||
QString fileName = generateChatFileName(shortMessage, dir);
|
||||
return QDir(dir).filePath(fileName + ".json");
|
||||
}
|
||||
|
||||
void ChatFileStore::setBindingReader(BindingReader reader)
|
||||
{
|
||||
m_bindingReader = std::move(reader);
|
||||
}
|
||||
|
||||
void ChatFileStore::setBindingWriter(BindingWriter writer)
|
||||
{
|
||||
m_bindingWriter = std::move(writer);
|
||||
}
|
||||
|
||||
SerializationResult ChatFileStore::save(const QString &filePath) const
|
||||
{
|
||||
if (!m_session)
|
||||
return {false, QString("Chat session is no longer available")};
|
||||
|
||||
const Acp::AgentBinding binding = m_bindingReader ? m_bindingReader() : Acp::AgentBinding{};
|
||||
return saveToFile(m_session->history(), binding, filePath);
|
||||
}
|
||||
|
||||
SerializationResult ChatFileStore::load(const QString &filePath) const
|
||||
{
|
||||
if (!m_session)
|
||||
return {false, QString("Chat session is no longer available")};
|
||||
|
||||
Session::ConversationHistory history;
|
||||
Acp::AgentBinding binding;
|
||||
const SerializationResult result = loadFromFile(history, binding, filePath);
|
||||
if (!result.success)
|
||||
return result;
|
||||
|
||||
m_session->setHistory(history);
|
||||
if (m_bindingWriter)
|
||||
m_bindingWriter(binding);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void ChatFileStore::showSaveDialog()
|
||||
{
|
||||
QString initialDir = historyDir();
|
||||
|
||||
QFileDialog *dialog = new QFileDialog(nullptr, tr("Save Chat History"));
|
||||
dialog->setAcceptMode(QFileDialog::AcceptSave);
|
||||
dialog->setFileMode(QFileDialog::AnyFile);
|
||||
dialog->setNameFilter(tr("JSON files (*.json)"));
|
||||
dialog->setDefaultSuffix("json");
|
||||
if (!initialDir.isEmpty()) {
|
||||
dialog->setDirectory(initialDir);
|
||||
dialog->selectFile(suggestedFileName() + ".json");
|
||||
}
|
||||
|
||||
connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
|
||||
if (result == QFileDialog::Accepted) {
|
||||
QStringList files = dialog->selectedFiles();
|
||||
if (!files.isEmpty()) {
|
||||
emit saveRequested(files.first());
|
||||
}
|
||||
}
|
||||
dialog->deleteLater();
|
||||
});
|
||||
|
||||
dialog->open();
|
||||
}
|
||||
|
||||
void ChatFileStore::showLoadDialog()
|
||||
{
|
||||
QString initialDir = historyDir();
|
||||
|
||||
QFileDialog *dialog = new QFileDialog(nullptr, tr("Load Chat History"));
|
||||
dialog->setAcceptMode(QFileDialog::AcceptOpen);
|
||||
dialog->setFileMode(QFileDialog::ExistingFile);
|
||||
dialog->setNameFilter(tr("JSON files (*.json)"));
|
||||
if (!initialDir.isEmpty()) {
|
||||
dialog->setDirectory(initialDir);
|
||||
}
|
||||
|
||||
connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
|
||||
if (result == QFileDialog::Accepted) {
|
||||
QStringList files = dialog->selectedFiles();
|
||||
if (!files.isEmpty()) {
|
||||
emit loadRequested(files.first());
|
||||
}
|
||||
}
|
||||
dialog->deleteLater();
|
||||
});
|
||||
|
||||
dialog->open();
|
||||
}
|
||||
|
||||
void ChatFileStore::openHistoryFolder() const
|
||||
{
|
||||
QString path;
|
||||
if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
|
||||
Settings::ProjectSettings projectSettings(project);
|
||||
path = projectSettings.chatHistoryPath().toFSPathString();
|
||||
} else {
|
||||
QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
|
||||
path = baseDir.filePath("qodeassist/chat_history");
|
||||
}
|
||||
|
||||
QDir dir(path);
|
||||
if (!dir.exists()) {
|
||||
dir.mkpath(".");
|
||||
}
|
||||
|
||||
QUrl url = QUrl::fromLocalFile(dir.absolutePath());
|
||||
QDesktopServices::openUrl(url);
|
||||
}
|
||||
|
||||
QString ChatFileStore::generateChatFileName(const QString &shortMessage, const QString &dir) const
|
||||
{
|
||||
static const QRegularExpression saitizeSymbols = QRegularExpression("[\\/:*?\"<>|\\s]");
|
||||
static const QRegularExpression underSymbols = QRegularExpression("_+");
|
||||
|
||||
QStringList parts;
|
||||
QString sanitizedMessage = shortMessage;
|
||||
sanitizedMessage.replace(saitizeSymbols, "_");
|
||||
sanitizedMessage.replace(underSymbols, "_");
|
||||
sanitizedMessage = sanitizedMessage.trimmed();
|
||||
|
||||
if (!sanitizedMessage.isEmpty()) {
|
||||
if (sanitizedMessage.startsWith('_')) {
|
||||
sanitizedMessage.remove(0, 1);
|
||||
}
|
||||
if (sanitizedMessage.endsWith('_')) {
|
||||
sanitizedMessage.chop(1);
|
||||
}
|
||||
|
||||
QString fullPath = QDir(dir).filePath(sanitizedMessage);
|
||||
QFileInfo fileInfo(fullPath);
|
||||
if (!fileInfo.exists() && QFileInfo(fileInfo.path()).isWritable()) {
|
||||
parts << sanitizedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
parts << QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm");
|
||||
|
||||
QString fileName = parts.join("_");
|
||||
QString fullPath = QDir(dir).filePath(fileName);
|
||||
QFileInfo finalCheck(fullPath);
|
||||
|
||||
if (fileName.isEmpty() || finalCheck.exists() || !QFileInfo(finalCheck.path()).isWritable()) {
|
||||
fileName = QString("chat_%1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm"));
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
SerializationResult ChatFileStore::saveToFile(
|
||||
const Session::ConversationHistory &history,
|
||||
const Acp::AgentBinding &binding,
|
||||
const QString &filePath)
|
||||
{
|
||||
if (!ensureDirectoryExists(filePath)) {
|
||||
return {false, "Failed to create directory structure"};
|
||||
}
|
||||
|
||||
QJsonObject root = Session::HistorySerializer::toJson(history);
|
||||
if (!binding.isEmpty())
|
||||
root["agent"] = binding.toJson();
|
||||
|
||||
QSaveFile file(filePath);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
return {false, QString("Failed to open file for writing: %1").arg(filePath)};
|
||||
}
|
||||
|
||||
if (file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) {
|
||||
return {false, QString("Failed to write to file: %1").arg(file.errorString())};
|
||||
}
|
||||
|
||||
if (!file.commit()) {
|
||||
return {false, QString("Failed to save file: %1").arg(file.errorString())};
|
||||
}
|
||||
|
||||
return {true, QString()};
|
||||
}
|
||||
|
||||
SerializationResult ChatFileStore::loadFromFile(
|
||||
Session::ConversationHistory &history, Acp::AgentBinding &binding, const QString &filePath)
|
||||
{
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return {false, QString("Failed to open file for reading: %1").arg(filePath)};
|
||||
}
|
||||
|
||||
QJsonParseError error;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
|
||||
if (error.error != QJsonParseError::NoError) {
|
||||
return {false, QString("JSON parse error: %1").arg(error.errorString())};
|
||||
}
|
||||
|
||||
const QJsonObject root = doc.object();
|
||||
const QString version = root["version"].toString();
|
||||
|
||||
if (!Session::HistorySerializer::isSupportedVersion(version)) {
|
||||
return {false, QString("Unsupported version: %1").arg(version)};
|
||||
}
|
||||
|
||||
int droppedBlocks = 0;
|
||||
const auto loaded = Session::HistorySerializer::fromJson(root, &droppedBlocks);
|
||||
if (!loaded) {
|
||||
return {false, QString("Failed to read chat history from: %1").arg(filePath)};
|
||||
}
|
||||
|
||||
if (version != Session::HistorySerializer::currentVersion()) {
|
||||
LOG_MESSAGE(QString("Converted chat from format %1 to %2")
|
||||
.arg(version, Session::HistorySerializer::currentVersion()));
|
||||
}
|
||||
|
||||
history = *loaded;
|
||||
|
||||
QString bindingError;
|
||||
binding = Acp::AgentBinding::fromJson(root["agent"], &bindingError);
|
||||
if (!bindingError.isEmpty()) {
|
||||
const QString warning
|
||||
= QString("This chat records which agent held it, but %1, so it opens unbound")
|
||||
.arg(bindingError);
|
||||
LOG_MESSAGE(QString("%1: %2").arg(filePath, warning));
|
||||
return {true, QString(), warning};
|
||||
}
|
||||
|
||||
if (droppedBlocks > 0) {
|
||||
const QString warning
|
||||
= QString(
|
||||
"%1 message part(s) in this chat could not be read and will be lost if "
|
||||
"the chat is saved again")
|
||||
.arg(droppedBlocks);
|
||||
LOG_MESSAGE(QString("%1: %2").arg(filePath, warning));
|
||||
return {true, QString(), warning};
|
||||
}
|
||||
|
||||
return {true, QString(), QString()};
|
||||
}
|
||||
|
||||
bool ChatFileStore::ensureDirectoryExists(const QString &filePath)
|
||||
{
|
||||
QFileInfo fileInfo(filePath);
|
||||
QDir dir = fileInfo.dir();
|
||||
return dir.exists() || dir.mkpath(".");
|
||||
}
|
||||
|
||||
QString ChatFileStore::getChatContentFolder(const QString &chatFilePath)
|
||||
{
|
||||
QFileInfo fileInfo(chatFilePath);
|
||||
QString baseName = fileInfo.completeBaseName();
|
||||
QString dirPath = fileInfo.absolutePath();
|
||||
return QDir(dirPath).filePath(baseName + "_content");
|
||||
}
|
||||
|
||||
bool ChatFileStore::saveContentToStorage(
|
||||
const QString &chatFilePath,
|
||||
const QString &fileName,
|
||||
const QString &base64Data,
|
||||
QString &storedPath)
|
||||
{
|
||||
QString contentFolder = getChatContentFolder(chatFilePath);
|
||||
QDir dir;
|
||||
if (!dir.exists(contentFolder)) {
|
||||
if (!dir.mkpath(contentFolder)) {
|
||||
LOG_MESSAGE(QString("Failed to create content folder: %1").arg(contentFolder));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
QFileInfo originalFileInfo(fileName);
|
||||
QString extension = originalFileInfo.suffix();
|
||||
QString baseName = originalFileInfo.completeBaseName();
|
||||
QString uniqueName = QString("%1_%2.%3")
|
||||
.arg(baseName)
|
||||
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8))
|
||||
.arg(extension);
|
||||
|
||||
QString fullPath = QDir(contentFolder).filePath(uniqueName);
|
||||
|
||||
QByteArray contentData = QByteArray::fromBase64(base64Data.toUtf8());
|
||||
QFile file(fullPath);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
LOG_MESSAGE(QString("Failed to open file for writing: %1").arg(fullPath));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.write(contentData) == -1) {
|
||||
LOG_MESSAGE(QString("Failed to write content data: %1").arg(file.errorString()));
|
||||
return false;
|
||||
}
|
||||
|
||||
file.close();
|
||||
|
||||
storedPath = uniqueName;
|
||||
LOG_MESSAGE(QString("Saved content: %1 to %2").arg(fileName, fullPath));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
QByteArray ChatFileStore::loadRawContentFromStorage(
|
||||
const QString &chatFilePath, const QString &storedPath)
|
||||
{
|
||||
QString contentFolder = getChatContentFolder(chatFilePath);
|
||||
QString fullPath = QDir(contentFolder).filePath(storedPath);
|
||||
|
||||
QFile file(fullPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
LOG_MESSAGE(QString("Failed to open content file: %1").arg(fullPath));
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
QByteArray contentData = file.readAll();
|
||||
file.close();
|
||||
|
||||
return contentData;
|
||||
}
|
||||
|
||||
QString ChatFileStore::loadContentFromStorage(
|
||||
const QString &chatFilePath, const QString &storedPath)
|
||||
{
|
||||
return QString::fromLatin1(loadRawContentFromStorage(chatFilePath, storedPath).toBase64());
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
|
||||
#include "acp/AgentBinding.hpp"
|
||||
#include "session/ConversationHistory.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
class Session;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
struct SerializationResult
|
||||
{
|
||||
bool success{false};
|
||||
QString errorMessage;
|
||||
QString warningMessage;
|
||||
};
|
||||
|
||||
class ChatFileStore : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatFileStore(Session::Session *session, QObject *parent = nullptr);
|
||||
|
||||
QString historyDir() const;
|
||||
QString suggestedFileName() const;
|
||||
QString autosaveFilePath(const QString &recentFilePath) const;
|
||||
QString autosaveFilePath(
|
||||
const QString &recentFilePath,
|
||||
const QString &firstMessage,
|
||||
bool hasImageAttachments) const;
|
||||
|
||||
using BindingReader = std::function<Acp::AgentBinding()>;
|
||||
using BindingWriter = std::function<void(const Acp::AgentBinding &)>;
|
||||
|
||||
void setBindingReader(BindingReader reader);
|
||||
void setBindingWriter(BindingWriter writer);
|
||||
|
||||
SerializationResult save(const QString &filePath) const;
|
||||
SerializationResult load(const QString &filePath) const;
|
||||
|
||||
void showSaveDialog();
|
||||
void showLoadDialog();
|
||||
void openHistoryFolder() const;
|
||||
|
||||
static SerializationResult saveToFile(
|
||||
const Session::ConversationHistory &history,
|
||||
const Acp::AgentBinding &binding,
|
||||
const QString &filePath);
|
||||
static SerializationResult loadFromFile(
|
||||
Session::ConversationHistory &history,
|
||||
Acp::AgentBinding &binding,
|
||||
const QString &filePath);
|
||||
|
||||
static QString getChatContentFolder(const QString &chatFilePath);
|
||||
static bool saveContentToStorage(
|
||||
const QString &chatFilePath,
|
||||
const QString &fileName,
|
||||
const QString &base64Data,
|
||||
QString &storedPath);
|
||||
static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath);
|
||||
static QByteArray loadRawContentFromStorage(
|
||||
const QString &chatFilePath, const QString &storedPath);
|
||||
|
||||
signals:
|
||||
void saveRequested(const QString &filePath);
|
||||
void loadRequested(const QString &filePath);
|
||||
|
||||
private:
|
||||
QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
|
||||
static bool ensureDirectoryExists(const QString &filePath);
|
||||
|
||||
QPointer<Session::Session> m_session;
|
||||
BindingReader m_bindingReader;
|
||||
BindingWriter m_bindingWriter;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -1,107 +0,0 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatHistoryBridge.hpp"
|
||||
|
||||
#include "ChatModel.hpp"
|
||||
#include "session/Session.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
namespace {
|
||||
|
||||
ChatModel::ChatRole toChatRole(Session::RowKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case Session::RowKind::System:
|
||||
return ChatModel::ChatRole::System;
|
||||
case Session::RowKind::User:
|
||||
return ChatModel::ChatRole::User;
|
||||
case Session::RowKind::Assistant:
|
||||
return ChatModel::ChatRole::Assistant;
|
||||
case Session::RowKind::Tool:
|
||||
return ChatModel::ChatRole::Tool;
|
||||
case Session::RowKind::FileEdit:
|
||||
return ChatModel::ChatRole::FileEdit;
|
||||
case Session::RowKind::Thinking:
|
||||
return ChatModel::ChatRole::Thinking;
|
||||
}
|
||||
return ChatModel::ChatRole::Assistant;
|
||||
}
|
||||
|
||||
ChatModel::Message toChatMessage(const Session::MessageRow &row)
|
||||
{
|
||||
ChatModel::Message message;
|
||||
message.role = toChatRole(row.kind);
|
||||
message.content = row.content;
|
||||
message.id = row.id;
|
||||
message.isRedacted = row.redacted;
|
||||
message.signature = row.signature;
|
||||
message.toolName = row.toolName;
|
||||
message.toolArguments = row.toolArguments;
|
||||
message.toolResult = row.toolResult;
|
||||
|
||||
for (const Session::AttachmentBlock &attachment : row.attachments)
|
||||
message.attachments.append(Context::ContentFile{attachment.fileName, attachment.storedPath});
|
||||
|
||||
for (const Session::ImageBlock &image : row.images)
|
||||
message.images.append(
|
||||
ChatModel::ImageAttachment{image.fileName, image.storedPath, image.mediaType});
|
||||
|
||||
message.promptTokens = row.usage.promptTokens;
|
||||
message.completionTokens = row.usage.completionTokens;
|
||||
message.cachedPromptTokens = row.usage.cachedPromptTokens;
|
||||
message.reasoningTokens = row.usage.reasoningTokens;
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
QVector<ChatModel::Message> toChatMessages(const QList<Session::MessageRow> &rows)
|
||||
{
|
||||
QVector<ChatModel::Message> messages;
|
||||
messages.reserve(rows.size());
|
||||
for (const Session::MessageRow &row : rows)
|
||||
messages.append(toChatMessage(row));
|
||||
return messages;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ChatHistoryBridge::ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_model(model)
|
||||
{
|
||||
connect(session, &Session::Session::rowsReset, this, &ChatHistoryBridge::onRowsReset);
|
||||
connect(session, &Session::Session::rowsAppended, this, &ChatHistoryBridge::onRowsAppended);
|
||||
connect(session, &Session::Session::rowUpdated, this, &ChatHistoryBridge::onRowUpdated);
|
||||
connect(session, &Session::Session::rowsRemoved, this, &ChatHistoryBridge::onRowsRemoved);
|
||||
|
||||
onRowsReset(session->rows());
|
||||
}
|
||||
|
||||
void ChatHistoryBridge::onRowsReset(const QList<Session::MessageRow> &rows)
|
||||
{
|
||||
if (m_model)
|
||||
m_model->resetMessages(toChatMessages(rows));
|
||||
}
|
||||
|
||||
void ChatHistoryBridge::onRowsAppended(const QList<Session::MessageRow> &rows)
|
||||
{
|
||||
if (m_model)
|
||||
m_model->appendMessages(toChatMessages(rows));
|
||||
}
|
||||
|
||||
void ChatHistoryBridge::onRowUpdated(int index, const Session::MessageRow &row)
|
||||
{
|
||||
if (m_model)
|
||||
m_model->updateMessage(index, toChatMessage(row));
|
||||
}
|
||||
|
||||
void ChatHistoryBridge::onRowsRemoved(int first, int count)
|
||||
{
|
||||
if (m_model)
|
||||
m_model->removeMessages(first, count);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
|
||||
#include "session/HistoryProjection.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
class Session;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatModel;
|
||||
|
||||
class ChatHistoryBridge : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent = nullptr);
|
||||
|
||||
private:
|
||||
void onRowsReset(const QList<Session::MessageRow> &rows);
|
||||
void onRowsAppended(const QList<Session::MessageRow> &rows);
|
||||
void onRowUpdated(int index, const Session::MessageRow &row);
|
||||
void onRowsRemoved(int first, int count);
|
||||
|
||||
QPointer<ChatModel> m_model;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -1,236 +0,0 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatHistoryStore.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QUrl>
|
||||
|
||||
#include <coreplugin/icore.h>
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
|
||||
#include "Logger.hpp"
|
||||
#include "ProjectSettings.hpp"
|
||||
#include "session/Session.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatHistoryStore::ChatHistoryStore(Session::Session *session, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_session(session)
|
||||
{}
|
||||
|
||||
QString ChatHistoryStore::historyDir() const
|
||||
{
|
||||
QString path;
|
||||
|
||||
if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
|
||||
Settings::ProjectSettings projectSettings(project);
|
||||
path = projectSettings.chatHistoryPath().toFSPathString();
|
||||
} else {
|
||||
QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
|
||||
path = baseDir.filePath("qodeassist/chat_history");
|
||||
}
|
||||
|
||||
QDir dir(path);
|
||||
if (!dir.exists() && !dir.mkpath(".")) {
|
||||
LOG_MESSAGE(QString("Failed to create directory: %1").arg(path));
|
||||
return QString();
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
QString ChatHistoryStore::suggestedFileName() const
|
||||
{
|
||||
QString shortMessage;
|
||||
|
||||
if (!m_session)
|
||||
return generateChatFileName(shortMessage, historyDir());
|
||||
|
||||
const QList<Session::MessageRow> &rows = m_session->rows();
|
||||
if (!rows.isEmpty()) {
|
||||
shortMessage = rows.first().content.split('\n').first().simplified().left(30);
|
||||
|
||||
if (shortMessage.isEmpty() && !rows.first().images.isEmpty())
|
||||
shortMessage = "image_chat";
|
||||
}
|
||||
|
||||
return generateChatFileName(shortMessage, historyDir());
|
||||
}
|
||||
|
||||
QString ChatHistoryStore::autosaveFilePath(const QString &recentFilePath) const
|
||||
{
|
||||
if (!recentFilePath.isEmpty()) {
|
||||
return recentFilePath;
|
||||
}
|
||||
|
||||
QString dir = historyDir();
|
||||
if (dir.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
return QDir(dir).filePath(suggestedFileName() + ".json");
|
||||
}
|
||||
|
||||
QString ChatHistoryStore::autosaveFilePath(
|
||||
const QString &recentFilePath, const QString &firstMessage, bool hasImageAttachments) const
|
||||
{
|
||||
if (!recentFilePath.isEmpty()) {
|
||||
return recentFilePath;
|
||||
}
|
||||
|
||||
QString dir = historyDir();
|
||||
if (dir.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString shortMessage = firstMessage.split('\n').first().simplified().left(30);
|
||||
|
||||
if (shortMessage.isEmpty() && hasImageAttachments) {
|
||||
shortMessage = "image_chat";
|
||||
}
|
||||
|
||||
QString fileName = generateChatFileName(shortMessage, dir);
|
||||
return QDir(dir).filePath(fileName + ".json");
|
||||
}
|
||||
|
||||
SerializationResult ChatHistoryStore::save(const QString &filePath) const
|
||||
{
|
||||
if (!m_session)
|
||||
return {false, QString("Chat session is no longer available")};
|
||||
|
||||
return ChatSerializer::saveToFile(m_session->history(), filePath);
|
||||
}
|
||||
|
||||
SerializationResult ChatHistoryStore::load(const QString &filePath) const
|
||||
{
|
||||
if (!m_session)
|
||||
return {false, QString("Chat session is no longer available")};
|
||||
|
||||
Session::ConversationHistory history;
|
||||
const SerializationResult result = ChatSerializer::loadFromFile(history, filePath);
|
||||
if (result.success)
|
||||
m_session->setHistory(history);
|
||||
return result;
|
||||
}
|
||||
|
||||
void ChatHistoryStore::showSaveDialog()
|
||||
{
|
||||
QString initialDir = historyDir();
|
||||
|
||||
QFileDialog *dialog = new QFileDialog(nullptr, tr("Save Chat History"));
|
||||
dialog->setAcceptMode(QFileDialog::AcceptSave);
|
||||
dialog->setFileMode(QFileDialog::AnyFile);
|
||||
dialog->setNameFilter(tr("JSON files (*.json)"));
|
||||
dialog->setDefaultSuffix("json");
|
||||
if (!initialDir.isEmpty()) {
|
||||
dialog->setDirectory(initialDir);
|
||||
dialog->selectFile(suggestedFileName() + ".json");
|
||||
}
|
||||
|
||||
connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
|
||||
if (result == QFileDialog::Accepted) {
|
||||
QStringList files = dialog->selectedFiles();
|
||||
if (!files.isEmpty()) {
|
||||
emit saveRequested(files.first());
|
||||
}
|
||||
}
|
||||
dialog->deleteLater();
|
||||
});
|
||||
|
||||
dialog->open();
|
||||
}
|
||||
|
||||
void ChatHistoryStore::showLoadDialog()
|
||||
{
|
||||
QString initialDir = historyDir();
|
||||
|
||||
QFileDialog *dialog = new QFileDialog(nullptr, tr("Load Chat History"));
|
||||
dialog->setAcceptMode(QFileDialog::AcceptOpen);
|
||||
dialog->setFileMode(QFileDialog::ExistingFile);
|
||||
dialog->setNameFilter(tr("JSON files (*.json)"));
|
||||
if (!initialDir.isEmpty()) {
|
||||
dialog->setDirectory(initialDir);
|
||||
}
|
||||
|
||||
connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
|
||||
if (result == QFileDialog::Accepted) {
|
||||
QStringList files = dialog->selectedFiles();
|
||||
if (!files.isEmpty()) {
|
||||
emit loadRequested(files.first());
|
||||
}
|
||||
}
|
||||
dialog->deleteLater();
|
||||
});
|
||||
|
||||
dialog->open();
|
||||
}
|
||||
|
||||
void ChatHistoryStore::openHistoryFolder() const
|
||||
{
|
||||
QString path;
|
||||
if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
|
||||
Settings::ProjectSettings projectSettings(project);
|
||||
path = projectSettings.chatHistoryPath().toFSPathString();
|
||||
} else {
|
||||
QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
|
||||
path = baseDir.filePath("qodeassist/chat_history");
|
||||
}
|
||||
|
||||
QDir dir(path);
|
||||
if (!dir.exists()) {
|
||||
dir.mkpath(".");
|
||||
}
|
||||
|
||||
QUrl url = QUrl::fromLocalFile(dir.absolutePath());
|
||||
QDesktopServices::openUrl(url);
|
||||
}
|
||||
|
||||
QString ChatHistoryStore::generateChatFileName(const QString &shortMessage, const QString &dir) const
|
||||
{
|
||||
static const QRegularExpression saitizeSymbols = QRegularExpression("[\\/:*?\"<>|\\s]");
|
||||
static const QRegularExpression underSymbols = QRegularExpression("_+");
|
||||
|
||||
QStringList parts;
|
||||
QString sanitizedMessage = shortMessage;
|
||||
sanitizedMessage.replace(saitizeSymbols, "_");
|
||||
sanitizedMessage.replace(underSymbols, "_");
|
||||
sanitizedMessage = sanitizedMessage.trimmed();
|
||||
|
||||
if (!sanitizedMessage.isEmpty()) {
|
||||
if (sanitizedMessage.startsWith('_')) {
|
||||
sanitizedMessage.remove(0, 1);
|
||||
}
|
||||
if (sanitizedMessage.endsWith('_')) {
|
||||
sanitizedMessage.chop(1);
|
||||
}
|
||||
|
||||
QString fullPath = QDir(dir).filePath(sanitizedMessage);
|
||||
QFileInfo fileInfo(fullPath);
|
||||
if (!fileInfo.exists() && QFileInfo(fileInfo.path()).isWritable()) {
|
||||
parts << sanitizedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
parts << QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm");
|
||||
|
||||
QString fileName = parts.join("_");
|
||||
QString fullPath = QDir(dir).filePath(fileName);
|
||||
QFileInfo finalCheck(fullPath);
|
||||
|
||||
if (fileName.isEmpty() || finalCheck.exists() || !QFileInfo(finalCheck.path()).isWritable()) {
|
||||
fileName = QString("chat_%1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm"));
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
|
||||
#include "ChatSerializer.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
class Session;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatHistoryStore : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatHistoryStore(Session::Session *session, QObject *parent = nullptr);
|
||||
|
||||
QString historyDir() const;
|
||||
QString suggestedFileName() const;
|
||||
QString autosaveFilePath(const QString &recentFilePath) const;
|
||||
QString autosaveFilePath(
|
||||
const QString &recentFilePath,
|
||||
const QString &firstMessage,
|
||||
bool hasImageAttachments) const;
|
||||
|
||||
SerializationResult save(const QString &filePath) const;
|
||||
SerializationResult load(const QString &filePath) const;
|
||||
|
||||
void showSaveDialog();
|
||||
void showLoadDialog();
|
||||
void openHistoryFolder() const;
|
||||
|
||||
signals:
|
||||
void saveRequested(const QString &filePath);
|
||||
void loadRequested(const QString &filePath);
|
||||
|
||||
private:
|
||||
QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
|
||||
|
||||
QPointer<Session::Session> m_session;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -10,7 +10,6 @@
|
||||
#include <QUrl>
|
||||
|
||||
#include <algorithm>
|
||||
#include <tuple>
|
||||
|
||||
#include "logger/Logger.hpp"
|
||||
|
||||
@@ -18,19 +17,33 @@ namespace QodeAssist::Chat {
|
||||
|
||||
namespace {
|
||||
|
||||
auto usageOf(const ChatModel::Message &message)
|
||||
ChatModel::ChatRole toChatRole(Session::RowKind kind)
|
||||
{
|
||||
return std::tie(
|
||||
message.promptTokens,
|
||||
message.completionTokens,
|
||||
message.cachedPromptTokens,
|
||||
message.reasoningTokens);
|
||||
switch (kind) {
|
||||
case Session::RowKind::System:
|
||||
return ChatModel::ChatRole::System;
|
||||
case Session::RowKind::User:
|
||||
return ChatModel::ChatRole::User;
|
||||
case Session::RowKind::Assistant:
|
||||
return ChatModel::ChatRole::Assistant;
|
||||
case Session::RowKind::Tool:
|
||||
case Session::RowKind::AgentTool:
|
||||
return ChatModel::ChatRole::Tool;
|
||||
case Session::RowKind::FileEdit:
|
||||
return ChatModel::ChatRole::FileEdit;
|
||||
case Session::RowKind::Thinking:
|
||||
return ChatModel::ChatRole::Thinking;
|
||||
case Session::RowKind::Permission:
|
||||
return ChatModel::ChatRole::Permission;
|
||||
case Session::RowKind::Plan:
|
||||
return ChatModel::ChatRole::Plan;
|
||||
}
|
||||
return ChatModel::ChatRole::Assistant;
|
||||
}
|
||||
|
||||
bool carriesUsage(const ChatModel::Message &message)
|
||||
bool carriesUsage(const Session::MessageRow &row)
|
||||
{
|
||||
return message.promptTokens != 0 || message.completionTokens != 0
|
||||
|| message.cachedPromptTokens != 0 || message.reasoningTokens != 0;
|
||||
return !row.usage.isEmpty();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -49,10 +62,10 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
|
||||
if (!index.isValid() || index.row() >= m_messages.size())
|
||||
return QVariant();
|
||||
|
||||
const Message &message = m_messages[index.row()];
|
||||
const Session::MessageRow &message = m_messages[index.row()];
|
||||
switch (static_cast<Roles>(role)) {
|
||||
case Roles::RoleType:
|
||||
return QVariant::fromValue(message.role);
|
||||
return QVariant::fromValue(toChatRole(message.kind));
|
||||
case Roles::Content: {
|
||||
return message.content;
|
||||
}
|
||||
@@ -60,37 +73,47 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
|
||||
QVariantList attachmentsList;
|
||||
for (const auto &attachment : message.attachments) {
|
||||
QVariantMap attachmentMap;
|
||||
attachmentMap["fileName"] = attachment.filename;
|
||||
attachmentMap["storedPath"] = attachment.content;
|
||||
|
||||
attachmentMap["fileName"] = attachment.fileName;
|
||||
attachmentMap["storedPath"] = attachment.storedPath;
|
||||
|
||||
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);
|
||||
QString fullPath = QDir(contentFolder).filePath(attachment.storedPath);
|
||||
attachmentMap["filePath"] = fullPath;
|
||||
} else {
|
||||
attachmentMap["filePath"] = QString();
|
||||
}
|
||||
|
||||
|
||||
attachmentsList.append(attachmentMap);
|
||||
}
|
||||
return attachmentsList;
|
||||
}
|
||||
case Roles::IsRedacted: {
|
||||
return message.isRedacted;
|
||||
return message.redacted;
|
||||
}
|
||||
case Roles::PromptTokens:
|
||||
return message.promptTokens;
|
||||
return message.usage.promptTokens;
|
||||
case Roles::CompletionTokens:
|
||||
return message.completionTokens;
|
||||
return message.usage.completionTokens;
|
||||
case Roles::CachedPromptTokens:
|
||||
return message.cachedPromptTokens;
|
||||
return message.usage.cachedPromptTokens;
|
||||
case Roles::ReasoningTokens:
|
||||
return message.reasoningTokens;
|
||||
return message.usage.reasoningTokens;
|
||||
case Roles::TotalTokens:
|
||||
return message.promptTokens + message.completionTokens;
|
||||
return message.usage.promptTokens + message.usage.completionTokens;
|
||||
case Roles::ToolKind:
|
||||
return message.toolKind;
|
||||
case Roles::ToolStatus:
|
||||
return message.toolStatus;
|
||||
case Roles::ToolName:
|
||||
return message.toolName;
|
||||
case Roles::ToolResult:
|
||||
return message.toolResult;
|
||||
case Roles::ToolDetails:
|
||||
return QVariant::fromValue(message.toolDetails);
|
||||
case Roles::Images: {
|
||||
QVariantList imagesList;
|
||||
for (const auto &image : message.images) {
|
||||
@@ -98,7 +121,7 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
|
||||
imageMap["fileName"] = image.fileName;
|
||||
imageMap["storedPath"] = image.storedPath;
|
||||
imageMap["mediaType"] = image.mediaType;
|
||||
|
||||
|
||||
if (!m_chatFilePath.isEmpty()) {
|
||||
QFileInfo fileInfo(m_chatFilePath);
|
||||
QString baseName = fileInfo.completeBaseName();
|
||||
@@ -111,7 +134,7 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
|
||||
imageMap["imageUrl"] = QString();
|
||||
imageMap["filePath"] = QString();
|
||||
}
|
||||
|
||||
|
||||
imagesList.append(imageMap);
|
||||
}
|
||||
return imagesList;
|
||||
@@ -134,32 +157,37 @@ QHash<int, QByteArray> ChatModel::roleNames() const
|
||||
roles[Roles::CachedPromptTokens] = "cachedPromptTokens";
|
||||
roles[Roles::ReasoningTokens] = "reasoningTokens";
|
||||
roles[Roles::TotalTokens] = "totalTokens";
|
||||
roles[Roles::ToolKind] = "toolKind";
|
||||
roles[Roles::ToolStatus] = "toolStatus";
|
||||
roles[Roles::ToolDetails] = "toolDetails";
|
||||
roles[Roles::ToolName] = "toolName";
|
||||
roles[Roles::ToolResult] = "toolResult";
|
||||
return roles;
|
||||
}
|
||||
|
||||
void ChatModel::resetMessages(const QVector<Message> &messages)
|
||||
void ChatModel::resetMessages(const QList<Session::MessageRow> &rows)
|
||||
{
|
||||
beginResetModel();
|
||||
m_messages = messages;
|
||||
m_messages = rows;
|
||||
endResetModel();
|
||||
emit modelReseted();
|
||||
emit sessionUsageChanged();
|
||||
}
|
||||
|
||||
void ChatModel::appendMessages(const QVector<Message> &messages)
|
||||
void ChatModel::appendMessages(const QList<Session::MessageRow> &rows)
|
||||
{
|
||||
if (messages.isEmpty())
|
||||
if (rows.isEmpty())
|
||||
return;
|
||||
|
||||
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size() + messages.size() - 1);
|
||||
m_messages.append(messages);
|
||||
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size() + rows.size() - 1);
|
||||
m_messages.append(rows);
|
||||
endInsertRows();
|
||||
|
||||
if (std::any_of(messages.cbegin(), messages.cend(), carriesUsage))
|
||||
if (std::any_of(rows.cbegin(), rows.cend(), carriesUsage))
|
||||
emit sessionUsageChanged();
|
||||
}
|
||||
|
||||
void ChatModel::updateMessage(int index, const Message &message)
|
||||
void ChatModel::updateMessage(int index, const Session::MessageRow &row)
|
||||
{
|
||||
if (index < 0 || index >= m_messages.size()) {
|
||||
LOG_MESSAGE(QString("Session/model desync: update of row %1 with %2 rows present")
|
||||
@@ -168,8 +196,8 @@ void ChatModel::updateMessage(int index, const Message &message)
|
||||
return;
|
||||
}
|
||||
|
||||
const bool usageChanged = usageOf(m_messages[index]) != usageOf(message);
|
||||
m_messages[index] = message;
|
||||
const bool usageChanged = m_messages[index].usage != row.usage;
|
||||
m_messages[index] = row;
|
||||
emit dataChanged(this->index(index), this->index(index));
|
||||
|
||||
if (usageChanged)
|
||||
@@ -262,7 +290,7 @@ 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)
|
||||
if (m_messages[i].kind != Session::RowKind::User)
|
||||
continue;
|
||||
QString preview = m_messages[i].content;
|
||||
preview.replace(QLatin1Char('\n'), QLatin1Char(' '));
|
||||
@@ -283,7 +311,7 @@ int ChatModel::sessionPromptTokens() const
|
||||
{
|
||||
int total = 0;
|
||||
for (const auto &m : m_messages)
|
||||
total += m.promptTokens;
|
||||
total += m.usage.promptTokens;
|
||||
return total;
|
||||
}
|
||||
|
||||
@@ -291,7 +319,7 @@ int ChatModel::sessionCompletionTokens() const
|
||||
{
|
||||
int total = 0;
|
||||
for (const auto &m : m_messages)
|
||||
total += m.completionTokens;
|
||||
total += m.usage.completionTokens;
|
||||
return total;
|
||||
}
|
||||
|
||||
@@ -299,7 +327,7 @@ int ChatModel::sessionCachedPromptTokens() const
|
||||
{
|
||||
int total = 0;
|
||||
for (const auto &m : m_messages)
|
||||
total += m.cachedPromptTokens;
|
||||
total += m.usage.cachedPromptTokens;
|
||||
return total;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
#include "MessagePart.hpp"
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QJsonObject>
|
||||
#include <QtQmlIntegration>
|
||||
|
||||
#include "context/ContentFile.hpp"
|
||||
#include "session/HistoryProjection.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
@@ -24,7 +23,7 @@ class ChatModel : public QAbstractListModel
|
||||
QML_ELEMENT
|
||||
|
||||
public:
|
||||
enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking };
|
||||
enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking, Permission, Plan };
|
||||
Q_ENUM(ChatRole)
|
||||
|
||||
enum Roles {
|
||||
@@ -37,47 +36,24 @@ public:
|
||||
CompletionTokens,
|
||||
CachedPromptTokens,
|
||||
ReasoningTokens,
|
||||
TotalTokens
|
||||
TotalTokens,
|
||||
ToolKind,
|
||||
ToolStatus,
|
||||
ToolDetails,
|
||||
ToolName,
|
||||
ToolResult
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
struct ImageAttachment
|
||||
{
|
||||
QString fileName; // Original filename
|
||||
QString storedPath; // Path to stored image file (relative to chat folder)
|
||||
QString mediaType; // MIME type
|
||||
};
|
||||
|
||||
struct Message
|
||||
{
|
||||
ChatRole role;
|
||||
QString content;
|
||||
QString id;
|
||||
bool isRedacted = false;
|
||||
QString signature = QString();
|
||||
|
||||
QList<Context::ContentFile> attachments;
|
||||
QList<ImageAttachment> images;
|
||||
|
||||
QString toolName;
|
||||
QJsonObject toolArguments;
|
||||
QString toolResult;
|
||||
|
||||
int promptTokens = 0;
|
||||
int completionTokens = 0;
|
||||
int cachedPromptTokens = 0;
|
||||
int reasoningTokens = 0;
|
||||
};
|
||||
|
||||
explicit ChatModel(QObject *parent = nullptr);
|
||||
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
QHash<int, QByteArray> roleNames() const override;
|
||||
|
||||
void resetMessages(const QVector<Message> &messages);
|
||||
void appendMessages(const QVector<Message> &messages);
|
||||
void updateMessage(int index, const Message &message);
|
||||
void resetMessages(const QList<Session::MessageRow> &rows);
|
||||
void appendMessages(const QList<Session::MessageRow> &rows);
|
||||
void updateMessage(int index, const Session::MessageRow &row);
|
||||
void removeMessages(int first, int count);
|
||||
|
||||
Q_INVOKABLE QList<MessagePart> processMessageContent(const QString &content) const;
|
||||
@@ -96,10 +72,9 @@ signals:
|
||||
void sessionUsageChanged();
|
||||
|
||||
private:
|
||||
QVector<Message> m_messages;
|
||||
QList<Session::MessageRow> m_messages;
|
||||
QString m_chatFilePath;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
Q_DECLARE_METATYPE(QodeAssist::Chat::ChatModel::Message)
|
||||
Q_DECLARE_METATYPE(QodeAssist::Chat::MessagePart)
|
||||
|
||||
+305
-434
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,11 @@
|
||||
#include <QVariantList>
|
||||
|
||||
#include "ChatController.hpp"
|
||||
#include "ChatFileManager.hpp"
|
||||
#include "AttachmentStaging.hpp"
|
||||
#include "ChatModel.hpp"
|
||||
#include "ConversationCoordinator.hpp"
|
||||
#include "templates/PromptProviderChat.hpp"
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <utils/id.h>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
@@ -21,21 +22,21 @@ class SkillsManager;
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatCompressor;
|
||||
class AgentRoleController;
|
||||
class ChatConfigurationController;
|
||||
class FileEditController;
|
||||
class InputTokenCounter;
|
||||
class ChatHistoryStore;
|
||||
class ChatFileStore;
|
||||
class SessionFileRegistry;
|
||||
|
||||
class ChatRootView : public QQuickItem
|
||||
class ChatRootView : public QQuickItem,
|
||||
private IAgentCatalogPort,
|
||||
private ICompressionPort,
|
||||
private ISendPort
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QodeAssist::Chat::ChatModel *chatModel READ chatModel NOTIFY chatModelChanged FINAL)
|
||||
Q_PROPERTY(QString currentTemplate READ currentTemplate NOTIFY currentTemplateChanged FINAL)
|
||||
Q_PROPERTY(bool isSyncOpenFiles READ isSyncOpenFiles NOTIFY isSyncOpenFilesChanged FINAL)
|
||||
Q_PROPERTY(QStringList attachmentFiles READ attachmentFiles NOTIFY attachmentFilesChanged FINAL)
|
||||
Q_PROPERTY(QStringList linkedFiles READ linkedFiles NOTIFY linkedFilesChanged FINAL)
|
||||
Q_PROPERTY(int inputTokensCount READ inputTokensCount NOTIFY inputTokensCountChanged FINAL)
|
||||
Q_PROPERTY(QString chatFileName READ chatFileName NOTIFY chatFileNameChanged FINAL)
|
||||
Q_PROPERTY(QString textFontFamily READ textFontFamily NOTIFY textFamilyChanged FINAL)
|
||||
@@ -46,24 +47,25 @@ class ChatRootView : public QQuickItem
|
||||
Q_PROPERTY(bool isRequestInProgress READ isRequestInProgress NOTIFY isRequestInProgressChanged FINAL)
|
||||
Q_PROPERTY(QString lastErrorMessage READ lastErrorMessage NOTIFY lastErrorMessageChanged FINAL)
|
||||
Q_PROPERTY(QString lastInfoMessage READ lastInfoMessage NOTIFY lastInfoMessageChanged FINAL)
|
||||
Q_PROPERTY(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL)
|
||||
Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL)
|
||||
Q_PROPERTY(bool useTools READ useTools WRITE setUseTools NOTIFY useToolsChanged FINAL)
|
||||
Q_PROPERTY(bool useThinking READ useThinking WRITE setUseThinking NOTIFY useThinkingChanged FINAL)
|
||||
Q_PROPERTY(QString sendShortcutText READ sendShortcutText NOTIFY sendShortcutTextChanged FINAL)
|
||||
|
||||
Q_PROPERTY(int currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(int currentMessagePendingEdits READ currentMessagePendingEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(int currentMessageRejectedEdits READ currentMessageRejectedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(bool isThinkingSupport READ isThinkingSupport NOTIFY isThinkingSupportChanged FINAL)
|
||||
Q_PROPERTY(bool isAgentBound READ isAgentBound NOTIFY isAgentBoundChanged FINAL)
|
||||
Q_PROPERTY(QString agentSessionIssue READ agentSessionIssue NOTIFY agentSessionIssueChanged FINAL)
|
||||
Q_PROPERTY(bool canStartNewAgentSession READ canStartNewAgentSession NOTIFY
|
||||
agentSessionIssueChanged FINAL)
|
||||
Q_PROPERTY(bool canHandOverSummary READ canHandOverSummary NOTIFY agentSessionIssueChanged FINAL)
|
||||
Q_PROPERTY(QString summaryHandoverTooltip READ summaryHandoverTooltip NOTIFY
|
||||
agentSessionIssueChanged FINAL)
|
||||
Q_PROPERTY(bool canShrinkContext READ canShrinkContext NOTIFY shrinkContextStateChanged FINAL)
|
||||
Q_PROPERTY(QString shrinkContextTooltip READ shrinkContextTooltip NOTIFY
|
||||
shrinkContextStateChanged FINAL)
|
||||
Q_PROPERTY(QStringList availableConfigurations READ availableConfigurations NOTIFY availableConfigurationsChanged FINAL)
|
||||
Q_PROPERTY(QString currentConfiguration READ currentConfiguration NOTIFY currentConfigurationChanged FINAL)
|
||||
Q_PROPERTY(QStringList availableAgentRoles READ availableAgentRoles NOTIFY availableAgentRolesChanged FINAL)
|
||||
Q_PROPERTY(QString currentAgentRole READ currentAgentRole NOTIFY currentAgentRoleChanged FINAL)
|
||||
Q_PROPERTY(QString baseSystemPrompt READ baseSystemPrompt NOTIFY baseSystemPromptChanged FINAL)
|
||||
Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL)
|
||||
Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL)
|
||||
Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged FINAL)
|
||||
Q_PROPERTY(bool isInEditor READ isInEditor NOTIFY isInEditorChanged FINAL)
|
||||
Q_PROPERTY(QString chatTitle READ chatTitle NOTIFY chatTitleChanged FINAL)
|
||||
@@ -88,23 +90,17 @@ public:
|
||||
QString getAutosaveFilePath(const QString &firstMessage, const QStringList &attachments) const;
|
||||
|
||||
QStringList attachmentFiles() const;
|
||||
QStringList linkedFiles() const;
|
||||
|
||||
Q_INVOKABLE void showAttachFilesDialog();
|
||||
Q_INVOKABLE void addFilesToAttachList(const QStringList &filePaths);
|
||||
Q_INVOKABLE void removeFileFromAttachList(int index);
|
||||
Q_INVOKABLE void showLinkFilesDialog();
|
||||
Q_INVOKABLE void addFilesToLinkList(const QStringList &filePaths);
|
||||
Q_INVOKABLE void removeFileFromLinkList(int index);
|
||||
Q_INVOKABLE QStringList convertUrlsToLocalPaths(const QVariantList &urls) const;
|
||||
Q_INVOKABLE void showAddImageDialog();
|
||||
Q_INVOKABLE bool isImageFile(const QString &filePath) const;
|
||||
Q_INVOKABLE void calculateMessageTokensCount(const QString &message);
|
||||
Q_INVOKABLE bool isSendShortcut(int key, int modifiers) const;
|
||||
QString sendShortcutText() const;
|
||||
Q_INVOKABLE void setIsSyncOpenFiles(bool state);
|
||||
Q_INVOKABLE void openChatHistoryFolder();
|
||||
Q_INVOKABLE void openRulesFolder();
|
||||
Q_INVOKABLE void openSettings();
|
||||
|
||||
Q_INVOKABLE void openFileInEditor(const QString &filePath);
|
||||
@@ -117,16 +113,9 @@ public:
|
||||
Q_INVOKABLE void updateInputTokensCount();
|
||||
int inputTokensCount() const;
|
||||
|
||||
bool isSyncOpenFiles() const;
|
||||
|
||||
void onEditorAboutToClose(Core::IEditor *editor);
|
||||
void onAppendLinkFileFromEditor(Core::IEditor *editor);
|
||||
void onEditorCreated(Core::IEditor *editor, const Utils::FilePath &filePath);
|
||||
|
||||
QString chatFileName() const;
|
||||
Q_INVOKABLE QString chatFilePath() const;
|
||||
void setRecentFilePath(const QString &filePath);
|
||||
bool shouldIgnoreFileForAttach(const Utils::FilePath &filePath);
|
||||
|
||||
QString textFontFamily() const;
|
||||
QString codeFontFamily() const;
|
||||
@@ -139,18 +128,10 @@ public:
|
||||
void setRequestProgressStatus(bool state);
|
||||
|
||||
QString lastErrorMessage() const;
|
||||
|
||||
QVariantList activeRules() const;
|
||||
int activeRulesCount() const;
|
||||
Q_INVOKABLE QString getRuleContent(int index);
|
||||
Q_INVOKABLE void refreshRules();
|
||||
|
||||
Q_INVOKABLE QVariantList searchSkills(const QString &query) const;
|
||||
Q_INVOKABLE QVariantList searchSlashCommands(const QString &query) const;
|
||||
|
||||
bool useTools() const;
|
||||
void setUseTools(bool enabled);
|
||||
bool useThinking() const;
|
||||
void setUseThinking(bool enabled);
|
||||
Q_INVOKABLE void respondToPermission(const QString &requestId, const QString &optionId);
|
||||
|
||||
Q_INVOKABLE void applyFileEdit(const QString &editId);
|
||||
Q_INVOKABLE void rejectFileEdit(const QString &editId);
|
||||
@@ -163,21 +144,16 @@ public:
|
||||
|
||||
Q_INVOKABLE void loadAvailableConfigurations();
|
||||
Q_INVOKABLE void applyConfiguration(const QString &configName);
|
||||
Q_INVOKABLE void confirmChatTargetSwitch();
|
||||
Q_INVOKABLE void cancelChatTargetSwitch();
|
||||
QStringList availableConfigurations() const;
|
||||
QString currentConfiguration() const;
|
||||
|
||||
Q_INVOKABLE void compressCurrentChat();
|
||||
Q_INVOKABLE void cancelCompression();
|
||||
|
||||
Q_INVOKABLE void loadAvailableAgentRoles();
|
||||
Q_INVOKABLE void applyAgentRole(const QString &roleId);
|
||||
Q_INVOKABLE void openAgentRolesSettings();
|
||||
QStringList availableAgentRoles() const;
|
||||
QString currentAgentRole() const;
|
||||
QString baseSystemPrompt() const;
|
||||
QString currentAgentRoleDescription() const;
|
||||
QString currentAgentRoleSystemPrompt() const;
|
||||
|
||||
|
||||
int currentMessageTotalEdits() const;
|
||||
int currentMessageAppliedEdits() const;
|
||||
int currentMessagePendingEdits() const;
|
||||
@@ -185,8 +161,16 @@ public:
|
||||
|
||||
QString lastInfoMessage() const;
|
||||
|
||||
bool isThinkingSupport() const;
|
||||
|
||||
bool isAgentBound() const;
|
||||
QString agentSessionIssue() const;
|
||||
bool canStartNewAgentSession() const;
|
||||
bool canHandOverSummary() const;
|
||||
QString summaryHandoverTooltip() const;
|
||||
bool canShrinkContext() const;
|
||||
QString shrinkContextTooltip() const;
|
||||
Q_INVOKABLE void startNewAgentSession();
|
||||
Q_INVOKABLE void startNewAgentSessionWithSummary();
|
||||
|
||||
bool isCompressing() const;
|
||||
|
||||
bool isInEditor() const;
|
||||
@@ -201,7 +185,6 @@ public slots:
|
||||
void copyToClipboard(const QString &text);
|
||||
void cancelRequest();
|
||||
void clearAttachmentFiles();
|
||||
void clearLinkedFiles();
|
||||
void clearMessages();
|
||||
void resetChatToMessage(int index);
|
||||
|
||||
@@ -209,9 +192,7 @@ signals:
|
||||
void chatModelChanged();
|
||||
void currentTemplateChanged();
|
||||
void attachmentFilesChanged();
|
||||
void linkedFilesChanged();
|
||||
void inputTokensCountChanged();
|
||||
void isSyncOpenFilesChanged();
|
||||
void chatFileNameChanged();
|
||||
void textFamilyChanged();
|
||||
void codeFamilyChanged();
|
||||
@@ -224,19 +205,17 @@ signals:
|
||||
void lastErrorMessageChanged();
|
||||
void lastInfoMessageChanged();
|
||||
void sendShortcutTextChanged();
|
||||
void activeRulesChanged();
|
||||
void activeRulesCountChanged();
|
||||
|
||||
void useToolsChanged();
|
||||
void useThinkingChanged();
|
||||
void currentMessageEditsStatsChanged();
|
||||
|
||||
void isThinkingSupportChanged();
|
||||
void isAgentBoundChanged();
|
||||
void agentSessionIssueChanged();
|
||||
void shrinkContextStateChanged();
|
||||
void slashCommandsChanged();
|
||||
void availableConfigurationsChanged();
|
||||
void currentConfigurationChanged();
|
||||
void chatTargetSwitchNeedsNewChat(const QString &targetName);
|
||||
|
||||
void availableAgentRolesChanged();
|
||||
void currentAgentRoleChanged();
|
||||
void baseSystemPromptChanged();
|
||||
|
||||
void isCompressingChanged();
|
||||
@@ -246,69 +225,59 @@ signals:
|
||||
void isInEditorChanged();
|
||||
void chatTitleChanged();
|
||||
|
||||
void openFilesChanged();
|
||||
|
||||
void closeHostRequested();
|
||||
|
||||
protected:
|
||||
void componentComplete() override;
|
||||
|
||||
private:
|
||||
QVariantList searchSkills(const QString &query) const;
|
||||
QVariantList searchAgentCommands(const QString &query) const;
|
||||
QString computeChatTitle() const;
|
||||
void triggerOpenChatCommand(Utils::Id commandId);
|
||||
void handOffSession();
|
||||
bool deferSendForAutoCompress(
|
||||
const QString &message,
|
||||
const QStringList &attachments,
|
||||
const QStringList &linkedFiles,
|
||||
bool useTools,
|
||||
bool useThinking);
|
||||
void dispatchSend(
|
||||
const QString &message,
|
||||
const QStringList &attachments,
|
||||
const QStringList &linkedFiles,
|
||||
bool useTools,
|
||||
bool useThinking);
|
||||
bool hasImageAttachments(const QStringList &attachments) const;
|
||||
|
||||
std::optional<Acp::AgentDefinition> agentById(const QString &agentId) const override;
|
||||
QString compressionConfigurationIssue() const override;
|
||||
bool isCompressionRunning() const override;
|
||||
void startTranscriptSummary() override;
|
||||
void startCompression() override;
|
||||
bool autoCompressEnabled() const override;
|
||||
int autoCompressThreshold() const override;
|
||||
int estimatedNextTokens() const override;
|
||||
bool prepareChatFileForCompression(
|
||||
const QString &message, const QStringList &attachments) override;
|
||||
void dispatch(const QString &message, const QStringList &attachments) override;
|
||||
|
||||
SessionFileRegistry *sessionFileRegistry() const;
|
||||
Skills::SkillsManager *skillsManager() const;
|
||||
|
||||
ChatModel *m_chatModel;
|
||||
Templates::PromptProviderChat m_promptProvider;
|
||||
ChatController *m_controller;
|
||||
ChatFileManager *m_fileManager;
|
||||
AttachmentStaging *m_attachmentStaging;
|
||||
QString m_currentTemplate;
|
||||
QString m_recentFilePath;
|
||||
QStringList m_attachmentFiles;
|
||||
QStringList m_linkedFiles;
|
||||
|
||||
struct PendingSend {
|
||||
QString message;
|
||||
QStringList attachments;
|
||||
QStringList linkedFiles;
|
||||
bool useTools = false;
|
||||
bool useThinking = false;
|
||||
bool active = false;
|
||||
};
|
||||
PendingSend m_pendingSend;
|
||||
bool m_isSyncOpenFiles;
|
||||
bool m_isInEditor = false;
|
||||
mutable QString m_cachedChatTitle;
|
||||
QList<Core::IEditor *> m_currentEditors;
|
||||
bool m_isRequestInProgress;
|
||||
QString m_lastErrorMessage;
|
||||
QVariantList m_activeRules;
|
||||
|
||||
|
||||
QString m_lastInfoMessage;
|
||||
|
||||
ChatCompressor *m_chatCompressor;
|
||||
AgentRoleController *m_agentRoleController;
|
||||
ChatConfigurationController *m_configurationController;
|
||||
FileEditController *m_fileEditController;
|
||||
InputTokenCounter *m_tokenCounter;
|
||||
ChatHistoryStore *m_historyStore;
|
||||
ChatFileStore *m_historyStore;
|
||||
mutable QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
||||
mutable bool m_sessionFileRegistryResolved = false;
|
||||
mutable QPointer<Skills::SkillsManager> m_skillsManager;
|
||||
mutable bool m_skillsManagerResolved = false;
|
||||
ConversationCoordinator *m_coordinator = nullptr;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatSerializer.hpp"
|
||||
#include "Logger.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSaveFile>
|
||||
#include <QUuid>
|
||||
|
||||
#include "session/HistorySerializer.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
SerializationResult ChatSerializer::saveToFile(
|
||||
const Session::ConversationHistory &history, const QString &filePath)
|
||||
{
|
||||
if (!ensureDirectoryExists(filePath)) {
|
||||
return {false, "Failed to create directory structure"};
|
||||
}
|
||||
|
||||
const QJsonObject root = Session::HistorySerializer::toJson(history);
|
||||
|
||||
QSaveFile file(filePath);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
return {false, QString("Failed to open file for writing: %1").arg(filePath)};
|
||||
}
|
||||
|
||||
if (file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) {
|
||||
return {false, QString("Failed to write to file: %1").arg(file.errorString())};
|
||||
}
|
||||
|
||||
if (!file.commit()) {
|
||||
return {false, QString("Failed to save file: %1").arg(file.errorString())};
|
||||
}
|
||||
|
||||
return {true, QString()};
|
||||
}
|
||||
|
||||
SerializationResult ChatSerializer::loadFromFile(
|
||||
Session::ConversationHistory &history, const QString &filePath)
|
||||
{
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return {false, QString("Failed to open file for reading: %1").arg(filePath)};
|
||||
}
|
||||
|
||||
QJsonParseError error;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
|
||||
if (error.error != QJsonParseError::NoError) {
|
||||
return {false, QString("JSON parse error: %1").arg(error.errorString())};
|
||||
}
|
||||
|
||||
const QJsonObject root = doc.object();
|
||||
const QString version = root["version"].toString();
|
||||
|
||||
if (!Session::HistorySerializer::isSupportedVersion(version)) {
|
||||
return {false, QString("Unsupported version: %1").arg(version)};
|
||||
}
|
||||
|
||||
int droppedBlocks = 0;
|
||||
const auto loaded = Session::HistorySerializer::fromJson(root, &droppedBlocks);
|
||||
if (!loaded) {
|
||||
return {false, QString("Failed to read chat history from: %1").arg(filePath)};
|
||||
}
|
||||
|
||||
if (version != Session::HistorySerializer::currentVersion()) {
|
||||
LOG_MESSAGE(QString("Converted chat from format %1 to %2")
|
||||
.arg(version, Session::HistorySerializer::currentVersion()));
|
||||
}
|
||||
|
||||
history = *loaded;
|
||||
|
||||
if (droppedBlocks > 0) {
|
||||
const QString warning
|
||||
= QString(
|
||||
"%1 message part(s) in this chat could not be read and will be lost if "
|
||||
"the chat is saved again")
|
||||
.arg(droppedBlocks);
|
||||
LOG_MESSAGE(QString("%1: %2").arg(filePath, warning));
|
||||
return {true, QString(), warning};
|
||||
}
|
||||
|
||||
return {true, QString(), QString()};
|
||||
}
|
||||
|
||||
bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
|
||||
{
|
||||
QFileInfo fileInfo(filePath);
|
||||
QDir dir = fileInfo.dir();
|
||||
return dir.exists() || dir.mkpath(".");
|
||||
}
|
||||
|
||||
QString ChatSerializer::getChatContentFolder(const QString &chatFilePath)
|
||||
{
|
||||
QFileInfo fileInfo(chatFilePath);
|
||||
QString baseName = fileInfo.completeBaseName();
|
||||
QString dirPath = fileInfo.absolutePath();
|
||||
return QDir(dirPath).filePath(baseName + "_content");
|
||||
}
|
||||
|
||||
bool ChatSerializer::saveContentToStorage(
|
||||
const QString &chatFilePath,
|
||||
const QString &fileName,
|
||||
const QString &base64Data,
|
||||
QString &storedPath)
|
||||
{
|
||||
QString contentFolder = getChatContentFolder(chatFilePath);
|
||||
QDir dir;
|
||||
if (!dir.exists(contentFolder)) {
|
||||
if (!dir.mkpath(contentFolder)) {
|
||||
LOG_MESSAGE(QString("Failed to create content folder: %1").arg(contentFolder));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
QFileInfo originalFileInfo(fileName);
|
||||
QString extension = originalFileInfo.suffix();
|
||||
QString baseName = originalFileInfo.completeBaseName();
|
||||
QString uniqueName = QString("%1_%2.%3")
|
||||
.arg(baseName)
|
||||
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8))
|
||||
.arg(extension);
|
||||
|
||||
QString fullPath = QDir(contentFolder).filePath(uniqueName);
|
||||
|
||||
QByteArray contentData = QByteArray::fromBase64(base64Data.toUtf8());
|
||||
QFile file(fullPath);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
LOG_MESSAGE(QString("Failed to open file for writing: %1").arg(fullPath));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.write(contentData) == -1) {
|
||||
LOG_MESSAGE(QString("Failed to write content data: %1").arg(file.errorString()));
|
||||
return false;
|
||||
}
|
||||
|
||||
file.close();
|
||||
|
||||
storedPath = uniqueName;
|
||||
LOG_MESSAGE(QString("Saved content: %1 to %2").arg(fileName, fullPath));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
QString ChatSerializer::loadContentFromStorage(const QString &chatFilePath, const QString &storedPath)
|
||||
{
|
||||
QString contentFolder = getChatContentFolder(chatFilePath);
|
||||
QString fullPath = QDir(contentFolder).filePath(storedPath);
|
||||
|
||||
QFile file(fullPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
LOG_MESSAGE(QString("Failed to open content file: %1").arg(fullPath));
|
||||
return QString();
|
||||
}
|
||||
|
||||
QByteArray contentData = file.readAll();
|
||||
file.close();
|
||||
|
||||
return contentData.toBase64();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include "session/ConversationHistory.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
struct SerializationResult
|
||||
{
|
||||
bool success{false};
|
||||
QString errorMessage;
|
||||
QString warningMessage;
|
||||
};
|
||||
|
||||
class ChatSerializer
|
||||
{
|
||||
public:
|
||||
static SerializationResult saveToFile(
|
||||
const Session::ConversationHistory &history, const QString &filePath);
|
||||
static SerializationResult loadFromFile(
|
||||
Session::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 QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath);
|
||||
|
||||
private:
|
||||
static bool ensureDirectoryExists(const QString &filePath);
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -0,0 +1,373 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ConversationCoordinator.hpp"
|
||||
|
||||
#include "logger/Logger.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ConversationCoordinator::ConversationCoordinator(const Ports &ports, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_ports(ports)
|
||||
{}
|
||||
|
||||
void ConversationCoordinator::requestSend(const QString &message, const QStringList &attachments)
|
||||
{
|
||||
if (refuseWhileReadOnly())
|
||||
return;
|
||||
|
||||
if (!m_ports.ops->boundAgentId().isEmpty()
|
||||
&& m_ports.compression->isCompressionRunning()) {
|
||||
emit errorSurfaced(
|
||||
tr("A handover summary is being prepared; wait for it to finish before sending"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (deferSendForAutoCompress(message, attachments))
|
||||
return;
|
||||
|
||||
m_ports.send->dispatch(message, attachments);
|
||||
}
|
||||
|
||||
bool ConversationCoordinator::refuseWhileReadOnly()
|
||||
{
|
||||
if (m_sessionIssue.isEmpty())
|
||||
return false;
|
||||
|
||||
emit errorSurfaced(m_sessionIssue);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConversationCoordinator::hasDeferredSend() const
|
||||
{
|
||||
return m_deferredSend.active;
|
||||
}
|
||||
|
||||
bool ConversationCoordinator::deferSendForAutoCompress(
|
||||
const QString &message, const QStringList &attachments)
|
||||
{
|
||||
if (!m_ports.ops->boundAgentId().isEmpty())
|
||||
return false;
|
||||
|
||||
if (!m_ports.send->autoCompressEnabled())
|
||||
return false;
|
||||
|
||||
const int threshold = m_ports.send->autoCompressThreshold();
|
||||
const int inputTokens = m_ports.send->estimatedNextTokens();
|
||||
if (inputTokens < threshold)
|
||||
return false;
|
||||
|
||||
if (!m_ports.send->prepareChatFileForCompression(message, attachments))
|
||||
return false;
|
||||
|
||||
if (m_ports.compression->isCompressionRunning() || m_deferredSend.active)
|
||||
return false;
|
||||
|
||||
LOG_MESSAGE(QString("Auto-compress preempt: estimated next=%1 ≥ threshold=%2; deferring send")
|
||||
.arg(inputTokens)
|
||||
.arg(threshold));
|
||||
|
||||
m_deferredSend = {message, attachments, true};
|
||||
m_ports.compression->startCompression();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConversationCoordinator::compressionSettled()
|
||||
{
|
||||
if (!m_deferredSend.active)
|
||||
return;
|
||||
|
||||
const DeferredSend deferred = m_deferredSend;
|
||||
m_deferredSend = {};
|
||||
|
||||
if (refuseWhileReadOnly())
|
||||
return;
|
||||
|
||||
m_ports.send->dispatch(deferred.message, deferred.attachments);
|
||||
}
|
||||
|
||||
void ConversationCoordinator::chooseAgent(const Acp::AgentDefinition &agent)
|
||||
{
|
||||
if (m_ports.ops->boundAgentId() == agent.id)
|
||||
return;
|
||||
|
||||
if (m_ports.ops->conversationStarted()) {
|
||||
m_pendingAgent = agent;
|
||||
m_pendingLlmSwitch = false;
|
||||
emit switchConfirmationNeeded(agent.name);
|
||||
return;
|
||||
}
|
||||
|
||||
bindAgentNow(agent);
|
||||
}
|
||||
|
||||
void ConversationCoordinator::chooseLlm()
|
||||
{
|
||||
if (m_ports.ops->boundAgentId().isEmpty())
|
||||
return;
|
||||
|
||||
if (m_ports.ops->conversationStarted()) {
|
||||
m_pendingAgent.reset();
|
||||
m_pendingLlmSwitch = true;
|
||||
emit switchConfirmationNeeded(tr("direct LLM chat"));
|
||||
return;
|
||||
}
|
||||
|
||||
bindLlmNow();
|
||||
}
|
||||
|
||||
void ConversationCoordinator::confirmSwitch()
|
||||
{
|
||||
const auto agent = m_pendingAgent;
|
||||
const bool toLlm = m_pendingLlmSwitch;
|
||||
|
||||
m_pendingAgent.reset();
|
||||
m_pendingLlmSwitch = false;
|
||||
|
||||
if (!agent && !toLlm)
|
||||
return;
|
||||
|
||||
m_ports.ops->clearConversation();
|
||||
|
||||
if (agent)
|
||||
bindAgentNow(*agent);
|
||||
else
|
||||
bindLlmNow();
|
||||
}
|
||||
|
||||
void ConversationCoordinator::cancelSwitch()
|
||||
{
|
||||
m_pendingAgent.reset();
|
||||
m_pendingLlmSwitch = false;
|
||||
emit switchCancelled();
|
||||
}
|
||||
|
||||
bool ConversationCoordinator::switchPending() const
|
||||
{
|
||||
return m_pendingAgent.has_value() || m_pendingLlmSwitch;
|
||||
}
|
||||
|
||||
void ConversationCoordinator::bindAgentNow(const Acp::AgentDefinition &agent)
|
||||
{
|
||||
m_ports.ops->bindAgent(agent);
|
||||
emit boundToAgent(agent);
|
||||
}
|
||||
|
||||
void ConversationCoordinator::bindLlmNow()
|
||||
{
|
||||
m_ports.ops->bindLlm();
|
||||
emit boundToLlm();
|
||||
}
|
||||
|
||||
void ConversationCoordinator::restoreAgentBinding(const Acp::AgentBinding &binding)
|
||||
{
|
||||
setSessionIssue(QString(), false);
|
||||
m_quarantinedBinding = {};
|
||||
m_ports.ops->releaseAgentSession();
|
||||
|
||||
if (binding.isEmpty()) {
|
||||
bindLlmNow();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto agent = m_ports.catalog->agentById(binding.agentId);
|
||||
if (!agent || !agent->isLaunchable()) {
|
||||
m_quarantinedBinding = binding;
|
||||
bindLlmNow();
|
||||
setSessionIssue(
|
||||
tr("This chat was held with the agent \"%1\", which is not available any more. "
|
||||
"The transcript is read-only, and the agent it names is kept so the chat can be "
|
||||
"continued once that agent is installed again.")
|
||||
.arg(binding.displayId()),
|
||||
false);
|
||||
return;
|
||||
}
|
||||
|
||||
bindAgentNow(*agent);
|
||||
|
||||
if (!binding.sessionId.isEmpty()) {
|
||||
m_ports.ops->resumeAgentSession(binding.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_ports.ops->conversationStarted()) {
|
||||
setSessionIssue(
|
||||
tr("This chat records the agent \"%1\" but not a session to reopen, so the transcript "
|
||||
"is read-only. Start a new session to keep working with this agent — it will not "
|
||||
"have the context above.")
|
||||
.arg(binding.displayId()),
|
||||
true);
|
||||
}
|
||||
}
|
||||
|
||||
Acp::AgentBinding ConversationCoordinator::bindingForSave() const
|
||||
{
|
||||
return m_quarantinedBinding.isEmpty() ? m_ports.ops->agentBinding() : m_quarantinedBinding;
|
||||
}
|
||||
|
||||
QString ConversationCoordinator::sessionIssue() const
|
||||
{
|
||||
return m_sessionIssue;
|
||||
}
|
||||
|
||||
bool ConversationCoordinator::readOnly() const
|
||||
{
|
||||
return !m_sessionIssue.isEmpty();
|
||||
}
|
||||
|
||||
bool ConversationCoordinator::canStartFreshSession() const
|
||||
{
|
||||
return m_sessionRecoverable;
|
||||
}
|
||||
|
||||
bool ConversationCoordinator::canHandOverSummary() const
|
||||
{
|
||||
return m_sessionRecoverable && m_ports.compression->compressionConfigurationIssue().isEmpty()
|
||||
&& !m_ports.ops->transcriptEmpty();
|
||||
}
|
||||
|
||||
QString ConversationCoordinator::summaryHandoverTooltip() const
|
||||
{
|
||||
if (!m_sessionRecoverable)
|
||||
return {};
|
||||
|
||||
if (m_ports.ops->transcriptEmpty())
|
||||
return tr("There is nothing to summarise yet.");
|
||||
|
||||
const QString issue = m_ports.compression->compressionConfigurationIssue();
|
||||
if (!issue.isEmpty()) {
|
||||
return tr("A summary cannot be produced because %1. Start a new session without one, or "
|
||||
"assign the chat feature in the settings.")
|
||||
.arg(issue);
|
||||
}
|
||||
|
||||
return tr("Summarise this transcript and give it to the new session as context.");
|
||||
}
|
||||
|
||||
void ConversationCoordinator::startFreshSession()
|
||||
{
|
||||
if (!m_sessionRecoverable)
|
||||
return;
|
||||
|
||||
m_ports.ops->startFreshAgentSession();
|
||||
setSessionIssue(QString(), false);
|
||||
}
|
||||
|
||||
void ConversationCoordinator::handOverSummary()
|
||||
{
|
||||
if (!canHandOverSummary())
|
||||
return;
|
||||
|
||||
if (m_ports.compression->isCompressionRunning()) {
|
||||
emit errorSurfaced(tr("A summary is already being prepared"));
|
||||
return;
|
||||
}
|
||||
|
||||
m_ports.compression->startTranscriptSummary();
|
||||
}
|
||||
|
||||
void ConversationCoordinator::shrinkContext()
|
||||
{
|
||||
if (refuseWhileReadOnly())
|
||||
return;
|
||||
|
||||
if (!canShrinkContext()) {
|
||||
emit errorSurfaced(shrinkContextTooltip());
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_ports.ops->boundAgentId().isEmpty()) {
|
||||
m_ports.compression->startCompression();
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_ports.compression->isCompressionRunning()) {
|
||||
emit errorSurfaced(tr("A summary is already being prepared"));
|
||||
return;
|
||||
}
|
||||
|
||||
m_ports.compression->startTranscriptSummary();
|
||||
}
|
||||
|
||||
bool ConversationCoordinator::canShrinkContext() const
|
||||
{
|
||||
if (readOnly())
|
||||
return false;
|
||||
|
||||
if (!m_ports.compression->compressionConfigurationIssue().isEmpty())
|
||||
return false;
|
||||
|
||||
if (m_ports.ops->boundAgentId().isEmpty())
|
||||
return true;
|
||||
|
||||
return !m_ports.ops->transcriptEmpty();
|
||||
}
|
||||
|
||||
QString ConversationCoordinator::shrinkContextTooltip() const
|
||||
{
|
||||
const QString issue = m_ports.compression->compressionConfigurationIssue();
|
||||
if (!issue.isEmpty()) {
|
||||
return tr("Unavailable because %1. Assign the chat feature in the settings.").arg(issue);
|
||||
}
|
||||
|
||||
if (m_ports.ops->boundAgentId().isEmpty())
|
||||
return tr("Compress chat (create summarized copy using LLM)");
|
||||
|
||||
if (m_ports.ops->transcriptEmpty())
|
||||
return tr("There is nothing to summarise yet.");
|
||||
|
||||
return tr("Summarise this conversation and hand it to a fresh agent session as context.");
|
||||
}
|
||||
|
||||
void ConversationCoordinator::summaryProduced(const QString &summary)
|
||||
{
|
||||
m_ports.ops->startFreshAgentSession(summary);
|
||||
setSessionIssue(QString(), false);
|
||||
}
|
||||
|
||||
QString ConversationCoordinator::agentTitle() const
|
||||
{
|
||||
return m_agentTitle;
|
||||
}
|
||||
|
||||
void ConversationCoordinator::titleSuggested(const QString &title)
|
||||
{
|
||||
if (m_agentTitle == title)
|
||||
return;
|
||||
|
||||
m_agentTitle = title;
|
||||
emit agentTitleChanged();
|
||||
}
|
||||
|
||||
void ConversationCoordinator::agentSessionUnavailable(const QString &reason)
|
||||
{
|
||||
LOG_MESSAGE(QString("Agent session could not be reopened: %1").arg(reason));
|
||||
setSessionIssue(
|
||||
tr("The previous session with this agent could not be reopened, so the transcript "
|
||||
"is read-only. Start a new session to keep working with this agent — it will "
|
||||
"not have the context above."),
|
||||
true);
|
||||
}
|
||||
|
||||
void ConversationCoordinator::conversationReset()
|
||||
{
|
||||
if (!m_agentTitle.isEmpty()) {
|
||||
m_agentTitle.clear();
|
||||
emit agentTitleChanged();
|
||||
}
|
||||
setSessionIssue(QString(), false);
|
||||
}
|
||||
|
||||
void ConversationCoordinator::setSessionIssue(const QString &issue, bool recoverable)
|
||||
{
|
||||
if (m_sessionIssue == issue && m_sessionRecoverable == recoverable)
|
||||
return;
|
||||
|
||||
m_sessionIssue = issue;
|
||||
m_sessionRecoverable = recoverable;
|
||||
emit sessionIssueChanged();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include "ConversationPorts.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ConversationCoordinator : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
struct Ports
|
||||
{
|
||||
IConversationPort *ops = nullptr;
|
||||
IAgentCatalogPort *catalog = nullptr;
|
||||
ICompressionPort *compression = nullptr;
|
||||
ISendPort *send = nullptr;
|
||||
};
|
||||
|
||||
explicit ConversationCoordinator(const Ports &ports, QObject *parent = nullptr);
|
||||
|
||||
void requestSend(const QString &message, const QStringList &attachments);
|
||||
bool refuseWhileReadOnly();
|
||||
bool hasDeferredSend() const;
|
||||
|
||||
void chooseAgent(const Acp::AgentDefinition &agent);
|
||||
void chooseLlm();
|
||||
void confirmSwitch();
|
||||
void cancelSwitch();
|
||||
bool switchPending() const;
|
||||
|
||||
void restoreAgentBinding(const Acp::AgentBinding &binding);
|
||||
Acp::AgentBinding bindingForSave() const;
|
||||
|
||||
QString sessionIssue() const;
|
||||
bool readOnly() const;
|
||||
bool canStartFreshSession() const;
|
||||
|
||||
bool canHandOverSummary() const;
|
||||
QString summaryHandoverTooltip() const;
|
||||
void startFreshSession();
|
||||
void handOverSummary();
|
||||
|
||||
void shrinkContext();
|
||||
bool canShrinkContext() const;
|
||||
QString shrinkContextTooltip() const;
|
||||
|
||||
QString agentTitle() const;
|
||||
|
||||
void conversationReset();
|
||||
|
||||
public slots:
|
||||
void titleSuggested(const QString &title);
|
||||
void agentSessionUnavailable(const QString &reason);
|
||||
void summaryProduced(const QString &summary);
|
||||
void compressionSettled();
|
||||
|
||||
signals:
|
||||
void sessionIssueChanged();
|
||||
void agentTitleChanged();
|
||||
void boundToAgent(const QodeAssist::Acp::AgentDefinition &agent);
|
||||
void boundToLlm();
|
||||
void switchConfirmationNeeded(const QString &targetName);
|
||||
void switchCancelled();
|
||||
void errorSurfaced(const QString &message);
|
||||
|
||||
private:
|
||||
struct DeferredSend
|
||||
{
|
||||
QString message;
|
||||
QStringList attachments;
|
||||
bool active = false;
|
||||
};
|
||||
|
||||
void setSessionIssue(const QString &issue, bool recoverable);
|
||||
bool deferSendForAutoCompress(const QString &message, const QStringList &attachments);
|
||||
void bindAgentNow(const Acp::AgentDefinition &agent);
|
||||
void bindLlmNow();
|
||||
|
||||
Ports m_ports;
|
||||
std::optional<Acp::AgentDefinition> m_pendingAgent;
|
||||
bool m_pendingLlmSwitch = false;
|
||||
DeferredSend m_deferredSend;
|
||||
QString m_agentTitle;
|
||||
QString m_sessionIssue;
|
||||
bool m_sessionRecoverable = false;
|
||||
Acp::AgentBinding m_quarantinedBinding;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include "acp/AgentBinding.hpp"
|
||||
#include "acp/AgentDefinition.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class IConversationPort
|
||||
{
|
||||
public:
|
||||
virtual ~IConversationPort() = default;
|
||||
|
||||
virtual QString boundAgentId() const = 0;
|
||||
virtual bool conversationStarted() const = 0;
|
||||
virtual bool transcriptEmpty() const = 0;
|
||||
virtual Acp::AgentBinding agentBinding() const = 0;
|
||||
|
||||
virtual void bindAgent(const Acp::AgentDefinition &agent) = 0;
|
||||
virtual void bindLlm() = 0;
|
||||
virtual void clearConversation() = 0;
|
||||
|
||||
virtual void resumeAgentSession(const QString &sessionId) = 0;
|
||||
virtual void startFreshAgentSession() = 0;
|
||||
virtual void startFreshAgentSession(const QString &handoverSummary) = 0;
|
||||
virtual void releaseAgentSession() = 0;
|
||||
};
|
||||
|
||||
class IAgentCatalogPort
|
||||
{
|
||||
public:
|
||||
virtual ~IAgentCatalogPort() = default;
|
||||
|
||||
virtual std::optional<Acp::AgentDefinition> agentById(const QString &agentId) const = 0;
|
||||
};
|
||||
|
||||
class ICompressionPort
|
||||
{
|
||||
public:
|
||||
virtual ~ICompressionPort() = default;
|
||||
|
||||
virtual QString compressionConfigurationIssue() const = 0;
|
||||
virtual bool isCompressionRunning() const = 0;
|
||||
virtual void startTranscriptSummary() = 0;
|
||||
virtual void startCompression() = 0;
|
||||
};
|
||||
|
||||
class ISendPort
|
||||
{
|
||||
public:
|
||||
virtual ~ISendPort() = default;
|
||||
|
||||
virtual bool autoCompressEnabled() const = 0;
|
||||
virtual int autoCompressThreshold() const = 0;
|
||||
virtual int estimatedNextTokens() const = 0;
|
||||
virtual bool prepareChatFileForCompression(
|
||||
const QString &message, const QStringList &attachments)
|
||||
= 0;
|
||||
virtual void dispatch(const QString &message, const QStringList &attachments) = 0;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -11,27 +11,27 @@
|
||||
#include <texteditor/texteditor.h>
|
||||
|
||||
#include "Logger.hpp"
|
||||
#include "context/ChangesManager.h"
|
||||
#include "context/FileEditManager.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
FileEditController::FileEditController(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
auto &changes = Context::ChangesManager::instance();
|
||||
connect(&changes, &Context::ChangesManager::fileEditAdded, this, [this](const QString &) {
|
||||
auto &changes = Context::FileEditManager::instance();
|
||||
connect(&changes, &Context::FileEditManager::fileEditAdded, this, [this](const QString &) {
|
||||
updateStats();
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &) {
|
||||
connect(&changes, &Context::FileEditManager::fileEditApplied, this, [this](const QString &) {
|
||||
updateStats();
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &) {
|
||||
connect(&changes, &Context::FileEditManager::fileEditRejected, this, [this](const QString &) {
|
||||
updateStats();
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditUndone, this, [this](const QString &) {
|
||||
connect(&changes, &Context::FileEditManager::fileEditUndone, this, [this](const QString &) {
|
||||
updateStats();
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &) {
|
||||
connect(&changes, &Context::FileEditManager::fileEditArchived, this, [this](const QString &) {
|
||||
updateStats();
|
||||
});
|
||||
}
|
||||
@@ -76,11 +76,11 @@ int FileEditController::rejectedEdits() const
|
||||
void FileEditController::applyFileEdit(const QString &editId)
|
||||
{
|
||||
LOG_MESSAGE(QString("Applying file edit: %1").arg(editId));
|
||||
if (Context::ChangesManager::instance().applyFileEdit(editId)) {
|
||||
if (Context::FileEditManager::instance().applyFileEdit(editId)) {
|
||||
emit infoMessage(QString("File edit applied successfully"));
|
||||
updateStats();
|
||||
} else {
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
auto edit = Context::FileEditManager::instance().getFileEdit(editId);
|
||||
emit errorOccurred(
|
||||
edit.statusMessage.isEmpty()
|
||||
? QString("Failed to apply file edit")
|
||||
@@ -91,11 +91,11 @@ void FileEditController::applyFileEdit(const QString &editId)
|
||||
void FileEditController::rejectFileEdit(const QString &editId)
|
||||
{
|
||||
LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId));
|
||||
if (Context::ChangesManager::instance().rejectFileEdit(editId)) {
|
||||
if (Context::FileEditManager::instance().rejectFileEdit(editId)) {
|
||||
emit infoMessage(QString("File edit rejected"));
|
||||
updateStats();
|
||||
} else {
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
auto edit = Context::FileEditManager::instance().getFileEdit(editId);
|
||||
emit errorOccurred(
|
||||
edit.statusMessage.isEmpty()
|
||||
? QString("Failed to reject file edit")
|
||||
@@ -106,11 +106,11 @@ void FileEditController::rejectFileEdit(const QString &editId)
|
||||
void FileEditController::undoFileEdit(const QString &editId)
|
||||
{
|
||||
LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId));
|
||||
if (Context::ChangesManager::instance().undoFileEdit(editId)) {
|
||||
if (Context::FileEditManager::instance().undoFileEdit(editId)) {
|
||||
emit infoMessage(QString("File edit undone successfully"));
|
||||
updateStats();
|
||||
} else {
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
auto edit = Context::FileEditManager::instance().getFileEdit(editId);
|
||||
emit errorOccurred(
|
||||
edit.statusMessage.isEmpty()
|
||||
? QString("Failed to undo file edit")
|
||||
@@ -122,7 +122,7 @@ void FileEditController::openFileEditInEditor(const QString &editId)
|
||||
{
|
||||
LOG_MESSAGE(QString("Opening file edit in editor: %1").arg(editId));
|
||||
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
auto edit = Context::FileEditManager::instance().getFileEdit(editId);
|
||||
if (edit.editId.isEmpty()) {
|
||||
emit errorOccurred(QString("File edit not found: %1").arg(editId));
|
||||
return;
|
||||
@@ -143,7 +143,7 @@ void FileEditController::openFileEditInEditor(const QString &editId)
|
||||
QString currentContent = doc->toPlainText();
|
||||
int position = -1;
|
||||
|
||||
if (edit.status == Context::ChangesManager::Applied && !edit.newContent.isEmpty()) {
|
||||
if (edit.status == Context::FileEditManager::Applied && !edit.newContent.isEmpty()) {
|
||||
position = currentContent.indexOf(edit.newContent);
|
||||
} else if (!edit.oldContent.isEmpty()) {
|
||||
position = currentContent.indexOf(edit.oldContent);
|
||||
@@ -171,7 +171,7 @@ void FileEditController::applyAllForCurrentMessage()
|
||||
LOG_MESSAGE(QString("Applying all file edits for message: %1").arg(m_currentRequestId));
|
||||
|
||||
QString errorMsg;
|
||||
bool success = Context::ChangesManager::instance()
|
||||
bool success = Context::FileEditManager::instance()
|
||||
.reapplyAllEditsForRequest(m_currentRequestId, &errorMsg);
|
||||
|
||||
if (success) {
|
||||
@@ -196,7 +196,7 @@ void FileEditController::undoAllForCurrentMessage()
|
||||
LOG_MESSAGE(QString("Undoing all file edits for message: %1").arg(m_currentRequestId));
|
||||
|
||||
QString errorMsg;
|
||||
bool success = Context::ChangesManager::instance()
|
||||
bool success = Context::FileEditManager::instance()
|
||||
.undoAllEditsForRequest(m_currentRequestId, &errorMsg);
|
||||
|
||||
if (success) {
|
||||
@@ -225,7 +225,7 @@ void FileEditController::updateStats()
|
||||
return;
|
||||
}
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
|
||||
auto edits = Context::FileEditManager::instance().getEditsForRequest(m_currentRequestId);
|
||||
|
||||
int total = edits.size();
|
||||
int applied = 0;
|
||||
@@ -234,16 +234,16 @@ void FileEditController::updateStats()
|
||||
|
||||
for (const auto &edit : edits) {
|
||||
switch (edit.status) {
|
||||
case Context::ChangesManager::Applied:
|
||||
case Context::FileEditManager::Applied:
|
||||
applied++;
|
||||
break;
|
||||
case Context::ChangesManager::Pending:
|
||||
case Context::FileEditManager::Pending:
|
||||
pending++;
|
||||
break;
|
||||
case Context::ChangesManager::Rejected:
|
||||
case Context::FileEditManager::Rejected:
|
||||
rejected++;
|
||||
break;
|
||||
case Context::ChangesManager::Archived:
|
||||
case Context::FileEditManager::Archived:
|
||||
total--;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
#include "FileMentionItem.hpp"
|
||||
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
@@ -11,6 +13,7 @@
|
||||
|
||||
#include <coreplugin/editormanager/documentmodel.h>
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
|
||||
@@ -113,8 +116,7 @@ void FileMentionItem::dismiss()
|
||||
emit dismissed();
|
||||
}
|
||||
|
||||
QVariantMap FileMentionItem::applyCurrentSelection(
|
||||
const QString &text, int cursorPosition, bool useTools)
|
||||
QVariantMap FileMentionItem::applyCurrentSelection(const QString &text, int cursorPosition)
|
||||
{
|
||||
if (m_currentIndex < 0 || m_currentIndex >= m_searchResults.size()) {
|
||||
dismiss();
|
||||
@@ -139,8 +141,7 @@ QVariantMap FileMentionItem::applyCurrentSelection(
|
||||
item.value("absolutePath").toString(),
|
||||
item.value("relativePath").toString(),
|
||||
item.value("projectName").toString(),
|
||||
currentQuery,
|
||||
useTools);
|
||||
currentQuery);
|
||||
|
||||
if (result.value("mode").toString() == "mention")
|
||||
replacement = result.value("mentionText").toString();
|
||||
@@ -158,8 +159,7 @@ QVariantMap FileMentionItem::handleFileSelection(
|
||||
const QString &absolutePath,
|
||||
const QString &relativePath,
|
||||
const QString &projectName,
|
||||
const QString ¤tQuery,
|
||||
bool useTools)
|
||||
const QString ¤tQuery)
|
||||
{
|
||||
QVariantMap result;
|
||||
const QString fileName = relativePath.section('/', -1);
|
||||
@@ -172,7 +172,7 @@ QVariantMap FileMentionItem::handleFileSelection(
|
||||
mentionKey = projPrefix + ":" + fileName;
|
||||
}
|
||||
|
||||
if (useTools) {
|
||||
if (Settings::chatAssistantSettings().enableChatTools()) {
|
||||
registerMention(mentionKey, absolutePath);
|
||||
result["mode"] = QStringLiteral("mention");
|
||||
result["mentionText"] = "@" + mentionKey + " ";
|
||||
|
||||
@@ -36,11 +36,9 @@ public:
|
||||
const QString &absolutePath,
|
||||
const QString &relativePath,
|
||||
const QString &projectName,
|
||||
const QString ¤tQuery,
|
||||
bool useTools);
|
||||
const QString ¤tQuery);
|
||||
|
||||
Q_INVOKABLE QVariantMap applyCurrentSelection(
|
||||
const QString &text, int cursorPosition, bool useTools);
|
||||
Q_INVOKABLE QVariantMap applyCurrentSelection(const QString &text, int cursorPosition);
|
||||
|
||||
Q_INVOKABLE void registerMention(const QString &mentionKey, const QString &absolutePath);
|
||||
Q_INVOKABLE void clearMentions();
|
||||
|
||||
@@ -79,12 +79,6 @@ void InputTokenCounter::setAttachments(const QStringList &attachments)
|
||||
recompute();
|
||||
}
|
||||
|
||||
void InputTokenCounter::setLinkedFiles(const QStringList &linkedFiles)
|
||||
{
|
||||
m_linkedFiles = linkedFiles;
|
||||
recompute();
|
||||
}
|
||||
|
||||
void InputTokenCounter::rewireToolsChangedConnection()
|
||||
{
|
||||
if (m_toolsChangedConn)
|
||||
@@ -160,14 +154,11 @@ void InputTokenCounter::recompute()
|
||||
inputTokens += estimateFileTokens(textPaths);
|
||||
}
|
||||
|
||||
if (!m_linkedFiles.isEmpty()) {
|
||||
QStringList textPaths;
|
||||
inputTokens += splitImageEstimate(m_linkedFiles, textPaths);
|
||||
inputTokens += estimateFileTokens(textPaths);
|
||||
}
|
||||
|
||||
if (m_session) {
|
||||
for (const Session::MessageRow &row : m_session->rows()) {
|
||||
if (Session::rowTreatmentFor(Session::RowAudience::TokenCount, row.kind)
|
||||
== Session::RowTreatment::Omit)
|
||||
continue;
|
||||
inputTokens += Context::TokenUtils::estimateTokens(row.content);
|
||||
inputTokens += 4; // + role
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ public:
|
||||
|
||||
void setMessage(const QString &message);
|
||||
void setAttachments(const QStringList &attachments);
|
||||
void setLinkedFiles(const QStringList &linkedFiles);
|
||||
void recompute();
|
||||
void recomputeSoon();
|
||||
|
||||
@@ -62,7 +61,6 @@ private:
|
||||
QHash<QString, CachedFileTokens> m_fileTokens;
|
||||
|
||||
QStringList m_attachments;
|
||||
QStringList m_linkedFiles;
|
||||
int m_messageTokens{0};
|
||||
int m_inputTokens{0};
|
||||
int m_lastSentEstimate{0};
|
||||
|
||||
@@ -6,14 +6,18 @@
|
||||
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <QPromise>
|
||||
|
||||
#include "ChatSerializer.hpp"
|
||||
#include <memory>
|
||||
|
||||
#include "ChatFileStore.hpp"
|
||||
#include "llmcore/ContextData.hpp"
|
||||
#include "logger/Logger.hpp"
|
||||
#include "providers/ProvidersManager.hpp"
|
||||
#include "session/FencedText.hpp"
|
||||
#include "session/FileEditPayload.hpp"
|
||||
#include "session/HistoryProjection.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ToolsSettings.hpp"
|
||||
#include "tools/ReadOriginalHistoryTool.hpp"
|
||||
@@ -24,8 +28,16 @@ namespace QodeAssist::Chat {
|
||||
LlmChatBackend::LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent)
|
||||
: Session::ChatBackend(parent)
|
||||
, m_promptProvider(promptProvider)
|
||||
, m_providerResolver([](const QString &name) {
|
||||
return Providers::ProvidersManager::instance().getProviderByName(name);
|
||||
})
|
||||
{}
|
||||
|
||||
void LlmChatBackend::setProviderResolver(ProviderResolver resolver)
|
||||
{
|
||||
m_providerResolver = std::move(resolver);
|
||||
}
|
||||
|
||||
LlmChatBackend::~LlmChatBackend()
|
||||
{
|
||||
cancel();
|
||||
@@ -42,7 +54,7 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
|
||||
}
|
||||
|
||||
const auto providerName = Settings::generalSettings().caProvider();
|
||||
auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
|
||||
auto *provider = m_providerResolver(providerName);
|
||||
|
||||
if (!provider) {
|
||||
const QString error = tr("No provider found with name: %1").arg(providerName);
|
||||
@@ -62,7 +74,7 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
|
||||
}
|
||||
|
||||
LLMCore::ContextData context;
|
||||
if (request.context)
|
||||
if (request.context && Settings::chatAssistantSettings().useSystemPrompt())
|
||||
context.systemPrompt = Session::renderSystemPrompt(*request.context);
|
||||
context.history = renderHistory(*request.history, provider, promptTemplate);
|
||||
|
||||
@@ -73,8 +85,8 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
|
||||
promptTemplate,
|
||||
context,
|
||||
LLMCore::RequestType::Chat,
|
||||
request.options.useTools,
|
||||
request.options.useThinking);
|
||||
Settings::chatAssistantSettings().enableChatTools(),
|
||||
Settings::chatAssistantSettings().enableThinkingMode());
|
||||
|
||||
provider->client()->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations());
|
||||
provider->client()->setTransferTimeout(
|
||||
@@ -88,10 +100,10 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
|
||||
|
||||
m_provider = provider;
|
||||
m_dropPreToolText = !promptTemplate->supportsToolHistory();
|
||||
m_requestId
|
||||
= provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint);
|
||||
const QString requestId = m_ledger.beginTurn(
|
||||
provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint));
|
||||
|
||||
emit sessionEvent(Session::TurnStarted{.turnId = m_requestId});
|
||||
emit sessionEvent(Session::TurnStarted{.turnId = requestId});
|
||||
|
||||
bindToolSessions(provider);
|
||||
}
|
||||
@@ -102,7 +114,7 @@ void LlmChatBackend::cancel()
|
||||
return;
|
||||
|
||||
auto *provider = m_provider;
|
||||
const QString requestId = m_requestId;
|
||||
const QString requestId = m_ledger.activeTurnId();
|
||||
|
||||
releaseRequest();
|
||||
|
||||
@@ -114,14 +126,23 @@ void LlmChatBackend::cancel()
|
||||
|
||||
void LlmChatBackend::releaseRequest()
|
||||
{
|
||||
if (m_provider)
|
||||
cancelPendingPermissions();
|
||||
|
||||
if (m_provider) {
|
||||
disconnect(m_provider->client(), nullptr, this, nullptr);
|
||||
if (m_provider->toolsManager())
|
||||
m_provider->toolsManager()->setExecutionGate({});
|
||||
}
|
||||
|
||||
m_provider = nullptr;
|
||||
m_requestId.clear();
|
||||
m_dropPreToolText = false;
|
||||
}
|
||||
|
||||
Session::TurnContextNeeds LlmChatBackend::contextNeeds() const
|
||||
{
|
||||
return {Settings::chatAssistantSettings().useSystemPrompt()};
|
||||
}
|
||||
|
||||
void LlmChatBackend::setChatFilePath(const QString &filePath)
|
||||
{
|
||||
m_chatFilePath = filePath;
|
||||
@@ -133,7 +154,7 @@ void LlmChatBackend::clearToolSession(const QString &filePath)
|
||||
return;
|
||||
|
||||
const auto providerName = Settings::generalSettings().caProvider();
|
||||
auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
|
||||
auto *provider = m_providerResolver(providerName);
|
||||
|
||||
if (!provider || !provider->capabilities().testFlag(Providers::ProviderCapability::Tools)
|
||||
|| !provider->toolsManager()) {
|
||||
@@ -209,6 +230,106 @@ void LlmChatBackend::bindToolSessions(Providers::Provider *provider)
|
||||
provider->toolsManager()->tool("read_original_history"))) {
|
||||
historyTool->setCurrentSessionId(m_chatFilePath);
|
||||
}
|
||||
|
||||
installExecutionGate(provider);
|
||||
}
|
||||
|
||||
void LlmChatBackend::installExecutionGate(Providers::Provider *provider)
|
||||
{
|
||||
provider->toolsManager()->setExecutionGate(
|
||||
[this](
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QJsonObject &input) {
|
||||
return gateToolExecution(requestId, toolId, toolName, input);
|
||||
});
|
||||
}
|
||||
|
||||
QFuture<bool> LlmChatBackend::gateToolExecution(
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QJsonObject &input)
|
||||
{
|
||||
const auto allow = [] {
|
||||
QPromise<bool> promise;
|
||||
promise.start();
|
||||
promise.addResult(true);
|
||||
promise.finish();
|
||||
return promise.future();
|
||||
};
|
||||
|
||||
if (!m_ledger.isActiveTurn(requestId))
|
||||
return allow();
|
||||
|
||||
if (!m_provider || !m_provider->toolsManager())
|
||||
return allow();
|
||||
|
||||
auto *tool = m_provider->toolsManager()->tool(toolName);
|
||||
if (tool && tool->safety() == ::LLMQore::ToolSafety::ReadOnly)
|
||||
return allow();
|
||||
|
||||
auto promise = std::make_shared<QPromise<bool>>();
|
||||
promise->start();
|
||||
|
||||
const QString permissionId = m_ledger.registerPermission(
|
||||
[promise](const QString &optionId) {
|
||||
const bool allowed = optionId == Session::PermissionOptionKind::AllowOnce
|
||||
|| optionId == Session::PermissionOptionKind::AllowAlways;
|
||||
promise->addResult(allowed);
|
||||
promise->finish();
|
||||
},
|
||||
[promise] {
|
||||
promise->addResult(false);
|
||||
promise->finish();
|
||||
});
|
||||
|
||||
QFuture<bool> decision = promise->future();
|
||||
|
||||
const QString title = tool && !tool->displayName().isEmpty()
|
||||
? tr("Run %1").arg(tool->displayName())
|
||||
: tr("Run %1").arg(toolName);
|
||||
|
||||
emit sessionEvent(
|
||||
Session::PermissionRequested{
|
||||
.turnId = requestId,
|
||||
.requestId = permissionId,
|
||||
.toolCallId = toolId,
|
||||
.title = title,
|
||||
.toolKind = toolName,
|
||||
.options
|
||||
= {Session::PermissionOption{"allow_once", tr("Allow"), "allow_once"},
|
||||
Session::PermissionOption{
|
||||
"allow_always", tr("Allow for this conversation"), "allow_always"},
|
||||
Session::PermissionOption{"reject_once", tr("Don't run it"), "reject_once"}}});
|
||||
|
||||
Q_UNUSED(input)
|
||||
return decision;
|
||||
}
|
||||
|
||||
bool LlmChatBackend::respondPermission(const QString &requestId, const QString &optionId)
|
||||
{
|
||||
if (!m_ledger.resolvePermission(requestId, optionId))
|
||||
return false;
|
||||
|
||||
emit sessionEvent(
|
||||
Session::PermissionResolved{
|
||||
.turnId = m_ledger.activeTurnId(), .requestId = requestId, .optionId = optionId});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void LlmChatBackend::cancelPendingPermissions()
|
||||
{
|
||||
const QString turnId = m_ledger.activeTurnId();
|
||||
const QStringList cancelled = m_ledger.endTurn();
|
||||
|
||||
for (const QString &requestId : cancelled) {
|
||||
emit sessionEvent(
|
||||
Session::PermissionResolved{
|
||||
.turnId = turnId, .requestId = requestId, .cancelled = true});
|
||||
}
|
||||
}
|
||||
|
||||
QVector<LLMCore::Message> LlmChatBackend::renderHistory(
|
||||
@@ -222,7 +343,10 @@ QVector<LLMCore::Message> LlmChatBackend::renderHistory(
|
||||
int toolCallMsgIdx = -1;
|
||||
|
||||
for (const Session::MessageRow &row : Session::projectToRows(history)) {
|
||||
if (row.kind == Session::RowKind::Tool) {
|
||||
const Session::RowTreatment treatment
|
||||
= Session::rowTreatmentFor(Session::RowAudience::Prompt, row.kind);
|
||||
|
||||
if (treatment == Session::RowTreatment::ToolExchange) {
|
||||
if (!toolHistory || row.toolName.isEmpty())
|
||||
continue;
|
||||
|
||||
@@ -250,29 +374,29 @@ QVector<LLMCore::Message> LlmChatBackend::renderHistory(
|
||||
|
||||
toolCallMsgIdx = -1;
|
||||
|
||||
if (row.kind == Session::RowKind::FileEdit)
|
||||
if (treatment == Session::RowTreatment::Omit)
|
||||
continue;
|
||||
|
||||
LLMCore::Message apiMessage;
|
||||
apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant";
|
||||
apiMessage.role = treatment == Session::RowTreatment::UserText ? "user" : "assistant";
|
||||
apiMessage.content = row.content;
|
||||
|
||||
if (!row.attachments.isEmpty() && !m_chatFilePath.isEmpty()) {
|
||||
apiMessage.content += "\n\nAttached files:";
|
||||
for (const Session::AttachmentBlock &attachment : row.attachments) {
|
||||
const QString fileContent
|
||||
= ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.storedPath);
|
||||
const QByteArray fileContent = ChatFileStore::loadRawContentFromStorage(
|
||||
m_chatFilePath, attachment.storedPath);
|
||||
if (fileContent.isEmpty())
|
||||
continue;
|
||||
|
||||
const QString decodedContent = QString::fromUtf8(
|
||||
QByteArray::fromBase64(fileContent.toUtf8()));
|
||||
apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```")
|
||||
.arg(attachment.fileName, decodedContent);
|
||||
apiMessage.content
|
||||
+= "\n\n"
|
||||
+ Session::fencedFileBlock(
|
||||
attachment.fileName, QString::fromUtf8(fileContent));
|
||||
}
|
||||
}
|
||||
|
||||
apiMessage.isThinking = row.kind == Session::RowKind::Thinking;
|
||||
apiMessage.isThinking = treatment == Session::RowTreatment::AssistantThinking;
|
||||
apiMessage.isRedacted = row.redacted;
|
||||
apiMessage.signature = row.signature;
|
||||
|
||||
@@ -296,7 +420,7 @@ QVector<LLMCore::ImageAttachment> LlmChatBackend::loadImagesFromStorage(
|
||||
|
||||
for (const Session::ImageBlock &storedImage : storedImages) {
|
||||
const QString base64Data
|
||||
= ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
|
||||
= ChatFileStore::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
|
||||
if (base64Data.isEmpty()) {
|
||||
LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath));
|
||||
continue;
|
||||
@@ -315,7 +439,7 @@ QVector<LLMCore::ImageAttachment> LlmChatBackend::loadImagesFromStorage(
|
||||
|
||||
void LlmChatBackend::handleChunk(const QString &requestId, const QString &chunk)
|
||||
{
|
||||
if (requestId != m_requestId)
|
||||
if (!m_ledger.isActiveTurn(requestId))
|
||||
return;
|
||||
|
||||
emit sessionEvent(Session::TextDelta{.turnId = requestId, .text = chunk});
|
||||
@@ -323,7 +447,7 @@ void LlmChatBackend::handleChunk(const QString &requestId, const QString &chunk)
|
||||
|
||||
void LlmChatBackend::handleCompleted(const QString &requestId, const QString &fullText)
|
||||
{
|
||||
if (requestId != m_requestId)
|
||||
if (!m_ledger.isActiveTurn(requestId))
|
||||
return;
|
||||
|
||||
LOG_MESSAGE(
|
||||
@@ -337,7 +461,7 @@ void LlmChatBackend::handleCompleted(const QString &requestId, const QString &fu
|
||||
void LlmChatBackend::handleFinalized(
|
||||
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
|
||||
{
|
||||
if (requestId != m_requestId || !info.usage)
|
||||
if (!m_ledger.isActiveTurn(requestId) || !info.usage)
|
||||
return;
|
||||
|
||||
const auto &usage = *info.usage;
|
||||
@@ -361,7 +485,7 @@ void LlmChatBackend::handleFinalized(
|
||||
|
||||
void LlmChatBackend::handleFailed(const QString &requestId, const QString &error)
|
||||
{
|
||||
if (requestId != m_requestId)
|
||||
if (!m_ledger.isActiveTurn(requestId))
|
||||
return;
|
||||
|
||||
LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error));
|
||||
@@ -374,7 +498,7 @@ void LlmChatBackend::handleFailed(const QString &requestId, const QString &error
|
||||
void LlmChatBackend::handleThinkingBlock(
|
||||
const QString &requestId, const QString &thinking, const QString &signature)
|
||||
{
|
||||
if (requestId != m_requestId)
|
||||
if (!m_ledger.isActiveTurn(requestId))
|
||||
return;
|
||||
|
||||
emit sessionEvent(
|
||||
@@ -391,14 +515,15 @@ void LlmChatBackend::handleToolStarted(
|
||||
const QString &toolName,
|
||||
const QJsonObject &arguments)
|
||||
{
|
||||
if (requestId != m_requestId)
|
||||
if (!m_ledger.isActiveTurn(requestId))
|
||||
return;
|
||||
|
||||
emit sessionEvent(
|
||||
Session::ToolCallStarted{
|
||||
Session::ToolCallUpdated{
|
||||
.turnId = requestId,
|
||||
.toolId = toolId,
|
||||
.name = toolName,
|
||||
.status = QStringLiteral("in_progress"),
|
||||
.arguments = arguments,
|
||||
.dropPrecedingText = m_dropPreToolText});
|
||||
}
|
||||
@@ -409,12 +534,17 @@ void LlmChatBackend::handleToolResult(
|
||||
const QString &toolName,
|
||||
const QString &toolOutput)
|
||||
{
|
||||
if (requestId != m_requestId)
|
||||
if (!m_ledger.isActiveTurn(requestId))
|
||||
return;
|
||||
|
||||
const bool failed = toolOutput.startsWith(QLatin1String("Error: "));
|
||||
emit sessionEvent(
|
||||
Session::ToolCallCompleted{
|
||||
.turnId = requestId, .toolId = toolId, .name = toolName, .result = toolOutput});
|
||||
Session::ToolCallUpdated{
|
||||
.turnId = requestId,
|
||||
.toolId = toolId,
|
||||
.name = toolName,
|
||||
.status = failed ? QStringLiteral("failed") : QStringLiteral("completed"),
|
||||
.result = toolOutput});
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <functional>
|
||||
|
||||
#include <QFuture>
|
||||
#include <QString>
|
||||
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
|
||||
#include "providers/Provider.hpp"
|
||||
#include "session/ChatBackend.hpp"
|
||||
#include "session/TurnLedger.hpp"
|
||||
#include "templates/IPromptProvider.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
@@ -20,19 +23,32 @@ class LlmChatBackend : public Session::ChatBackend
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using ProviderResolver = std::function<Providers::Provider *(const QString &name)>;
|
||||
|
||||
explicit LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent = nullptr);
|
||||
~LlmChatBackend() override;
|
||||
|
||||
void setProviderResolver(ProviderResolver resolver);
|
||||
|
||||
void sendTurn(const Session::TurnRequest &request) override;
|
||||
void cancel() override;
|
||||
bool respondPermission(const QString &requestId, const QString &optionId) override;
|
||||
Session::TurnContextNeeds contextNeeds() const override;
|
||||
|
||||
void setChatFilePath(const QString &filePath);
|
||||
void clearToolSession(const QString &filePath);
|
||||
void setChatFilePath(const QString &filePath) override;
|
||||
void clearToolSession(const QString &filePath) override;
|
||||
|
||||
private:
|
||||
void connectClient(Providers::Provider *provider);
|
||||
void releaseRequest();
|
||||
void bindToolSessions(Providers::Provider *provider);
|
||||
void installExecutionGate(Providers::Provider *provider);
|
||||
QFuture<bool> gateToolExecution(
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QJsonObject &input);
|
||||
void cancelPendingPermissions();
|
||||
QVector<LLMCore::Message> renderHistory(
|
||||
const Session::ConversationHistory &history,
|
||||
Providers::Provider *provider,
|
||||
@@ -59,10 +75,11 @@ private:
|
||||
const QString &toolOutput);
|
||||
|
||||
Templates::IPromptProvider *m_promptProvider = nullptr;
|
||||
ProviderResolver m_providerResolver;
|
||||
QString m_chatFilePath;
|
||||
|
||||
Providers::Provider *m_provider = nullptr;
|
||||
QString m_requestId;
|
||||
Session::TurnLedger m_ledger;
|
||||
bool m_dropPreToolText = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,18 +4,31 @@
|
||||
|
||||
#include "TurnContextAdapters.hpp"
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <projectexplorer/buildconfiguration.h>
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
#include <projectexplorer/target.h>
|
||||
|
||||
#include "ProjectSettings.hpp"
|
||||
#include "SkillsSettings.hpp"
|
||||
#include "context/ContextManager.hpp"
|
||||
#include "context/RulesLoader.hpp"
|
||||
#include "skills/SkillsManager.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ProjectExplorer::Project *activeProject()
|
||||
{
|
||||
auto currentEditor = Core::EditorManager::currentEditor();
|
||||
if (currentEditor && currentEditor->document()) {
|
||||
auto project = ProjectExplorer::ProjectManager::projectForFile(
|
||||
currentEditor->document()->filePath());
|
||||
if (project)
|
||||
return project;
|
||||
}
|
||||
|
||||
return ProjectExplorer::ProjectManager::startupProject();
|
||||
}
|
||||
|
||||
ProjectContextQtCreator::ProjectContextQtCreator(ProjectExplorer::Project *project)
|
||||
: m_project(project)
|
||||
{}
|
||||
@@ -38,14 +51,6 @@ Session::ProjectInfo ProjectContextQtCreator::projectInfo() const
|
||||
return info;
|
||||
}
|
||||
|
||||
QString ProjectContextQtCreator::projectRules() const
|
||||
{
|
||||
if (!m_project)
|
||||
return {};
|
||||
|
||||
return Context::RulesLoader::loadRulesForProject(m_project, Context::RulesContext::Chat);
|
||||
}
|
||||
|
||||
SkillsContextQtCreator::SkillsContextQtCreator(
|
||||
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project)
|
||||
: m_skillsManager(skillsManager)
|
||||
@@ -81,19 +86,6 @@ std::optional<Session::InvokedSkill> SkillsContextQtCreator::findSkill(const QSt
|
||||
return Session::InvokedSkill{skill->name, skill->body};
|
||||
}
|
||||
|
||||
LinkedFilesQtCreator::LinkedFilesQtCreator(Context::ContextManager *contextManager)
|
||||
: m_contextManager(contextManager)
|
||||
{}
|
||||
|
||||
QList<Session::LinkedFile> LinkedFilesQtCreator::readFiles(const QList<QString> &paths) const
|
||||
{
|
||||
QList<Session::LinkedFile> files;
|
||||
for (const auto &file : m_contextManager->getContentFiles(paths))
|
||||
files.append(Session::LinkedFile{file.filename, file.content});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
std::unique_ptr<SkillsContextQtCreator> makeSkillsContext(
|
||||
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project)
|
||||
{
|
||||
|
||||
@@ -12,23 +12,20 @@ namespace ProjectExplorer {
|
||||
class Project;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Context {
|
||||
class ContextManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ProjectExplorer::Project *activeProject();
|
||||
|
||||
class ProjectContextQtCreator : public Session::IProjectContextPort
|
||||
{
|
||||
public:
|
||||
explicit ProjectContextQtCreator(ProjectExplorer::Project *project);
|
||||
|
||||
Session::ProjectInfo projectInfo() const override;
|
||||
QString projectRules() const override;
|
||||
|
||||
private:
|
||||
ProjectExplorer::Project *m_project = nullptr;
|
||||
@@ -47,17 +44,6 @@ private:
|
||||
Skills::SkillsManager *m_skillsManager = nullptr;
|
||||
};
|
||||
|
||||
class LinkedFilesQtCreator : public Session::ILinkedFilesPort
|
||||
{
|
||||
public:
|
||||
explicit LinkedFilesQtCreator(Context::ContextManager *contextManager);
|
||||
|
||||
QList<Session::LinkedFile> readFiles(const QList<QString> &paths) const override;
|
||||
|
||||
private:
|
||||
Context::ContextManager *m_contextManager = nullptr;
|
||||
};
|
||||
|
||||
std::unique_ptr<SkillsContextQtCreator> makeSkillsContext(
|
||||
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project);
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<svg width="20" height="44" viewBox="0 0 20 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_49_24)">
|
||||
<path d="M10 12L10 32L10 12Z" fill="black"/>
|
||||
<path d="M10 12L10 32" stroke="black" stroke-width="3"/>
|
||||
<path d="M1.50001 12.484C1.50001 -1.99999 18.5 -1.99999 18.5 12.484M1.5 31.5334C1.50001 46 18.5 46 18.5 31.5334" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_49_24">
|
||||
<rect width="20" height="44" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 513 B |
@@ -1,12 +0,0 @@
|
||||
<svg width="20" height="44" viewBox="0 0 20 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_51_24)">
|
||||
<path d="M10 12L10 32Z" fill="white"/>
|
||||
<path d="M10 12L10 32" stroke="white" stroke-width="3"/>
|
||||
<path d="M1.50001 12.484C1.50001 -1.99999 18.5 -1.99999 18.5 12.484M1.5 31.5334C1.50001 46 18.5 46 18.5 31.5334" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_51_24">
|
||||
<rect width="20" height="44" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 507 B |
@@ -47,23 +47,16 @@ ChatRootView {
|
||||
color: palette.window
|
||||
}
|
||||
|
||||
SplitDropZone {
|
||||
DropZone {
|
||||
anchors.fill: parent
|
||||
z: 99
|
||||
|
||||
onFilesDroppedToAttach: (urlStrings) => {
|
||||
onFilesDropped: (urlStrings) => {
|
||||
var localPaths = root.convertUrlsToLocalPaths(urlStrings)
|
||||
if (localPaths.length > 0) {
|
||||
root.addFilesToAttachList(localPaths)
|
||||
}
|
||||
}
|
||||
|
||||
onFilesDroppedToLink: (urlStrings) => {
|
||||
var localPaths = root.convertUrlsToLocalPaths(urlStrings)
|
||||
if (localPaths.length > 0) {
|
||||
root.addFilesToLinkList(localPaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QoABusyOverlay {
|
||||
@@ -76,7 +69,7 @@ ChatRootView {
|
||||
anchors.bottomMargin: bottomBar.height
|
||||
|
||||
active: root.isCompressing
|
||||
text: qsTr("Compressing chat…")
|
||||
text: root.isAgentBound ? qsTr("Preparing the handover summary…") : qsTr("Compressing chat…")
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
@@ -138,19 +131,6 @@ ChatRootView {
|
||||
relocateTooltip.text: (typeof _chatview !== 'undefined')
|
||||
? qsTr("Move this chat to an editor tab")
|
||||
: qsTr("Move this chat to a separate window")
|
||||
toolsButton {
|
||||
checked: root.useTools
|
||||
onCheckedChanged: {
|
||||
root.useTools = toolsButton.checked
|
||||
}
|
||||
}
|
||||
thinkingMode {
|
||||
checked: root.useThinking
|
||||
enabled: root.isThinkingSupport
|
||||
onCheckedChanged: {
|
||||
root.useThinking = thinkingMode.checked
|
||||
}
|
||||
}
|
||||
settingsButton.onClicked: root.openSettings()
|
||||
configSelector {
|
||||
model: root.availableConfigurations
|
||||
@@ -165,18 +145,6 @@ ChatRootView {
|
||||
root.loadAvailableConfigurations()
|
||||
}
|
||||
}
|
||||
|
||||
roleSelector {
|
||||
model: root.availableAgentRoles
|
||||
displayText: root.currentAgentRole
|
||||
onActivated: function(index) {
|
||||
root.applyAgentRole(root.availableAgentRoles[index])
|
||||
}
|
||||
|
||||
popup.onAboutToShow: {
|
||||
root.loadAvailableAgentRoles()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
@@ -248,6 +216,10 @@ ChatRootView {
|
||||
return fileEditMessageComponent
|
||||
} else if (model.roleType === ChatModel.Thinking) {
|
||||
return thinkingMessageComponent
|
||||
} else if (model.roleType === ChatModel.Permission) {
|
||||
return permissionMessageComponent
|
||||
} else if (model.roleType === ChatModel.Plan) {
|
||||
return planMessageComponent
|
||||
} else {
|
||||
return chatItemComponent
|
||||
}
|
||||
@@ -357,7 +329,20 @@ ChatRootView {
|
||||
|
||||
ToolBlock {
|
||||
width: parent.width
|
||||
toolContent: model.content
|
||||
toolName: model.toolName || ""
|
||||
toolResult: model.toolResult || ""
|
||||
toolKind: model.toolKind || ""
|
||||
toolStatus: model.toolStatus || ""
|
||||
toolDetails: model.toolDetails || ({})
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: planMessageComponent
|
||||
|
||||
PlanBlock {
|
||||
width: parent.width
|
||||
planContent: model.content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +371,19 @@ ChatRootView {
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: permissionMessageComponent
|
||||
|
||||
PermissionBlock {
|
||||
width: parent.width
|
||||
permissionContent: model.content
|
||||
|
||||
onRespond: function(requestId, optionId) {
|
||||
root.respondToPermission(requestId, optionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: thinkingMessageComponent
|
||||
|
||||
@@ -405,12 +403,76 @@ ChatRootView {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: agentSessionBanner
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 5
|
||||
visible: root.agentSessionIssue.length > 0
|
||||
implicitHeight: bannerLayout.implicitHeight + 16
|
||||
radius: 4
|
||||
color: palette.base
|
||||
border.width: 1
|
||||
border.color: palette.mid
|
||||
|
||||
ColumnLayout {
|
||||
id: bannerLayout
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 8
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: root.agentSessionIssue
|
||||
textFormat: Text.PlainText
|
||||
color: palette.text
|
||||
font.pixelSize: 12
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: root.canStartNewAgentSession
|
||||
spacing: 8
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Continue with a new session")
|
||||
onClicked: root.startNewAgentSession()
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: handoverButton
|
||||
|
||||
text: qsTr("Continue with a summary")
|
||||
enabled: root.canHandOverSummary
|
||||
|
||||
onClicked: root.startNewAgentSessionWithSummary()
|
||||
|
||||
QoAToolTip {
|
||||
visible: handoverButton.hovered
|
||||
&& root.summaryHandoverTooltip.length > 0
|
||||
text: root.summaryHandoverTooltip
|
||||
delay: 300
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
id: view
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 30
|
||||
Layout.maximumHeight: root.height / 2
|
||||
enabled: root.agentSessionIssue.length === 0
|
||||
|
||||
QQC.TextArea {
|
||||
id: messageInput
|
||||
@@ -452,20 +514,7 @@ ChatRootView {
|
||||
}
|
||||
}
|
||||
fileMentionPopup.dismiss()
|
||||
|
||||
const slashIndex = textBefore.lastIndexOf('/')
|
||||
if (slashIndex >= 0) {
|
||||
const beforeSlash = slashIndex === 0
|
||||
? ' '
|
||||
: textBefore.charAt(slashIndex - 1)
|
||||
const skillQuery = textBefore.substring(slashIndex + 1)
|
||||
if ((beforeSlash === ' ' || beforeSlash === '\n')
|
||||
&& /^[a-z0-9-]*$/.test(skillQuery)) {
|
||||
skillCommandPopup.updateSearch(skillQuery)
|
||||
return
|
||||
}
|
||||
}
|
||||
skillCommandPopup.dismiss()
|
||||
root.refreshSlashPopup()
|
||||
}
|
||||
|
||||
Keys.onPressed: function(event) {
|
||||
@@ -561,17 +610,6 @@ ChatRootView {
|
||||
onRemoveFileFromListByIndex: (index) => root.removeFileFromAttachList(index)
|
||||
}
|
||||
|
||||
AttachedFilesPlace {
|
||||
id: linkedFilesPlace
|
||||
|
||||
Layout.fillWidth: true
|
||||
attachedFilesModel: root.linkedFiles
|
||||
iconPath: palette.window.hslLightness > 0.5 ? "qrc:/qt/qml/ChatView/icons/link-file-dark.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/link-file-light.svg"
|
||||
accentColor: Qt.tint(palette.mid, Qt.rgba(0, 0.3, 0.8, 0.4))
|
||||
onRemoveFileFromListByIndex: (index) => root.removeFileFromLinkList(index)
|
||||
}
|
||||
|
||||
FileEditsActionBar {
|
||||
id: fileEditsActionBar
|
||||
|
||||
@@ -593,6 +631,7 @@ ChatRootView {
|
||||
|
||||
isCompressing: root.isCompressing
|
||||
isProcessing: root.isRequestInProgress
|
||||
agentMode: root.isAgentBound
|
||||
sendButton.onClicked: !root.isRequestInProgress ? root.sendChatMessage()
|
||||
: root.cancelRequest()
|
||||
sendButton.icon.source: root.isRequestInProgress
|
||||
@@ -608,14 +647,12 @@ ChatRootView {
|
||||
? root.lastErrorMessage
|
||||
: qsTr("Send message to LLM %1").arg(root.sendShortcutText))
|
||||
compressButton.onClicked: compressConfirmDialog.open()
|
||||
compressButton.enabled: root.canShrinkContext
|
||||
compressButton.text: root.isAgentBound ? qsTr("Hand over") : qsTr("Compress")
|
||||
compressTooltip.text: root.shrinkContextTooltip
|
||||
cancelCompressButton.onClicked: root.cancelCompression()
|
||||
syncOpenFiles {
|
||||
checked: root.isSyncOpenFiles
|
||||
onCheckedChanged: root.setIsSyncOpenFiles(bottomBar.syncOpenFiles.checked)
|
||||
}
|
||||
attachFiles.onClicked: root.showAttachFilesDialog()
|
||||
attachImages.onClicked: root.showAddImageDialog()
|
||||
linkFiles.onClicked: root.showLinkFilesDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,13 +693,36 @@ ChatRootView {
|
||||
|
||||
function applyMentionSelection() {
|
||||
var result = fileMentionPopup.applyCurrentSelection(
|
||||
messageInput.text, messageInput.cursorPosition, root.useTools)
|
||||
messageInput.text, messageInput.cursorPosition)
|
||||
if (result.text !== undefined) {
|
||||
messageInput.text = result.text
|
||||
messageInput.cursorPosition = result.cursorPosition
|
||||
}
|
||||
}
|
||||
|
||||
onSlashCommandsChanged: {
|
||||
if (skillCommandPopup.visible)
|
||||
refreshSlashPopup()
|
||||
}
|
||||
|
||||
function refreshSlashPopup() {
|
||||
const cursorPos = messageInput.cursorPosition
|
||||
const textBefore = messageInput.text.substring(0, cursorPos)
|
||||
const slashIndex = textBefore.lastIndexOf('/')
|
||||
if (slashIndex >= 0) {
|
||||
const beforeSlash = slashIndex === 0
|
||||
? ' '
|
||||
: textBefore.charAt(slashIndex - 1)
|
||||
const skillQuery = textBefore.substring(slashIndex + 1)
|
||||
if ((beforeSlash === ' ' || beforeSlash === '\n')
|
||||
&& /^\S*$/.test(skillQuery)) {
|
||||
skillCommandPopup.updateSearch(skillQuery)
|
||||
return
|
||||
}
|
||||
}
|
||||
skillCommandPopup.dismiss()
|
||||
}
|
||||
|
||||
function applySkillSelection() {
|
||||
const name = skillCommandPopup.currentName()
|
||||
if (name === "")
|
||||
@@ -688,16 +748,42 @@ ChatRootView {
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
onChatTargetSwitchNeedsNewChat: function(targetName) {
|
||||
chatTargetSwitchDialog.targetName = targetName
|
||||
chatTargetSwitchDialog.open()
|
||||
}
|
||||
|
||||
Dialog {
|
||||
id: compressConfirmDialog
|
||||
id: chatTargetSwitchDialog
|
||||
|
||||
property string targetName: ""
|
||||
|
||||
anchors.centerIn: parent
|
||||
title: qsTr("Compress Chat")
|
||||
title: qsTr("Start a New Conversation")
|
||||
modal: true
|
||||
standardButtons: Dialog.Yes | Dialog.No
|
||||
|
||||
Label {
|
||||
text: qsTr("Create a summarized copy of this chat?\n\nThe summary will be generated by LLM and saved as a new chat file.")
|
||||
text: qsTr("A conversation stays with the kind it started with, so switching to %1 needs a new one.\n\nClear this chat and switch?").arg(chatTargetSwitchDialog.targetName)
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
onAccepted: root.confirmChatTargetSwitch()
|
||||
onRejected: root.cancelChatTargetSwitch()
|
||||
}
|
||||
|
||||
Dialog {
|
||||
id: compressConfirmDialog
|
||||
|
||||
anchors.centerIn: parent
|
||||
title: root.isAgentBound ? qsTr("Hand Over Session") : qsTr("Compress Chat")
|
||||
modal: true
|
||||
standardButtons: Dialog.Yes | Dialog.No
|
||||
|
||||
Label {
|
||||
text: root.isAgentBound
|
||||
? qsTr("Summarise this conversation and continue in a fresh agent session?\n\nThe summary will be generated by the LLM chat configuration and given to the new session as context.")
|
||||
: qsTr("Create a summarized copy of this chat?\n\nThe summary will be generated by LLM and saved as a new chat file.")
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
@@ -840,19 +926,8 @@ ChatRootView {
|
||||
y: (parent.height - height) / 2
|
||||
|
||||
baseSystemPrompt: root.baseSystemPrompt
|
||||
currentAgentRole: root.currentAgentRole
|
||||
currentAgentRoleDescription: root.currentAgentRoleDescription
|
||||
currentAgentRoleSystemPrompt: root.currentAgentRoleSystemPrompt
|
||||
activeRules: root.activeRules
|
||||
activeRulesCount: root.activeRulesCount
|
||||
|
||||
onOpenSettings: root.openSettings()
|
||||
onOpenAgentRolesSettings: root.openAgentRolesSettings()
|
||||
onOpenRulesFolder: root.openRulesFolder()
|
||||
onRefreshRules: root.refreshRules()
|
||||
onRuleSelected: function(index) {
|
||||
contextViewer.selectedRuleContent = root.getRuleContent(index)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
.pragma library
|
||||
|
||||
function parseMarkerPayload(marker, content) {
|
||||
if (!content || !content.startsWith(marker))
|
||||
return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content.substring(marker.length));
|
||||
return (parsed && typeof parsed === "object") ? parsed : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFileEdit(content) {
|
||||
return parseMarkerPayload("QODEASSIST_FILE_EDIT:", content);
|
||||
}
|
||||
|
||||
function parsePermission(content) {
|
||||
return parseMarkerPayload("QODEASSIST_PERMISSION:", content);
|
||||
}
|
||||
|
||||
function parsePlan(content) {
|
||||
return parseMarkerPayload("QODEASSIST_PLAN:", content);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import QtQuick.Layouts
|
||||
import UIControls
|
||||
import ChatView
|
||||
import Qt.labs.platform as Platform
|
||||
import "BlockPayload.js" as BlockPayload
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
@@ -39,13 +40,11 @@ Rectangle {
|
||||
readonly property bool isArchived: editStatus === "archived"
|
||||
|
||||
readonly property color appliedColor: Qt.rgba(0.2, 0.8, 0.2, 0.8)
|
||||
readonly property color revertedColor: Qt.rgba(0.8, 0.6, 0.2, 0.8)
|
||||
readonly property color rejectedColor: Qt.rgba(0.8, 0.2, 0.2, 0.8)
|
||||
readonly property color archivedColor: Qt.rgba(0.5, 0.5, 0.5, 0.8)
|
||||
readonly property color pendingColor: palette.highlight
|
||||
|
||||
readonly property color appliedBgColor: Qt.rgba(0.2, 0.8, 0.2, 0.3)
|
||||
readonly property color revertedBgColor: Qt.rgba(0.8, 0.6, 0.2, 0.3)
|
||||
readonly property color rejectedBgColor: Qt.rgba(0.8, 0.2, 0.2, 0.3)
|
||||
readonly property color archivedBgColor: Qt.rgba(0.5, 0.5, 0.5, 0.3)
|
||||
|
||||
@@ -84,23 +83,15 @@ Rectangle {
|
||||
readonly property int removedLines: countLines(oldContent)
|
||||
|
||||
function parseEditData(content) {
|
||||
try {
|
||||
const marker = "QODEASSIST_FILE_EDIT:";
|
||||
let jsonStr = content;
|
||||
if (content.indexOf(marker) >= 0) {
|
||||
jsonStr = content.substring(content.indexOf(marker) + marker.length);
|
||||
}
|
||||
return JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
return {
|
||||
edit_id: "",
|
||||
file: "",
|
||||
old_content: "",
|
||||
new_content: "",
|
||||
status: "error",
|
||||
status_message: ""
|
||||
};
|
||||
}
|
||||
const parsed = BlockPayload.parseFileEdit(content);
|
||||
return parsed !== null ? parsed : {
|
||||
edit_id: "",
|
||||
file: "",
|
||||
old_content: "",
|
||||
new_content: "",
|
||||
status: "error",
|
||||
status_message: ""
|
||||
};
|
||||
}
|
||||
|
||||
function getFileName(path) {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import UIControls
|
||||
import "BlockPayload.js" as BlockPayload
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string permissionContent: ""
|
||||
|
||||
readonly property var permissionData: parsePermissionData(permissionContent)
|
||||
readonly property bool isMalformed: permissionData === null
|
||||
readonly property string requestId: isMalformed ? "" : (permissionData.requestId || "")
|
||||
readonly property string title: isMalformed
|
||||
? qsTr("Unreadable permission request")
|
||||
: (permissionData.title || qsTr("The agent asks for permission"))
|
||||
readonly property string toolKind: isMalformed ? "" : (permissionData.toolKind || "")
|
||||
readonly property var options: isMalformed ? [] : (permissionData.options || [])
|
||||
readonly property string status: isMalformed ? "error" : (permissionData.status || "pending")
|
||||
readonly property string selectedOptionId: isMalformed ? "" : (permissionData.selectedOptionId || "")
|
||||
readonly property bool automatic: !isMalformed && permissionData.automatic === true
|
||||
|
||||
readonly property bool isPending: status === "pending" && options.length > 0
|
||||
readonly property bool isCancelled: status === "cancelled"
|
||||
readonly property bool wasDeclinedUnpresentable: isCancelled && options.length === 0
|
||||
|
||||
readonly property var selectedOption: findOption(selectedOptionId)
|
||||
readonly property bool wasAllowed: selectedOption !== null
|
||||
&& selectedOption.allows === true
|
||||
|
||||
readonly property int borderRadius: 6
|
||||
readonly property int contentMargin: 10
|
||||
readonly property int badgeRadius: 3
|
||||
readonly property int badgePaddingH: 12
|
||||
readonly property int badgePaddingV: 6
|
||||
readonly property int titleMaxLines: 4
|
||||
readonly property int rowSpacing: 8
|
||||
|
||||
readonly property color allowedColor: Qt.rgba(0.2, 0.8, 0.2, 0.8)
|
||||
readonly property color deniedColor: Qt.rgba(0.8, 0.2, 0.2, 0.8)
|
||||
readonly property color cancelledColor: Qt.rgba(0.5, 0.5, 0.5, 0.8)
|
||||
|
||||
readonly property color statusColor: {
|
||||
if (isMalformed)
|
||||
return deniedColor;
|
||||
if (isPending)
|
||||
return palette.highlight;
|
||||
if (isCancelled)
|
||||
return cancelledColor;
|
||||
return wasAllowed ? allowedColor : deniedColor;
|
||||
}
|
||||
|
||||
readonly property string statusText: {
|
||||
if (isMalformed)
|
||||
return qsTr("UNREADABLE");
|
||||
if (isPending)
|
||||
return qsTr("WAITING FOR YOU");
|
||||
if (wasDeclinedUnpresentable)
|
||||
return qsTr("DECLINED");
|
||||
if (isCancelled)
|
||||
return qsTr("NO LONGER AVAILABLE");
|
||||
if (automatic)
|
||||
return wasAllowed ? qsTr("ALLOWED AUTOMATICALLY") : qsTr("DENIED AUTOMATICALLY");
|
||||
return wasAllowed ? qsTr("ALLOWED") : qsTr("DENIED");
|
||||
}
|
||||
|
||||
readonly property string explanation: {
|
||||
if (isMalformed)
|
||||
return qsTr("This permission record could not be read, so it cannot be answered.");
|
||||
if (wasDeclinedUnpresentable)
|
||||
return qsTr("The agent did not offer a set of options this chat could present safely, so the request was declined.");
|
||||
if (isCancelled)
|
||||
return qsTr("This request ended before it was answered.");
|
||||
if (isPending)
|
||||
return "";
|
||||
if (selectedOption === null)
|
||||
return "";
|
||||
return automatic
|
||||
? qsTr("Answered automatically with \"%1\", because you chose that for this action type for the rest of the conversation.").arg(selectedOption.name)
|
||||
: qsTr("You answered \"%1\".").arg(selectedOption.name);
|
||||
}
|
||||
|
||||
signal respond(string requestId, string optionId)
|
||||
|
||||
implicitHeight: layout.implicitHeight + 2 * contentMargin
|
||||
radius: borderRadius
|
||||
color: palette.base
|
||||
border.width: 1
|
||||
border.color: statusColor
|
||||
|
||||
function parsePermissionData(content) {
|
||||
return BlockPayload.parsePermission(content);
|
||||
}
|
||||
|
||||
function findOption(optionId) {
|
||||
if (!optionId)
|
||||
return null;
|
||||
for (let i = 0; i < root.options.length; ++i) {
|
||||
if (root.options[i].id === optionId)
|
||||
return root.options[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: root.contentMargin
|
||||
}
|
||||
spacing: root.rowSpacing
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: root.rowSpacing
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: statusLabel.implicitWidth + root.badgePaddingH
|
||||
Layout.preferredHeight: statusLabel.implicitHeight + root.badgePaddingV
|
||||
Layout.alignment: Qt.AlignTop
|
||||
radius: root.badgeRadius
|
||||
color: root.statusColor
|
||||
|
||||
Text {
|
||||
id: statusLabel
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: root.statusText
|
||||
textFormat: Text.PlainText
|
||||
font.pixelSize: 10
|
||||
font.bold: true
|
||||
color: palette.base
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: root.title
|
||||
textFormat: Text.PlainText
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
wrapMode: Text.WordWrap
|
||||
maximumLineCount: root.titleMaxLines
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.toolKind.length > 0
|
||||
text: qsTr("Action type: %1").arg(root.toolKind)
|
||||
textFormat: Text.PlainText
|
||||
font.pixelSize: 11
|
||||
color: palette.placeholderText
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: text.length > 0
|
||||
text: root.explanation
|
||||
textFormat: Text.PlainText
|
||||
font.pixelSize: 11
|
||||
color: palette.placeholderText
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
visible: root.isPending
|
||||
spacing: root.rowSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.options
|
||||
|
||||
delegate: QoAButton {
|
||||
id: optionButton
|
||||
|
||||
required property var modelData
|
||||
|
||||
text: optionButton.modelData.name || optionButton.modelData.id
|
||||
accentColor: optionButton.modelData.allows === true
|
||||
? root.allowedColor
|
||||
: root.deniedColor
|
||||
|
||||
contentItem: Text {
|
||||
text: optionButton.text
|
||||
textFormat: Text.PlainText
|
||||
color: palette.buttonText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
onClicked: root.respond(root.requestId, optionButton.modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import "BlockPayload.js" as BlockPayload
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string planContent: ""
|
||||
property bool expanded: true
|
||||
|
||||
readonly property var planData: parsePlanData(planContent)
|
||||
readonly property var entries: planData === null ? [] : (planData.entries || [])
|
||||
readonly property int completedCount: countCompleted()
|
||||
readonly property bool isComplete: entries.length > 0 && completedCount === entries.length
|
||||
|
||||
readonly property int borderRadius: 6
|
||||
readonly property int contentMargin: 10
|
||||
readonly property int rowSpacing: 6
|
||||
|
||||
readonly property color completedColor: Qt.rgba(0.2, 0.8, 0.2, 0.9)
|
||||
readonly property color activeColor: palette.highlight
|
||||
readonly property color pendingColor: palette.placeholderText
|
||||
|
||||
implicitHeight: layout.implicitHeight + 2 * contentMargin
|
||||
radius: borderRadius
|
||||
color: palette.base
|
||||
border.width: 1
|
||||
border.color: isComplete ? completedColor : palette.mid
|
||||
|
||||
function parsePlanData(content) {
|
||||
return BlockPayload.parsePlan(content);
|
||||
}
|
||||
|
||||
function countCompleted() {
|
||||
let done = 0;
|
||||
for (let i = 0; i < root.entries.length; ++i) {
|
||||
if (root.entries[i].status === "completed")
|
||||
++done;
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
function entryColor(status) {
|
||||
if (status === "completed")
|
||||
return root.completedColor;
|
||||
if (status === "in_progress")
|
||||
return root.activeColor;
|
||||
return root.pendingColor;
|
||||
}
|
||||
|
||||
function entryMarker(status) {
|
||||
if (status === "completed")
|
||||
return "✓";
|
||||
if (status === "in_progress")
|
||||
return "▶";
|
||||
return "○";
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: root.contentMargin
|
||||
}
|
||||
spacing: root.rowSpacing
|
||||
|
||||
MouseArea {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: header.implicitHeight
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.expanded = !root.expanded
|
||||
|
||||
RowLayout {
|
||||
id: header
|
||||
|
||||
anchors.fill: parent
|
||||
spacing: root.rowSpacing
|
||||
|
||||
Text {
|
||||
text: qsTr("Plan")
|
||||
textFormat: Text.PlainText
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("%1 of %2 done").arg(root.completedCount).arg(root.entries.length)
|
||||
textFormat: Text.PlainText
|
||||
font.pixelSize: 11
|
||||
color: palette.placeholderText
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.expanded ? "▼" : "▶"
|
||||
font.pixelSize: 10
|
||||
color: palette.mid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.expanded ? root.entries : []
|
||||
|
||||
delegate: RowLayout {
|
||||
id: entryRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: root.rowSpacing
|
||||
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
text: root.entryMarker(entryRow.modelData.status)
|
||||
textFormat: Text.PlainText
|
||||
font.pixelSize: 12
|
||||
color: root.entryColor(entryRow.modelData.status)
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: entryRow.modelData.content || ""
|
||||
textFormat: Text.PlainText
|
||||
font.pixelSize: 12
|
||||
font.strikeout: entryRow.modelData.status === "completed"
|
||||
color: entryRow.modelData.status === "completed" ? palette.placeholderText
|
||||
: palette.text
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
visible: entryRow.modelData.priority === "high"
|
||||
text: qsTr("high")
|
||||
textFormat: Text.PlainText
|
||||
font.pixelSize: 10
|
||||
color: palette.highlight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,52 @@ import Qt.labs.platform as Platform
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string toolContent: ""
|
||||
property string toolName: ""
|
||||
property string toolResult: ""
|
||||
property string toolKind: ""
|
||||
property string toolStatus: ""
|
||||
property var toolDetails: ({})
|
||||
property bool expanded: false
|
||||
|
||||
property alias headerOpacity: headerRow.opacity
|
||||
|
||||
readonly property int firstNewline: toolContent.indexOf('\n')
|
||||
readonly property string toolName: firstNewline > 0 ? toolContent.substring(0, firstNewline) : toolContent
|
||||
readonly property string toolResult: firstNewline > 0 ? toolContent.substring(firstNewline + 1) : ""
|
||||
readonly property int legacyNewline: toolName === "" ? toolResult.indexOf('\n') : -1
|
||||
readonly property string headerName: {
|
||||
if (toolName !== "")
|
||||
return toolName;
|
||||
return legacyNewline > 0 ? toolResult.substring(0, legacyNewline) : toolResult;
|
||||
}
|
||||
readonly property string resultText: {
|
||||
if (toolName !== "")
|
||||
return toolResult;
|
||||
return legacyNewline > 0 ? toolResult.substring(legacyNewline + 1) : "";
|
||||
}
|
||||
|
||||
readonly property var locations: (toolDetails && toolDetails.locations) ? toolDetails.locations : []
|
||||
readonly property var diffs: (toolDetails && toolDetails.diffs) ? toolDetails.diffs : []
|
||||
|
||||
readonly property bool isRunning: toolStatus === "pending" || toolStatus === "in_progress"
|
||||
readonly property bool hasFailed: toolStatus === "failed"
|
||||
|
||||
readonly property color statusColor: {
|
||||
if (hasFailed)
|
||||
return Qt.rgba(0.8, 0.2, 0.2, 0.9);
|
||||
if (isRunning)
|
||||
return palette.highlight;
|
||||
if (toolStatus === "completed")
|
||||
return Qt.rgba(0.2, 0.8, 0.2, 0.9);
|
||||
return palette.mid;
|
||||
}
|
||||
|
||||
readonly property string statusMarker: {
|
||||
if (hasFailed)
|
||||
return "✗";
|
||||
if (toolStatus === "completed")
|
||||
return "✓";
|
||||
if (isRunning)
|
||||
return "…";
|
||||
return "";
|
||||
}
|
||||
|
||||
radius: 6
|
||||
color: palette.base
|
||||
@@ -45,10 +83,26 @@ Rectangle {
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: qsTr("Tool: %1").arg(root.toolName)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.statusMarker
|
||||
textFormat: Text.PlainText
|
||||
visible: text.length > 0
|
||||
font.pixelSize: 12
|
||||
color: root.statusColor
|
||||
}
|
||||
|
||||
Text {
|
||||
id: headerTitle
|
||||
|
||||
width: headerRow.width - x - 30
|
||||
text: root.toolKind.length > 0
|
||||
? root.toolKind + ": " + root.headerName
|
||||
: qsTr("Tool: %1").arg(root.headerName)
|
||||
textFormat: Text.PlainText
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
color: root.hasFailed ? root.statusColor : palette.text
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
@@ -60,8 +114,8 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: contentColumn
|
||||
Loader {
|
||||
id: contentLoader
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
@@ -69,20 +123,88 @@ Rectangle {
|
||||
top: header.bottom
|
||||
margins: 10
|
||||
}
|
||||
spacing: 8
|
||||
active: root.expanded
|
||||
sourceComponent: contentComponent
|
||||
}
|
||||
|
||||
TextEdit {
|
||||
id: resultText
|
||||
Component {
|
||||
id: contentComponent
|
||||
|
||||
width: parent.width
|
||||
text: root.toolResult
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
color: palette.text
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 11
|
||||
selectionColor: palette.highlight
|
||||
Column {
|
||||
property alias resultEditor: resultEditorItem
|
||||
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: root.locations
|
||||
|
||||
delegate: Text {
|
||||
id: locationEntry
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: contentLoader.width
|
||||
text: locationEntry.modelData.line !== undefined
|
||||
? "→ " + locationEntry.modelData.path + ":" + locationEntry.modelData.line
|
||||
: "→ " + locationEntry.modelData.path
|
||||
textFormat: Text.PlainText
|
||||
color: palette.placeholderText
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.diffs
|
||||
|
||||
delegate: Column {
|
||||
id: diffEntry
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: contentLoader.width
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: qsTr("Diff") + ": " + (diffEntry.modelData.path || "")
|
||||
textFormat: Text.PlainText
|
||||
color: palette.text
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
|
||||
TextEdit {
|
||||
width: parent.width
|
||||
text: (diffEntry.modelData.oldText || "") + "\n" + (diffEntry.modelData.newText || "")
|
||||
textFormat: TextEdit.PlainText
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
color: palette.text
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 11
|
||||
selectionColor: palette.highlight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextEdit {
|
||||
id: resultEditorItem
|
||||
|
||||
width: parent.width
|
||||
visible: text.length > 0
|
||||
text: root.resultText
|
||||
textFormat: TextEdit.PlainText
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
color: palette.text
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 11
|
||||
selectionColor: palette.highlight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,14 +220,14 @@ Rectangle {
|
||||
|
||||
Platform.MenuItem {
|
||||
text: qsTr("Copy")
|
||||
enabled: resultText.selectedText.length > 0
|
||||
onTriggered: resultText.copy()
|
||||
enabled: contentLoader.item && contentLoader.item.resultEditor.selectedText.length > 0
|
||||
onTriggered: contentLoader.item.resultEditor.copy()
|
||||
}
|
||||
|
||||
Platform.MenuItem {
|
||||
text: qsTr("Select All")
|
||||
enabled: resultText.text.length > 0
|
||||
onTriggered: resultText.selectAll()
|
||||
enabled: contentLoader.item && contentLoader.item.resultEditor.text.length > 0
|
||||
onTriggered: contentLoader.item.resultEditor.selectAll()
|
||||
}
|
||||
|
||||
Platform.MenuSeparator {}
|
||||
@@ -140,7 +262,7 @@ Rectangle {
|
||||
when: root.expanded
|
||||
PropertyChanges {
|
||||
target: root
|
||||
implicitHeight: header.height + contentColumn.height + 20
|
||||
implicitHeight: header.height + contentLoader.height + 20
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -12,15 +12,15 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
property alias sendButton: sendButtonId
|
||||
property alias syncOpenFiles: syncOpenFilesId
|
||||
property alias attachFiles: attachFilesId
|
||||
property alias attachImages: attachImagesId
|
||||
property alias linkFiles: linkFilesId
|
||||
property alias compressButton: compressButtonId
|
||||
property alias compressTooltip: compressTooltipId
|
||||
property alias cancelCompressButton: cancelCompressButtonId
|
||||
|
||||
property bool isCompressing: false
|
||||
property bool isProcessing: false
|
||||
property bool agentMode: false
|
||||
property alias sendButtonTooltip: sendButtonTooltipId
|
||||
|
||||
color: palette.window.hslLightness > 0.5 ?
|
||||
@@ -72,33 +72,6 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: linkFilesId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/link-file-dark.svg"
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: linkFilesId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Link file to context")
|
||||
}
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
id: syncOpenFilesId
|
||||
|
||||
text: qsTr("Sync open files")
|
||||
|
||||
QoAToolTip {
|
||||
visible: syncOpenFilesId.hovered
|
||||
text: qsTr("Automatically synchronize currently opened files with the model context")
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
@@ -119,7 +92,7 @@ Rectangle {
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("Compressing...")
|
||||
text: root.agentMode ? qsTr("Preparing summary...") : qsTr("Compressing...")
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: palette.text
|
||||
font.pixelSize: 12
|
||||
@@ -134,7 +107,7 @@ Rectangle {
|
||||
QoAToolTip {
|
||||
visible: cancelCompressButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Cancel compression")
|
||||
text: root.agentMode ? qsTr("Cancel the summary") : qsTr("Cancel compression")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +116,7 @@ Rectangle {
|
||||
id: compressButtonId
|
||||
|
||||
visible: !root.isCompressing
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
text: qsTr("Compress")
|
||||
|
||||
icon {
|
||||
@@ -152,9 +126,10 @@ Rectangle {
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
id: compressTooltipId
|
||||
|
||||
visible: compressButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Compress chat (create summarized copy using LLM)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,18 +14,8 @@ Popup {
|
||||
id: root
|
||||
|
||||
property string baseSystemPrompt
|
||||
property string currentAgentRole
|
||||
property string currentAgentRoleDescription
|
||||
property string currentAgentRoleSystemPrompt
|
||||
property var activeRules
|
||||
property int activeRulesCount
|
||||
property string selectedRuleContent
|
||||
|
||||
signal openSettings()
|
||||
signal openAgentRolesSettings()
|
||||
signal openRulesFolder()
|
||||
signal refreshRules()
|
||||
signal ruleSelected(int index)
|
||||
|
||||
modal: true
|
||||
focus: true
|
||||
@@ -59,11 +49,6 @@ Popup {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Refresh")
|
||||
onClicked: root.refreshRules()
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Close")
|
||||
onClicked: root.close()
|
||||
@@ -159,281 +144,6 @@ Popup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleSection {
|
||||
id: agentRoleSection
|
||||
|
||||
Layout.fillWidth: true
|
||||
title: qsTr("Agent Role")
|
||||
badge: root.currentAgentRole
|
||||
badgeColor: root.currentAgentRoleSystemPrompt.length > 0 ? Qt.rgba(0.3, 0.4, 0.7, 1.0) : palette.mid
|
||||
|
||||
sectionContent: ColumnLayout {
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: root.currentAgentRoleDescription
|
||||
font.pixelSize: 11
|
||||
font.italic: true
|
||||
color: palette.mid
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
visible: root.currentAgentRoleDescription.length > 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(Math.max(agentPromptText.implicitHeight + 16, 50), 200)
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 2
|
||||
visible: root.currentAgentRoleSystemPrompt.length > 0
|
||||
|
||||
Flickable {
|
||||
id: agentPromptFlickable
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
contentHeight: agentPromptText.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
TextEdit {
|
||||
id: agentPromptText
|
||||
|
||||
width: agentPromptFlickable.width
|
||||
text: root.currentAgentRoleSystemPrompt
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: Text.WordWrap
|
||||
color: palette.text
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
QQC.ScrollBar.vertical: QQC.ScrollBar {
|
||||
policy: agentPromptFlickable.contentHeight > agentPromptFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("No role selected. Using base system prompt only.")
|
||||
font.pixelSize: 11
|
||||
color: palette.mid
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
visible: root.currentAgentRoleSystemPrompt.length === 0
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Copy")
|
||||
enabled: root.currentAgentRoleSystemPrompt.length > 0
|
||||
onClicked: utils.copyToClipboard(root.currentAgentRoleSystemPrompt)
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Manage Roles")
|
||||
onClicked: {
|
||||
root.openAgentRolesSettings()
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleSection {
|
||||
id: projectRulesSection
|
||||
|
||||
Layout.fillWidth: true
|
||||
title: qsTr("Project Rules")
|
||||
badge: root.activeRulesCount > 0 ? qsTr("%1 active").arg(root.activeRulesCount) : qsTr("None")
|
||||
badgeColor: root.activeRulesCount > 0 ? Qt.rgba(0.6, 0.5, 0.2, 1.0) : palette.mid
|
||||
|
||||
sectionContent: ColumnLayout {
|
||||
spacing: 8
|
||||
|
||||
SplitView {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 220
|
||||
orientation: Qt.Horizontal
|
||||
visible: root.activeRulesCount > 0
|
||||
|
||||
Rectangle {
|
||||
SplitView.minimumWidth: 120
|
||||
SplitView.preferredWidth: 180
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 2
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 5
|
||||
spacing: 5
|
||||
|
||||
Text {
|
||||
text: qsTr("Rules (%1)").arg(rulesList.count)
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: rulesList
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
model: root.activeRules
|
||||
currentIndex: 0
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
delegate: ItemDelegate {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: ListView.view.width
|
||||
height: ruleItemContent.implicitHeight + 8
|
||||
highlighted: ListView.isCurrentItem
|
||||
|
||||
background: Rectangle {
|
||||
color: {
|
||||
if (parent.highlighted)
|
||||
return palette.highlight
|
||||
if (parent.hovered)
|
||||
return Qt.tint(palette.base, Qt.rgba(0, 0, 0, 0.05))
|
||||
return "transparent"
|
||||
}
|
||||
radius: 2
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
id: ruleItemContent
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
text: modelData.fileName
|
||||
font.pixelSize: 10
|
||||
color: parent.parent.highlighted ? palette.highlightedText : palette.text
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
text: modelData.category
|
||||
font.pixelSize: 9
|
||||
color: parent.parent.highlighted ? palette.highlightedText : palette.mid
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
rulesList.currentIndex = index
|
||||
root.ruleSelected(index)
|
||||
}
|
||||
}
|
||||
|
||||
QQC.ScrollBar.vertical: QQC.ScrollBar {
|
||||
policy: rulesList.contentHeight > rulesList.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
SplitView.fillWidth: true
|
||||
SplitView.minimumWidth: 200
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 2
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 5
|
||||
spacing: 5
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 5
|
||||
|
||||
Text {
|
||||
text: qsTr("Content")
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
color: palette.text
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Copy")
|
||||
enabled: root.selectedRuleContent.length > 0
|
||||
onClicked: utils.copyToClipboard(root.selectedRuleContent)
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: ruleContentFlickable
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
contentHeight: ruleContentArea.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
TextEdit {
|
||||
id: ruleContentArea
|
||||
|
||||
width: ruleContentFlickable.width
|
||||
text: root.selectedRuleContent
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: Text.WordWrap
|
||||
selectionColor: palette.highlight
|
||||
color: palette.text
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
QQC.ScrollBar.vertical: QQC.ScrollBar {
|
||||
policy: ruleContentFlickable.contentHeight > ruleContentFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("No project rules found.\nCreate .md files in .qodeassist/rules/common/ or .qodeassist/rules/chat/")
|
||||
font.pixelSize: 11
|
||||
color: palette.mid
|
||||
wrapMode: Text.WordWrap
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
Layout.fillWidth: true
|
||||
visible: root.activeRulesCount === 0
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
QoAButton {
|
||||
text: qsTr("Open Rules Folder")
|
||||
onClicked: root.openRulesFolder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QQC.ScrollBar.vertical: QQC.ScrollBar {
|
||||
@@ -448,7 +158,7 @@ Popup {
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("Final prompt: Base System Prompt + Agent Role + Project Info + Project Rules + Linked Files")
|
||||
text: qsTr("Final prompt: Base System Prompt + Project Info + Skills")
|
||||
font.pixelSize: 9
|
||||
color: palette.mid
|
||||
wrapMode: Text.WordWrap
|
||||
@@ -534,10 +244,4 @@ Popup {
|
||||
active: sectionRoot.expanded
|
||||
}
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
if (root.activeRulesCount > 0) {
|
||||
root.ruleSelected(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
signal filesDropped(var urlStrings)
|
||||
|
||||
property int filesCount: 0
|
||||
property bool isDragActive: false
|
||||
|
||||
Item {
|
||||
id: dropOverlay
|
||||
|
||||
anchors.fill: parent
|
||||
visible: false
|
||||
z: 999
|
||||
opacity: 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors {
|
||||
top: parent.top
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
topMargin: 30
|
||||
}
|
||||
width: fileCountText.width + 40
|
||||
height: 50
|
||||
color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.9)
|
||||
radius: 25
|
||||
visible: root.filesCount > 0
|
||||
|
||||
Text {
|
||||
id: fileCountText
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("%n file(s) to drop", "", root.filesCount)
|
||||
font.pixelSize: 16
|
||||
font.bold: true
|
||||
color: palette.highlightedText
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.isDragActive
|
||||
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
|
||||
border.width: root.isDragActive ? 3 : 2
|
||||
border.color: root.isDragActive
|
||||
? palette.highlight
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 15
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Attach")
|
||||
font.pixelSize: 24
|
||||
font.bold: true
|
||||
color: root.isDragActive ? palette.highlightedText : palette.text
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Images & Text Files")
|
||||
font.pixelSize: 14
|
||||
color: root.isDragActive ? palette.highlightedText : palette.text
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
Behavior on border.width { NumberAnimation { duration: 150 } }
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
}
|
||||
|
||||
DropArea {
|
||||
id: globalDropArea
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
onEntered: (drag) => {
|
||||
if (drag.hasUrls) {
|
||||
root.isDragActive = true
|
||||
root.filesCount = drag.urls.length
|
||||
dropOverlay.visible = true
|
||||
dropOverlay.opacity = 1
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
root.isDragActive = false
|
||||
root.filesCount = 0
|
||||
dropOverlay.opacity = 0
|
||||
|
||||
Qt.callLater(function() {
|
||||
if (!root.isDragActive) {
|
||||
dropOverlay.visible = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onDropped: (drop) => {
|
||||
root.isDragActive = false
|
||||
root.filesCount = 0
|
||||
dropOverlay.opacity = 0
|
||||
|
||||
Qt.callLater(function() {
|
||||
dropOverlay.visible = false
|
||||
})
|
||||
|
||||
if (!drop.hasUrls || drop.urls.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
var urlStrings = []
|
||||
for (var i = 0; i < drop.urls.length; i++) {
|
||||
var urlString = drop.urls[i].toString()
|
||||
if (urlString.startsWith("file://") || urlString.indexOf("://") === -1) {
|
||||
urlStrings.push(urlString)
|
||||
}
|
||||
}
|
||||
|
||||
if (urlStrings.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
drop.accept(Qt.CopyAction)
|
||||
|
||||
root.filesDropped(urlStrings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import QtQuick.Layouts
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// Object exposing Q_INVOKABLE QVariantList searchSkills(query).
|
||||
property var skillProvider: null
|
||||
property var searchResults: []
|
||||
property int currentIndex: 0
|
||||
@@ -25,7 +24,7 @@ Rectangle {
|
||||
radius: 4
|
||||
|
||||
function updateSearch(query) {
|
||||
searchResults = skillProvider ? skillProvider.searchSkills(query) : []
|
||||
searchResults = skillProvider ? skillProvider.searchSlashCommands(query) : []
|
||||
currentIndex = 0
|
||||
}
|
||||
|
||||
@@ -87,19 +86,55 @@ Rectangle {
|
||||
anchors.bottomMargin: 4
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
text: "/" + delegateItem.modelData.name
|
||||
color: delegateItem.index === root.currentIndex
|
||||
? palette.highlightedText
|
||||
: palette.text
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "/" + delegateItem.modelData.name
|
||||
textFormat: Text.PlainText
|
||||
color: delegateItem.index === root.currentIndex
|
||||
? palette.highlightedText
|
||||
: palette.text
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: sourceBadge.text.length > 0
|
||||
implicitWidth: Math.min(sourceBadge.implicitWidth + 10, 140)
|
||||
implicitHeight: sourceBadge.implicitHeight + 2
|
||||
radius: height / 2
|
||||
color: delegateItem.index === root.currentIndex
|
||||
? Qt.rgba(palette.highlightedText.r,
|
||||
palette.highlightedText.g,
|
||||
palette.highlightedText.b, 0.2)
|
||||
: palette.alternateBase
|
||||
|
||||
Text {
|
||||
id: sourceBadge
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 5
|
||||
anchors.rightMargin: 5
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: delegateItem.modelData.source || ""
|
||||
textFormat: Text.PlainText
|
||||
elide: Text.ElideRight
|
||||
color: delegateItem.index === root.currentIndex
|
||||
? palette.highlightedText
|
||||
: palette.mid
|
||||
font.pixelSize: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: delegateItem.modelData.description
|
||||
textFormat: Text.PlainText
|
||||
color: delegateItem.index === root.currentIndex
|
||||
? Qt.rgba(palette.highlightedText.r,
|
||||
palette.highlightedText.g,
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
signal filesDroppedToAttach(var urlStrings)
|
||||
signal filesDroppedToLink(var urlStrings)
|
||||
|
||||
property string activeZone: ""
|
||||
property int filesCount: 0
|
||||
property bool isDragActive: false
|
||||
|
||||
Item {
|
||||
id: splitDropOverlay
|
||||
|
||||
anchors.fill: parent
|
||||
visible: false
|
||||
z: 999
|
||||
opacity: 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors {
|
||||
top: parent.top
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
topMargin: 30
|
||||
}
|
||||
width: fileCountText.width + 40
|
||||
height: 50
|
||||
color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.9)
|
||||
radius: 25
|
||||
visible: root.filesCount > 0
|
||||
|
||||
Text {
|
||||
id: fileCountText
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("%n file(s) to drop", "", root.filesCount)
|
||||
font.pixelSize: 16
|
||||
font.bold: true
|
||||
color: palette.highlightedText
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: leftZone
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
color: root.activeZone === "left"
|
||||
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
|
||||
border.width: root.activeZone === "left" ? 3 : 2
|
||||
border.color: root.activeZone === "left"
|
||||
? palette.highlight
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 15
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Attach")
|
||||
font.pixelSize: 24
|
||||
font.bold: true
|
||||
color: root.activeZone === "left" ? palette.highlightedText : palette.text
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Images & Text Files")
|
||||
font.pixelSize: 14
|
||||
color: root.activeZone === "left" ? palette.highlightedText : palette.text
|
||||
opacity: 0.8
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("(for one-time use)")
|
||||
font.pixelSize: 12
|
||||
font.italic: true
|
||||
color: root.activeZone === "left" ? palette.highlightedText : palette.text
|
||||
opacity: 0.6
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
Behavior on border.width { NumberAnimation { duration: 150 } }
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: rightZone
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
color: root.activeZone === "right"
|
||||
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
|
||||
border.width: root.activeZone === "right" ? 3 : 2
|
||||
border.color: root.activeZone === "right"
|
||||
? palette.highlight
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 15
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("LINK")
|
||||
font.pixelSize: 24
|
||||
font.bold: true
|
||||
color: root.activeZone === "right" ? palette.highlightedText : palette.text
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Text Files")
|
||||
font.pixelSize: 14
|
||||
color: root.activeZone === "right" ? palette.highlightedText : palette.text
|
||||
opacity: 0.8
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("(added to context)")
|
||||
font.pixelSize: 12
|
||||
font.italic: true
|
||||
color: root.activeZone === "right" ? palette.highlightedText : palette.text
|
||||
opacity: 0.6
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
Behavior on border.width { NumberAnimation { duration: 150 } }
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors {
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: 2
|
||||
color: palette.mid
|
||||
opacity: 0.4
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: leftDropArea
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
root.activeZone = "left"
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rightDropArea
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
root.activeZone = "right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropArea {
|
||||
id: globalDropArea
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
onEntered: (drag) => {
|
||||
if (drag.hasUrls) {
|
||||
root.isDragActive = true
|
||||
root.filesCount = drag.urls.length
|
||||
splitDropOverlay.visible = true
|
||||
splitDropOverlay.opacity = 1
|
||||
root.activeZone = ""
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
root.isDragActive = false
|
||||
root.filesCount = 0
|
||||
splitDropOverlay.opacity = 0
|
||||
|
||||
Qt.callLater(function() {
|
||||
if (!root.isDragActive) {
|
||||
splitDropOverlay.visible = false
|
||||
root.activeZone = ""
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onPositionChanged: (drag) => {
|
||||
if (drag.hasUrls) {
|
||||
root.activeZone = drag.x < globalDropArea.width / 2 ? "left" : "right"
|
||||
}
|
||||
}
|
||||
|
||||
onDropped: (drop) => {
|
||||
const targetZone = root.activeZone
|
||||
root.isDragActive = false
|
||||
root.filesCount = 0
|
||||
splitDropOverlay.opacity = 0
|
||||
|
||||
Qt.callLater(function() {
|
||||
splitDropOverlay.visible = false
|
||||
root.activeZone = ""
|
||||
})
|
||||
|
||||
if (!drop.hasUrls || drop.urls.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
var urlStrings = []
|
||||
for (var i = 0; i < drop.urls.length; i++) {
|
||||
var urlString = drop.urls[i].toString()
|
||||
if (urlString.startsWith("file://") || urlString.indexOf("://") === -1) {
|
||||
urlStrings.push(urlString)
|
||||
}
|
||||
}
|
||||
|
||||
if (urlStrings.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
drop.accept(Qt.CopyAction)
|
||||
|
||||
if (targetZone === "right") {
|
||||
root.filesDroppedToLink(urlStrings)
|
||||
} else {
|
||||
root.filesDroppedToAttach(urlStrings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,8 @@ Rectangle {
|
||||
property alias pinButton: pinButtonId
|
||||
property alias relocateButton: relocateButtonId
|
||||
property alias contextButton: contextButtonId
|
||||
property alias toolsButton: toolsButtonId
|
||||
property alias thinkingMode: thinkingModeId
|
||||
property alias settingsButton: settingsButtonId
|
||||
property alias configSelector: configSelectorId
|
||||
property alias roleSelector: roleSelector
|
||||
property alias relocateTooltip: relocateTooltipId
|
||||
|
||||
color: palette.window.hslLightness > 0.5 ?
|
||||
@@ -147,82 +144,11 @@ Rectangle {
|
||||
text: qsTr("Switch saved AI configuration")
|
||||
}
|
||||
}
|
||||
|
||||
QoAComboBox {
|
||||
id: roleSelector
|
||||
|
||||
implicitHeight: 25
|
||||
|
||||
model: []
|
||||
currentIndex: 0
|
||||
|
||||
QoAToolTip {
|
||||
visible: roleSelector.hovered
|
||||
delay: 250
|
||||
text: qsTr("Switch agent role (different system prompts)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: 10
|
||||
|
||||
QoAButton {
|
||||
id: toolsButtonId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
checkable: true
|
||||
opacity: enabled ? 1.0 : 0.2
|
||||
|
||||
icon {
|
||||
source: checked ? "qrc:/qt/qml/ChatView/icons/tools-icon-on.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/tools-icon-off.svg"
|
||||
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: toolsButtonId.hovered
|
||||
delay: 250
|
||||
text: {
|
||||
if (!toolsButtonId.enabled) {
|
||||
return qsTr("Tools are disabled in General Settings")
|
||||
}
|
||||
return toolsButtonId.checked
|
||||
? qsTr("Tools enabled: AI can use tools to read files, search project, and build code")
|
||||
: qsTr("Tools disabled: Simple conversation without tool access")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: thinkingModeId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
checkable: true
|
||||
opacity: enabled ? 1.0 : 0.2
|
||||
|
||||
icon {
|
||||
source: checked ? "qrc:/qt/qml/ChatView/icons/thinking-icon-on.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/thinking-icon-off.svg"
|
||||
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: thinkingModeId.hovered
|
||||
delay: 250
|
||||
text: thinkingModeId.enabled
|
||||
? (thinkingModeId.checked ? qsTr("Thinking Mode enabled (Check model list support it)")
|
||||
: qsTr("Thinking Mode disabled"))
|
||||
: qsTr("Thinking Mode is not available for this provider")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: settingsButtonId
|
||||
|
||||
@@ -345,7 +271,7 @@ Rectangle {
|
||||
QoAToolTip {
|
||||
visible: contextButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("View chat context (system prompt, role, rules)")
|
||||
text: qsTr("View chat context (system prompt)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include <LLMQore/AcpTypes.hpp>
|
||||
|
||||
#include "acp/AgentDefinition.hpp"
|
||||
#include "acp/AgentKnowledgeService.hpp"
|
||||
#include "acp/AgentSpawn.hpp"
|
||||
#include "acp/ChatPermissionProvider.hpp"
|
||||
#include "session/ChatBackend.hpp"
|
||||
#include "session/ContentBlock.hpp"
|
||||
#include "session/TurnContext.hpp"
|
||||
#include "session/TurnLedger.hpp"
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
class AcpChatBackend : public Session::ChatBackend
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using ClientFactory = std::function<
|
||||
AgentProcess(const AgentDefinition &agent, const QString &cwd, QObject *parent)>;
|
||||
using StoredContentLoader
|
||||
= std::function<QByteArray(const QString &chatFilePath, const QString &storedPath)>;
|
||||
|
||||
explicit AcpChatBackend(QObject *parent = nullptr);
|
||||
|
||||
void setClientFactory(ClientFactory factory);
|
||||
void setStoredContentLoader(StoredContentLoader loader);
|
||||
void setKnowledgeService(AgentKnowledgeService *service);
|
||||
|
||||
void bindAgent(const AgentDefinition &agent);
|
||||
QString boundAgentId() const;
|
||||
QString boundAgentName() const;
|
||||
const QList<LLMQore::Acp::AvailableCommand> &availableCommands() const
|
||||
{
|
||||
return m_availableCommands;
|
||||
}
|
||||
QString acpSessionId() const { return m_sessionId; }
|
||||
QString bindingSessionId() const
|
||||
{
|
||||
return m_sessionId.isEmpty() ? m_resumeSessionId : m_sessionId;
|
||||
}
|
||||
|
||||
void resumeSession(const QString &sessionId);
|
||||
void startFreshSession();
|
||||
void setHandoverSummary(const QString &summary);
|
||||
bool isResumePending() const { return !m_resumeSessionId.isEmpty(); }
|
||||
|
||||
void sendTurn(const Session::TurnRequest &request) override;
|
||||
void cancel() override;
|
||||
bool respondPermission(const QString &requestId, const QString &optionId) override;
|
||||
Session::TurnContextNeeds contextNeeds() const override { return {false}; }
|
||||
void setChatFilePath(const QString &filePath) override;
|
||||
void clearToolSession(const QString &filePath) override;
|
||||
|
||||
signals:
|
||||
void agentSessionUnavailable(const QString &reason);
|
||||
void availableCommandsChanged();
|
||||
|
||||
private:
|
||||
struct PendingTurn
|
||||
{
|
||||
QList<Session::ContentBlock> userBlocks;
|
||||
};
|
||||
|
||||
void startClient();
|
||||
void adoptEarlyCommands();
|
||||
void startSession();
|
||||
void resumeOrStartSession();
|
||||
void sendPrompt();
|
||||
void authenticateAndRetry();
|
||||
|
||||
QList<LLMQore::Acp::ContentBlock> buildPrompt() const;
|
||||
void appendAttachment(
|
||||
QList<LLMQore::Acp::ContentBlock> &blocks,
|
||||
const Session::AttachmentBlock &attachment) const;
|
||||
void appendImage(
|
||||
QList<LLMQore::Acp::ContentBlock> &blocks, const Session::ImageBlock &image) const;
|
||||
|
||||
void connectClient();
|
||||
void requestPermission(
|
||||
const QString &requestId,
|
||||
const LLMQore::Acp::ToolCall &toolCall,
|
||||
const QList<LLMQore::Acp::PermissionOption> &options);
|
||||
void emitPermissionsCancelled(const QString &turnId, const QStringList &requestIds);
|
||||
void cancelPendingPermissions(const QString &turnId);
|
||||
void finishTurn(const QString &turnId);
|
||||
void failTurn(const QString &error, bool dropProcess);
|
||||
void releaseClient();
|
||||
|
||||
QList<LLMQore::Acp::McpServer> knowledgeServers();
|
||||
void stopKnowledgeServer();
|
||||
|
||||
ClientFactory m_clientFactory;
|
||||
StoredContentLoader m_storedContentLoader;
|
||||
AgentKnowledgeService *m_knowledgeService = nullptr;
|
||||
bool m_knowledgeServerRunning = false;
|
||||
ChatPermissionProvider *m_permissions = nullptr;
|
||||
std::optional<AgentDefinition> m_agent;
|
||||
|
||||
LLMQore::Acp::AcpClient *m_client = nullptr;
|
||||
LLMQore::Acp::InitializeResult m_agentInfo;
|
||||
QString m_sessionId;
|
||||
QString m_workingDirectory;
|
||||
QString m_runner;
|
||||
|
||||
QString m_chatFilePath;
|
||||
QString m_resumeSessionId;
|
||||
QString m_handoverSummary;
|
||||
QList<LLMQore::Acp::AvailableCommand> m_availableCommands;
|
||||
QList<LLMQore::Acp::AvailableCommand> m_earlyCommands;
|
||||
QString m_earlyCommandsSessionId;
|
||||
QString m_earlyTitle;
|
||||
QString m_earlyTitleSessionId;
|
||||
Session::TurnLedger m_ledger;
|
||||
int m_clientGeneration = 0;
|
||||
bool m_establishingSession = false;
|
||||
PendingTurn m_pendingTurn;
|
||||
QStringList m_stderr;
|
||||
int m_turnCounter = 0;
|
||||
bool m_authenticated = false;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "AgentBinding.hpp"
|
||||
|
||||
#include <QCoreApplication>
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
namespace {
|
||||
|
||||
bool isUsableId(const QString &id)
|
||||
{
|
||||
if (id.size() > maxAgentBindingIdLength)
|
||||
return false;
|
||||
|
||||
for (const QChar c : id) {
|
||||
if (c.category() == QChar::Other_Control || c.category() == QChar::Other_Format)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString AgentBinding::displayId() const
|
||||
{
|
||||
return agentId.size() <= maxAgentBindingIdLength
|
||||
? agentId
|
||||
: agentId.first(maxAgentBindingIdLength - 1) + QChar(0x2026);
|
||||
}
|
||||
|
||||
QJsonObject AgentBinding::toJson() const
|
||||
{
|
||||
QJsonObject json;
|
||||
json["agentId"] = agentId;
|
||||
if (!sessionId.isEmpty())
|
||||
json["sessionId"] = sessionId;
|
||||
return json;
|
||||
}
|
||||
|
||||
AgentBinding AgentBinding::fromJson(const QJsonValue &value, QString *error)
|
||||
{
|
||||
const auto fail = [error](const QString &reason) {
|
||||
if (error)
|
||||
*error = reason;
|
||||
return AgentBinding{};
|
||||
};
|
||||
|
||||
if (value.isUndefined() || value.isNull())
|
||||
return {};
|
||||
|
||||
if (!value.isObject()) {
|
||||
return fail(
|
||||
QCoreApplication::translate("QodeAssist", "the agent binding is not an object"));
|
||||
}
|
||||
|
||||
const QJsonObject json = value.toObject();
|
||||
|
||||
if (!json.value("agentId").isString() && json.contains("agentId")) {
|
||||
return fail(
|
||||
QCoreApplication::translate("QodeAssist", "the agent id is not a string"));
|
||||
}
|
||||
|
||||
if (!json.value("sessionId").isString() && json.contains("sessionId")) {
|
||||
return fail(
|
||||
QCoreApplication::translate("QodeAssist", "the agent session id is not a string"));
|
||||
}
|
||||
|
||||
AgentBinding binding{json["agentId"].toString(), json["sessionId"].toString()};
|
||||
|
||||
if (binding.agentId.isEmpty() && !binding.sessionId.isEmpty()) {
|
||||
return fail(
|
||||
QCoreApplication::translate(
|
||||
"QodeAssist", "the agent binding names a session but no agent"));
|
||||
}
|
||||
|
||||
if (!isUsableId(binding.agentId) || !isUsableId(binding.sessionId)) {
|
||||
return fail(
|
||||
QCoreApplication::translate("QodeAssist", "the agent binding holds an unusable id"));
|
||||
}
|
||||
|
||||
return binding;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
inline constexpr qsizetype maxAgentBindingIdLength = 256;
|
||||
|
||||
struct AgentBinding
|
||||
{
|
||||
QString agentId;
|
||||
QString sessionId;
|
||||
|
||||
bool isEmpty() const { return agentId.isEmpty(); }
|
||||
|
||||
QString displayId() const;
|
||||
|
||||
bool operator==(const AgentBinding &other) const = default;
|
||||
|
||||
QJsonObject toJson() const;
|
||||
static AgentBinding fromJson(const QJsonValue &value, QString *error = nullptr);
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "AgentCatalog.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QHash>
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::array<AgentSource, agentSourceCount> mergeOrderLowestPriorityFirst{
|
||||
AgentSource::BundledSnapshot, AgentSource::LiveRegistry, AgentSource::UserFile};
|
||||
|
||||
} // namespace
|
||||
|
||||
void AgentCatalog::setLayer(AgentSource source, const QList<AgentDefinition> &agents)
|
||||
{
|
||||
m_layers[static_cast<int>(source)] = agents;
|
||||
rebuild();
|
||||
}
|
||||
|
||||
QList<AgentDefinition> AgentCatalog::launchableAgents() const
|
||||
{
|
||||
QList<AgentDefinition> result;
|
||||
for (const AgentDefinition &agent : m_merged) {
|
||||
if (agent.isLaunchable())
|
||||
result.append(agent);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<AgentDefinition> AgentCatalog::agent(const QString &id) const
|
||||
{
|
||||
for (const AgentDefinition &agent : m_merged) {
|
||||
if (agent.id == id)
|
||||
return agent;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void AgentCatalog::rebuild()
|
||||
{
|
||||
QHash<QString, AgentDefinition> byId;
|
||||
for (AgentSource source : mergeOrderLowestPriorityFirst) {
|
||||
for (const AgentDefinition &agent : std::as_const(m_layers[static_cast<int>(source)]))
|
||||
byId.insert(agent.id, agent);
|
||||
}
|
||||
|
||||
m_merged = byId.values();
|
||||
std::sort(
|
||||
m_merged.begin(),
|
||||
m_merged.end(),
|
||||
[](const AgentDefinition &left, const AgentDefinition &right) {
|
||||
const int byName = QString::compare(left.name, right.name, Qt::CaseInsensitive);
|
||||
if (byName != 0)
|
||||
return byName < 0;
|
||||
return left.id < right.id;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
#include "acp/AgentDefinition.hpp"
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
class AgentCatalog
|
||||
{
|
||||
public:
|
||||
void setLayer(AgentSource source, const QList<AgentDefinition> &agents);
|
||||
|
||||
QList<AgentDefinition> agents() const { return m_merged; }
|
||||
QList<AgentDefinition> launchableAgents() const;
|
||||
std::optional<AgentDefinition> agent(const QString &id) const;
|
||||
|
||||
int size() const { return m_merged.size(); }
|
||||
|
||||
private:
|
||||
void rebuild();
|
||||
|
||||
std::array<QList<AgentDefinition>, agentSourceCount> m_layers;
|
||||
QList<AgentDefinition> m_merged;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "AgentCatalogStore.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSaveFile>
|
||||
|
||||
#include <coreplugin/icore.h>
|
||||
|
||||
#include <logger/Logger.hpp>
|
||||
|
||||
#include "acp/AgentRegistryParser.hpp"
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
namespace {
|
||||
|
||||
QByteArray readFile(const QString &path)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
return {};
|
||||
return file.readAll();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AgentCatalogStore::AgentCatalogStore(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
{}
|
||||
|
||||
QString AgentCatalogStore::userAgentsDirectory()
|
||||
{
|
||||
const QString path = QStringLiteral("%1/qodeassist/agents")
|
||||
.arg(Core::ICore::userResourcePath().toFSPathString());
|
||||
QDir().mkpath(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
QString AgentCatalogStore::registryCachePath()
|
||||
{
|
||||
const QString directory
|
||||
= QStringLiteral("%1/qodeassist").arg(Core::ICore::userResourcePath().toFSPathString());
|
||||
QDir().mkpath(directory);
|
||||
return directory + QStringLiteral("/acp-registry-cache.json");
|
||||
}
|
||||
|
||||
QUrl AgentCatalogStore::registryUrl()
|
||||
{
|
||||
return QUrl(
|
||||
QStringLiteral("https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"));
|
||||
}
|
||||
|
||||
QString AgentCatalogStore::bundledSnapshotPath()
|
||||
{
|
||||
return QStringLiteral(":/resources/agents/acp-registry-snapshot.json");
|
||||
}
|
||||
|
||||
bool AgentCatalogStore::hasCachedRegistry() const
|
||||
{
|
||||
return QFileInfo::exists(registryCachePath());
|
||||
}
|
||||
|
||||
void AgentCatalogStore::reload()
|
||||
{
|
||||
m_warnings.clear();
|
||||
loadBundledSnapshot();
|
||||
loadRegistryCache();
|
||||
loadUserFiles();
|
||||
emit catalogChanged();
|
||||
}
|
||||
|
||||
void AgentCatalogStore::loadBundledSnapshot()
|
||||
{
|
||||
const AgentParseResult result = AgentRegistryParser::parse(
|
||||
readFile(bundledSnapshotPath()),
|
||||
AgentSource::BundledSnapshot,
|
||||
QStringLiteral("bundled snapshot"));
|
||||
|
||||
m_warnings.append(result.warnings);
|
||||
m_catalog.setLayer(AgentSource::BundledSnapshot, result.agents);
|
||||
}
|
||||
|
||||
void AgentCatalogStore::loadRegistryCache()
|
||||
{
|
||||
const QString path = registryCachePath();
|
||||
if (!QFileInfo::exists(path)) {
|
||||
m_catalog.setLayer(AgentSource::LiveRegistry, {});
|
||||
return;
|
||||
}
|
||||
|
||||
const AgentParseResult result = AgentRegistryParser::parse(
|
||||
readFile(path), AgentSource::LiveRegistry, QStringLiteral("registry cache"));
|
||||
|
||||
m_warnings.append(result.warnings);
|
||||
m_catalog.setLayer(AgentSource::LiveRegistry, result.agents);
|
||||
}
|
||||
|
||||
void AgentCatalogStore::loadUserFiles()
|
||||
{
|
||||
const QDir directory(userAgentsDirectory());
|
||||
const QStringList files
|
||||
= directory.entryList({QStringLiteral("*.json")}, QDir::Files, QDir::Name);
|
||||
|
||||
QList<AgentDefinition> agents;
|
||||
for (const QString &file : files) {
|
||||
const QString path = directory.filePath(file);
|
||||
const AgentParseResult result
|
||||
= AgentRegistryParser::parse(readFile(path), AgentSource::UserFile, file);
|
||||
m_warnings.append(result.warnings);
|
||||
agents.append(result.agents);
|
||||
}
|
||||
|
||||
m_catalog.setLayer(AgentSource::UserFile, agents);
|
||||
}
|
||||
|
||||
void AgentCatalogStore::refreshFromRegistry()
|
||||
{
|
||||
if (m_reply)
|
||||
return;
|
||||
|
||||
m_reply = m_network->get(QNetworkRequest(registryUrl()));
|
||||
connect(m_reply, &QNetworkReply::finished, this, [this]() {
|
||||
QNetworkReply *reply = m_reply;
|
||||
m_reply = nullptr;
|
||||
reply->deleteLater();
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
emit refreshFinished(false, reply->errorString());
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray payload = reply->readAll();
|
||||
const AgentParseResult result = AgentRegistryParser::parse(
|
||||
payload, AgentSource::LiveRegistry, QStringLiteral("registry"));
|
||||
|
||||
if (result.agents.isEmpty()) {
|
||||
emit refreshFinished(false, tr("The registry response contained no agents."));
|
||||
return;
|
||||
}
|
||||
|
||||
QSaveFile cache(registryCachePath());
|
||||
if (!cache.open(QIODevice::WriteOnly) || cache.write(payload) != payload.size()
|
||||
|| !cache.commit()) {
|
||||
emit refreshFinished(
|
||||
false, tr("Cannot write the registry cache: %1").arg(cache.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_MESSAGE(QString("ACP registry refreshed: %1 agents").arg(result.agents.size()));
|
||||
|
||||
reload();
|
||||
emit refreshFinished(true, {});
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
#include "acp/AgentCatalog.hpp"
|
||||
|
||||
QT_BEGIN_NAMESPACE
|
||||
class QNetworkAccessManager;
|
||||
class QNetworkReply;
|
||||
QT_END_NAMESPACE
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
class AgentCatalogStore : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit AgentCatalogStore(QObject *parent = nullptr);
|
||||
|
||||
static QString userAgentsDirectory();
|
||||
static QString registryCachePath();
|
||||
static QUrl registryUrl();
|
||||
static QString bundledSnapshotPath();
|
||||
|
||||
const AgentCatalog &catalog() const { return m_catalog; }
|
||||
QStringList warnings() const { return m_warnings; }
|
||||
|
||||
bool hasCachedRegistry() const;
|
||||
bool isRefreshing() const { return m_reply != nullptr; }
|
||||
|
||||
void reload();
|
||||
void refreshFromRegistry();
|
||||
|
||||
signals:
|
||||
void catalogChanged();
|
||||
void refreshFinished(bool ok, const QString &error);
|
||||
|
||||
private:
|
||||
void loadBundledSnapshot();
|
||||
void loadRegistryCache();
|
||||
void loadUserFiles();
|
||||
|
||||
QNetworkAccessManager *m_network = nullptr;
|
||||
QNetworkReply *m_reply = nullptr;
|
||||
AgentCatalog m_catalog;
|
||||
QStringList m_warnings;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "AgentDefinition.hpp"
|
||||
|
||||
#include <QSysInfo>
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
bool AgentDefinition::isLaunchable() const
|
||||
{
|
||||
switch (distribution.kind) {
|
||||
case AgentDistributionKind::Npx:
|
||||
case AgentDistributionKind::Uvx:
|
||||
return !distribution.package.isEmpty();
|
||||
case AgentDistributionKind::Command:
|
||||
return !distribution.command.isEmpty();
|
||||
case AgentDistributionKind::Binary:
|
||||
case AgentDistributionKind::Unknown:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QString agentSourceName(AgentSource source)
|
||||
{
|
||||
switch (source) {
|
||||
case AgentSource::UserFile:
|
||||
return QStringLiteral("user file");
|
||||
case AgentSource::LiveRegistry:
|
||||
return QStringLiteral("registry");
|
||||
case AgentSource::BundledSnapshot:
|
||||
return QStringLiteral("bundled");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QString agentDistributionName(AgentDistributionKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case AgentDistributionKind::Npx:
|
||||
return QStringLiteral("npx");
|
||||
case AgentDistributionKind::Uvx:
|
||||
return QStringLiteral("uvx");
|
||||
case AgentDistributionKind::Binary:
|
||||
return QStringLiteral("binary");
|
||||
case AgentDistributionKind::Command:
|
||||
return QStringLiteral("command");
|
||||
case AgentDistributionKind::Unknown:
|
||||
return QStringLiteral("unknown");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QString currentBinaryPlatform()
|
||||
{
|
||||
const QString kernel = QSysInfo::kernelType();
|
||||
QString os;
|
||||
if (kernel == QLatin1String("darwin"))
|
||||
os = QStringLiteral("darwin");
|
||||
else if (kernel == QLatin1String("linux"))
|
||||
os = QStringLiteral("linux");
|
||||
else if (kernel == QLatin1String("winnt"))
|
||||
os = QStringLiteral("windows");
|
||||
else
|
||||
return {};
|
||||
|
||||
const QString cpu = QSysInfo::currentCpuArchitecture();
|
||||
QString arch;
|
||||
if (cpu == QLatin1String("arm64") || cpu == QLatin1String("aarch64"))
|
||||
arch = QStringLiteral("aarch64");
|
||||
else if (cpu == QLatin1String("x86_64"))
|
||||
arch = QStringLiteral("x86_64");
|
||||
else
|
||||
return {};
|
||||
|
||||
return os + QLatin1Char('-') + arch;
|
||||
}
|
||||
|
||||
const AgentBinaryTarget *binaryTargetForCurrentPlatform(const AgentDefinition &agent)
|
||||
{
|
||||
const QString platform = currentBinaryPlatform();
|
||||
if (platform.isEmpty())
|
||||
return nullptr;
|
||||
|
||||
for (const AgentBinaryTarget &target : agent.distribution.binaryTargets) {
|
||||
if (target.platform == platform)
|
||||
return ⌖
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
enum class AgentSource { BundledSnapshot, LiveRegistry, UserFile };
|
||||
|
||||
inline constexpr int agentSourceCount = 3;
|
||||
|
||||
enum class AgentDistributionKind { Unknown, Npx, Uvx, Binary, Command };
|
||||
|
||||
struct AgentEnvVariable
|
||||
{
|
||||
QString name;
|
||||
QString value;
|
||||
};
|
||||
|
||||
struct AgentBinaryTarget
|
||||
{
|
||||
QString platform;
|
||||
QString archive;
|
||||
QString sha256;
|
||||
QString cmd;
|
||||
QStringList args;
|
||||
QList<AgentEnvVariable> env;
|
||||
};
|
||||
|
||||
struct AgentDistribution
|
||||
{
|
||||
AgentDistributionKind kind = AgentDistributionKind::Unknown;
|
||||
QString package;
|
||||
QString command;
|
||||
QStringList args;
|
||||
QList<AgentEnvVariable> env;
|
||||
QList<AgentBinaryTarget> binaryTargets;
|
||||
};
|
||||
|
||||
struct AgentDefinition
|
||||
{
|
||||
QString id;
|
||||
QString name;
|
||||
QString version;
|
||||
QString description;
|
||||
QString icon;
|
||||
QString repository;
|
||||
QString website;
|
||||
QString license;
|
||||
QStringList authors;
|
||||
AgentDistribution distribution;
|
||||
AgentSource source = AgentSource::BundledSnapshot;
|
||||
QString origin;
|
||||
|
||||
bool isLaunchable() const;
|
||||
};
|
||||
|
||||
QString agentSourceName(AgentSource source);
|
||||
QString agentDistributionName(AgentDistributionKind kind);
|
||||
|
||||
QString currentBinaryPlatform();
|
||||
const AgentBinaryTarget *binaryTargetForCurrentPlatform(const AgentDefinition &agent);
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
class AgentKnowledgeService
|
||||
{
|
||||
public:
|
||||
virtual ~AgentKnowledgeService() = default;
|
||||
|
||||
virtual QString start() = 0;
|
||||
virtual void stop() = 0;
|
||||
virtual QString serverName() const = 0;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "AgentLaunch.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QProcess>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QRegularExpression>
|
||||
#include <QStandardPaths>
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
namespace {
|
||||
|
||||
QList<LLMQore::Acp::EnvVariable> toLlmqoreEnv(const QList<AgentEnvVariable> &env)
|
||||
{
|
||||
QList<LLMQore::Acp::EnvVariable> result;
|
||||
result.reserve(env.size());
|
||||
for (const AgentEnvVariable &variable : env)
|
||||
result.append({variable.name, variable.value});
|
||||
return result;
|
||||
}
|
||||
|
||||
void applyRunner(
|
||||
LLMQore::Acp::AcpAgentConfig &config,
|
||||
const QString &runner,
|
||||
const QStringList &runnerFlags,
|
||||
const AgentDistribution &distribution)
|
||||
{
|
||||
config.command = runner;
|
||||
config.args = runnerFlags;
|
||||
config.args.append(distribution.package);
|
||||
config.args.append(distribution.args);
|
||||
}
|
||||
|
||||
QProcessEnvironment harvestLoginShellEnvironment()
|
||||
{
|
||||
QProcessEnvironment result;
|
||||
|
||||
#ifndef Q_OS_WIN
|
||||
static const QRegularExpression assignment(QStringLiteral("^([A-Za-z_][A-Za-z0-9_]*)=(.*)$"));
|
||||
|
||||
const QString shell = qEnvironmentVariable("SHELL", QStringLiteral("/bin/sh"));
|
||||
|
||||
QProcess process;
|
||||
process.setProcessChannelMode(QProcess::SeparateChannels);
|
||||
process.start(
|
||||
shell,
|
||||
{QStringLiteral("-l"), QStringLiteral("-i"), QStringLiteral("-c"), QStringLiteral("env")});
|
||||
|
||||
if (!process.waitForFinished(5000)) {
|
||||
process.kill();
|
||||
process.waitForFinished(1000);
|
||||
return result;
|
||||
}
|
||||
|
||||
const QString output = QString::fromUtf8(process.readAllStandardOutput());
|
||||
const QList<QString> lines = output.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
|
||||
for (const QString &line : lines) {
|
||||
const QRegularExpressionMatch match = assignment.match(line);
|
||||
if (match.hasMatch())
|
||||
result.insert(match.captured(1), match.captured(2));
|
||||
}
|
||||
#endif
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const QProcessEnvironment &loginShellEnvironment()
|
||||
{
|
||||
static const QProcessEnvironment environment = harvestLoginShellEnvironment();
|
||||
return environment;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<LLMQore::Acp::AcpAgentConfig> agentLaunchConfig(
|
||||
const AgentDefinition &agent, const QString &workingDirectory)
|
||||
{
|
||||
if (!agent.isLaunchable())
|
||||
return std::nullopt;
|
||||
|
||||
LLMQore::Acp::AcpAgentConfig config;
|
||||
config.cwd = workingDirectory;
|
||||
config.env = toLlmqoreEnv(agent.distribution.env);
|
||||
|
||||
switch (agent.distribution.kind) {
|
||||
case AgentDistributionKind::Npx:
|
||||
applyRunner(
|
||||
config, QStringLiteral("npx"), QStringList{QStringLiteral("-y")}, agent.distribution);
|
||||
break;
|
||||
case AgentDistributionKind::Uvx:
|
||||
applyRunner(config, QStringLiteral("uvx"), {}, agent.distribution);
|
||||
break;
|
||||
case AgentDistributionKind::Command:
|
||||
config.command = agent.distribution.command;
|
||||
config.args = agent.distribution.args;
|
||||
break;
|
||||
case AgentDistributionKind::Binary:
|
||||
case AgentDistributionKind::Unknown:
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
QStringList splitSearchPaths(const QString &value)
|
||||
{
|
||||
QStringList directories;
|
||||
const QList<QString> entries = value.split(QDir::listSeparator(), Qt::SkipEmptyParts);
|
||||
for (const QString &entry : entries) {
|
||||
const QString trimmed = entry.trimmed();
|
||||
if (!trimmed.isEmpty())
|
||||
directories.append(trimmed);
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
void applyExtraSearchPaths(LLMQore::Acp::AcpAgentConfig &config, const QStringList &extraDirectories)
|
||||
{
|
||||
if (extraDirectories.isEmpty())
|
||||
return;
|
||||
|
||||
const bool isBareName = !config.command.isEmpty() && !config.command.contains(QLatin1Char('/'))
|
||||
&& !config.command.contains(QLatin1Char('\\'));
|
||||
if (isBareName) {
|
||||
const QString resolved = QStandardPaths::findExecutable(config.command, extraDirectories);
|
||||
if (!resolved.isEmpty())
|
||||
config.command = resolved;
|
||||
}
|
||||
|
||||
const QChar separator = QDir::listSeparator();
|
||||
const QString inherited = QProcessEnvironment::systemEnvironment().value(QStringLiteral("PATH"));
|
||||
const QString merged = inherited.isEmpty()
|
||||
? extraDirectories.join(separator)
|
||||
: extraDirectories.join(separator) + separator + inherited;
|
||||
|
||||
config.env.prepend({QStringLiteral("PATH"), merged});
|
||||
}
|
||||
|
||||
QStringList splitVariableNames(const QString &value)
|
||||
{
|
||||
static const QRegularExpression separators(QStringLiteral("[,;\\s]+"));
|
||||
|
||||
QStringList names;
|
||||
const QList<QString> entries = value.split(separators, Qt::SkipEmptyParts);
|
||||
for (const QString &entry : entries) {
|
||||
const QString trimmed = entry.trimmed();
|
||||
if (!trimmed.isEmpty())
|
||||
names.append(trimmed);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
void applyForwardedEnvironment(LLMQore::Acp::AcpAgentConfig &config, const QStringList &variableNames)
|
||||
{
|
||||
if (variableNames.isEmpty())
|
||||
return;
|
||||
|
||||
const QProcessEnvironment inherited = QProcessEnvironment::systemEnvironment();
|
||||
|
||||
QList<LLMQore::Acp::EnvVariable> forwarded;
|
||||
QStringList missing;
|
||||
for (const QString &name : variableNames) {
|
||||
if (inherited.contains(name))
|
||||
forwarded.append({name, inherited.value(name)});
|
||||
else
|
||||
missing.append(name);
|
||||
}
|
||||
|
||||
if (!missing.isEmpty()) {
|
||||
const QProcessEnvironment &shell = loginShellEnvironment();
|
||||
for (const QString &name : std::as_const(missing)) {
|
||||
if (shell.contains(name))
|
||||
forwarded.append({name, shell.value(name)});
|
||||
}
|
||||
}
|
||||
|
||||
for (auto it = forwarded.crbegin(); it != forwarded.crend(); ++it)
|
||||
config.env.prepend(*it);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include <LLMQore/AcpAgentConfig.hpp>
|
||||
|
||||
#include "acp/AgentDefinition.hpp"
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
std::optional<LLMQore::Acp::AcpAgentConfig> agentLaunchConfig(
|
||||
const AgentDefinition &agent, const QString &workingDirectory);
|
||||
|
||||
QStringList splitSearchPaths(const QString &value);
|
||||
|
||||
void applyExtraSearchPaths(LLMQore::Acp::AcpAgentConfig &config, const QStringList &extraDirectories);
|
||||
|
||||
QStringList splitVariableNames(const QString &value);
|
||||
|
||||
void applyForwardedEnvironment(
|
||||
LLMQore::Acp::AcpAgentConfig &config, const QStringList &variableNames);
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,223 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "AgentRegistryParser.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
namespace {
|
||||
|
||||
QStringList readStringList(const QJsonValue &value)
|
||||
{
|
||||
QStringList result;
|
||||
const QJsonArray array = value.toArray();
|
||||
for (const QJsonValue &entry : array) {
|
||||
if (entry.isString())
|
||||
result.append(entry.toString());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QList<AgentEnvVariable> readEnv(const QJsonValue &value)
|
||||
{
|
||||
QList<AgentEnvVariable> result;
|
||||
|
||||
if (value.isObject()) {
|
||||
const QJsonObject object = value.toObject();
|
||||
for (auto it = object.constBegin(); it != object.constEnd(); ++it)
|
||||
result.append({it.key(), it.value().toString()});
|
||||
return result;
|
||||
}
|
||||
|
||||
const QJsonArray array = value.toArray();
|
||||
for (const QJsonValue &entry : array) {
|
||||
const QJsonObject object = entry.toObject();
|
||||
const QString name = object.value(QLatin1String("name")).toString();
|
||||
if (!name.isEmpty())
|
||||
result.append({name, object.value(QLatin1String("value")).toString()});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
AgentDistribution readRunnerDistribution(const QJsonObject &object, AgentDistributionKind kind)
|
||||
{
|
||||
AgentDistribution distribution;
|
||||
distribution.kind = kind;
|
||||
distribution.package = object.value(QLatin1String("package")).toString();
|
||||
distribution.args = readStringList(object.value(QLatin1String("args")));
|
||||
distribution.env = readEnv(object.value(QLatin1String("env")));
|
||||
return distribution;
|
||||
}
|
||||
|
||||
AgentDistribution readBinaryDistribution(const QJsonObject &object)
|
||||
{
|
||||
AgentDistribution distribution;
|
||||
distribution.kind = AgentDistributionKind::Binary;
|
||||
|
||||
for (auto it = object.constBegin(); it != object.constEnd(); ++it) {
|
||||
const QJsonObject entry = it.value().toObject();
|
||||
AgentBinaryTarget target;
|
||||
target.platform = it.key();
|
||||
target.archive = entry.value(QLatin1String("archive")).toString();
|
||||
target.sha256 = entry.value(QLatin1String("sha256")).toString();
|
||||
target.cmd = entry.value(QLatin1String("cmd")).toString();
|
||||
target.args = readStringList(entry.value(QLatin1String("args")));
|
||||
target.env = readEnv(entry.value(QLatin1String("env")));
|
||||
distribution.binaryTargets.append(target);
|
||||
}
|
||||
return distribution;
|
||||
}
|
||||
|
||||
AgentDistribution readCommandDistribution(const QJsonObject &object)
|
||||
{
|
||||
AgentDistribution distribution;
|
||||
distribution.kind = AgentDistributionKind::Command;
|
||||
distribution.command = object.value(QLatin1String("cmd")).toString();
|
||||
if (distribution.command.isEmpty())
|
||||
distribution.command = object.value(QLatin1String("command")).toString();
|
||||
distribution.args = readStringList(object.value(QLatin1String("args")));
|
||||
distribution.env = readEnv(object.value(QLatin1String("env")));
|
||||
return distribution;
|
||||
}
|
||||
|
||||
QString distributionProblem(const AgentDistribution &distribution)
|
||||
{
|
||||
switch (distribution.kind) {
|
||||
case AgentDistributionKind::Unknown:
|
||||
return QStringLiteral("has no supported distribution");
|
||||
case AgentDistributionKind::Npx:
|
||||
case AgentDistributionKind::Uvx:
|
||||
if (distribution.package.isEmpty())
|
||||
return QStringLiteral("has a %1 distribution without a package")
|
||||
.arg(agentDistributionName(distribution.kind));
|
||||
break;
|
||||
case AgentDistributionKind::Command:
|
||||
if (distribution.command.isEmpty())
|
||||
return QStringLiteral("has a command distribution without a cmd");
|
||||
break;
|
||||
case AgentDistributionKind::Binary:
|
||||
if (distribution.binaryTargets.isEmpty())
|
||||
return QStringLiteral("has a binary distribution without platform targets");
|
||||
break;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
AgentDistribution readDistribution(const QJsonObject &object)
|
||||
{
|
||||
if (object.contains(QLatin1String("npx")))
|
||||
return readRunnerDistribution(
|
||||
object.value(QLatin1String("npx")).toObject(), AgentDistributionKind::Npx);
|
||||
|
||||
if (object.contains(QLatin1String("uvx")))
|
||||
return readRunnerDistribution(
|
||||
object.value(QLatin1String("uvx")).toObject(), AgentDistributionKind::Uvx);
|
||||
|
||||
if (object.contains(QLatin1String("command")))
|
||||
return readCommandDistribution(object.value(QLatin1String("command")).toObject());
|
||||
|
||||
if (object.contains(QLatin1String("binary")))
|
||||
return readBinaryDistribution(object.value(QLatin1String("binary")).toObject());
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace AgentRegistryParser {
|
||||
|
||||
AgentParseResult parse(const QJsonValue &root, AgentSource source, const QString &origin)
|
||||
{
|
||||
AgentParseResult result;
|
||||
|
||||
QJsonArray entries;
|
||||
if (root.isArray()) {
|
||||
entries = root.toArray();
|
||||
} else if (root.isObject()) {
|
||||
const QJsonObject object = root.toObject();
|
||||
if (object.contains(QLatin1String("agents"))) {
|
||||
const QJsonValue agents = object.value(QLatin1String("agents"));
|
||||
if (!agents.isArray()) {
|
||||
result.warnings.append(QStringLiteral("%1: 'agents' is not an array").arg(origin));
|
||||
return result;
|
||||
}
|
||||
entries = agents.toArray();
|
||||
} else if (object.contains(QLatin1String("id")))
|
||||
entries.append(root);
|
||||
else
|
||||
result.warnings.append(QStringLiteral("%1: no agents found").arg(origin));
|
||||
} else {
|
||||
result.warnings.append(QStringLiteral("%1: not a JSON object or array").arg(origin));
|
||||
return result;
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
for (const QJsonValue &entry : std::as_const(entries)) {
|
||||
const int position = index++;
|
||||
if (!entry.isObject()) {
|
||||
result.warnings.append(
|
||||
QStringLiteral("%1: entry %2 is not an object").arg(origin).arg(position));
|
||||
continue;
|
||||
}
|
||||
|
||||
const QJsonObject object = entry.toObject();
|
||||
AgentDefinition agent;
|
||||
agent.id = object.value(QLatin1String("id")).toString();
|
||||
if (agent.id.isEmpty()) {
|
||||
result.warnings.append(
|
||||
QStringLiteral("%1: entry %2 has no id").arg(origin).arg(position));
|
||||
continue;
|
||||
}
|
||||
|
||||
agent.name = object.value(QLatin1String("name")).toString();
|
||||
if (agent.name.isEmpty())
|
||||
agent.name = agent.id;
|
||||
|
||||
agent.version = object.value(QLatin1String("version")).toString();
|
||||
agent.description = object.value(QLatin1String("description")).toString();
|
||||
agent.icon = object.value(QLatin1String("icon")).toString();
|
||||
agent.repository = object.value(QLatin1String("repository")).toString();
|
||||
agent.website = object.value(QLatin1String("website")).toString();
|
||||
agent.license = object.value(QLatin1String("license")).toString();
|
||||
agent.authors = readStringList(object.value(QLatin1String("authors")));
|
||||
agent.distribution = readDistribution(
|
||||
object.value(QLatin1String("distribution")).toObject());
|
||||
agent.source = source;
|
||||
agent.origin = origin;
|
||||
|
||||
const QString problem = distributionProblem(agent.distribution);
|
||||
if (!problem.isEmpty()) {
|
||||
result.warnings.append(
|
||||
QStringLiteral("%1: agent '%2' %3").arg(origin, agent.id, problem));
|
||||
}
|
||||
|
||||
result.agents.append(agent);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
AgentParseResult parse(const QByteArray &json, AgentSource source, const QString &origin)
|
||||
{
|
||||
QJsonParseError error;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(json, &error);
|
||||
if (document.isNull()) {
|
||||
AgentParseResult result;
|
||||
result.warnings.append(QStringLiteral("%1: %2").arg(origin, error.errorString()));
|
||||
return result;
|
||||
}
|
||||
|
||||
if (document.isArray())
|
||||
return parse(QJsonValue(document.array()), source, origin);
|
||||
return parse(QJsonValue(document.object()), source, origin);
|
||||
}
|
||||
|
||||
} // namespace AgentRegistryParser
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonValue>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include "acp/AgentDefinition.hpp"
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
struct AgentParseResult
|
||||
{
|
||||
QList<AgentDefinition> agents;
|
||||
QStringList warnings;
|
||||
};
|
||||
|
||||
namespace AgentRegistryParser {
|
||||
|
||||
AgentParseResult parse(const QJsonValue &root, AgentSource source, const QString &origin);
|
||||
AgentParseResult parse(const QByteArray &json, AgentSource source, const QString &origin);
|
||||
|
||||
} // namespace AgentRegistryParser
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "AgentSpawn.hpp"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
|
||||
#include <extensionsystem/pluginmanager.h>
|
||||
#include <extensionsystem/pluginspec.h>
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
|
||||
#include <LLMQore/AcpClient.hpp>
|
||||
|
||||
#include "acp/AgentLaunch.hpp"
|
||||
#include "settings/AgentsSettings.hpp"
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
namespace {
|
||||
|
||||
LLMQore::Acp::Implementation clientIdentity()
|
||||
{
|
||||
QString version;
|
||||
for (const ExtensionSystem::PluginSpec *spec : ExtensionSystem::PluginManager::plugins()) {
|
||||
if (spec->name() == QLatin1String("QodeAssist")) {
|
||||
version = spec->version();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {QStringLiteral("QodeAssist"), version, QStringLiteral("QodeAssist")};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString agentWorkingDirectory()
|
||||
{
|
||||
if (const ProjectExplorer::Project *project = ProjectExplorer::ProjectManager::startupProject())
|
||||
return project->projectDirectory().path();
|
||||
return QDir::homePath();
|
||||
}
|
||||
|
||||
AgentProcess spawnAgent(const AgentDefinition &agent, const QString &cwd, QObject *parent)
|
||||
{
|
||||
auto config = agentLaunchConfig(agent, cwd);
|
||||
if (!config)
|
||||
return {};
|
||||
|
||||
auto &settings = Settings::agentsSettings();
|
||||
applyExtraSearchPaths(*config, splitSearchPaths(settings.agentExtraPaths.volatileValue()));
|
||||
applyForwardedEnvironment(
|
||||
*config, splitVariableNames(settings.agentForwardedVariables.volatileValue()));
|
||||
|
||||
auto *transport = config->createTransport(nullptr);
|
||||
auto *client = new LLMQore::Acp::AcpClient(transport, clientIdentity(), parent);
|
||||
transport->setParent(client);
|
||||
|
||||
return {client, config->command};
|
||||
}
|
||||
|
||||
QString runnerHint(const QString &command)
|
||||
{
|
||||
if (command.endsWith(QLatin1String("npx"))) {
|
||||
return QCoreApplication::translate(
|
||||
"QtC::QodeAssist", "Make sure Node.js is installed and 'npx' is on PATH.");
|
||||
}
|
||||
if (command.endsWith(QLatin1String("uvx"))) {
|
||||
return QCoreApplication::translate(
|
||||
"QtC::QodeAssist", "Make sure uv is installed and 'uvx' is on PATH.");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
#include "acp/AgentDefinition.hpp"
|
||||
|
||||
namespace LLMQore::Acp {
|
||||
class AcpClient;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
struct AgentProcess
|
||||
{
|
||||
LLMQore::Acp::AcpClient *client = nullptr;
|
||||
QString command;
|
||||
};
|
||||
|
||||
QString agentWorkingDirectory();
|
||||
|
||||
AgentProcess spawnAgent(const AgentDefinition &agent, const QString &cwd, QObject *parent);
|
||||
|
||||
QString runnerHint(const QString &command);
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "AgentTester.hpp"
|
||||
|
||||
#include <chrono>
|
||||
|
||||
#include <LLMQore/AcpClient.hpp>
|
||||
#include <LLMQore/RpcStdioTransport.hpp>
|
||||
|
||||
#include "acp/AgentSpawn.hpp"
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int maxStderrLines = 20;
|
||||
|
||||
QString capabilityList(const LLMQore::Acp::AgentCapabilities &capabilities)
|
||||
{
|
||||
QStringList prompt;
|
||||
if (capabilities.promptCapabilities.image)
|
||||
prompt.append(AgentTester::tr("image"));
|
||||
if (capabilities.promptCapabilities.audio)
|
||||
prompt.append(AgentTester::tr("audio"));
|
||||
if (capabilities.promptCapabilities.embeddedContext)
|
||||
prompt.append(AgentTester::tr("embedded context"));
|
||||
return prompt.join(QStringLiteral(", "));
|
||||
}
|
||||
|
||||
QString mcpList(const LLMQore::Acp::AgentCapabilities &capabilities)
|
||||
{
|
||||
QStringList transports;
|
||||
if (capabilities.mcpCapabilities.http)
|
||||
transports.append(QStringLiteral("http"));
|
||||
if (capabilities.mcpCapabilities.sse)
|
||||
transports.append(QStringLiteral("sse"));
|
||||
return transports.join(QStringLiteral(", "));
|
||||
}
|
||||
|
||||
QString describe(const AgentDefinition &agent, const LLMQore::Acp::InitializeResult &result)
|
||||
{
|
||||
const QString name = result.agentInfo && !result.agentInfo->name.isEmpty()
|
||||
? result.agentInfo->name
|
||||
: agent.name;
|
||||
const QString version = result.agentInfo && !result.agentInfo->version.isEmpty()
|
||||
? result.agentInfo->version
|
||||
: agent.version;
|
||||
|
||||
QStringList lines;
|
||||
lines.append(version.isEmpty() ? name : QStringLiteral("%1 %2").arg(name, version));
|
||||
lines.append(AgentTester::tr("Protocol version: %1").arg(result.protocolVersion));
|
||||
lines.append(
|
||||
AgentTester::tr("Session persistence (loadSession): %1")
|
||||
.arg(
|
||||
result.agentCapabilities.loadSession ? AgentTester::tr("supported")
|
||||
: AgentTester::tr("not supported")));
|
||||
|
||||
const QString prompt = capabilityList(result.agentCapabilities);
|
||||
lines.append(
|
||||
AgentTester::tr("Prompt content: %1")
|
||||
.arg(prompt.isEmpty() ? AgentTester::tr("text only") : prompt));
|
||||
|
||||
const QString mcp = mcpList(result.agentCapabilities);
|
||||
if (!mcp.isEmpty())
|
||||
lines.append(AgentTester::tr("MCP transports: %1").arg(mcp));
|
||||
|
||||
if (result.authMethods.isEmpty()) {
|
||||
lines.append(AgentTester::tr("Authentication: not required"));
|
||||
} else {
|
||||
QStringList methods;
|
||||
for (const LLMQore::Acp::AuthMethod &method : result.authMethods)
|
||||
methods.append(method.name.isEmpty() ? method.id : method.name);
|
||||
lines.append(AgentTester::tr("Authentication: %1").arg(methods.join(QStringLiteral(", "))));
|
||||
}
|
||||
|
||||
return lines.join(QLatin1Char('\n'));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AgentTester::AgentTester(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
void AgentTester::start(const AgentDefinition &agent, const QString &workingDirectory)
|
||||
{
|
||||
if (m_running)
|
||||
return;
|
||||
|
||||
const AgentProcess process = spawnAgent(agent, workingDirectory, this);
|
||||
if (!process.client) {
|
||||
emit finished(false, tr("This agent has no launchable distribution."));
|
||||
return;
|
||||
}
|
||||
|
||||
m_stderr.clear();
|
||||
m_runner = process.command;
|
||||
m_running = true;
|
||||
|
||||
m_client = process.client;
|
||||
m_transport = qobject_cast<LLMQore::Rpc::StdioClientTransport *>(m_client->transport());
|
||||
|
||||
connect(m_client, &LLMQore::Acp::AcpClient::agentStderr, this, [this](const QString &line) {
|
||||
if (m_stderr.size() < maxStderrLines)
|
||||
m_stderr.append(line);
|
||||
});
|
||||
connect(m_client, &LLMQore::Acp::AcpClient::errorOccurred, this, [this](const QString &error) {
|
||||
report(false, error);
|
||||
});
|
||||
|
||||
m_client->connectAndInitialize(std::chrono::seconds(60))
|
||||
.then(
|
||||
this,
|
||||
[this, agent](const LLMQore::Acp::InitializeResult &result) {
|
||||
report(true, describe(agent, result));
|
||||
})
|
||||
.onFailed(this, [this](const std::exception &e) {
|
||||
report(false, QString::fromUtf8(e.what()));
|
||||
});
|
||||
}
|
||||
|
||||
void AgentTester::cancel()
|
||||
{
|
||||
if (m_running)
|
||||
report(false, tr("Test cancelled."));
|
||||
}
|
||||
|
||||
void AgentTester::report(bool ok, const QString &text)
|
||||
{
|
||||
if (!m_running)
|
||||
return;
|
||||
|
||||
m_running = false;
|
||||
const QString details = ok ? text : text + diagnostics();
|
||||
releaseClient();
|
||||
emit finished(ok, details);
|
||||
}
|
||||
|
||||
QString AgentTester::diagnostics() const
|
||||
{
|
||||
QString result;
|
||||
|
||||
const QString hint = runnerHint(m_runner);
|
||||
if (!hint.isEmpty())
|
||||
result += QLatin1Char('\n') + hint;
|
||||
|
||||
if (!m_stderr.isEmpty()) {
|
||||
result += QLatin1Char('\n') + tr("Agent output:") + QLatin1Char('\n')
|
||||
+ m_stderr.join(QLatin1Char('\n'));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void AgentTester::releaseClient()
|
||||
{
|
||||
LLMQore::Acp::AcpClient *client = m_client;
|
||||
LLMQore::Rpc::StdioClientTransport *transport = m_transport;
|
||||
m_client = nullptr;
|
||||
m_transport = nullptr;
|
||||
|
||||
if (client)
|
||||
client->disconnect(this);
|
||||
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[client, transport]() {
|
||||
if (client) {
|
||||
client->shutdown();
|
||||
client->deleteLater();
|
||||
}
|
||||
if (transport)
|
||||
transport->deleteLater();
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include "acp/AgentDefinition.hpp"
|
||||
|
||||
namespace LLMQore::Acp {
|
||||
class AcpClient;
|
||||
}
|
||||
|
||||
namespace LLMQore::Rpc {
|
||||
class StdioClientTransport;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
class AgentTester : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit AgentTester(QObject *parent = nullptr);
|
||||
|
||||
void start(const AgentDefinition &agent, const QString &workingDirectory);
|
||||
void cancel();
|
||||
|
||||
bool isRunning() const { return m_running; }
|
||||
|
||||
signals:
|
||||
void finished(bool ok, const QString &report);
|
||||
|
||||
private:
|
||||
void report(bool ok, const QString &text);
|
||||
void releaseClient();
|
||||
QString diagnostics() const;
|
||||
|
||||
LLMQore::Acp::AcpClient *m_client = nullptr;
|
||||
LLMQore::Rpc::StdioClientTransport *m_transport = nullptr;
|
||||
QStringList m_stderr;
|
||||
QString m_runner;
|
||||
bool m_running = false;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,29 @@
|
||||
add_library(QodeAssistAcp STATIC
|
||||
AgentBinding.hpp AgentBinding.cpp
|
||||
AgentDefinition.hpp AgentDefinition.cpp
|
||||
AgentRegistryParser.hpp AgentRegistryParser.cpp
|
||||
AgentCatalog.hpp AgentCatalog.cpp
|
||||
AgentLaunch.hpp AgentLaunch.cpp
|
||||
AgentCatalogStore.hpp AgentCatalogStore.cpp
|
||||
AgentSpawn.hpp AgentSpawn.cpp
|
||||
AgentTester.hpp AgentTester.cpp
|
||||
ChatPermissionProvider.hpp ChatPermissionProvider.cpp
|
||||
AcpChatBackend.hpp AcpChatBackend.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(QodeAssistAcp
|
||||
PUBLIC
|
||||
Qt::Core
|
||||
Qt::Network
|
||||
QtCreator::Core
|
||||
QtCreator::Utils
|
||||
QtCreator::ProjectExplorer
|
||||
QtCreator::ExtensionSystem
|
||||
LLMQore
|
||||
QodeAssistSession
|
||||
PRIVATE
|
||||
QodeAssistLogger
|
||||
QodeAssistSettings
|
||||
)
|
||||
|
||||
target_include_directories(QodeAssistAcp PUBLIC ${CMAKE_SOURCE_DIR}/sources)
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChatPermissionProvider.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#include <QPromise>
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
ChatPermissionProvider::ChatPermissionProvider(Session::TurnLedger *ledger, QObject *parent)
|
||||
: LLMQore::Acp::AcpPermissionProvider(parent)
|
||||
, m_ledger(ledger)
|
||||
{}
|
||||
|
||||
void ChatPermissionProvider::setRequestHandler(RequestHandler handler)
|
||||
{
|
||||
m_requestHandler = std::move(handler);
|
||||
}
|
||||
|
||||
QFuture<LLMQore::Acp::RequestPermissionResult> ChatPermissionProvider::requestPermission(
|
||||
const QString &sessionId,
|
||||
const LLMQore::Acp::ToolCall &toolCall,
|
||||
const QList<LLMQore::Acp::PermissionOption> &options)
|
||||
{
|
||||
Q_UNUSED(sessionId)
|
||||
|
||||
auto promise = std::make_shared<QPromise<LLMQore::Acp::RequestPermissionResult>>();
|
||||
promise->start();
|
||||
|
||||
const auto finish = [promise](const LLMQore::Acp::RequestPermissionResult &result) {
|
||||
promise->addResult(result);
|
||||
promise->finish();
|
||||
};
|
||||
|
||||
if (!m_requestHandler) {
|
||||
finish(LLMQore::Acp::RequestPermissionResult::cancelled());
|
||||
return promise->future();
|
||||
}
|
||||
|
||||
const QString requestId = m_ledger->registerPermission(
|
||||
[finish](const QString &optionId) {
|
||||
finish(LLMQore::Acp::RequestPermissionResult::selected(optionId));
|
||||
},
|
||||
[finish] { finish(LLMQore::Acp::RequestPermissionResult::cancelled()); });
|
||||
|
||||
QFuture<LLMQore::Acp::RequestPermissionResult> future = promise->future();
|
||||
m_requestHandler(requestId, toolCall, options);
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include <LLMQore/AcpPermissionProvider.hpp>
|
||||
|
||||
#include "session/TurnLedger.hpp"
|
||||
|
||||
namespace QodeAssist::Acp {
|
||||
|
||||
class ChatPermissionProvider : public LLMQore::Acp::AcpPermissionProvider
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using RequestHandler = std::function<void(
|
||||
const QString &requestId,
|
||||
const LLMQore::Acp::ToolCall &toolCall,
|
||||
const QList<LLMQore::Acp::PermissionOption> &options)>;
|
||||
|
||||
explicit ChatPermissionProvider(Session::TurnLedger *ledger, QObject *parent = nullptr);
|
||||
|
||||
void setRequestHandler(RequestHandler handler);
|
||||
|
||||
QFuture<LLMQore::Acp::RequestPermissionResult> requestPermission(
|
||||
const QString &sessionId,
|
||||
const LLMQore::Acp::ToolCall &toolCall,
|
||||
const QList<LLMQore::Acp::PermissionOption> &options) override;
|
||||
|
||||
private:
|
||||
RequestHandler m_requestHandler;
|
||||
Session::TurnLedger *m_ledger = nullptr;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Acp
|
||||
@@ -15,7 +15,6 @@
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "context/RulesLoader.hpp"
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
@@ -283,17 +282,6 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
|
||||
? m_completeSettings.systemPromptForNonFimModels()
|
||||
: m_completeSettings.systemPrompt());
|
||||
|
||||
auto project = Context::RulesLoader::getActiveProject();
|
||||
if (project) {
|
||||
QString projectRules
|
||||
= Context::RulesLoader::loadRulesForProject(project, Context::RulesContext::Completions);
|
||||
|
||||
if (!projectRules.isEmpty()) {
|
||||
systemPrompt += "\n\n# Project Rules\n\n" + projectRules;
|
||||
LOG_MESSAGE("Loaded project rules for completion");
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedContext.fileContext.has_value())
|
||||
systemPrompt.append(updatedContext.fileContext.value());
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "widgets/RefactorWidgetHandler.hpp"
|
||||
#include "refactor/RefactorContextHelper.hpp"
|
||||
#include <context/ChangesManager.h>
|
||||
#include <logger/Logger.hpp>
|
||||
|
||||
using namespace LanguageServerProtocol;
|
||||
@@ -201,10 +200,6 @@ void QodeAssistClient::openDocument(TextEditor::TextDocument *document)
|
||||
if (!textEditor || textEditor->document() != document)
|
||||
return;
|
||||
|
||||
if (Settings::codeCompletionSettings().useProjectChangesCache())
|
||||
Context::ChangesManager::instance()
|
||||
.addChange(document, position, charsRemoved, charsAdded);
|
||||
|
||||
TextEditorWidget *widget = textEditor->editorWidget();
|
||||
if (widget->isReadOnly() || widget->multiTextCursor().hasMultipleCursors())
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
add_library(Context STATIC
|
||||
DocumentContextReader.hpp DocumentContextReader.cpp
|
||||
ChangesManager.h ChangesManager.cpp
|
||||
FileEditManager.hpp FileEditManager.cpp
|
||||
ContextManager.hpp ContextManager.cpp
|
||||
ContentFile.hpp
|
||||
DocumentReaderQtCreator.hpp
|
||||
@@ -10,7 +10,6 @@ add_library(Context STATIC
|
||||
IContextManager.hpp
|
||||
IgnoreManager.hpp IgnoreManager.cpp
|
||||
ProjectUtils.hpp ProjectUtils.cpp
|
||||
RulesLoader.hpp RulesLoader.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(Context
|
||||
|
||||
@@ -12,6 +12,7 @@ struct ContentFile
|
||||
{
|
||||
QString filename;
|
||||
QString content;
|
||||
QString path;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Context
|
||||
|
||||
@@ -41,9 +41,9 @@ QString ContextManager::readFile(const QString &filePath) const
|
||||
return content;
|
||||
}
|
||||
|
||||
QList<ContentFile> ContextManager::getContentFiles(const QStringList &filePaths) const
|
||||
QStringList ContextManager::allowedPaths(const QStringList &filePaths) const
|
||||
{
|
||||
QList<ContentFile> files;
|
||||
QStringList allowed;
|
||||
for (const QString &path : filePaths) {
|
||||
auto project = ProjectExplorer::ProjectManager::projectForFile(
|
||||
Utils::FilePath::fromString(path));
|
||||
@@ -52,9 +52,16 @@ QList<ContentFile> ContextManager::getContentFiles(const QStringList &filePaths)
|
||||
continue;
|
||||
}
|
||||
|
||||
ContentFile contentFile = createContentFile(path);
|
||||
files.append(contentFile);
|
||||
allowed.append(path);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
QList<ContentFile> ContextManager::getContentFiles(const QStringList &filePaths) const
|
||||
{
|
||||
QList<ContentFile> files;
|
||||
for (const QString &path : allowedPaths(filePaths))
|
||||
files.append(createContentFile(path));
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -85,6 +92,7 @@ ContentFile ContextManager::createContentFile(const QString &filePath) const
|
||||
QFileInfo fileInfo(filePath);
|
||||
contentFile.filename = fileInfo.fileName();
|
||||
contentFile.content = readFile(filePath);
|
||||
contentFile.path = fileInfo.absoluteFilePath();
|
||||
return contentFile;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ public:
|
||||
~ContextManager() override = default;
|
||||
|
||||
QString readFile(const QString &filePath) const override;
|
||||
QStringList allowedPaths(const QStringList &filePaths) const override;
|
||||
QList<ContentFile> getContentFiles(const QStringList &filePaths) const override;
|
||||
QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const override;
|
||||
ContentFile createContentFile(const QString &filePath) const override;
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
|
||||
#include "CodeCompletionSettings.hpp"
|
||||
|
||||
#include "ChangesManager.h"
|
||||
|
||||
const QRegularExpression &getYearRegex()
|
||||
{
|
||||
static const QRegularExpression yearRegex("\\b(19|20)\\d{2}\\b");
|
||||
@@ -274,10 +272,6 @@ LLMCore::ContextData DocumentContextReader::prepareContext(
|
||||
QString fileContext;
|
||||
fileContext.append("\n ").append(getLanguageAndFileInfo());
|
||||
|
||||
if (settings.useProjectChangesCache())
|
||||
fileContext.append("Recent Project Changes Context:\n ")
|
||||
.append(ChangesManager::instance().getRecentChangesContext(m_textDocument));
|
||||
|
||||
return {.prefix = contextBefore, .suffix = contextAfter, .fileContext = fileContext};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "ChangesManager.h"
|
||||
#include "CodeCompletionSettings.hpp"
|
||||
#include "FileEditManager.hpp"
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <coreplugin/editormanager/ieditor.h>
|
||||
#include <texteditor/textdocument.h>
|
||||
#include <logger/Logger.hpp>
|
||||
#include <algorithm>
|
||||
#include <QFile>
|
||||
@@ -15,61 +15,20 @@
|
||||
|
||||
namespace QodeAssist::Context {
|
||||
|
||||
ChangesManager &ChangesManager::instance()
|
||||
FileEditManager &FileEditManager::instance()
|
||||
{
|
||||
static ChangesManager instance;
|
||||
static FileEditManager instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
ChangesManager::ChangesManager()
|
||||
FileEditManager::FileEditManager()
|
||||
: QObject(nullptr)
|
||||
, m_undoStack(new QUndoStack(this))
|
||||
{}
|
||||
|
||||
ChangesManager::~ChangesManager() {}
|
||||
FileEditManager::~FileEditManager() {}
|
||||
|
||||
void ChangesManager::addChange(
|
||||
TextEditor::TextDocument *document, int position, int charsRemoved, int charsAdded)
|
||||
{
|
||||
auto &documentQueue = m_documentChanges[document];
|
||||
|
||||
QTextBlock block = document->document()->findBlock(position);
|
||||
int lineNumber = block.blockNumber();
|
||||
QString lineContent = block.text();
|
||||
QString fileName = document->filePath().fileName();
|
||||
|
||||
ChangeInfo change{fileName, lineNumber, lineContent};
|
||||
|
||||
auto it
|
||||
= std::find_if(documentQueue.begin(), documentQueue.end(), [lineNumber](const ChangeInfo &c) {
|
||||
return c.lineNumber == lineNumber;
|
||||
});
|
||||
|
||||
if (it != documentQueue.end()) {
|
||||
it->lineContent = lineContent;
|
||||
} else {
|
||||
documentQueue.enqueue(change);
|
||||
|
||||
if (documentQueue.size() > Settings::codeCompletionSettings().maxChangesCacheSize()) {
|
||||
documentQueue.dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString ChangesManager::getRecentChangesContext(const TextEditor::TextDocument *currentDocument) const
|
||||
{
|
||||
QString context;
|
||||
for (auto it = m_documentChanges.constBegin(); it != m_documentChanges.constEnd(); ++it) {
|
||||
if (it.key() != currentDocument) {
|
||||
for (const auto &change : it.value()) {
|
||||
context += change.lineContent + "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
void ChangesManager::addFileEdit(
|
||||
void FileEditManager::addFileEdit(
|
||||
const QString &editId,
|
||||
const QString &filePath,
|
||||
const QString &oldContent,
|
||||
@@ -139,7 +98,44 @@ void ChangesManager::addFileEdit(
|
||||
}
|
||||
}
|
||||
|
||||
bool ChangesManager::applyFileEdit(const QString &editId)
|
||||
void FileEditManager::registerAppliedFileEdit(
|
||||
const QString &editId,
|
||||
const QString &filePath,
|
||||
const QString &oldContent,
|
||||
const QString &newContent,
|
||||
const QString &requestId)
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
|
||||
if (m_fileEdits.contains(editId)) {
|
||||
LOG_MESSAGE(QString("File edit already exists, skipping: %1").arg(editId));
|
||||
return;
|
||||
}
|
||||
|
||||
FileEdit edit;
|
||||
edit.editId = editId;
|
||||
edit.filePath = filePath;
|
||||
edit.oldContent = oldContent;
|
||||
edit.newContent = newContent;
|
||||
edit.timestamp = QDateTime::currentDateTime();
|
||||
edit.wasAutoApplied = true;
|
||||
edit.isFromHistory = false;
|
||||
edit.status = Applied;
|
||||
edit.statusMessage = "Applied by agent";
|
||||
|
||||
m_fileEdits.insert(editId, edit);
|
||||
|
||||
if (!requestId.isEmpty())
|
||||
m_requestEdits[requestId].editIds.append(editId);
|
||||
|
||||
locker.unlock();
|
||||
emit fileEditAdded(editId);
|
||||
|
||||
LOG_MESSAGE(QString("Agent file edit registered as applied: %1 for file %2")
|
||||
.arg(editId, filePath));
|
||||
}
|
||||
|
||||
bool FileEditManager::applyFileEdit(const QString &editId)
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
|
||||
@@ -193,7 +189,7 @@ bool ChangesManager::applyFileEdit(const QString &editId)
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChangesManager::rejectFileEdit(const QString &editId)
|
||||
bool FileEditManager::rejectFileEdit(const QString &editId)
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
|
||||
@@ -220,7 +216,7 @@ bool ChangesManager::rejectFileEdit(const QString &editId)
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChangesManager::undoFileEdit(const QString &editId)
|
||||
bool FileEditManager::undoFileEdit(const QString &editId)
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
|
||||
@@ -276,13 +272,13 @@ bool ChangesManager::undoFileEdit(const QString &editId)
|
||||
return false;
|
||||
}
|
||||
|
||||
ChangesManager::FileEdit ChangesManager::getFileEdit(const QString &editId) const
|
||||
FileEditManager::FileEdit FileEditManager::getFileEdit(const QString &editId) const
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
return m_fileEdits.value(editId);
|
||||
}
|
||||
|
||||
QList<ChangesManager::FileEdit> ChangesManager::getPendingEdits() const
|
||||
QList<FileEditManager::FileEdit> FileEditManager::getPendingEdits() const
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
|
||||
@@ -295,7 +291,7 @@ QList<ChangesManager::FileEdit> ChangesManager::getPendingEdits() const
|
||||
return pendingEdits;
|
||||
}
|
||||
|
||||
bool ChangesManager::performFileEdit(
|
||||
bool FileEditManager::performFileEdit(
|
||||
const QString &filePath, const QString &oldContent, const QString &newContent, QString *errorMsg)
|
||||
{
|
||||
auto setError = [errorMsg](const QString &msg) {
|
||||
@@ -451,7 +447,7 @@ bool ChangesManager::performFileEdit(
|
||||
return true;
|
||||
}
|
||||
|
||||
int ChangesManager::levenshteinDistance(const QString &s1, const QString &s2) const
|
||||
int FileEditManager::levenshteinDistance(const QString &s1, const QString &s2) const
|
||||
{
|
||||
const int len1 = s1.length();
|
||||
const int len2 = s2.length();
|
||||
@@ -484,7 +480,7 @@ int ChangesManager::levenshteinDistance(const QString &s1, const QString &s2) co
|
||||
return d[len1][len2];
|
||||
}
|
||||
|
||||
QString ChangesManager::findBestMatchLineBased(
|
||||
QString FileEditManager::findBestMatchLineBased(
|
||||
const QString &fileContent,
|
||||
const QString &searchContent,
|
||||
double threshold,
|
||||
@@ -550,7 +546,7 @@ QString ChangesManager::findBestMatchLineBased(
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
QString ChangesManager::findBestMatch(const QString &fileContent, const QString &searchContent, double threshold, double *outSimilarity) const
|
||||
QString FileEditManager::findBestMatch(const QString &fileContent, const QString &searchContent, double threshold, double *outSimilarity) const
|
||||
{
|
||||
if (searchContent.isEmpty() || fileContent.isEmpty()) {
|
||||
if (outSimilarity) *outSimilarity = 0.0;
|
||||
@@ -631,7 +627,7 @@ QString ChangesManager::findBestMatch(const QString &fileContent, const QString
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
QString ChangesManager::findBestMatchWithNormalization(
|
||||
QString FileEditManager::findBestMatchWithNormalization(
|
||||
const QString &fileContent,
|
||||
const QString &searchContent,
|
||||
double *outSimilarity,
|
||||
@@ -677,7 +673,7 @@ QString ChangesManager::findBestMatchWithNormalization(
|
||||
return QString();
|
||||
}
|
||||
|
||||
bool ChangesManager::performFragmentReplacement(
|
||||
bool FileEditManager::performFragmentReplacement(
|
||||
const QString &filePath,
|
||||
const QString &searchContent,
|
||||
const QString &replaceContent,
|
||||
@@ -821,7 +817,7 @@ bool ChangesManager::performFragmentReplacement(
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChangesManager::applyPendingEditsForRequest(const QString &requestId, QString *errorMsg)
|
||||
bool FileEditManager::applyPendingEditsForRequest(const QString &requestId, QString *errorMsg)
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
|
||||
@@ -857,7 +853,7 @@ bool ChangesManager::applyPendingEditsForRequest(const QString &requestId, QStri
|
||||
return true;
|
||||
}
|
||||
|
||||
QList<ChangesManager::FileEdit> ChangesManager::getEditsForRequest(const QString &requestId) const
|
||||
QList<FileEditManager::FileEdit> FileEditManager::getEditsForRequest(const QString &requestId) const
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
|
||||
@@ -877,7 +873,7 @@ QList<ChangesManager::FileEdit> ChangesManager::getEditsForRequest(const QString
|
||||
return edits;
|
||||
}
|
||||
|
||||
bool ChangesManager::undoAllEditsForRequest(const QString &requestId, QString *errorMsg)
|
||||
bool FileEditManager::undoAllEditsForRequest(const QString &requestId, QString *errorMsg)
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
|
||||
@@ -962,7 +958,7 @@ bool ChangesManager::undoAllEditsForRequest(const QString &requestId, QString *e
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChangesManager::reapplyAllEditsForRequest(const QString &requestId, QString *errorMsg)
|
||||
bool FileEditManager::reapplyAllEditsForRequest(const QString &requestId, QString *errorMsg)
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
|
||||
@@ -1046,7 +1042,7 @@ bool ChangesManager::reapplyAllEditsForRequest(const QString &requestId, QString
|
||||
return true;
|
||||
}
|
||||
|
||||
void ChangesManager::archiveAllNonArchivedEdits()
|
||||
void FileEditManager::archiveAllNonArchivedEdits()
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
|
||||
@@ -1080,7 +1076,7 @@ void ChangesManager::archiveAllNonArchivedEdits()
|
||||
}
|
||||
}
|
||||
|
||||
QString ChangesManager::readFileContent(const QString &filePath) const
|
||||
QString FileEditManager::readFileContent(const QString &filePath) const
|
||||
{
|
||||
LOG_MESSAGE(QString("Reading current file content: %1").arg(filePath));
|
||||
|
||||
@@ -1112,7 +1108,7 @@ QString ChangesManager::readFileContent(const QString &filePath) const
|
||||
return content;
|
||||
}
|
||||
|
||||
bool ChangesManager::performFileEditWithDiff(
|
||||
bool FileEditManager::performFileEditWithDiff(
|
||||
const QString &filePath,
|
||||
const DiffInfo &diffInfo,
|
||||
bool reverse,
|
||||
@@ -1244,7 +1240,7 @@ bool ChangesManager::performFileEditWithDiff(
|
||||
return true;
|
||||
}
|
||||
|
||||
ChangesManager::DiffInfo ChangesManager::createDiffInfo(
|
||||
FileEditManager::DiffInfo FileEditManager::createDiffInfo(
|
||||
const QString &originalContent,
|
||||
const QString &modifiedContent,
|
||||
const QString &filePath)
|
||||
@@ -1390,7 +1386,7 @@ ChangesManager::DiffInfo ChangesManager::createDiffInfo(
|
||||
return diffInfo;
|
||||
}
|
||||
|
||||
bool ChangesManager::findHunkLocation(
|
||||
bool FileEditManager::findHunkLocation(
|
||||
const QStringList &fileLines,
|
||||
const DiffHunk &hunk,
|
||||
int &actualStartLine,
|
||||
@@ -1537,7 +1533,7 @@ bool ChangesManager::findHunkLocation(
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChangesManager::applyDiffToContent(
|
||||
bool FileEditManager::applyDiffToContent(
|
||||
QString &content,
|
||||
const DiffInfo &diffInfo,
|
||||
bool reverse,
|
||||
@@ -4,28 +4,22 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <texteditor/textdocument.h>
|
||||
#include <QDateTime>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QMutex>
|
||||
#include <QQueue>
|
||||
#include <QTimer>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUndoStack>
|
||||
|
||||
namespace QodeAssist::Context {
|
||||
|
||||
class ChangesManager : public QObject
|
||||
class FileEditManager : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
struct ChangeInfo
|
||||
{
|
||||
QString fileName;
|
||||
int lineNumber;
|
||||
QString lineContent;
|
||||
};
|
||||
|
||||
enum FileEditStatus { Pending, Applied, Rejected, Archived };
|
||||
|
||||
struct DiffHunk
|
||||
@@ -63,11 +57,7 @@ public:
|
||||
QString statusMessage;
|
||||
};
|
||||
|
||||
static ChangesManager &instance();
|
||||
|
||||
void addChange(
|
||||
TextEditor::TextDocument *document, int position, int charsRemoved, int charsAdded);
|
||||
QString getRecentChangesContext(const TextEditor::TextDocument *currentDocument) const;
|
||||
static FileEditManager &instance();
|
||||
|
||||
void addFileEdit(
|
||||
const QString &editId,
|
||||
@@ -77,20 +67,26 @@ public:
|
||||
bool autoApply = true,
|
||||
bool isFromHistory = false,
|
||||
const QString &requestId = QString());
|
||||
void registerAppliedFileEdit(
|
||||
const QString &editId,
|
||||
const QString &filePath,
|
||||
const QString &oldContent,
|
||||
const QString &newContent,
|
||||
const QString &requestId = QString());
|
||||
bool applyFileEdit(const QString &editId);
|
||||
bool rejectFileEdit(const QString &editId);
|
||||
bool undoFileEdit(const QString &editId);
|
||||
FileEdit getFileEdit(const QString &editId) const;
|
||||
QList<FileEdit> getPendingEdits() const;
|
||||
|
||||
|
||||
bool applyPendingEditsForRequest(const QString &requestId, QString *errorMsg = nullptr);
|
||||
|
||||
|
||||
QList<FileEdit> getEditsForRequest(const QString &requestId) const;
|
||||
|
||||
|
||||
bool undoAllEditsForRequest(const QString &requestId, QString *errorMsg = nullptr);
|
||||
|
||||
|
||||
bool reapplyAllEditsForRequest(const QString &requestId, QString *errorMsg = nullptr);
|
||||
|
||||
|
||||
void archiveAllNonArchivedEdits();
|
||||
|
||||
signals:
|
||||
@@ -101,19 +97,19 @@ signals:
|
||||
void fileEditArchived(const QString &editId);
|
||||
|
||||
private:
|
||||
ChangesManager();
|
||||
~ChangesManager();
|
||||
ChangesManager(const ChangesManager &) = delete;
|
||||
ChangesManager &operator=(const ChangesManager &) = delete;
|
||||
FileEditManager();
|
||||
~FileEditManager();
|
||||
FileEditManager(const FileEditManager &) = delete;
|
||||
FileEditManager &operator=(const FileEditManager &) = delete;
|
||||
|
||||
bool performFileEdit(const QString &filePath, const QString &oldContent, const QString &newContent, QString *errorMsg = nullptr);
|
||||
bool performFileEditWithDiff(const QString &filePath, const DiffInfo &diffInfo, bool reverse, QString *errorMsg = nullptr);
|
||||
QString readFileContent(const QString &filePath) const;
|
||||
|
||||
|
||||
DiffInfo createDiffInfo(const QString &originalContent, const QString &modifiedContent, const QString &filePath);
|
||||
bool applyDiffToContent(QString &content, const DiffInfo &diffInfo, bool reverse, QString *errorMsg = nullptr);
|
||||
bool findHunkLocation(const QStringList &fileLines, const DiffHunk &hunk, int &actualStartLine, QString *debugInfo = nullptr) const;
|
||||
|
||||
|
||||
// Helper method for fragment-based apply/undo operations
|
||||
bool performFragmentReplacement(
|
||||
const QString &filePath,
|
||||
@@ -122,7 +118,7 @@ private:
|
||||
bool isAppendOperation,
|
||||
QString *errorMsg = nullptr,
|
||||
bool isUndo = false);
|
||||
|
||||
|
||||
int levenshteinDistance(const QString &s1, const QString &s2) const;
|
||||
QString findBestMatch(const QString &fileContent, const QString &searchContent, double threshold = 0.82, double *outSimilarity = nullptr) const;
|
||||
QString findBestMatchLineBased(const QString &fileContent, const QString &searchContent, double threshold = 0.82, double *outSimilarity = nullptr) const;
|
||||
@@ -134,7 +130,6 @@ private:
|
||||
bool autoApplyPending = false;
|
||||
};
|
||||
|
||||
QHash<TextEditor::TextDocument *, QQueue<ChangeInfo>> m_documentChanges;
|
||||
QHash<QString, FileEdit> m_fileEdits;
|
||||
QHash<QString, RequestEdits> m_requestEdits; // requestId → ordered edits
|
||||
QUndoStack *m_undoStack;
|
||||
@@ -23,6 +23,7 @@ public:
|
||||
virtual ~IContextManager() = default;
|
||||
|
||||
virtual QString readFile(const QString &filePath) const = 0;
|
||||
virtual QStringList allowedPaths(const QStringList &filePaths) const = 0;
|
||||
virtual QList<ContentFile> getContentFiles(const QStringList &filePaths) const = 0;
|
||||
virtual QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const = 0;
|
||||
virtual ContentFile createContentFile(const QString &filePath) const = 0;
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "context/RulesLoader.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
|
||||
namespace QodeAssist::Context {
|
||||
|
||||
QString RulesLoader::loadRules(const QString &projectPath, RulesContext context)
|
||||
{
|
||||
if (projectPath.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString combined;
|
||||
QString basePath = projectPath + "/.qodeassist/rules";
|
||||
|
||||
switch (context) {
|
||||
case RulesContext::Completions:
|
||||
combined += loadAllMarkdownFiles(basePath + "/completions");
|
||||
break;
|
||||
case RulesContext::Chat:
|
||||
combined += loadAllMarkdownFiles(basePath + "/common");
|
||||
combined += loadAllMarkdownFiles(basePath + "/chat");
|
||||
break;
|
||||
case RulesContext::QuickRefactor:
|
||||
combined += loadAllMarkdownFiles(basePath + "/common");
|
||||
combined += loadAllMarkdownFiles(basePath + "/quickrefactor");
|
||||
break;
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
QString RulesLoader::loadRulesForProject(ProjectExplorer::Project *project, RulesContext context)
|
||||
{
|
||||
if (!project) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString projectPath = getProjectPath(project);
|
||||
return loadRules(projectPath, context);
|
||||
}
|
||||
|
||||
ProjectExplorer::Project *RulesLoader::getActiveProject()
|
||||
{
|
||||
auto currentEditor = Core::EditorManager::currentEditor();
|
||||
if (currentEditor && currentEditor->document()) {
|
||||
Utils::FilePath filePath = currentEditor->document()->filePath();
|
||||
auto project = ProjectExplorer::ProjectManager::projectForFile(filePath);
|
||||
if (project) {
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
return ProjectExplorer::ProjectManager::startupProject();
|
||||
}
|
||||
|
||||
QString RulesLoader::loadAllMarkdownFiles(const QString &dirPath)
|
||||
{
|
||||
QString combined;
|
||||
QDir dir(dirPath);
|
||||
|
||||
if (!dir.exists()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QStringList mdFiles = dir.entryList({"*.md"}, QDir::Files, QDir::Name);
|
||||
|
||||
for (const QString &fileName : mdFiles) {
|
||||
QFile file(dir.filePath(fileName));
|
||||
if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
combined += file.readAll();
|
||||
combined += "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
QString RulesLoader::getProjectPath(ProjectExplorer::Project *project)
|
||||
{
|
||||
if (!project) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
return project->projectDirectory().toUrlishString();
|
||||
}
|
||||
|
||||
QVector<RuleFileInfo> RulesLoader::getRuleFiles(const QString &projectPath, RulesContext context)
|
||||
{
|
||||
if (projectPath.isEmpty()) {
|
||||
return QVector<RuleFileInfo>();
|
||||
}
|
||||
|
||||
QVector<RuleFileInfo> result;
|
||||
QString basePath = projectPath + "/.qodeassist/rules";
|
||||
|
||||
// Always include common rules
|
||||
result.append(collectMarkdownFiles(basePath + "/common", "common"));
|
||||
|
||||
// Add context-specific rules
|
||||
switch (context) {
|
||||
case RulesContext::Completions:
|
||||
result.append(collectMarkdownFiles(basePath + "/completions", "completions"));
|
||||
break;
|
||||
case RulesContext::Chat:
|
||||
result.append(collectMarkdownFiles(basePath + "/chat", "chat"));
|
||||
break;
|
||||
case RulesContext::QuickRefactor:
|
||||
result.append(collectMarkdownFiles(basePath + "/quickrefactor", "quickrefactor"));
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
QVector<RuleFileInfo> RulesLoader::getRuleFilesForProject(
|
||||
ProjectExplorer::Project *project, RulesContext context)
|
||||
{
|
||||
if (!project) {
|
||||
return QVector<RuleFileInfo>();
|
||||
}
|
||||
|
||||
QString projectPath = getProjectPath(project);
|
||||
return getRuleFiles(projectPath, context);
|
||||
}
|
||||
|
||||
QString RulesLoader::loadRuleFileContent(const QString &filePath)
|
||||
{
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
return file.readAll();
|
||||
}
|
||||
|
||||
QVector<RuleFileInfo> RulesLoader::collectMarkdownFiles(
|
||||
const QString &dirPath, const QString &category)
|
||||
{
|
||||
QVector<RuleFileInfo> result;
|
||||
QDir dir(dirPath);
|
||||
|
||||
if (!dir.exists()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
QStringList mdFiles = dir.entryList({"*.md"}, QDir::Files, QDir::Name);
|
||||
|
||||
for (const QString &fileName : mdFiles) {
|
||||
QString fullPath = dir.filePath(fileName);
|
||||
result.append({fullPath, fileName, category});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Context
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (C) 2025-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace ProjectExplorer {
|
||||
class Project;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Context {
|
||||
|
||||
enum class RulesContext { Completions, Chat, QuickRefactor };
|
||||
|
||||
struct RuleFileInfo
|
||||
{
|
||||
QString filePath;
|
||||
QString fileName;
|
||||
QString category; // "common", "chat", "completions", "quickrefactor"
|
||||
};
|
||||
|
||||
class RulesLoader
|
||||
{
|
||||
public:
|
||||
static QString loadRules(const QString &projectPath, RulesContext context);
|
||||
static QString loadRulesForProject(ProjectExplorer::Project *project, RulesContext context);
|
||||
static ProjectExplorer::Project *getActiveProject();
|
||||
|
||||
// New methods for getting rule files info
|
||||
static QVector<RuleFileInfo> getRuleFiles(const QString &projectPath, RulesContext context);
|
||||
static QVector<RuleFileInfo> getRuleFilesForProject(ProjectExplorer::Project *project, RulesContext context);
|
||||
static QString loadRuleFileContent(const QString &filePath);
|
||||
|
||||
private:
|
||||
static QString loadAllMarkdownFiles(const QString &dirPath);
|
||||
static QVector<RuleFileInfo> collectMarkdownFiles(const QString &dirPath, const QString &category);
|
||||
static QString getProjectPath(ProjectExplorer::Project *project);
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Context
|
||||
Vendored
+1
-1
Submodule sources/external/llmqore updated: 3f9c8f5253...1e7088ad32
@@ -0,0 +1,129 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "AgentKnowledgeServer.hpp"
|
||||
|
||||
#include <QHostAddress>
|
||||
#include <QUuid>
|
||||
|
||||
#include <LLMQore/McpHttpServerTransport.hpp>
|
||||
#include <LLMQore/McpServer.hpp>
|
||||
#include <LLMQore/McpTypes.hpp>
|
||||
#include <LLMQore/Version.hpp>
|
||||
|
||||
#include <logger/Logger.hpp>
|
||||
|
||||
#include "tools/EditorStateTools.hpp"
|
||||
#include "tools/GetIssuesListTool.hpp"
|
||||
|
||||
namespace QodeAssist::Mcp {
|
||||
|
||||
namespace {
|
||||
|
||||
QString knowledgeServerName()
|
||||
{
|
||||
return QStringLiteral("qodeassist");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AgentKnowledgeServer::AgentKnowledgeServer(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
AgentKnowledgeServer::~AgentKnowledgeServer()
|
||||
{
|
||||
stop();
|
||||
}
|
||||
|
||||
QString AgentKnowledgeServer::serverName() const
|
||||
{
|
||||
return knowledgeServerName();
|
||||
}
|
||||
|
||||
void AgentKnowledgeServer::setIgnorePredicate(std::function<bool(const QString &)> predicate)
|
||||
{
|
||||
m_ignorePredicate = std::move(predicate);
|
||||
}
|
||||
|
||||
QStringList AgentKnowledgeServer::toolIds()
|
||||
{
|
||||
return {
|
||||
Tools::ListOpenEditorsTool().id(),
|
||||
Tools::GetEditorSelectionTool().id(),
|
||||
Tools::GetProjectModelTool().id(),
|
||||
Tools::GetIssuesListTool().id()};
|
||||
}
|
||||
|
||||
QString AgentKnowledgeServer::endpointUrl() const
|
||||
{
|
||||
return m_runningPort == 0
|
||||
? QString()
|
||||
: QStringLiteral("http://127.0.0.1:%1/mcp/%2").arg(m_runningPort).arg(m_pathToken);
|
||||
}
|
||||
|
||||
QString AgentKnowledgeServer::start()
|
||||
{
|
||||
if (m_runningPort != 0)
|
||||
return endpointUrl();
|
||||
|
||||
m_pathToken = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
|
||||
::LLMQore::Mcp::HttpServerConfig transportConfig;
|
||||
transportConfig.address = QHostAddress::LocalHost;
|
||||
transportConfig.port = 0;
|
||||
transportConfig.path = QStringLiteral("/mcp/%1").arg(m_pathToken);
|
||||
|
||||
m_transport = new ::LLMQore::Mcp::McpHttpServerTransport(transportConfig, this);
|
||||
|
||||
::LLMQore::Mcp::McpServerConfig serverConfig;
|
||||
serverConfig.serverInfo = {"QodeAssist", QStringLiteral(LLMQORE_VERSION_STRING)};
|
||||
serverConfig.instructions = tr(
|
||||
"Read-only access to what Qt Creator knows about this session: files open in the editor "
|
||||
"with their unsaved contents, the current selection, build issues, and the configured "
|
||||
"kit and build directory. Use it for anything you cannot answer by reading the files on "
|
||||
"disk yourself.");
|
||||
|
||||
m_server = new ::LLMQore::Mcp::McpServer(m_transport.data(), serverConfig, this);
|
||||
|
||||
auto *openEditors = new Tools::ListOpenEditorsTool(m_server);
|
||||
openEditors->setIgnorePredicate(m_ignorePredicate);
|
||||
|
||||
m_server->addTool(openEditors);
|
||||
m_server->addTool(new Tools::GetEditorSelectionTool(m_server));
|
||||
m_server->addTool(new Tools::GetProjectModelTool(m_server));
|
||||
m_server->addTool(new Tools::GetIssuesListTool(m_server));
|
||||
|
||||
m_server->start();
|
||||
|
||||
if (!m_transport->isOpen()) {
|
||||
LOG_MESSAGE("The QodeAssist knowledge server could not bind a loopback port");
|
||||
stop();
|
||||
return {};
|
||||
}
|
||||
|
||||
m_runningPort = m_transport->serverPort();
|
||||
LOG_MESSAGE(QString("QodeAssist knowledge server listening on 127.0.0.1:%1").arg(m_runningPort));
|
||||
|
||||
return endpointUrl();
|
||||
}
|
||||
|
||||
void AgentKnowledgeServer::stop()
|
||||
{
|
||||
if (m_server) {
|
||||
m_server->stop();
|
||||
m_server->deleteLater();
|
||||
m_server.clear();
|
||||
}
|
||||
|
||||
if (m_transport) {
|
||||
m_transport->deleteLater();
|
||||
m_transport.clear();
|
||||
}
|
||||
|
||||
m_runningPort = 0;
|
||||
m_pathToken.clear();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Mcp
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QStringList>
|
||||
|
||||
#include "acp/AgentKnowledgeService.hpp"
|
||||
|
||||
namespace LLMQore::Mcp {
|
||||
class McpServer;
|
||||
class McpHttpServerTransport;
|
||||
} // namespace LLMQore::Mcp
|
||||
|
||||
namespace QodeAssist::Mcp {
|
||||
|
||||
class AgentKnowledgeServer : public QObject, public Acp::AgentKnowledgeService
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit AgentKnowledgeServer(QObject *parent = nullptr);
|
||||
~AgentKnowledgeServer() override;
|
||||
|
||||
QString start() override;
|
||||
void stop() override;
|
||||
QString serverName() const override;
|
||||
|
||||
void setIgnorePredicate(std::function<bool(const QString &)> predicate);
|
||||
|
||||
quint16 runningPort() const { return m_runningPort; }
|
||||
QString endpointUrl() const;
|
||||
static QStringList toolIds();
|
||||
|
||||
private:
|
||||
QPointer<::LLMQore::Mcp::McpServer> m_server;
|
||||
QPointer<::LLMQore::Mcp::McpHttpServerTransport> m_transport;
|
||||
std::function<bool(const QString &)> m_ignorePredicate;
|
||||
QString m_pathToken;
|
||||
quint16 m_runningPort = 0;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Mcp
|
||||
@@ -60,8 +60,12 @@
|
||||
#include "templates/Templates.hpp"
|
||||
#include "widgets/CustomInstructionsManager.hpp"
|
||||
#include "widgets/QuickRefactorDialog.hpp"
|
||||
#include <QQmlContext>
|
||||
|
||||
#include <ChatView/ChatView.hpp>
|
||||
#include <ChatView/ChatFileManager.hpp>
|
||||
#include <acp/AgentCatalogStore.hpp>
|
||||
#include <settings/AgentsWidget.hpp>
|
||||
#include <ChatView/AttachmentStaging.hpp>
|
||||
#include <ChatView/ChatRootView.hpp>
|
||||
#include <ChatView/ChatWidget.hpp>
|
||||
#include <ChatView/SessionFileRegistry.hpp>
|
||||
@@ -96,7 +100,7 @@ public:
|
||||
|
||||
~QodeAssistPlugin() final
|
||||
{
|
||||
Chat::ChatFileManager::cleanupGlobalIntermediateStorage();
|
||||
Chat::AttachmentStaging::cleanupGlobalIntermediateStorage();
|
||||
|
||||
delete m_qodeAssistClient;
|
||||
if (m_chatOutputPane) {
|
||||
@@ -176,6 +180,10 @@ public:
|
||||
m_engine = new QQmlEngine{this};
|
||||
m_sessionFileRegistry = new Chat::SessionFileRegistry{this};
|
||||
m_skillsManager = new Skills::SkillsManager{this};
|
||||
m_agentCatalog = new Acp::AgentCatalogStore{this};
|
||||
m_agentCatalog->reload();
|
||||
m_engine->rootContext()->setContextProperty("agentCatalog", m_agentCatalog);
|
||||
m_agentsSettingsPage = std::make_unique<Settings::AgentsSettingsPage>(m_agentCatalog);
|
||||
|
||||
{
|
||||
auto &providers = Providers::ProvidersManager::instance();
|
||||
@@ -308,7 +316,7 @@ public:
|
||||
Core::Constants::G_DEFAULT_THREE);
|
||||
}
|
||||
|
||||
Chat::ChatFileManager::cleanupGlobalIntermediateStorage();
|
||||
Chat::AttachmentStaging::cleanupGlobalIntermediateStorage();
|
||||
|
||||
#ifdef WITH_TESTS
|
||||
addTest<QodeAssistTest>();
|
||||
@@ -499,6 +507,8 @@ private:
|
||||
QPointer<Mcp::McpServerManager> m_mcpServerManager;
|
||||
QPointer<QQmlEngine> m_engine;
|
||||
QPointer<Skills::SkillsManager> m_skillsManager;
|
||||
QPointer<Acp::AgentCatalogStore> m_agentCatalog;
|
||||
std::unique_ptr<Settings::AgentsSettingsPage> m_agentsSettingsPage;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Internal
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
#include <context/Utils.hpp>
|
||||
#include "templates/PromptTemplateManager.hpp"
|
||||
#include "providers/ProvidersManager.hpp"
|
||||
#include "context/RulesLoader.hpp"
|
||||
#include <logger/Logger.hpp>
|
||||
#include <settings/ChatAssistantSettings.hpp>
|
||||
#include <settings/GeneralSettings.hpp>
|
||||
@@ -270,17 +269,6 @@ LLMCore::ContextData QuickRefactorHandler::prepareContext(
|
||||
|
||||
QString systemPrompt = Settings::quickRefactorSettings().systemPrompt();
|
||||
|
||||
auto project = Context::RulesLoader::getActiveProject();
|
||||
if (project) {
|
||||
QString projectRules = Context::RulesLoader::loadRulesForProject(
|
||||
project, Context::RulesContext::QuickRefactor);
|
||||
|
||||
if (!projectRules.isEmpty()) {
|
||||
systemPrompt += "\n\n# Project Rules\n\n" + projectRules;
|
||||
LOG_MESSAGE("Loaded project rules for quick refactor");
|
||||
}
|
||||
}
|
||||
|
||||
systemPrompt += "\n\nFile information:";
|
||||
systemPrompt += "\nLanguage: " + documentInfo.mimeType;
|
||||
systemPrompt += "\nFile path: " + documentInfo.filePath;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "session/AgentPlan.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
|
||||
#include "session/BlockCodec.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
QJsonObject planBlockToJson(const PlanBlock &block)
|
||||
{
|
||||
QJsonArray entries;
|
||||
for (const PlanEntry &entry : block.entries) {
|
||||
QJsonObject json{{"content", entry.content}, {"status", entry.status}};
|
||||
if (!entry.priority.isEmpty())
|
||||
json["priority"] = entry.priority;
|
||||
entries.append(json);
|
||||
}
|
||||
|
||||
return QJsonObject{{"entries", entries}};
|
||||
}
|
||||
|
||||
PlanBlock planBlockFromJson(const QJsonObject &json)
|
||||
{
|
||||
PlanBlock block;
|
||||
|
||||
const QJsonArray entries = json["entries"].toArray();
|
||||
for (const QJsonValue &value : entries) {
|
||||
const QJsonObject entry = value.toObject();
|
||||
block.entries.append(
|
||||
PlanEntry{
|
||||
entry["content"].toString(),
|
||||
entry["priority"].toString(),
|
||||
entry["status"].toString()});
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
QString encodePlanBlock(const PlanBlock &block)
|
||||
{
|
||||
return encodeMarkerPayload(planPayloadMarker, planBlockToJson(block));
|
||||
}
|
||||
|
||||
std::optional<PlanBlock> decodePlanBlock(const QString &text)
|
||||
{
|
||||
const auto payload = decodeMarkerPayload(planPayloadMarker, text);
|
||||
if (!payload)
|
||||
return std::nullopt;
|
||||
|
||||
return planBlockFromJson(*payload);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDebug>
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
#include <optional>
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
struct PlanEntry
|
||||
{
|
||||
QString content;
|
||||
QString priority;
|
||||
QString status;
|
||||
|
||||
bool operator==(const PlanEntry &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const PlanEntry &entry)
|
||||
{
|
||||
return debug.nospace() << "PlanEntry(" << entry.content << ", " << entry.priority << ", "
|
||||
<< entry.status << ")";
|
||||
}
|
||||
};
|
||||
|
||||
struct PlanBlock
|
||||
{
|
||||
QList<PlanEntry> entries;
|
||||
|
||||
bool operator==(const PlanBlock &other) const = default;
|
||||
|
||||
friend QDebug operator<<(QDebug debug, const PlanBlock &block)
|
||||
{
|
||||
return debug.nospace() << "Plan(" << block.entries << ")";
|
||||
}
|
||||
};
|
||||
|
||||
QJsonObject planBlockToJson(const PlanBlock &block);
|
||||
PlanBlock planBlockFromJson(const QJsonObject &json);
|
||||
|
||||
QString encodePlanBlock(const PlanBlock &block);
|
||||
std::optional<PlanBlock> decodePlanBlock(const QString &text);
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "session/BlockCodec.hpp"
|
||||
|
||||
#include <QJsonDocument>
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
QList<QLatin1StringView> knownPayloadMarkers()
|
||||
{
|
||||
return {payloadMarkers.begin(), payloadMarkers.end()};
|
||||
}
|
||||
|
||||
bool hasPayloadMarker(QLatin1StringView marker, const QString &text)
|
||||
{
|
||||
return text.startsWith(marker);
|
||||
}
|
||||
|
||||
QString encodeMarkerPayload(QLatin1StringView marker, const QJsonObject &payload)
|
||||
{
|
||||
return marker + QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
std::optional<QJsonObject> decodeMarkerPayload(QLatin1StringView marker, const QString &text)
|
||||
{
|
||||
if (!hasPayloadMarker(marker, text))
|
||||
return std::nullopt;
|
||||
|
||||
const QJsonDocument document = QJsonDocument::fromJson(text.mid(marker.size()).toUtf8());
|
||||
if (!document.isObject())
|
||||
return std::nullopt;
|
||||
|
||||
return document.object();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QLatin1StringView>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
inline constexpr std::array<QLatin1StringView, 3> payloadMarkers{
|
||||
QLatin1StringView{"QODEASSIST_FILE_EDIT:"},
|
||||
QLatin1StringView{"QODEASSIST_PERMISSION:"},
|
||||
QLatin1StringView{"QODEASSIST_PLAN:"}};
|
||||
|
||||
inline constexpr QLatin1StringView fileEditPayloadMarker = payloadMarkers[0];
|
||||
inline constexpr QLatin1StringView permissionPayloadMarker = payloadMarkers[1];
|
||||
inline constexpr QLatin1StringView planPayloadMarker = payloadMarkers[2];
|
||||
|
||||
QList<QLatin1StringView> knownPayloadMarkers();
|
||||
|
||||
bool hasPayloadMarker(QLatin1StringView marker, const QString &text);
|
||||
QString encodeMarkerPayload(QLatin1StringView marker, const QJsonObject &payload);
|
||||
std::optional<QJsonObject> decodeMarkerPayload(QLatin1StringView marker, const QString &text);
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
@@ -1,11 +1,16 @@
|
||||
add_library(QodeAssistSession STATIC
|
||||
AgentPlan.hpp AgentPlan.cpp
|
||||
BlockCodec.hpp BlockCodec.cpp
|
||||
ContentBlock.hpp
|
||||
Message.hpp
|
||||
ConversationHistory.hpp ConversationHistory.cpp
|
||||
FencedText.hpp FencedText.cpp
|
||||
FileEditPayload.hpp FileEditPayload.cpp
|
||||
HistoryProjection.hpp HistoryProjection.cpp
|
||||
HistorySerializer.hpp HistorySerializer.cpp
|
||||
PermissionRequest.hpp PermissionRequest.cpp
|
||||
SessionEvent.hpp SessionEvent.cpp
|
||||
TurnLedger.hpp TurnLedger.cpp
|
||||
TurnRequest.hpp
|
||||
ChatBackend.hpp
|
||||
Session.hpp Session.cpp
|
||||
|
||||
@@ -21,6 +21,18 @@ public:
|
||||
virtual void sendTurn(const TurnRequest &request) = 0;
|
||||
virtual void cancel() = 0;
|
||||
|
||||
virtual bool respondPermission(const QString &requestId, const QString &optionId)
|
||||
{
|
||||
Q_UNUSED(requestId)
|
||||
Q_UNUSED(optionId)
|
||||
return false;
|
||||
}
|
||||
|
||||
virtual TurnContextNeeds contextNeeds() const { return {}; }
|
||||
|
||||
virtual void setChatFilePath(const QString &filePath) { Q_UNUSED(filePath) }
|
||||
virtual void clearToolSession(const QString &filePath) { Q_UNUSED(filePath) }
|
||||
|
||||
signals:
|
||||
void sessionEvent(const QodeAssist::Session::SessionEvent &event);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
|
||||
#include <variant>
|
||||
|
||||
#include "session/AgentPlan.hpp"
|
||||
#include "session/PermissionRequest.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
struct TextBlock
|
||||
@@ -45,6 +48,10 @@ struct ToolCallBlock
|
||||
QString name;
|
||||
QJsonObject arguments;
|
||||
QString result;
|
||||
QString kind;
|
||||
QString status;
|
||||
QJsonObject details;
|
||||
bool fromAgent = false;
|
||||
|
||||
bool operator==(const ToolCallBlock &other) const = default;
|
||||
|
||||
@@ -52,7 +59,9 @@ struct ToolCallBlock
|
||||
{
|
||||
return debug.nospace() << "ToolCall(" << block.id << ", " << block.name
|
||||
<< ", args=" << block.arguments << ", result=" << block.result
|
||||
<< ")";
|
||||
<< ", kind=" << block.kind << ", status=" << block.status
|
||||
<< ", details=" << block.details
|
||||
<< ", fromAgent=" << block.fromAgent << ")";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,8 +107,15 @@ struct FileEditBlock
|
||||
}
|
||||
};
|
||||
|
||||
using ContentBlock = std::
|
||||
variant<TextBlock, ThinkingBlock, ToolCallBlock, AttachmentBlock, ImageBlock, FileEditBlock>;
|
||||
using ContentBlock = std::variant<
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolCallBlock,
|
||||
AttachmentBlock,
|
||||
ImageBlock,
|
||||
FileEditBlock,
|
||||
PermissionBlock,
|
||||
PlanBlock>;
|
||||
|
||||
inline QDebug operator<<(QDebug debug, const ContentBlock &block)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include "session/FencedText.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int minimumFenceLength = 3;
|
||||
|
||||
int longestBacktickRun(const QString &content)
|
||||
{
|
||||
int longest = 0;
|
||||
int current = 0;
|
||||
|
||||
for (const QChar character : content) {
|
||||
current = character == QLatin1Char('`') ? current + 1 : 0;
|
||||
longest = qMax(longest, current);
|
||||
}
|
||||
|
||||
return longest;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString fencedFileBlock(const QString &fileName, const QString &content)
|
||||
{
|
||||
const int fenceLength = qMax(minimumFenceLength, longestBacktickRun(content) + 1);
|
||||
const QString fence(fenceLength, QLatin1Char('`'));
|
||||
|
||||
return QStringLiteral("File: %1\n%2\n%3\n%2").arg(fileName, fence, content);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
QString fencedFileBlock(const QString &fileName, const QString &content);
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
@@ -4,46 +4,23 @@
|
||||
|
||||
#include "session/FileEditPayload.hpp"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include "session/BlockCodec.hpp"
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
namespace {
|
||||
|
||||
QString fileEditMarker()
|
||||
{
|
||||
return QStringLiteral("QODEASSIST_FILE_EDIT:");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool isFileEditPayload(const QString &text)
|
||||
{
|
||||
return text.startsWith(fileEditMarker());
|
||||
return hasPayloadMarker(fileEditPayloadMarker, text);
|
||||
}
|
||||
|
||||
std::optional<QJsonObject> parseFileEditPayload(const QString &text)
|
||||
{
|
||||
const QString marker = fileEditMarker();
|
||||
const int markerPos = text.indexOf(marker);
|
||||
if (markerPos < 0)
|
||||
return std::nullopt;
|
||||
|
||||
const int jsonStart = markerPos + marker.length();
|
||||
if (jsonStart >= text.length())
|
||||
return std::nullopt;
|
||||
|
||||
const QJsonDocument document = QJsonDocument::fromJson(text.mid(jsonStart).toUtf8());
|
||||
if (!document.isObject())
|
||||
return std::nullopt;
|
||||
|
||||
return document.object();
|
||||
return decodeMarkerPayload(fileEditPayloadMarker, text);
|
||||
}
|
||||
|
||||
QString encodeFileEditPayload(const QJsonObject &payload)
|
||||
{
|
||||
return fileEditMarker()
|
||||
+ QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact));
|
||||
return encodeMarkerPayload(fileEditPayloadMarker, payload);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Session
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
#if defined(Q_CC_MSVC)
|
||||
#pragma warning(error : 4062)
|
||||
#else
|
||||
#pragma GCC diagnostic error "-Wswitch"
|
||||
#endif
|
||||
|
||||
namespace QodeAssist::Session {
|
||||
|
||||
namespace {
|
||||
@@ -52,7 +58,15 @@ QString toolDisplayText(const ToolCallBlock &block)
|
||||
|
||||
ToolCallBlock toolCallOf(const MessageRow &row)
|
||||
{
|
||||
ToolCallBlock block{row.id, row.toolName, row.toolArguments, row.toolResult};
|
||||
ToolCallBlock block{
|
||||
row.id,
|
||||
row.toolName,
|
||||
row.toolArguments,
|
||||
row.toolResult,
|
||||
row.toolKind,
|
||||
restoredToolStatus(row.toolStatus),
|
||||
row.toolDetails,
|
||||
row.kind == RowKind::AgentTool};
|
||||
if (block.name.isEmpty() && block.result.isEmpty())
|
||||
block.result = row.content;
|
||||
return block;
|
||||
@@ -73,8 +87,87 @@ RowKind textRowKind(MessageRole role)
|
||||
|
||||
} // namespace
|
||||
|
||||
bool isTerminalToolStatus(const QString &status)
|
||||
{
|
||||
return status.isEmpty() || status == QLatin1String("completed")
|
||||
|| status == QLatin1String("failed") || status == QLatin1String("interrupted");
|
||||
}
|
||||
|
||||
QString restoredToolStatus(QString status)
|
||||
{
|
||||
if (!isTerminalToolStatus(status))
|
||||
return QStringLiteral("interrupted");
|
||||
return status;
|
||||
}
|
||||
|
||||
bool isTranscriptOnlyRow(RowKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case RowKind::AgentTool:
|
||||
case RowKind::FileEdit:
|
||||
case RowKind::Permission:
|
||||
case RowKind::Plan:
|
||||
return true;
|
||||
case RowKind::User:
|
||||
case RowKind::Assistant:
|
||||
case RowKind::System:
|
||||
case RowKind::Tool:
|
||||
case RowKind::Thinking:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
RowTreatment rowTreatmentFor(RowAudience audience, RowKind kind)
|
||||
{
|
||||
switch (audience) {
|
||||
case RowAudience::Prompt:
|
||||
switch (kind) {
|
||||
case RowKind::User:
|
||||
return RowTreatment::UserText;
|
||||
case RowKind::Assistant:
|
||||
case RowKind::System:
|
||||
return RowTreatment::AssistantText;
|
||||
case RowKind::Thinking:
|
||||
return RowTreatment::AssistantThinking;
|
||||
case RowKind::Tool:
|
||||
return RowTreatment::ToolExchange;
|
||||
case RowKind::AgentTool:
|
||||
case RowKind::FileEdit:
|
||||
case RowKind::Permission:
|
||||
case RowKind::Plan:
|
||||
return RowTreatment::Omit;
|
||||
}
|
||||
break;
|
||||
case RowAudience::Compression:
|
||||
switch (kind) {
|
||||
case RowKind::User:
|
||||
return RowTreatment::UserText;
|
||||
case RowKind::Assistant:
|
||||
case RowKind::System:
|
||||
return RowTreatment::AssistantText;
|
||||
case RowKind::Thinking:
|
||||
case RowKind::Tool:
|
||||
case RowKind::AgentTool:
|
||||
case RowKind::FileEdit:
|
||||
case RowKind::Permission:
|
||||
case RowKind::Plan:
|
||||
return RowTreatment::Omit;
|
||||
}
|
||||
break;
|
||||
case RowAudience::TokenCount:
|
||||
return rowTreatmentFor(RowAudience::Prompt, kind);
|
||||
}
|
||||
return RowTreatment::Omit;
|
||||
}
|
||||
|
||||
std::optional<MessageRow> projectBlockToRow(const Message &message, const ContentBlock &block)
|
||||
{
|
||||
static_assert(
|
||||
std::variant_size_v<ContentBlock> == 8,
|
||||
"ContentBlock gained an alternative; extend the projection below or the block lives in the "
|
||||
"history without ever rendering");
|
||||
|
||||
if (const auto *text = std::get_if<TextBlock>(&block)) {
|
||||
MessageRow row;
|
||||
row.kind = textRowKind(message.role);
|
||||
@@ -95,12 +188,15 @@ std::optional<MessageRow> projectBlockToRow(const Message &message, const Conten
|
||||
|
||||
if (const auto *tool = std::get_if<ToolCallBlock>(&block)) {
|
||||
MessageRow row;
|
||||
row.kind = RowKind::Tool;
|
||||
row.kind = tool->fromAgent ? RowKind::AgentTool : RowKind::Tool;
|
||||
row.id = tool->id;
|
||||
row.content = toolDisplayText(*tool);
|
||||
row.toolName = tool->name;
|
||||
row.toolArguments = tool->arguments;
|
||||
row.toolResult = tool->result;
|
||||
row.toolKind = tool->kind;
|
||||
row.toolStatus = tool->status;
|
||||
row.toolDetails = tool->details;
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -112,6 +208,22 @@ std::optional<MessageRow> projectBlockToRow(const Message &message, const Conten
|
||||
return row;
|
||||
}
|
||||
|
||||
if (const auto *permission = std::get_if<PermissionBlock>(&block)) {
|
||||
MessageRow row;
|
||||
row.kind = RowKind::Permission;
|
||||
row.id = permission->requestId;
|
||||
row.content = encodePermissionBlock(*permission);
|
||||
return row;
|
||||
}
|
||||
|
||||
if (const auto *plan = std::get_if<PlanBlock>(&block)) {
|
||||
MessageRow row;
|
||||
row.kind = RowKind::Plan;
|
||||
row.id = message.id;
|
||||
row.content = encodePlanBlock(*plan);
|
||||
return row;
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -123,7 +235,7 @@ QList<MessageRow> projectMessageToRows(const Message &message)
|
||||
qsizetype textRowIndex = -1;
|
||||
|
||||
auto appendRow = [&](MessageRow row) {
|
||||
if (!usageAssigned && row.id == message.id) {
|
||||
if (!usageAssigned && row.id == message.id && !isTranscriptOnlyRow(row.kind)) {
|
||||
row.usage = message.usage;
|
||||
usageAssigned = true;
|
||||
}
|
||||
@@ -223,7 +335,8 @@ ConversationHistory buildFromRows(const QList<MessageRow> &rows)
|
||||
message.usage = row.usage;
|
||||
break;
|
||||
}
|
||||
case RowKind::Tool: {
|
||||
case RowKind::Tool:
|
||||
case RowKind::AgentTool: {
|
||||
Message &message = openAssistant();
|
||||
message.blocks.append(toolCallOf(row));
|
||||
break;
|
||||
@@ -233,6 +346,26 @@ ConversationHistory buildFromRows(const QList<MessageRow> &rows)
|
||||
message.blocks.append(FileEditBlock{row.id, row.content});
|
||||
break;
|
||||
}
|
||||
case RowKind::Permission: {
|
||||
Message &message = openAssistant();
|
||||
if (auto permission = decodePermissionBlock(row.content))
|
||||
message.blocks.append(restoredPermissionBlock(*permission));
|
||||
else
|
||||
message.blocks.append(TextBlock{row.content});
|
||||
break;
|
||||
}
|
||||
case RowKind::Plan: {
|
||||
Message &message = openAssistant();
|
||||
if (message.id.isEmpty())
|
||||
message.id = row.id;
|
||||
if (!row.usage.isEmpty())
|
||||
message.usage = row.usage;
|
||||
if (auto plan = decodePlanBlock(row.content))
|
||||
message.blocks.append(*plan);
|
||||
else
|
||||
message.blocks.append(TextBlock{row.content});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user