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:
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user