mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-06-12 17:29:13 -04:00
feat: Add agents and agents settings
This commit is contained in:
@@ -41,9 +41,13 @@ add_definitions(
|
|||||||
add_subdirectory(sources/external)
|
add_subdirectory(sources/external)
|
||||||
add_subdirectory(sources/tomlSerializer)
|
add_subdirectory(sources/tomlSerializer)
|
||||||
add_subdirectory(sources/skills)
|
add_subdirectory(sources/skills)
|
||||||
|
add_subdirectory(logger)
|
||||||
|
add_subdirectory(sources/common)
|
||||||
|
add_subdirectory(sources/providers)
|
||||||
|
add_subdirectory(sources/templates)
|
||||||
|
add_subdirectory(sources/agents)
|
||||||
add_subdirectory(pluginllmcore)
|
add_subdirectory(pluginllmcore)
|
||||||
add_subdirectory(settings)
|
add_subdirectory(settings)
|
||||||
add_subdirectory(logger)
|
|
||||||
add_subdirectory(sources/providersConfig)
|
add_subdirectory(sources/providersConfig)
|
||||||
add_subdirectory(UIControls)
|
add_subdirectory(UIControls)
|
||||||
add_subdirectory(ChatView)
|
add_subdirectory(ChatView)
|
||||||
@@ -72,6 +76,7 @@ add_qtc_plugin(QodeAssist
|
|||||||
LLMQore
|
LLMQore
|
||||||
PluginLLMCore
|
PluginLLMCore
|
||||||
ProvidersConfig
|
ProvidersConfig
|
||||||
|
Agents
|
||||||
Skills
|
Skills
|
||||||
QodeAssistChatViewplugin
|
QodeAssistChatViewplugin
|
||||||
SOURCES
|
SOURCES
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Provider::Provider(QObject *parent)
|
|||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
{}
|
{}
|
||||||
|
|
||||||
RequestID Provider::sendRequest(
|
LLMQore::RequestID Provider::sendRequest(
|
||||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||||
{
|
{
|
||||||
auto *c = client();
|
auto *c = client();
|
||||||
@@ -35,7 +35,7 @@ RequestID Provider::sendRequest(
|
|||||||
return requestId;
|
return requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Provider::cancelRequest(const RequestID &requestId)
|
void Provider::cancelRequest(const LLMQore::RequestID &requestId)
|
||||||
{
|
{
|
||||||
LOG_MESSAGE(QString("%1: Cancelling request %2").arg(name(), requestId));
|
LOG_MESSAGE(QString("%1: Cancelling request %2").arg(name(), requestId));
|
||||||
client()->cancelRequest(requestId);
|
client()->cancelRequest(requestId);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
#include "ContextData.hpp"
|
#include "ContextData.hpp"
|
||||||
#include "PromptTemplate.hpp"
|
#include "PromptTemplate.hpp"
|
||||||
|
#include "LLMQore/BaseClient.hpp"
|
||||||
#include "RequestType.hpp"
|
#include "RequestType.hpp"
|
||||||
|
|
||||||
namespace LLMQore {
|
namespace LLMQore {
|
||||||
@@ -56,9 +57,9 @@ public:
|
|||||||
virtual ::LLMQore::BaseClient *client() const = 0;
|
virtual ::LLMQore::BaseClient *client() const = 0;
|
||||||
virtual QString apiKey() const = 0;
|
virtual QString apiKey() const = 0;
|
||||||
|
|
||||||
virtual RequestID sendRequest(
|
virtual LLMQore::RequestID sendRequest(
|
||||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint);
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint);
|
||||||
void cancelRequest(const RequestID &requestId);
|
void cancelRequest(const LLMQore::RequestID &requestId);
|
||||||
::LLMQore::ToolsManager *toolsManager() const;
|
::LLMQore::ToolsManager *toolsManager() const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,4 @@ namespace QodeAssist::PluginLLMCore {
|
|||||||
|
|
||||||
enum RequestType { CodeCompletion, Chat, Embedding, QuickRefactoring };
|
enum RequestType { CodeCompletion, Chat, Embedding, QuickRefactoring };
|
||||||
|
|
||||||
using RequestID = QString;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,14 +139,9 @@ PluginLLMCore::ProviderCapabilities GoogleAIProvider::capabilities() const
|
|||||||
| PluginLLMCore::ProviderCapability::ModelListing;
|
| PluginLLMCore::ProviderCapability::ModelListing;
|
||||||
}
|
}
|
||||||
|
|
||||||
PluginLLMCore::RequestID GoogleAIProvider::sendRequest(
|
LLMQore::RequestID GoogleAIProvider::sendRequest(
|
||||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||||
{
|
{
|
||||||
// Gemini takes the model from the URL path and streaming from the
|
|
||||||
// action suffix (:streamGenerateContent vs :generateContent), and
|
|
||||||
// rejects unknown top-level body fields. The shared call-site seeds
|
|
||||||
// payload with {model, stream}; consume them here into client state
|
|
||||||
// before they hit the wire.
|
|
||||||
QJsonObject cleaned = payload;
|
QJsonObject cleaned = payload;
|
||||||
if (cleaned.contains("model")) {
|
if (cleaned.contains("model")) {
|
||||||
m_client->setModel(cleaned["model"].toString());
|
m_client->setModel(cleaned["model"].toString());
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public:
|
|||||||
PluginLLMCore::ProviderID providerID() const override;
|
PluginLLMCore::ProviderID providerID() const override;
|
||||||
PluginLLMCore::ProviderCapabilities capabilities() const override;
|
PluginLLMCore::ProviderCapabilities capabilities() const override;
|
||||||
|
|
||||||
PluginLLMCore::RequestID sendRequest(
|
LLMQore::RequestID sendRequest(
|
||||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
||||||
|
|
||||||
::LLMQore::BaseClient *client() const override;
|
::LLMQore::BaseClient *client() const override;
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ void LMStudioProvider::prepareRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PluginLLMCore::RequestID LMStudioProvider::sendRequest(
|
LLMQore::RequestID LMStudioProvider::sendRequest(
|
||||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||||
{
|
{
|
||||||
return PluginLLMCore::Provider::sendRequest(
|
return PluginLLMCore::Provider::sendRequest(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public:
|
|||||||
::LLMQore::BaseClient *client() const override;
|
::LLMQore::BaseClient *client() const override;
|
||||||
QString apiKey() const override;
|
QString apiKey() const override;
|
||||||
|
|
||||||
PluginLLMCore::RequestID sendRequest(
|
LLMQore::RequestID sendRequest(
|
||||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ QFuture<QList<QString>> LMStudioResponsesProvider::getInstalledModels(const QStr
|
|||||||
return m_client->listModels();
|
return m_client->listModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
PluginLLMCore::RequestID LMStudioResponsesProvider::sendRequest(
|
LLMQore::RequestID LMStudioResponsesProvider::sendRequest(
|
||||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||||
{
|
{
|
||||||
return PluginLLMCore::Provider::sendRequest(
|
return PluginLLMCore::Provider::sendRequest(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public:
|
|||||||
::LLMQore::BaseClient *client() const override;
|
::LLMQore::BaseClient *client() const override;
|
||||||
QString apiKey() const override;
|
QString apiKey() const override;
|
||||||
|
|
||||||
PluginLLMCore::RequestID sendRequest(
|
LLMQore::RequestID sendRequest(
|
||||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ QFuture<QList<QString>> OllamaCompatProvider::getInstalledModels(const QString &
|
|||||||
return m_client->listModels();
|
return m_client->listModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
PluginLLMCore::RequestID OllamaCompatProvider::sendRequest(
|
LLMQore::RequestID OllamaCompatProvider::sendRequest(
|
||||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||||
{
|
{
|
||||||
const QString effectiveEndpoint
|
const QString effectiveEndpoint
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public:
|
|||||||
::LLMQore::BaseClient *client() const override;
|
::LLMQore::BaseClient *client() const override;
|
||||||
QString apiKey() const override;
|
QString apiKey() const override;
|
||||||
|
|
||||||
PluginLLMCore::RequestID sendRequest(
|
LLMQore::RequestID sendRequest(
|
||||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
#include "settings/GeneralSettings.hpp"
|
#include "settings/GeneralSettings.hpp"
|
||||||
#include "settings/ProjectSettingsPanel.hpp"
|
#include "settings/ProjectSettingsPanel.hpp"
|
||||||
#ifdef QODEASSIST_EXPERIMENTAL
|
#ifdef QODEASSIST_EXPERIMENTAL
|
||||||
|
#include "settings/AgentsSettingsPage.hpp"
|
||||||
#include "settings/ProvidersSettingsPage.hpp"
|
#include "settings/ProvidersSettingsPage.hpp"
|
||||||
#endif
|
#endif
|
||||||
#include "settings/QuickRefactorSettings.hpp"
|
#include "settings/QuickRefactorSettings.hpp"
|
||||||
@@ -63,6 +64,8 @@
|
|||||||
#include "ProviderInstanceFactory.hpp"
|
#include "ProviderInstanceFactory.hpp"
|
||||||
#include "ProviderLauncher.hpp"
|
#include "ProviderLauncher.hpp"
|
||||||
#include "ProviderSecretsStore.hpp"
|
#include "ProviderSecretsStore.hpp"
|
||||||
|
|
||||||
|
#include <AgentFactory.hpp>
|
||||||
#endif
|
#endif
|
||||||
#include "templates/Templates.hpp"
|
#include "templates/Templates.hpp"
|
||||||
#include "widgets/CustomInstructionsManager.hpp"
|
#include "widgets/CustomInstructionsManager.hpp"
|
||||||
@@ -214,6 +217,11 @@ public:
|
|||||||
m_providerSecretsStore,
|
m_providerSecretsStore,
|
||||||
m_providerLauncher,
|
m_providerLauncher,
|
||||||
m_providersPageNavigator);
|
m_providersPageNavigator);
|
||||||
|
|
||||||
|
m_agentFactory = new AgentFactory(m_providerInstanceFactory, m_providerSecretsStore, this);
|
||||||
|
m_agentsPageNavigator = new Settings::AgentsPageNavigator(this);
|
||||||
|
m_agentsOptionsPage = Settings::createAgentsSettingsPage(
|
||||||
|
m_agentFactory, m_agentsPageNavigator);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
m_mcpServerManager = new Mcp::McpServerManager(this);
|
m_mcpServerManager = new Mcp::McpServerManager(this);
|
||||||
@@ -516,6 +524,9 @@ private:
|
|||||||
QPointer<Providers::ProviderLauncher> m_providerLauncher;
|
QPointer<Providers::ProviderLauncher> m_providerLauncher;
|
||||||
QPointer<Settings::ProvidersPageNavigator> m_providersPageNavigator;
|
QPointer<Settings::ProvidersPageNavigator> m_providersPageNavigator;
|
||||||
std::unique_ptr<Core::IOptionsPage> m_providersOptionsPage;
|
std::unique_ptr<Core::IOptionsPage> m_providersOptionsPage;
|
||||||
|
QPointer<AgentFactory> m_agentFactory;
|
||||||
|
QPointer<Settings::AgentsPageNavigator> m_agentsPageNavigator;
|
||||||
|
std::unique_ptr<Core::IOptionsPage> m_agentsOptionsPage;
|
||||||
#endif
|
#endif
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
475
settings/AgentDetailPane.cpp
Normal file
475
settings/AgentDetailPane.cpp
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "AgentDetailPane.hpp"
|
||||||
|
|
||||||
|
#include "SectionBox.hpp"
|
||||||
|
#include "SettingsTheme.hpp"
|
||||||
|
#include "SettingsUiBuilders.hpp"
|
||||||
|
|
||||||
|
#include <ProviderInstance.hpp>
|
||||||
|
#include <ProviderInstanceFactory.hpp>
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QPlainTextEdit>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScopedValueRollback>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr qint64 kRawTomlMaxBytes = 256 * 1024;
|
||||||
|
|
||||||
|
enum class FileReadStatus { Ok, Empty, Truncated, OpenFailed };
|
||||||
|
|
||||||
|
struct FileReadResult
|
||||||
|
{
|
||||||
|
FileReadStatus status = FileReadStatus::OpenFailed;
|
||||||
|
QString content;
|
||||||
|
QString error;
|
||||||
|
};
|
||||||
|
|
||||||
|
FileReadResult readFileTextCapped(const QString &path, qint64 maxBytes)
|
||||||
|
{
|
||||||
|
FileReadResult result;
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
result.status = FileReadStatus::OpenFailed;
|
||||||
|
result.error = f.errorString();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const qint64 size = f.size();
|
||||||
|
const QByteArray bytes = f.read(maxBytes);
|
||||||
|
result.content = QString::fromUtf8(bytes);
|
||||||
|
if (size == 0)
|
||||||
|
result.status = FileReadStatus::Empty;
|
||||||
|
else if (size > maxBytes)
|
||||||
|
result.status = FileReadStatus::Truncated;
|
||||||
|
else
|
||||||
|
result.status = FileReadStatus::Ok;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
AgentDetailPane::AgentDetailPane(QWidget *parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
{
|
||||||
|
m_name = new QLabel(this);
|
||||||
|
QFont nf = m_name->font();
|
||||||
|
nf.setBold(true);
|
||||||
|
nf.setPixelSize(15);
|
||||||
|
m_name->setFont(nf);
|
||||||
|
m_name->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||||
|
|
||||||
|
m_path = new QLabel(this);
|
||||||
|
m_path->setFont(monospaceFont(11));
|
||||||
|
m_path->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||||
|
QPalette pp = m_path->palette();
|
||||||
|
pp.setColor(QPalette::WindowText, pp.color(QPalette::Mid));
|
||||||
|
m_path->setPalette(pp);
|
||||||
|
|
||||||
|
m_openBtn = new QPushButton(tr("Open in editor"), this);
|
||||||
|
m_dupBtn = new QPushButton(tr("Duplicate…"), this);
|
||||||
|
m_deleteBtn = new QPushButton(tr("Delete"), this);
|
||||||
|
connect(m_openBtn, &QPushButton::clicked, this,
|
||||||
|
[this] { if (m_current) emit openInEditorRequested(*m_current); });
|
||||||
|
connect(m_dupBtn, &QPushButton::clicked, this,
|
||||||
|
[this] { if (m_current) emit customizeRequested(*m_current); });
|
||||||
|
connect(m_deleteBtn, &QPushButton::clicked, this,
|
||||||
|
[this] { if (m_current) emit deleteRequested(*m_current); });
|
||||||
|
|
||||||
|
auto *actions = new QHBoxLayout;
|
||||||
|
actions->setContentsMargins(0, 0, 0, 0);
|
||||||
|
actions->setSpacing(6);
|
||||||
|
actions->addWidget(m_openBtn);
|
||||||
|
actions->addWidget(m_dupBtn);
|
||||||
|
actions->addWidget(m_deleteBtn);
|
||||||
|
|
||||||
|
auto *titleRow = new QHBoxLayout;
|
||||||
|
titleRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
titleRow->setSpacing(8);
|
||||||
|
titleRow->addWidget(m_name);
|
||||||
|
titleRow->addStretch(1);
|
||||||
|
|
||||||
|
auto *headerLeft = new QVBoxLayout;
|
||||||
|
headerLeft->setContentsMargins(0, 0, 0, 0);
|
||||||
|
headerLeft->setSpacing(2);
|
||||||
|
headerLeft->addLayout(titleRow);
|
||||||
|
headerLeft->addWidget(m_path);
|
||||||
|
|
||||||
|
auto *headerRow = new QHBoxLayout;
|
||||||
|
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
headerRow->setSpacing(8);
|
||||||
|
headerRow->addLayout(headerLeft, 1);
|
||||||
|
headerRow->addLayout(actions);
|
||||||
|
|
||||||
|
auto *headerSep = new QFrame(this);
|
||||||
|
headerSep->setFrameShape(QFrame::HLine);
|
||||||
|
headerSep->setFrameShadow(QFrame::Sunken);
|
||||||
|
|
||||||
|
m_description = new QLabel(this);
|
||||||
|
m_description->setWordWrap(true);
|
||||||
|
m_description->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||||
|
|
||||||
|
auto *identity = new SectionBox(tr("Identity"), this);
|
||||||
|
m_nameValue = makeReadOnlyLine();
|
||||||
|
m_extendsLabel = new QLabel(tr("Extends:"), this);
|
||||||
|
m_extendsLabel->setMinimumWidth(96);
|
||||||
|
m_extendsLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop);
|
||||||
|
m_extendsValue = makeReadOnlyLine();
|
||||||
|
m_descriptionEdit = new QPlainTextEdit(this);
|
||||||
|
m_descriptionEdit->setReadOnly(true);
|
||||||
|
m_descriptionEdit->setMaximumHeight(56);
|
||||||
|
m_tagsValue = makeReadOnlyLine();
|
||||||
|
|
||||||
|
auto *idGrid = new QGridLayout;
|
||||||
|
idGrid->setContentsMargins(0, 0, 0, 0);
|
||||||
|
idGrid->setHorizontalSpacing(8);
|
||||||
|
idGrid->setVerticalSpacing(4);
|
||||||
|
FormBuilder idForm(idGrid);
|
||||||
|
idForm.row(tr("Name:"), m_nameValue);
|
||||||
|
{
|
||||||
|
auto *holder = new QWidget;
|
||||||
|
holder->setLayout(singleField(m_extendsValue));
|
||||||
|
const int row = idForm.currentRow();
|
||||||
|
idGrid->addWidget(m_extendsLabel, row, 0, Qt::AlignTop);
|
||||||
|
idGrid->addWidget(holder, row, 1);
|
||||||
|
m_extendsHolder = holder;
|
||||||
|
idForm = FormBuilder(idGrid, row + 1);
|
||||||
|
}
|
||||||
|
idForm.row(tr("Description:"), m_descriptionEdit);
|
||||||
|
idForm.row(tr("Tags:"), m_tagsValue,
|
||||||
|
tr("Comma-separated. Free-form — used to filter and "
|
||||||
|
"group the agent list."));
|
||||||
|
identity->bodyLayout()->addLayout(idGrid);
|
||||||
|
|
||||||
|
auto *roleSection = new SectionBox(tr("System role"), this);
|
||||||
|
auto *roleHint = makeHintLabel(
|
||||||
|
tr("Prepended to every request as the system message."));
|
||||||
|
m_roleText = new QPlainTextEdit(this);
|
||||||
|
m_roleText->setReadOnly(true);
|
||||||
|
m_roleText->setMinimumHeight(120);
|
||||||
|
roleSection->bodyLayout()->addWidget(roleHint);
|
||||||
|
roleSection->bodyLayout()->addWidget(m_roleText);
|
||||||
|
|
||||||
|
auto *contextSection = new SectionBox(tr("Context"), this);
|
||||||
|
auto *contextHint = makeHintLabel(
|
||||||
|
tr("Jinja2 template rendered with ContextManager bindings into the "
|
||||||
|
"agent.context system-prompt layer. Empty = no context block."));
|
||||||
|
m_contextText = new QPlainTextEdit(this);
|
||||||
|
m_contextText->setReadOnly(true);
|
||||||
|
m_contextText->setFont(monospaceFont(11));
|
||||||
|
m_contextText->setMinimumHeight(120);
|
||||||
|
contextSection->bodyLayout()->addWidget(contextHint);
|
||||||
|
contextSection->bodyLayout()->addWidget(m_contextText);
|
||||||
|
|
||||||
|
auto *connection = new SectionBox(tr("Connection"), this);
|
||||||
|
m_providerCombo = new QComboBox(this);
|
||||||
|
m_providerCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents);
|
||||||
|
m_providerCombo->setEnabled(false);
|
||||||
|
m_endpointValue = makeReadOnlyLine(true);
|
||||||
|
m_modelValue = makeReadOnlyLine(true);
|
||||||
|
|
||||||
|
auto *connGrid = new QGridLayout;
|
||||||
|
connGrid->setContentsMargins(0, 0, 0, 0);
|
||||||
|
connGrid->setHorizontalSpacing(8);
|
||||||
|
connGrid->setVerticalSpacing(4);
|
||||||
|
FormBuilder(connGrid)
|
||||||
|
.row(tr("Provider:"), m_providerCombo,
|
||||||
|
tr("The provider instance this agent uses. URL is "
|
||||||
|
"inherited from the instance."))
|
||||||
|
.row(tr("Endpoint:"), m_endpointValue,
|
||||||
|
tr("Appended to the provider's URL. Blank uses the "
|
||||||
|
"provider default."))
|
||||||
|
.row(tr("Model:"), m_modelValue);
|
||||||
|
connection->bodyLayout()->addLayout(connGrid);
|
||||||
|
|
||||||
|
m_effectiveUrl = new QLabel(this);
|
||||||
|
m_effectiveUrl->setFont(monospaceFont(11));
|
||||||
|
m_effectiveUrl->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||||
|
m_effectiveUrl->setWordWrap(true);
|
||||||
|
m_effectiveUrl->setContentsMargins(6, 4, 6, 4);
|
||||||
|
m_effectiveUrl->setAutoFillBackground(true);
|
||||||
|
connection->bodyLayout()->addWidget(m_effectiveUrl);
|
||||||
|
|
||||||
|
auto *match = new SectionBox(tr("Match"), this);
|
||||||
|
auto *matchHint = makeHintLabel(
|
||||||
|
tr("When a feature slot has multiple bound agents, the first whose "
|
||||||
|
"match rules satisfy the current context wins."));
|
||||||
|
m_filePatternsValue = makeReadOnlyLine(true);
|
||||||
|
auto *matchGrid = new QGridLayout;
|
||||||
|
matchGrid->setContentsMargins(0, 0, 0, 0);
|
||||||
|
matchGrid->setHorizontalSpacing(8);
|
||||||
|
matchGrid->setVerticalSpacing(4);
|
||||||
|
FormBuilder(matchGrid).row(tr("File patterns:"), m_filePatternsValue,
|
||||||
|
tr("Globs, comma-separated. Empty matches every file."));
|
||||||
|
match->bodyLayout()->addWidget(matchHint);
|
||||||
|
match->bodyLayout()->addLayout(matchGrid);
|
||||||
|
|
||||||
|
auto *templ = new SectionBox(tr("Template"), this);
|
||||||
|
auto *templHint = makeHintLabel(
|
||||||
|
tr("Jinja2 template (via inja) rendered to the request body. "
|
||||||
|
"Built-in context: ctx.prefix, ctx.suffix, ctx.history, "
|
||||||
|
"ctx.system_prompt, agent.model."));
|
||||||
|
m_messageFormat = new QPlainTextEdit(this);
|
||||||
|
m_messageFormat->setReadOnly(true);
|
||||||
|
m_messageFormat->setFont(monospaceFont(11));
|
||||||
|
m_messageFormat->setMinimumHeight(140);
|
||||||
|
|
||||||
|
templ->bodyLayout()->addWidget(templHint);
|
||||||
|
auto *mfLabel = new QLabel(tr("message_format:"), this);
|
||||||
|
templ->bodyLayout()->addWidget(mfLabel);
|
||||||
|
templ->bodyLayout()->addWidget(m_messageFormat);
|
||||||
|
|
||||||
|
m_diagnostics = new SectionBox(tr("Load errors"), this);
|
||||||
|
m_diagnosticsView = new QPlainTextEdit(this);
|
||||||
|
m_diagnosticsView->setReadOnly(true);
|
||||||
|
m_diagnosticsView->setMaximumHeight(110);
|
||||||
|
m_diagnosticsView->setFont(monospaceFont(11));
|
||||||
|
m_diagnostics->bodyLayout()->addWidget(m_diagnosticsView);
|
||||||
|
m_diagnostics->setVisible(false);
|
||||||
|
|
||||||
|
m_rawToggle = new QToolButton(this);
|
||||||
|
m_rawToggle->setText(tr("▸ Show raw TOML"));
|
||||||
|
m_rawToggle->setCursor(Qt::PointingHandCursor);
|
||||||
|
m_rawToggle->setAutoRaise(true);
|
||||||
|
m_rawToggle->setCheckable(true);
|
||||||
|
m_rawToml = new QPlainTextEdit(this);
|
||||||
|
m_rawToml->setReadOnly(true);
|
||||||
|
m_rawToml->setFont(monospaceFont(11));
|
||||||
|
m_rawToml->setMinimumHeight(140);
|
||||||
|
m_rawToml->setVisible(false);
|
||||||
|
connect(m_rawToggle, &QToolButton::toggled, this, [this](bool on) {
|
||||||
|
m_rawToml->setVisible(on);
|
||||||
|
m_rawToggle->setText(on ? tr("▾ Hide raw TOML") : tr("▸ Show raw TOML"));
|
||||||
|
});
|
||||||
|
|
||||||
|
auto *root = new QVBoxLayout(this);
|
||||||
|
root->setContentsMargins(12, 12, 12, 12);
|
||||||
|
root->setSpacing(10);
|
||||||
|
root->addLayout(headerRow);
|
||||||
|
root->addWidget(headerSep);
|
||||||
|
root->addWidget(m_description);
|
||||||
|
root->addWidget(identity);
|
||||||
|
root->addWidget(connection);
|
||||||
|
root->addWidget(match);
|
||||||
|
root->addWidget(templ);
|
||||||
|
root->addWidget(roleSection);
|
||||||
|
root->addWidget(contextSection);
|
||||||
|
root->addWidget(m_diagnostics);
|
||||||
|
root->addWidget(m_rawToggle, 0, Qt::AlignLeft);
|
||||||
|
root->addWidget(m_rawToml);
|
||||||
|
root->addStretch(1);
|
||||||
|
|
||||||
|
clear();
|
||||||
|
applyCodePalette();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentDetailPane::setInstanceFactory(Providers::ProviderInstanceFactory *factory)
|
||||||
|
{
|
||||||
|
m_instanceFactory = factory;
|
||||||
|
m_providerComboPopulated = false;
|
||||||
|
populateProviderCombo();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentDetailPane::populateProviderCombo()
|
||||||
|
{
|
||||||
|
if (m_providerComboPopulated)
|
||||||
|
return;
|
||||||
|
m_providerCombo->clear();
|
||||||
|
m_providerComboHasSentinel = false;
|
||||||
|
if (m_instanceFactory) {
|
||||||
|
for (const auto &inst : m_instanceFactory->instances()) {
|
||||||
|
m_providerCombo->addItem(
|
||||||
|
QStringLiteral("%1 (%2)").arg(inst.name, inst.clientApi), inst.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m_providerComboPopulated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentDetailPane::setAgent(const AgentConfig &cfg)
|
||||||
|
{
|
||||||
|
m_currentStorage = cfg;
|
||||||
|
m_current = &m_currentStorage;
|
||||||
|
const bool user = cfg.isUserSource();
|
||||||
|
|
||||||
|
m_name->setText(cfg.name);
|
||||||
|
m_path->setText(cfg.sourcePath);
|
||||||
|
m_description->setText(cfg.description.isEmpty()
|
||||||
|
? tr("No description provided.")
|
||||||
|
: cfg.description);
|
||||||
|
|
||||||
|
m_nameValue->setText(cfg.name);
|
||||||
|
if (cfg.extendsName.isEmpty()) {
|
||||||
|
m_extendsLabel->setVisible(false);
|
||||||
|
m_extendsHolder->setVisible(false);
|
||||||
|
} else {
|
||||||
|
m_extendsLabel->setVisible(true);
|
||||||
|
m_extendsHolder->setVisible(true);
|
||||||
|
m_extendsValue->setText(cfg.extendsName);
|
||||||
|
}
|
||||||
|
m_descriptionEdit->setPlainText(cfg.description);
|
||||||
|
m_tagsValue->setText(cfg.tags.join(QStringLiteral(", ")));
|
||||||
|
|
||||||
|
populateProviderCombo();
|
||||||
|
|
||||||
|
if (m_providerComboHasSentinel) {
|
||||||
|
m_providerCombo->removeItem(0);
|
||||||
|
m_providerComboHasSentinel = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString resolvedUrl;
|
||||||
|
if (m_instanceFactory) {
|
||||||
|
if (const auto *inst = m_instanceFactory->instanceByName(cfg.providerInstance))
|
||||||
|
resolvedUrl = inst->url;
|
||||||
|
}
|
||||||
|
const int idx = m_providerCombo->findData(cfg.providerInstance);
|
||||||
|
if (idx >= 0) {
|
||||||
|
m_providerCombo->setCurrentIndex(idx);
|
||||||
|
} else if (!cfg.providerInstance.isEmpty()) {
|
||||||
|
m_providerCombo->insertItem(
|
||||||
|
0, tr("%1 (missing — not in provider library)")
|
||||||
|
.arg(cfg.providerInstance),
|
||||||
|
cfg.providerInstance);
|
||||||
|
m_providerCombo->setCurrentIndex(0);
|
||||||
|
m_providerComboHasSentinel = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_endpointValue->setText(cfg.endpoint);
|
||||||
|
m_endpointValue->setPlaceholderText(tr("(provider default)"));
|
||||||
|
m_modelValue->setText(cfg.model);
|
||||||
|
|
||||||
|
const QString eff = resolvedUrl + cfg.endpoint;
|
||||||
|
m_effectiveUrl->setText(
|
||||||
|
eff.isEmpty()
|
||||||
|
? tr("# effective request line\n(unknown — provider instance not found)")
|
||||||
|
: QStringLiteral("# %1\nPOST %2")
|
||||||
|
.arg(tr("effective request line"), eff));
|
||||||
|
|
||||||
|
m_roleText->setPlainText(
|
||||||
|
cfg.role.isEmpty() ? tr("(no system role set)") : cfg.role);
|
||||||
|
m_contextText->setPlainText(
|
||||||
|
cfg.context.isEmpty() ? tr("(no context block)") : cfg.context);
|
||||||
|
|
||||||
|
m_filePatternsValue->setText(cfg.match.filePatterns.join(QStringLiteral(", ")));
|
||||||
|
m_filePatternsValue->setPlaceholderText(tr("(matches every file)"));
|
||||||
|
|
||||||
|
m_messageFormat->setPlainText(
|
||||||
|
cfg.messageFormat.isEmpty() ? tr("(inherited from parent / none)")
|
||||||
|
: cfg.messageFormat);
|
||||||
|
|
||||||
|
const FileReadResult raw = readFileTextCapped(cfg.sourcePath, kRawTomlMaxBytes);
|
||||||
|
switch (raw.status) {
|
||||||
|
case FileReadStatus::Ok:
|
||||||
|
m_rawToml->setPlainText(raw.content);
|
||||||
|
break;
|
||||||
|
case FileReadStatus::Truncated:
|
||||||
|
m_rawToml->setPlainText(
|
||||||
|
raw.content + QStringLiteral("\n\n")
|
||||||
|
+ tr("(truncated at %1 bytes)").arg(kRawTomlMaxBytes));
|
||||||
|
break;
|
||||||
|
case FileReadStatus::Empty:
|
||||||
|
m_rawToml->setPlainText(tr("(source file is empty)"));
|
||||||
|
break;
|
||||||
|
case FileReadStatus::OpenFailed:
|
||||||
|
m_rawToml->setPlainText(tr("(source file unavailable: %1)").arg(raw.error));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_openBtn->setEnabled(user);
|
||||||
|
m_openBtn->setToolTip(user ? QString()
|
||||||
|
: tr("Bundled agents are read-only — "
|
||||||
|
"duplicate to edit."));
|
||||||
|
m_deleteBtn->setEnabled(user);
|
||||||
|
m_deleteBtn->setToolTip(user ? QString()
|
||||||
|
: tr("Bundled agents cannot be deleted."));
|
||||||
|
m_dupBtn->setEnabled(true);
|
||||||
|
applyCodePalette();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentDetailPane::clear()
|
||||||
|
{
|
||||||
|
m_currentStorage = AgentConfig{};
|
||||||
|
m_current = nullptr;
|
||||||
|
m_name->setText(tr("Select an agent"));
|
||||||
|
m_path->clear();
|
||||||
|
m_description->setText(tr("Pick an agent from the list to see its details."));
|
||||||
|
m_nameValue->clear();
|
||||||
|
m_extendsLabel->setVisible(false);
|
||||||
|
m_extendsHolder->setVisible(false);
|
||||||
|
m_descriptionEdit->clear();
|
||||||
|
m_tagsValue->clear();
|
||||||
|
if (m_providerComboHasSentinel) {
|
||||||
|
m_providerCombo->removeItem(0);
|
||||||
|
m_providerComboHasSentinel = false;
|
||||||
|
}
|
||||||
|
m_providerCombo->setCurrentIndex(-1);
|
||||||
|
m_endpointValue->clear();
|
||||||
|
m_modelValue->clear();
|
||||||
|
m_effectiveUrl->clear();
|
||||||
|
m_roleText->clear();
|
||||||
|
m_contextText->clear();
|
||||||
|
m_filePatternsValue->clear();
|
||||||
|
m_messageFormat->clear();
|
||||||
|
m_rawToml->clear();
|
||||||
|
m_openBtn->setEnabled(false);
|
||||||
|
m_dupBtn->setEnabled(false);
|
||||||
|
m_deleteBtn->setEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentDetailPane::setLoadDiagnostics(const QStringList &errors, const QStringList &warnings)
|
||||||
|
{
|
||||||
|
QStringList lines;
|
||||||
|
for (const QString &e : errors)
|
||||||
|
lines << tr("error: %1").arg(e);
|
||||||
|
for (const QString &w : warnings)
|
||||||
|
lines << tr("warning: %1").arg(w);
|
||||||
|
m_diagnostics->setVisible(!lines.isEmpty());
|
||||||
|
m_diagnosticsView->setPlainText(lines.join(QLatin1Char('\n')));
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentDetailPane::changeEvent(QEvent *event)
|
||||||
|
{
|
||||||
|
QWidget::changeEvent(event);
|
||||||
|
if (m_inApplyPalette)
|
||||||
|
return;
|
||||||
|
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||||
|
applyCodePalette();
|
||||||
|
}
|
||||||
|
|
||||||
|
QLineEdit *AgentDetailPane::makeReadOnlyLine(bool mono)
|
||||||
|
{
|
||||||
|
auto *e = new QLineEdit(this);
|
||||||
|
e->setReadOnly(true);
|
||||||
|
if (mono)
|
||||||
|
e->setFont(monospaceFont(11));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentDetailPane::applyCodePalette()
|
||||||
|
{
|
||||||
|
QScopedValueRollback<bool> guard(m_inApplyPalette, true);
|
||||||
|
const Theme theme = themeFor(palette());
|
||||||
|
QPalette p = m_effectiveUrl->palette();
|
||||||
|
p.setColor(QPalette::Window, QColor(theme.codeBg));
|
||||||
|
p.setColor(QPalette::WindowText, palette().color(QPalette::Text));
|
||||||
|
m_effectiveUrl->setPalette(p);
|
||||||
|
m_effectiveUrl->setStyleSheet(QStringLiteral(
|
||||||
|
"QLabel { background:%1; border:1px solid %2; }")
|
||||||
|
.arg(theme.codeBg, theme.rowSeparator));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
91
settings/AgentDetailPane.hpp
Normal file
91
settings/AgentDetailPane.hpp
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include <AgentConfig.hpp>
|
||||||
|
|
||||||
|
class QComboBox;
|
||||||
|
class QLabel;
|
||||||
|
class QLineEdit;
|
||||||
|
class QPlainTextEdit;
|
||||||
|
class QPushButton;
|
||||||
|
class QToolButton;
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
class ProviderInstanceFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class SectionBox;
|
||||||
|
|
||||||
|
class AgentDetailPane : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit AgentDetailPane(QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
void setInstanceFactory(Providers::ProviderInstanceFactory *factory);
|
||||||
|
void setAgent(const AgentConfig &cfg);
|
||||||
|
void clear();
|
||||||
|
void setLoadDiagnostics(const QStringList &errors, const QStringList &warnings);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void openInEditorRequested(const AgentConfig &cfg);
|
||||||
|
void customizeRequested(const AgentConfig &cfg);
|
||||||
|
void deleteRequested(const AgentConfig &cfg);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void changeEvent(QEvent *event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QLineEdit *makeReadOnlyLine(bool mono = false);
|
||||||
|
void applyCodePalette();
|
||||||
|
void populateProviderCombo();
|
||||||
|
|
||||||
|
bool m_inApplyPalette = false;
|
||||||
|
bool m_providerComboPopulated = false;
|
||||||
|
bool m_providerComboHasSentinel = false;
|
||||||
|
|
||||||
|
AgentConfig m_currentStorage;
|
||||||
|
const AgentConfig *m_current = nullptr;
|
||||||
|
|
||||||
|
QLabel *m_name = nullptr;
|
||||||
|
QLabel *m_path = nullptr;
|
||||||
|
QPushButton *m_openBtn = nullptr;
|
||||||
|
QPushButton *m_dupBtn = nullptr;
|
||||||
|
QPushButton *m_deleteBtn = nullptr;
|
||||||
|
QLabel *m_description = nullptr;
|
||||||
|
|
||||||
|
QLineEdit *m_nameValue = nullptr;
|
||||||
|
QLabel *m_extendsLabel = nullptr;
|
||||||
|
QWidget *m_extendsHolder = nullptr;
|
||||||
|
QLineEdit *m_extendsValue = nullptr;
|
||||||
|
QPlainTextEdit *m_descriptionEdit = nullptr;
|
||||||
|
QLineEdit *m_tagsValue = nullptr;
|
||||||
|
|
||||||
|
QComboBox *m_providerCombo = nullptr;
|
||||||
|
QPointer<Providers::ProviderInstanceFactory> m_instanceFactory;
|
||||||
|
QLineEdit *m_endpointValue = nullptr;
|
||||||
|
QLineEdit *m_modelValue = nullptr;
|
||||||
|
QLabel *m_effectiveUrl = nullptr;
|
||||||
|
|
||||||
|
QLineEdit *m_filePatternsValue = nullptr;
|
||||||
|
|
||||||
|
QPlainTextEdit *m_roleText = nullptr;
|
||||||
|
QPlainTextEdit *m_contextText = nullptr;
|
||||||
|
QPlainTextEdit *m_messageFormat = nullptr;
|
||||||
|
|
||||||
|
SectionBox *m_diagnostics = nullptr;
|
||||||
|
QPlainTextEdit *m_diagnosticsView = nullptr;
|
||||||
|
|
||||||
|
QToolButton *m_rawToggle = nullptr;
|
||||||
|
QPlainTextEdit *m_rawToml = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
120
settings/AgentDuplicator.cpp
Normal file
120
settings/AgentDuplicator.cpp
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "AgentDuplicator.hpp"
|
||||||
|
|
||||||
|
#include <Agent.hpp>
|
||||||
|
#include <AgentConfig.hpp>
|
||||||
|
#include <AgentFactory.hpp>
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QSaveFile>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString tomlEscape(const QString &s)
|
||||||
|
{
|
||||||
|
QString out;
|
||||||
|
out.reserve(s.size());
|
||||||
|
for (QChar c : s) {
|
||||||
|
switch (c.unicode()) {
|
||||||
|
case '\\': out += QLatin1String("\\\\"); break;
|
||||||
|
case '"': out += QLatin1String("\\\""); break;
|
||||||
|
case '\n': out += QLatin1String("\\n"); break;
|
||||||
|
case '\r': out += QLatin1String("\\r"); break;
|
||||||
|
case '\t': out += QLatin1String("\\t"); break;
|
||||||
|
default: out += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int kMaxUniqueAttempts = 1000;
|
||||||
|
|
||||||
|
QString uniqueFilename(const QString &userDir, const QString &parentBasename)
|
||||||
|
{
|
||||||
|
QString fileName = parentBasename + QStringLiteral("_custom.toml");
|
||||||
|
for (int n = 2; n < kMaxUniqueAttempts
|
||||||
|
&& QFile::exists(QDir(userDir).filePath(fileName));
|
||||||
|
++n)
|
||||||
|
fileName = QStringLiteral("%1_custom_%2.toml").arg(parentBasename).arg(n);
|
||||||
|
return QDir(userDir).filePath(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString uniqueName(const QString &parentName, const AgentFactory &factory)
|
||||||
|
{
|
||||||
|
QString newName = QStringLiteral("%1 (Custom)").arg(parentName);
|
||||||
|
for (int n = 2; n < kMaxUniqueAttempts && factory.configByName(newName); ++n)
|
||||||
|
newName = QStringLiteral("%1 (Custom %2)").arg(parentName).arg(n);
|
||||||
|
return newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString trUser(const char *src)
|
||||||
|
{
|
||||||
|
return QCoreApplication::translate("QodeAssist::Settings::AgentDuplicator", src);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
AgentDuplicateResult duplicateAgentInUserDir(
|
||||||
|
const AgentConfig &parent, const AgentFactory &factory)
|
||||||
|
{
|
||||||
|
AgentDuplicateResult result;
|
||||||
|
if (parent.name.trimmed().isEmpty()) {
|
||||||
|
result.error = trUser("Parent agent has no name; cannot duplicate.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString userDir = AgentFactory::userAgentsDir();
|
||||||
|
if (!QDir().mkpath(userDir)) {
|
||||||
|
result.error = trUser("Cannot create user agents folder: %1").arg(userDir);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString parentBasename = QFileInfo(parent.sourcePath).baseName();
|
||||||
|
result.filePath = uniqueFilename(userDir, parentBasename);
|
||||||
|
if (QFile::exists(result.filePath)) {
|
||||||
|
result.error = trUser("Could not find a free filename after %1 attempts.")
|
||||||
|
.arg(kMaxUniqueAttempts);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.newName = uniqueName(parent.name, factory);
|
||||||
|
if (factory.configByName(result.newName)) {
|
||||||
|
result.error = trUser("Could not find a free agent name after %1 attempts.")
|
||||||
|
.arg(kMaxUniqueAttempts);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSaveFile f(result.filePath);
|
||||||
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
|
result.error = trUser("Cannot create %1: %2").arg(result.filePath, f.errorString());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const QString description
|
||||||
|
= QStringLiteral("User customization of '%1'. Override fields below to taste; "
|
||||||
|
"values not overridden are inherited from the parent.")
|
||||||
|
.arg(parent.name);
|
||||||
|
const QString body = QStringLiteral(
|
||||||
|
"schema_version = 1\n"
|
||||||
|
"name = \"%1\"\n"
|
||||||
|
"extends = \"%2\"\n"
|
||||||
|
"description = \"%3\"\n")
|
||||||
|
.arg(tomlEscape(result.newName),
|
||||||
|
tomlEscape(parent.name),
|
||||||
|
tomlEscape(description));
|
||||||
|
const QByteArray payload = body.toUtf8();
|
||||||
|
if (f.write(payload) != payload.size() || !f.commit()) {
|
||||||
|
result.error = trUser("Failed to write %1: %2").arg(result.filePath, f.errorString());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.ok = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
26
settings/AgentDuplicator.hpp
Normal file
26
settings/AgentDuplicator.hpp
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace QodeAssist {
|
||||||
|
class AgentFactory;
|
||||||
|
struct AgentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
struct AgentDuplicateResult
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
QString filePath;
|
||||||
|
QString newName;
|
||||||
|
QString error;
|
||||||
|
};
|
||||||
|
|
||||||
|
AgentDuplicateResult duplicateAgentInUserDir(
|
||||||
|
const AgentConfig &parent, const AgentFactory &factory);
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
127
settings/AgentListItem.cpp
Normal file
127
settings/AgentListItem.cpp
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "AgentListItem.hpp"
|
||||||
|
|
||||||
|
#include "SettingsTheme.hpp"
|
||||||
|
#include "TagChip.hpp"
|
||||||
|
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QScopedValueRollback>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
AgentListItem::AgentListItem(const AgentConfig &cfg, QWidget *parent)
|
||||||
|
: QFrame(parent)
|
||||||
|
, m_name(cfg.name)
|
||||||
|
{
|
||||||
|
setObjectName(QStringLiteral("AgentListItem"));
|
||||||
|
setFrameShape(QFrame::NoFrame);
|
||||||
|
setAutoFillBackground(true);
|
||||||
|
setCursor(Qt::PointingHandCursor);
|
||||||
|
|
||||||
|
auto *dot = new QLabel(QStringLiteral("●"), this);
|
||||||
|
QFont df = dot->font();
|
||||||
|
df.setPixelSize(10);
|
||||||
|
dot->setFont(df);
|
||||||
|
QPalette dp = dot->palette();
|
||||||
|
dp.setColor(QPalette::WindowText, dp.color(QPalette::Mid));
|
||||||
|
dot->setPalette(dp);
|
||||||
|
|
||||||
|
auto *nameLbl = new QLabel(cfg.name, this);
|
||||||
|
QFont nf = nameLbl->font();
|
||||||
|
nf.setBold(true);
|
||||||
|
nf.setPixelSize(12);
|
||||||
|
nameLbl->setFont(nf);
|
||||||
|
|
||||||
|
auto *headerRow = new QHBoxLayout;
|
||||||
|
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
headerRow->setSpacing(6);
|
||||||
|
headerRow->addWidget(dot, 0, Qt::AlignVCenter);
|
||||||
|
headerRow->addWidget(nameLbl, 1);
|
||||||
|
|
||||||
|
auto *col = new QVBoxLayout;
|
||||||
|
col->setContentsMargins(0, 0, 0, 0);
|
||||||
|
col->setSpacing(2);
|
||||||
|
col->addLayout(headerRow);
|
||||||
|
|
||||||
|
if (!cfg.model.isEmpty()) {
|
||||||
|
auto *model = new QLabel(cfg.model, this);
|
||||||
|
model->setFont(monospaceFont(11));
|
||||||
|
model->setContentsMargins(16, 0, 0, 0);
|
||||||
|
QPalette mp = model->palette();
|
||||||
|
mp.setColor(QPalette::WindowText, mp.color(QPalette::Mid));
|
||||||
|
model->setPalette(mp);
|
||||||
|
col->addWidget(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cfg.tags.isEmpty()) {
|
||||||
|
auto *tagsHolder = new QWidget(this);
|
||||||
|
auto *tagsLay = new QHBoxLayout(tagsHolder);
|
||||||
|
tagsLay->setContentsMargins(16, 2, 0, 0);
|
||||||
|
tagsLay->setSpacing(3);
|
||||||
|
for (const QString &t : cfg.tags) {
|
||||||
|
auto *chip = new TagChip(t, -1, tagsHolder);
|
||||||
|
connect(chip, &TagChip::clicked, this, &AgentListItem::tagClicked);
|
||||||
|
m_chips.append(chip);
|
||||||
|
tagsLay->addWidget(chip);
|
||||||
|
}
|
||||||
|
tagsLay->addStretch(1);
|
||||||
|
col->addWidget(tagsHolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto *outer = new QVBoxLayout(this);
|
||||||
|
outer->setContentsMargins(8, 6, 8, 6);
|
||||||
|
outer->setSpacing(0);
|
||||||
|
outer->addLayout(col);
|
||||||
|
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListItem::setSelected(bool selected)
|
||||||
|
{
|
||||||
|
if (m_selected == selected)
|
||||||
|
return;
|
||||||
|
m_selected = selected;
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListItem::setActiveTags(const QSet<QString> &active)
|
||||||
|
{
|
||||||
|
for (auto *chip : m_chips)
|
||||||
|
chip->setActive(active.contains(chip->tag()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListItem::mouseReleaseEvent(QMouseEvent *event)
|
||||||
|
{
|
||||||
|
if (event->button() == Qt::LeftButton)
|
||||||
|
emit clicked(m_name);
|
||||||
|
QFrame::mouseReleaseEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListItem::changeEvent(QEvent *event)
|
||||||
|
{
|
||||||
|
QFrame::changeEvent(event);
|
||||||
|
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListItem::applyTheme()
|
||||||
|
{
|
||||||
|
if (m_inApplyTheme)
|
||||||
|
return;
|
||||||
|
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||||
|
const Theme theme = themeFor(palette());
|
||||||
|
setStyleSheet(QStringLiteral(
|
||||||
|
"#AgentListItem { background:%1; border-top:1px solid %2; }")
|
||||||
|
.arg(m_selected ? theme.rowSelectedBg : QStringLiteral("transparent"),
|
||||||
|
theme.rowSeparator));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
44
settings/AgentListItem.hpp
Normal file
44
settings/AgentListItem.hpp
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QList>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include <AgentConfig.hpp>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class TagChip;
|
||||||
|
|
||||||
|
class AgentListItem : public QFrame
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit AgentListItem(const AgentConfig &cfg, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
QString agentName() const { return m_name; }
|
||||||
|
void setSelected(bool selected);
|
||||||
|
void setActiveTags(const QSet<QString> &active);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void clicked(const QString &name);
|
||||||
|
void tagClicked(const QString &tag);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||||
|
void changeEvent(QEvent *event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void applyTheme();
|
||||||
|
|
||||||
|
QString m_name;
|
||||||
|
bool m_selected = false;
|
||||||
|
bool m_inApplyTheme = false;
|
||||||
|
QList<TagChip *> m_chips;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
236
settings/AgentListPane.cpp
Normal file
236
settings/AgentListPane.cpp
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "AgentListPane.hpp"
|
||||||
|
|
||||||
|
#include "AgentListItem.hpp"
|
||||||
|
#include "SettingsTheme.hpp"
|
||||||
|
#include "SettingsUiBuilders.hpp"
|
||||||
|
#include "TagFilterStrip.hpp"
|
||||||
|
|
||||||
|
#include <Agent.hpp>
|
||||||
|
#include <AgentFactory.hpp>
|
||||||
|
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QMap>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
AgentListPane::AgentListPane(AgentFactory *factory, QWidget *parent)
|
||||||
|
: QFrame(parent)
|
||||||
|
, m_factory(factory)
|
||||||
|
{
|
||||||
|
setFrameShape(QFrame::StyledPanel);
|
||||||
|
|
||||||
|
m_filterEdit = new QLineEdit(this);
|
||||||
|
m_filterEdit->setPlaceholderText(tr("Filter agents…"));
|
||||||
|
m_filterEdit->setClearButtonEnabled(true);
|
||||||
|
|
||||||
|
auto *filterRow = new QHBoxLayout;
|
||||||
|
filterRow->setContentsMargins(6, 6, 6, 6);
|
||||||
|
filterRow->addWidget(m_filterEdit, 1);
|
||||||
|
m_filterHolder = new QWidget(this);
|
||||||
|
m_filterHolder->setObjectName(QStringLiteral("FilterHolder"));
|
||||||
|
m_filterHolder->setLayout(filterRow);
|
||||||
|
m_filterHolder->setAutoFillBackground(true);
|
||||||
|
|
||||||
|
m_tagStrip = new TagFilterStrip(this);
|
||||||
|
|
||||||
|
m_listScroll = new QScrollArea(this);
|
||||||
|
m_listScroll->setWidgetResizable(true);
|
||||||
|
m_listScroll->setFrameShape(QFrame::NoFrame);
|
||||||
|
m_listScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
|
||||||
|
auto *outer = new QVBoxLayout(this);
|
||||||
|
outer->setContentsMargins(0, 0, 0, 0);
|
||||||
|
outer->setSpacing(0);
|
||||||
|
outer->addWidget(m_filterHolder);
|
||||||
|
outer->addWidget(m_tagStrip);
|
||||||
|
outer->addWidget(m_listScroll, 1);
|
||||||
|
|
||||||
|
m_filterDebounce = new QTimer(this);
|
||||||
|
m_filterDebounce->setSingleShot(true);
|
||||||
|
m_filterDebounce->setInterval(100);
|
||||||
|
connect(m_filterDebounce, &QTimer::timeout, this, &AgentListPane::rebuildList);
|
||||||
|
connect(m_filterEdit, &QLineEdit::textChanged, this,
|
||||||
|
[this](const QString &) { m_filterDebounce->start(); });
|
||||||
|
|
||||||
|
connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this,
|
||||||
|
[this](const QSet<QString> &) { rebuildList(); },
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
|
applyFilterHolderTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListPane::selectByName(const QString &name)
|
||||||
|
{
|
||||||
|
if (name.isEmpty())
|
||||||
|
return;
|
||||||
|
setCurrentNameInternal(name, false);
|
||||||
|
rebuildList();
|
||||||
|
for (auto *item : m_rows) {
|
||||||
|
if (item->agentName() == name) {
|
||||||
|
QTimer::singleShot(0, this, [this, item] {
|
||||||
|
m_listScroll->ensureWidgetVisible(item, 0, 60);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListPane::refresh()
|
||||||
|
{
|
||||||
|
QMap<QString, int> counts;
|
||||||
|
for (const auto *a : visibleAgents())
|
||||||
|
for (const QString &t : a->tags)
|
||||||
|
counts[t] += 1;
|
||||||
|
m_tagStrip->setAvailableTags(counts);
|
||||||
|
rebuildList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListPane::changeEvent(QEvent *event)
|
||||||
|
{
|
||||||
|
QFrame::changeEvent(event);
|
||||||
|
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||||
|
applyFilterHolderTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListPane::applyFilterHolderTheme()
|
||||||
|
{
|
||||||
|
if (!m_filterHolder)
|
||||||
|
return;
|
||||||
|
const Theme theme = themeFor(palette());
|
||||||
|
m_filterHolder->setStyleSheet(
|
||||||
|
QStringLiteral("QWidget#FilterHolder { background:%1;"
|
||||||
|
" border-bottom:1px solid %2; }")
|
||||||
|
.arg(theme.listHeaderBg, theme.rowSeparator));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<const AgentConfig *> AgentListPane::visibleAgents() const
|
||||||
|
{
|
||||||
|
std::vector<const AgentConfig *> out;
|
||||||
|
if (!m_factory)
|
||||||
|
return out;
|
||||||
|
for (const auto &a : m_factory->configs()) {
|
||||||
|
if (a.hidden)
|
||||||
|
continue;
|
||||||
|
out.push_back(&a);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AgentListPane::matchesFilters(const AgentConfig &a, const QString &lowerFilter) const
|
||||||
|
{
|
||||||
|
if (!lowerFilter.isEmpty()
|
||||||
|
&& !(a.name + QLatin1Char(' ') + a.model).toLower().contains(lowerFilter))
|
||||||
|
return false;
|
||||||
|
const QSet<QString> &active = m_tagStrip->activeTags();
|
||||||
|
for (const QString &t : active)
|
||||||
|
if (!a.tags.contains(t))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListPane::rebuildList()
|
||||||
|
{
|
||||||
|
const QString lowerFilter = m_filterEdit->text().trimmed().toLower();
|
||||||
|
|
||||||
|
std::vector<const AgentConfig *> userAgents;
|
||||||
|
std::vector<const AgentConfig *> bundledAgents;
|
||||||
|
for (const auto *a : visibleAgents()) {
|
||||||
|
if (!matchesFilters(*a, lowerFilter))
|
||||||
|
continue;
|
||||||
|
if (a->isUserSource())
|
||||||
|
userAgents.push_back(a);
|
||||||
|
else
|
||||||
|
bundledAgents.push_back(a);
|
||||||
|
}
|
||||||
|
auto byName = [](const AgentConfig *a, const AgentConfig *b) {
|
||||||
|
return a->name.localeAwareCompare(b->name) < 0;
|
||||||
|
};
|
||||||
|
std::sort(userAgents.begin(), userAgents.end(), byName);
|
||||||
|
std::sort(bundledAgents.begin(), bundledAgents.end(), byName);
|
||||||
|
|
||||||
|
QList<AgentListItem *> newRows;
|
||||||
|
auto *content = new QWidget;
|
||||||
|
content->setAutoFillBackground(true);
|
||||||
|
auto *contentLayout = new QVBoxLayout(content);
|
||||||
|
contentLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
contentLayout->setSpacing(0);
|
||||||
|
|
||||||
|
const QSet<QString> &activeTags = m_tagStrip->activeTags();
|
||||||
|
auto addAgents = [&](const std::vector<const AgentConfig *> &agents) {
|
||||||
|
for (const AgentConfig *cfg : agents) {
|
||||||
|
auto *item = new AgentListItem(*cfg, content);
|
||||||
|
item->setSelected(cfg->name == m_currentName);
|
||||||
|
item->setActiveTags(activeTags);
|
||||||
|
connect(item, &AgentListItem::clicked, this, &AgentListPane::onRowClicked);
|
||||||
|
connect(item, &AgentListItem::tagClicked, this,
|
||||||
|
[this](const QString &) { refresh(); },
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
contentLayout->addWidget(item);
|
||||||
|
newRows.append(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!userAgents.empty()) {
|
||||||
|
contentLayout->addWidget(makeSectionHeader(tr("User"), content));
|
||||||
|
addAgents(userAgents);
|
||||||
|
}
|
||||||
|
if (!bundledAgents.empty()) {
|
||||||
|
contentLayout->addWidget(makeSectionHeader(tr("Bundled"), content));
|
||||||
|
addAgents(bundledAgents);
|
||||||
|
}
|
||||||
|
if (newRows.isEmpty()) {
|
||||||
|
auto *empty = new QLabel(tr("No agents match these filters."), content);
|
||||||
|
empty->setAlignment(Qt::AlignCenter);
|
||||||
|
empty->setContentsMargins(10, 16, 10, 16);
|
||||||
|
QPalette ep = empty->palette();
|
||||||
|
ep.setColor(QPalette::WindowText, ep.color(QPalette::Mid));
|
||||||
|
empty->setPalette(ep);
|
||||||
|
contentLayout->addWidget(empty);
|
||||||
|
}
|
||||||
|
contentLayout->addStretch(1);
|
||||||
|
|
||||||
|
m_rows = newRows;
|
||||||
|
m_listScroll->setWidget(content);
|
||||||
|
|
||||||
|
const AgentConfig *current
|
||||||
|
= m_currentName.isEmpty() || !m_factory
|
||||||
|
? nullptr
|
||||||
|
: m_factory->configByName(m_currentName);
|
||||||
|
if (!current && !m_rows.isEmpty()) {
|
||||||
|
const QString fallback = m_rows.front()->agentName();
|
||||||
|
m_rows.front()->setSelected(true);
|
||||||
|
setCurrentNameInternal(fallback, /*emitSignal*/ true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit currentAgentChanged(m_currentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListPane::onRowClicked(const QString &name)
|
||||||
|
{
|
||||||
|
setCurrentNameInternal(name, /*emitSignal*/ true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentListPane::setCurrentNameInternal(const QString &name, bool emitSignal)
|
||||||
|
{
|
||||||
|
if (name == m_currentName)
|
||||||
|
return;
|
||||||
|
m_currentName = name;
|
||||||
|
for (auto *item : m_rows)
|
||||||
|
item->setSelected(item->agentName() == name);
|
||||||
|
if (emitSignal)
|
||||||
|
emit currentAgentChanged(m_currentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
62
settings/AgentListPane.hpp
Normal file
62
settings/AgentListPane.hpp
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QList>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QString>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <AgentConfig.hpp>
|
||||||
|
|
||||||
|
class QLineEdit;
|
||||||
|
class QScrollArea;
|
||||||
|
class QTimer;
|
||||||
|
class QVBoxLayout;
|
||||||
|
|
||||||
|
namespace QodeAssist {
|
||||||
|
class AgentFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class AgentListItem;
|
||||||
|
class TagFilterStrip;
|
||||||
|
|
||||||
|
class AgentListPane : public QFrame
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit AgentListPane(AgentFactory *factory, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
QString currentName() const { return m_currentName; }
|
||||||
|
void selectByName(const QString &name);
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void currentAgentChanged(const QString &name);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void changeEvent(QEvent *event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void rebuildList();
|
||||||
|
void applyFilterHolderTheme();
|
||||||
|
bool matchesFilters(const AgentConfig &a, const QString &lowerFilter) const;
|
||||||
|
std::vector<const AgentConfig *> visibleAgents() const;
|
||||||
|
void setCurrentNameInternal(const QString &name, bool emitSignal);
|
||||||
|
void onRowClicked(const QString &name);
|
||||||
|
|
||||||
|
AgentFactory *m_factory;
|
||||||
|
QLineEdit *m_filterEdit = nullptr;
|
||||||
|
QTimer *m_filterDebounce = nullptr;
|
||||||
|
QWidget *m_filterHolder = nullptr;
|
||||||
|
TagFilterStrip *m_tagStrip = nullptr;
|
||||||
|
QScrollArea *m_listScroll = nullptr;
|
||||||
|
QList<AgentListItem *> m_rows;
|
||||||
|
QString m_currentName;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
280
settings/AgentsSettingsPage.cpp
Normal file
280
settings/AgentsSettingsPage.cpp
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "AgentsSettingsPage.hpp"
|
||||||
|
|
||||||
|
#include "AgentDetailPane.hpp"
|
||||||
|
#include "AgentDuplicator.hpp"
|
||||||
|
#include "AgentListPane.hpp"
|
||||||
|
#include "SettingsTheme.hpp"
|
||||||
|
#include "SettingsConstants.hpp"
|
||||||
|
|
||||||
|
#include <coreplugin/dialogs/ioptionspage.h>
|
||||||
|
#include <coreplugin/editormanager/editormanager.h>
|
||||||
|
|
||||||
|
#include <utils/filepath.h>
|
||||||
|
|
||||||
|
#include <QDesktopServices>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QFontMetrics>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QSplitter>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include <Agent.hpp>
|
||||||
|
#include <AgentFactory.hpp>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
AgentsPageNavigator::AgentsPageNavigator(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{}
|
||||||
|
|
||||||
|
void AgentsPageNavigator::requestSelectAgent(const QString &name)
|
||||||
|
{
|
||||||
|
m_pending = name;
|
||||||
|
emit selectAgentRequested(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AgentsPageNavigator::takePendingSelection()
|
||||||
|
{
|
||||||
|
QString p = m_pending;
|
||||||
|
m_pending.clear();
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
class AgentsWidget : public Core::IOptionsPageWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit AgentsWidget(AgentFactory *agentFactory, AgentsPageNavigator *navigator)
|
||||||
|
: m_agentFactory(agentFactory)
|
||||||
|
, m_navigator(navigator)
|
||||||
|
{
|
||||||
|
Q_ASSERT(m_agentFactory);
|
||||||
|
|
||||||
|
m_titleLabel = new QLabel(tr("Agents"), this);
|
||||||
|
QFont tf = m_titleLabel->font();
|
||||||
|
tf.setBold(true);
|
||||||
|
tf.setPixelSize(13);
|
||||||
|
m_titleLabel->setFont(tf);
|
||||||
|
|
||||||
|
m_reload = new QPushButton(tr("Reload from disk"), this);
|
||||||
|
m_openUserDir = new QPushButton(tr("Open agents folder"), this);
|
||||||
|
|
||||||
|
m_userPathLabel = new QLabel(this);
|
||||||
|
m_userPathLabel->setFont(monospaceFont(11));
|
||||||
|
QPalette mutedPal = m_userPathLabel->palette();
|
||||||
|
mutedPal.setColor(QPalette::WindowText, mutedPal.color(QPalette::Mid));
|
||||||
|
m_userPathLabel->setPalette(mutedPal);
|
||||||
|
m_userPathLabel->setMaximumWidth(260);
|
||||||
|
|
||||||
|
auto *headerRow = new QHBoxLayout;
|
||||||
|
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
headerRow->setSpacing(8);
|
||||||
|
headerRow->addWidget(m_titleLabel);
|
||||||
|
headerRow->addStretch(1);
|
||||||
|
headerRow->addWidget(m_reload);
|
||||||
|
headerRow->addWidget(m_userPathLabel);
|
||||||
|
headerRow->addWidget(m_openUserDir);
|
||||||
|
|
||||||
|
auto *headerSep = new QFrame(this);
|
||||||
|
headerSep->setFrameShape(QFrame::HLine);
|
||||||
|
headerSep->setFrameShadow(QFrame::Sunken);
|
||||||
|
|
||||||
|
m_listPane = new AgentListPane(m_agentFactory, this);
|
||||||
|
|
||||||
|
m_detail = new AgentDetailPane(this);
|
||||||
|
m_detail->setInstanceFactory(m_agentFactory->instanceFactory());
|
||||||
|
m_detailScroll = new QScrollArea(this);
|
||||||
|
m_detailScroll->setWidgetResizable(true);
|
||||||
|
m_detailScroll->setFrameShape(QFrame::StyledPanel);
|
||||||
|
m_detailScroll->setWidget(m_detail);
|
||||||
|
|
||||||
|
auto *splitter = new QSplitter(Qt::Horizontal, this);
|
||||||
|
splitter->addWidget(m_listPane);
|
||||||
|
splitter->addWidget(m_detailScroll);
|
||||||
|
splitter->setStretchFactor(0, 0);
|
||||||
|
splitter->setStretchFactor(1, 1);
|
||||||
|
splitter->setSizes({320, 700});
|
||||||
|
|
||||||
|
auto *root = new QVBoxLayout(this);
|
||||||
|
root->setContentsMargins(8, 8, 8, 8);
|
||||||
|
root->setSpacing(6);
|
||||||
|
root->addLayout(headerRow);
|
||||||
|
root->addWidget(headerSep);
|
||||||
|
root->addWidget(splitter, 1);
|
||||||
|
|
||||||
|
connect(m_reload, &QPushButton::clicked, this, &AgentsWidget::reloadFromDisk);
|
||||||
|
connect(m_openUserDir, &QPushButton::clicked, this, [] {
|
||||||
|
const QString dir = QodeAssist::AgentFactory::userAgentsDir();
|
||||||
|
QDir().mkpath(dir);
|
||||||
|
QDesktopServices::openUrl(QUrl::fromLocalFile(dir));
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_listPane, &AgentListPane::currentAgentChanged, this,
|
||||||
|
[this](const QString &name) {
|
||||||
|
if (const AgentConfig *cfg = m_agentFactory->configByName(name))
|
||||||
|
m_detail->setAgent(*cfg);
|
||||||
|
else
|
||||||
|
m_detail->clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_detail, &AgentDetailPane::openInEditorRequested,
|
||||||
|
this, &AgentsWidget::openAgentInEditor);
|
||||||
|
connect(m_detail, &AgentDetailPane::customizeRequested,
|
||||||
|
this, &AgentsWidget::customizeAgent);
|
||||||
|
connect(m_detail, &AgentDetailPane::deleteRequested,
|
||||||
|
this, &AgentsWidget::deleteAgent);
|
||||||
|
|
||||||
|
if (m_navigator) {
|
||||||
|
connect(m_navigator, &AgentsPageNavigator::selectAgentRequested,
|
||||||
|
m_listPane, &AgentListPane::selectByName);
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadFromDisk();
|
||||||
|
|
||||||
|
if (m_navigator) {
|
||||||
|
QTimer::singleShot(0, this, [this] {
|
||||||
|
if (!m_navigator)
|
||||||
|
return;
|
||||||
|
const QString pending = m_navigator->takePendingSelection();
|
||||||
|
if (!pending.isEmpty())
|
||||||
|
m_listPane->selectByName(pending);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void apply() final {}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void reloadFromDisk()
|
||||||
|
{
|
||||||
|
m_agentFactory->reload();
|
||||||
|
m_detail->setLoadDiagnostics(
|
||||||
|
m_agentFactory->lastLoadErrors(), m_agentFactory->lastLoadWarnings());
|
||||||
|
updateUserPathLabel();
|
||||||
|
m_listPane->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateUserPathLabel()
|
||||||
|
{
|
||||||
|
const QString dir = QodeAssist::AgentFactory::userAgentsDir();
|
||||||
|
m_userPathLabel->setText(
|
||||||
|
QFontMetrics(m_userPathLabel->font()).elidedText(dir, Qt::ElideLeft, 256));
|
||||||
|
m_userPathLabel->setToolTip(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
void openAgentInEditor(const AgentConfig &agent)
|
||||||
|
{
|
||||||
|
const QString name = agent.name;
|
||||||
|
const QString sourcePath = agent.sourcePath;
|
||||||
|
const bool isUser = agent.isUserSource();
|
||||||
|
|
||||||
|
if (!isUser) {
|
||||||
|
QMessageBox::information(
|
||||||
|
this, tr("Open agent"),
|
||||||
|
tr("'%1' is bundled with the plugin and read-only.\n"
|
||||||
|
"Use Duplicate to create an editable user copy.")
|
||||||
|
.arg(name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sourcePath.isEmpty() || sourcePath.startsWith(QLatin1String(":/"))) {
|
||||||
|
QMessageBox::warning(
|
||||||
|
this, tr("Open agent"),
|
||||||
|
tr("Agent '%1' has no editable source file.").arg(name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Core::EditorManager::openEditor(Utils::FilePath::fromString(sourcePath))) {
|
||||||
|
QMessageBox::warning(
|
||||||
|
this, tr("Open agent"),
|
||||||
|
tr("Could not open %1.").arg(sourcePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void customizeAgent(const AgentConfig &parent)
|
||||||
|
{
|
||||||
|
const AgentDuplicateResult res = duplicateAgentInUserDir(parent, *m_agentFactory);
|
||||||
|
if (!res.ok) {
|
||||||
|
QMessageBox::warning(this, tr("Duplicate"), res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString newName = res.newName;
|
||||||
|
reloadFromDisk();
|
||||||
|
m_listPane->selectByName(newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteAgent(const AgentConfig &agent)
|
||||||
|
{
|
||||||
|
if (!agent.isUserSource())
|
||||||
|
return;
|
||||||
|
const QString name = agent.name;
|
||||||
|
const QString sourcePath = agent.sourcePath;
|
||||||
|
|
||||||
|
if (QMessageBox::question(
|
||||||
|
this, tr("Delete Agent"),
|
||||||
|
tr("Delete agent '%1'?\n\nThis will remove the file:\n%2")
|
||||||
|
.arg(name, sourcePath),
|
||||||
|
QMessageBox::Yes | QMessageBox::No, QMessageBox::No)
|
||||||
|
!= QMessageBox::Yes)
|
||||||
|
return;
|
||||||
|
if (!QFile::remove(sourcePath)) {
|
||||||
|
QMessageBox::warning(
|
||||||
|
this, tr("Delete Agent"),
|
||||||
|
tr("Could not delete the agent file:\n%1").arg(sourcePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reloadFromDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentFactory *m_agentFactory;
|
||||||
|
QPointer<AgentsPageNavigator> m_navigator;
|
||||||
|
|
||||||
|
QLabel *m_titleLabel = nullptr;
|
||||||
|
QPushButton *m_reload = nullptr;
|
||||||
|
QPushButton *m_openUserDir = nullptr;
|
||||||
|
QLabel *m_userPathLabel = nullptr;
|
||||||
|
|
||||||
|
AgentListPane *m_listPane = nullptr;
|
||||||
|
QScrollArea *m_detailScroll = nullptr;
|
||||||
|
AgentDetailPane *m_detail = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AgentsSettingsPage : public Core::IOptionsPage
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AgentsSettingsPage(AgentFactory *agentFactory, AgentsPageNavigator *navigator)
|
||||||
|
{
|
||||||
|
setId(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID);
|
||||||
|
setDisplayName(QObject::tr("Agents"));
|
||||||
|
setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY);
|
||||||
|
setWidgetCreator([agentFactory, navigator]() {
|
||||||
|
return new AgentsWidget(agentFactory, navigator);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::unique_ptr<Core::IOptionsPage> createAgentsSettingsPage(
|
||||||
|
AgentFactory *agentFactory, AgentsPageNavigator *navigator)
|
||||||
|
{
|
||||||
|
return std::make_unique<AgentsSettingsPage>(agentFactory, navigator);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
|
|
||||||
|
#include "AgentsSettingsPage.moc"
|
||||||
38
settings/AgentsSettingsPage.hpp
Normal file
38
settings/AgentsSettingsPage.hpp
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace Core { class IOptionsPage; }
|
||||||
|
|
||||||
|
namespace QodeAssist {
|
||||||
|
class AgentFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class AgentsPageNavigator : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit AgentsPageNavigator(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
void requestSelectAgent(const QString &name);
|
||||||
|
QString takePendingSelection();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void selectAgentRequested(const QString &name);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::unique_ptr<Core::IOptionsPage> createAgentsSettingsPage(
|
||||||
|
AgentFactory *agentFactory, AgentsPageNavigator *navigator);
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
@@ -17,16 +17,23 @@ add_library(QodeAssistSettings STATIC
|
|||||||
ProviderSettings.hpp ProviderSettings.cpp
|
ProviderSettings.hpp ProviderSettings.cpp
|
||||||
ProviderNameMigration.hpp
|
ProviderNameMigration.hpp
|
||||||
ProvidersSettingsPage.hpp ProvidersSettingsPage.cpp
|
ProvidersSettingsPage.hpp ProvidersSettingsPage.cpp
|
||||||
ProvidersSettingsHelpers.hpp
|
SettingsTheme.hpp
|
||||||
|
SettingsUiBuilders.hpp SettingsUiBuilders.cpp
|
||||||
SectionBox.hpp SectionBox.cpp
|
SectionBox.hpp SectionBox.cpp
|
||||||
|
TagChip.hpp TagChip.cpp
|
||||||
ProviderListItem.hpp ProviderListItem.cpp
|
ProviderListItem.hpp ProviderListItem.cpp
|
||||||
ProviderDetailPane.hpp ProviderDetailPane.cpp
|
ProviderDetailPane.hpp ProviderDetailPane.cpp
|
||||||
NewProviderDialog.hpp NewProviderDialog.cpp
|
|
||||||
PluginUpdater.hpp PluginUpdater.cpp
|
PluginUpdater.hpp PluginUpdater.cpp
|
||||||
UpdateDialog.hpp UpdateDialog.cpp
|
UpdateDialog.hpp UpdateDialog.cpp
|
||||||
AgentRole.hpp AgentRole.cpp
|
AgentRole.hpp AgentRole.cpp
|
||||||
AgentRoleDialog.hpp AgentRoleDialog.cpp
|
AgentRoleDialog.hpp AgentRoleDialog.cpp
|
||||||
AgentRolesWidget.hpp AgentRolesWidget.cpp
|
AgentRolesWidget.hpp AgentRolesWidget.cpp
|
||||||
|
AgentsSettingsPage.hpp AgentsSettingsPage.cpp
|
||||||
|
AgentDetailPane.hpp AgentDetailPane.cpp
|
||||||
|
AgentListItem.hpp AgentListItem.cpp
|
||||||
|
AgentListPane.hpp AgentListPane.cpp
|
||||||
|
AgentDuplicator.hpp AgentDuplicator.cpp
|
||||||
|
TagFilterStrip.hpp TagFilterStrip.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(QodeAssistSettings
|
target_link_libraries(QodeAssistSettings
|
||||||
@@ -38,6 +45,7 @@ target_link_libraries(QodeAssistSettings
|
|||||||
QtCreator::Utils
|
QtCreator::Utils
|
||||||
QodeAssistLogger
|
QodeAssistLogger
|
||||||
ProvidersConfig
|
ProvidersConfig
|
||||||
|
Agents
|
||||||
Skills
|
Skills
|
||||||
)
|
)
|
||||||
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
// Copyright (C) 2024-2026 Petr Mironychev
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
#include "NewProviderDialog.hpp"
|
|
||||||
|
|
||||||
#include <QComboBox>
|
|
||||||
#include <QDialogButtonBox>
|
|
||||||
#include <QFormLayout>
|
|
||||||
#include <QLabel>
|
|
||||||
#include <QLineEdit>
|
|
||||||
#include <QPushButton>
|
|
||||||
#include <QVBoxLayout>
|
|
||||||
|
|
||||||
namespace QodeAssist::Settings {
|
|
||||||
|
|
||||||
NewProviderDialog::NewProviderDialog(const QStringList &types, QWidget *parent)
|
|
||||||
: QDialog(parent)
|
|
||||||
{
|
|
||||||
setWindowTitle(tr("New provider"));
|
|
||||||
setMinimumWidth(520);
|
|
||||||
|
|
||||||
auto *intro = new QLabel(
|
|
||||||
tr("A provider binds a client API to a URL and API key. "
|
|
||||||
"Agents reference providers by name."),
|
|
||||||
this);
|
|
||||||
intro->setWordWrap(true);
|
|
||||||
QPalette ip = intro->palette();
|
|
||||||
ip.setColor(QPalette::WindowText, ip.color(QPalette::Mid));
|
|
||||||
intro->setPalette(ip);
|
|
||||||
|
|
||||||
m_typeCombo = new QComboBox(this);
|
|
||||||
m_typeCombo->addItems(types);
|
|
||||||
m_typeCombo->setEditable(false);
|
|
||||||
|
|
||||||
m_nameEdit = new QLineEdit(this);
|
|
||||||
m_nameEdit->setPlaceholderText(tr("Shown in the providers list and referenced by agents."));
|
|
||||||
|
|
||||||
m_urlEdit = new QLineEdit(this);
|
|
||||||
m_urlEdit->setPlaceholderText(QStringLiteral("https://api.example.com"));
|
|
||||||
|
|
||||||
m_descriptionEdit = new QLineEdit(this);
|
|
||||||
m_descriptionEdit->setPlaceholderText(tr("Optional — what this provider is for."));
|
|
||||||
|
|
||||||
m_apiKeyEdit = new QLineEdit(this);
|
|
||||||
m_apiKeyEdit->setEchoMode(QLineEdit::Password);
|
|
||||||
m_apiKeyEdit->setPlaceholderText(tr("(stored — leave blank to set later)"));
|
|
||||||
|
|
||||||
auto *form = new QFormLayout;
|
|
||||||
form->addRow(tr("Client API:"), m_typeCombo);
|
|
||||||
form->addRow(tr("Name:"), m_nameEdit);
|
|
||||||
form->addRow(tr("URL:"), m_urlEdit);
|
|
||||||
form->addRow(tr("Description:"), m_descriptionEdit);
|
|
||||||
form->addRow(tr("API key:"), m_apiKeyEdit);
|
|
||||||
|
|
||||||
auto *buttons = new QDialogButtonBox(
|
|
||||||
QDialogButtonBox::Cancel | QDialogButtonBox::Ok, this);
|
|
||||||
buttons->button(QDialogButtonBox::Ok)->setText(tr("Create"));
|
|
||||||
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
|
||||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
|
||||||
|
|
||||||
auto *root = new QVBoxLayout(this);
|
|
||||||
root->addWidget(intro);
|
|
||||||
root->addLayout(form);
|
|
||||||
root->addWidget(buttons);
|
|
||||||
|
|
||||||
connect(m_typeCombo, &QComboBox::currentTextChanged, this, [this](const QString &type) {
|
|
||||||
if (m_nameEdit->text().isEmpty())
|
|
||||||
m_nameEdit->setText(type);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!types.isEmpty() && m_nameEdit->text().isEmpty())
|
|
||||||
m_nameEdit->setText(types.front());
|
|
||||||
}
|
|
||||||
|
|
||||||
QString NewProviderDialog::providerType() const { return m_typeCombo->currentText(); }
|
|
||||||
QString NewProviderDialog::providerName() const { return m_nameEdit->text().trimmed(); }
|
|
||||||
QString NewProviderDialog::url() const { return m_urlEdit->text().trimmed(); }
|
|
||||||
QString NewProviderDialog::description() const { return m_descriptionEdit->text().trimmed(); }
|
|
||||||
QString NewProviderDialog::apiKey() const { return m_apiKeyEdit->text(); }
|
|
||||||
|
|
||||||
} // namespace QodeAssist::Settings
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// Copyright (C) 2024-2026 Petr Mironychev
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QDialog>
|
|
||||||
#include <QStringList>
|
|
||||||
|
|
||||||
class QComboBox;
|
|
||||||
class QLineEdit;
|
|
||||||
|
|
||||||
namespace QodeAssist::Settings {
|
|
||||||
|
|
||||||
class NewProviderDialog : public QDialog
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
public:
|
|
||||||
explicit NewProviderDialog(const QStringList &types, QWidget *parent = nullptr);
|
|
||||||
|
|
||||||
QString providerType() const;
|
|
||||||
QString providerName() const;
|
|
||||||
QString url() const;
|
|
||||||
QString description() const;
|
|
||||||
QString apiKey() const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
QComboBox *m_typeCombo = nullptr;
|
|
||||||
QLineEdit *m_nameEdit = nullptr;
|
|
||||||
QLineEdit *m_urlEdit = nullptr;
|
|
||||||
QLineEdit *m_descriptionEdit = nullptr;
|
|
||||||
QLineEdit *m_apiKeyEdit = nullptr;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace QodeAssist::Settings
|
|
||||||
@@ -18,8 +18,9 @@
|
|||||||
#include <solutions/terminal/terminalview.h>
|
#include <solutions/terminal/terminalview.h>
|
||||||
|
|
||||||
#include "ProviderInstanceWriter.hpp"
|
#include "ProviderInstanceWriter.hpp"
|
||||||
#include "ProvidersSettingsHelpers.hpp"
|
|
||||||
#include "SectionBox.hpp"
|
#include "SectionBox.hpp"
|
||||||
|
#include "SettingsTheme.hpp"
|
||||||
|
#include "SettingsUiBuilders.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Settings {
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
@@ -112,15 +113,12 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
|
|||||||
identityGrid->setContentsMargins(0, 0, 0, 0);
|
identityGrid->setContentsMargins(0, 0, 0, 0);
|
||||||
identityGrid->setHorizontalSpacing(8);
|
identityGrid->setHorizontalSpacing(8);
|
||||||
identityGrid->setVerticalSpacing(4);
|
identityGrid->setVerticalSpacing(4);
|
||||||
int identityRow = 0;
|
FormBuilder(identityGrid)
|
||||||
identityRow = addFormRow(identityGrid, identityRow, tr("Name:"),
|
.row(tr("Name:"), m_nameEdit)
|
||||||
singleField(m_nameEdit));
|
.row(tr("Client API:"), m_typeEdit,
|
||||||
identityRow = addFormRow(identityGrid, identityRow, tr("Client API:"),
|
tr("The client API this provider speaks. "
|
||||||
singleField(m_typeEdit),
|
"Cannot be changed after creation."))
|
||||||
tr("The client API this provider speaks. "
|
.row(tr("Description:"), m_descriptionEdit);
|
||||||
"Cannot be changed after creation."));
|
|
||||||
identityRow = addFormRow(identityGrid, identityRow, tr("Description:"),
|
|
||||||
singleField(m_descriptionEdit));
|
|
||||||
identitySection->bodyLayout()->addLayout(identityGrid);
|
identitySection->bodyLayout()->addLayout(identityGrid);
|
||||||
|
|
||||||
auto *endpointSection = new SectionBox(tr("Endpoint"), this);
|
auto *endpointSection = new SectionBox(tr("Endpoint"), this);
|
||||||
@@ -130,11 +128,9 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
|
|||||||
endpointGrid->setContentsMargins(0, 0, 0, 0);
|
endpointGrid->setContentsMargins(0, 0, 0, 0);
|
||||||
endpointGrid->setHorizontalSpacing(8);
|
endpointGrid->setHorizontalSpacing(8);
|
||||||
endpointGrid->setVerticalSpacing(4);
|
endpointGrid->setVerticalSpacing(4);
|
||||||
int endpointRow = 0;
|
FormBuilder(endpointGrid).row(tr("URL:"), m_urlEdit,
|
||||||
endpointRow = addFormRow(endpointGrid, endpointRow, tr("URL:"),
|
tr("Base URL. Agents append their endpoint path "
|
||||||
singleField(m_urlEdit),
|
"(e.g. /chat/completions) to this."));
|
||||||
tr("Base URL. Agents append their endpoint path "
|
|
||||||
"(e.g. /chat/completions) to this."));
|
|
||||||
endpointSection->bodyLayout()->addLayout(endpointGrid);
|
endpointSection->bodyLayout()->addLayout(endpointGrid);
|
||||||
|
|
||||||
m_samplePreview = new QLabel(this);
|
m_samplePreview = new QLabel(this);
|
||||||
@@ -176,14 +172,7 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
|
|||||||
});
|
});
|
||||||
connect(m_apiKeyClearBtn, &QPushButton::clicked, this,
|
connect(m_apiKeyClearBtn, &QPushButton::clicked, this,
|
||||||
[this] { emit apiKeyClearRequested(); });
|
[this] { emit apiKeyClearRequested(); });
|
||||||
m_keyHint = new QLabel(this);
|
m_keyHint = makeHintLabel(QString{}, this);
|
||||||
QFont khf = m_keyHint->font();
|
|
||||||
khf.setPixelSize(11);
|
|
||||||
m_keyHint->setFont(khf);
|
|
||||||
m_keyHint->setWordWrap(true);
|
|
||||||
QPalette khp = m_keyHint->palette();
|
|
||||||
khp.setColor(QPalette::WindowText, khp.color(QPalette::Mid));
|
|
||||||
m_keyHint->setPalette(khp);
|
|
||||||
|
|
||||||
auto *keyRow = new QHBoxLayout;
|
auto *keyRow = new QHBoxLayout;
|
||||||
keyRow->setContentsMargins(0, 0, 0, 0);
|
keyRow->setContentsMargins(0, 0, 0, 0);
|
||||||
@@ -197,9 +186,9 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
|
|||||||
credGrid->setContentsMargins(0, 0, 0, 0);
|
credGrid->setContentsMargins(0, 0, 0, 0);
|
||||||
credGrid->setHorizontalSpacing(8);
|
credGrid->setHorizontalSpacing(8);
|
||||||
credGrid->setVerticalSpacing(4);
|
credGrid->setVerticalSpacing(4);
|
||||||
int credRow = 0;
|
FormBuilder credForm(credGrid);
|
||||||
credRow = addFormRow(credGrid, credRow, tr("API key:"), keyRow);
|
credForm.row(tr("API key:"), keyRow);
|
||||||
credGrid->addWidget(m_keyHint, credRow, 1);
|
credGrid->addWidget(m_keyHint, credForm.currentRow(), 1);
|
||||||
credSection->bodyLayout()->addLayout(credGrid);
|
credSection->bodyLayout()->addLayout(credGrid);
|
||||||
|
|
||||||
m_launchSection = new SectionBox(tr("Launch"), this);
|
m_launchSection = new SectionBox(tr("Launch"), this);
|
||||||
@@ -483,12 +472,10 @@ Providers::ProviderInstance ProviderDetailPane::collectEdits() const
|
|||||||
|
|
||||||
void ProviderDetailPane::applyPreviewPalette()
|
void ProviderDetailPane::applyPreviewPalette()
|
||||||
{
|
{
|
||||||
const bool dark = isDarkPalette(palette());
|
const Theme theme = themeFor(palette());
|
||||||
const QString bg = dark ? QStringLiteral("#1f1f1f") : QStringLiteral("#f4f4f4");
|
|
||||||
const QString bd = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
|
|
||||||
m_samplePreview->setStyleSheet(QStringLiteral(
|
m_samplePreview->setStyleSheet(QStringLiteral(
|
||||||
"QLabel { background:%1; border:1px solid %2; }")
|
"QLabel { background:%1; border:1px solid %2; }")
|
||||||
.arg(bg, bd));
|
.arg(theme.codeBg, theme.rowSeparator));
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProviderDetailPane::applyTerminalPalette()
|
void ProviderDetailPane::applyTerminalPalette()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
#include "ProviderInstance.hpp"
|
#include "ProviderInstance.hpp"
|
||||||
#include "ProvidersSettingsHelpers.hpp"
|
#include "SettingsTheme.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Settings {
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
@@ -99,12 +99,11 @@ void ProviderListItem::applyTheme()
|
|||||||
if (m_inApplyTheme)
|
if (m_inApplyTheme)
|
||||||
return;
|
return;
|
||||||
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||||
const bool dark = isDarkPalette(palette());
|
const Theme theme = themeFor(palette());
|
||||||
const QString sep = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
|
|
||||||
const QString sel = dark ? QStringLiteral("#2c4060") : QStringLiteral("#cfe2ff");
|
|
||||||
setStyleSheet(QStringLiteral(
|
setStyleSheet(QStringLiteral(
|
||||||
"#ProvListItem { background:%1; border-top: 1px solid %2; }")
|
"#ProvListItem { background:%1; border-top: 1px solid %2; }")
|
||||||
.arg(m_selected ? sel : QStringLiteral("transparent"), sep));
|
.arg(m_selected ? theme.rowSelectedBg : QStringLiteral("transparent"),
|
||||||
|
theme.rowSeparator));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace QodeAssist::Settings
|
} // namespace QodeAssist::Settings
|
||||||
|
|||||||
@@ -227,6 +227,8 @@ public:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#ifndef QODEASSIST_EXPERIMENTAL
|
||||||
const ProviderSettingsPage providerSettingsPage;
|
const ProviderSettingsPage providerSettingsPage;
|
||||||
|
#endif
|
||||||
|
|
||||||
} // namespace QodeAssist::Settings
|
} // namespace QodeAssist::Settings
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
// Copyright (C) 2024-2026 Petr Mironychev
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QFont>
|
|
||||||
#include <QFontDatabase>
|
|
||||||
#include <QGridLayout>
|
|
||||||
#include <QHBoxLayout>
|
|
||||||
#include <QLabel>
|
|
||||||
#include <QPalette>
|
|
||||||
#include <QString>
|
|
||||||
#include <QWidget>
|
|
||||||
|
|
||||||
namespace QodeAssist::Settings {
|
|
||||||
|
|
||||||
inline QFont monospaceFont(int pixelSize = 11)
|
|
||||||
{
|
|
||||||
QFont f = QFontDatabase::systemFont(QFontDatabase::FixedFont);
|
|
||||||
f.setStyleHint(QFont::Monospace);
|
|
||||||
if (pixelSize > 0)
|
|
||||||
f.setPixelSize(pixelSize);
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline bool isDarkPalette(const QPalette &p)
|
|
||||||
{
|
|
||||||
return p.color(QPalette::Window).lightness() < 128;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline int addFormRow(
|
|
||||||
QGridLayout *grid, int row, const QString &label, QLayout *value, const QString &hint = {})
|
|
||||||
{
|
|
||||||
auto *l = new QLabel(label);
|
|
||||||
l->setMinimumWidth(96);
|
|
||||||
l->setAlignment(Qt::AlignLeft | Qt::AlignTop);
|
|
||||||
grid->addWidget(l, row, 0, Qt::AlignTop);
|
|
||||||
auto *holder = new QWidget;
|
|
||||||
holder->setLayout(value);
|
|
||||||
grid->addWidget(holder, row, 1);
|
|
||||||
if (hint.isEmpty())
|
|
||||||
return row + 1;
|
|
||||||
auto *h = new QLabel(hint);
|
|
||||||
QFont hf = h->font();
|
|
||||||
hf.setPixelSize(11);
|
|
||||||
h->setFont(hf);
|
|
||||||
h->setWordWrap(true);
|
|
||||||
QPalette p = h->palette();
|
|
||||||
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
|
|
||||||
h->setPalette(p);
|
|
||||||
grid->addWidget(h, row + 1, 1);
|
|
||||||
return row + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline QHBoxLayout *singleField(QWidget *w)
|
|
||||||
{
|
|
||||||
auto *lay = new QHBoxLayout;
|
|
||||||
lay->setContentsMargins(0, 0, 0, 0);
|
|
||||||
lay->setSpacing(4);
|
|
||||||
lay->addWidget(w, 1);
|
|
||||||
return lay;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace QodeAssist::Settings
|
|
||||||
@@ -28,7 +28,6 @@
|
|||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
#include "NewProviderDialog.hpp"
|
|
||||||
#include "ProviderDetailPane.hpp"
|
#include "ProviderDetailPane.hpp"
|
||||||
#include "ProviderInstance.hpp"
|
#include "ProviderInstance.hpp"
|
||||||
#include "ProviderInstanceFactory.hpp"
|
#include "ProviderInstanceFactory.hpp"
|
||||||
@@ -36,8 +35,8 @@
|
|||||||
#include "ProviderLauncher.hpp"
|
#include "ProviderLauncher.hpp"
|
||||||
#include "ProviderListItem.hpp"
|
#include "ProviderListItem.hpp"
|
||||||
#include "ProviderSecretsStore.hpp"
|
#include "ProviderSecretsStore.hpp"
|
||||||
#include "ProvidersSettingsHelpers.hpp"
|
|
||||||
#include "SettingsConstants.hpp"
|
#include "SettingsConstants.hpp"
|
||||||
|
#include "SettingsTheme.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Settings {
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
@@ -80,13 +79,10 @@ public:
|
|||||||
tf.setPixelSize(13);
|
tf.setPixelSize(13);
|
||||||
m_titleLabel->setFont(tf);
|
m_titleLabel->setFont(tf);
|
||||||
|
|
||||||
m_newBtn = new QPushButton(tr("+ New provider…"), this);
|
|
||||||
|
|
||||||
auto *headerRow = new QHBoxLayout;
|
auto *headerRow = new QHBoxLayout;
|
||||||
headerRow->setContentsMargins(0, 0, 0, 0);
|
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||||
headerRow->setSpacing(8);
|
headerRow->setSpacing(8);
|
||||||
headerRow->addWidget(m_titleLabel, 1);
|
headerRow->addWidget(m_titleLabel, 1);
|
||||||
headerRow->addWidget(m_newBtn);
|
|
||||||
|
|
||||||
auto *headerSep = new QFrame(this);
|
auto *headerSep = new QFrame(this);
|
||||||
headerSep->setFrameShape(QFrame::HLine);
|
headerSep->setFrameShape(QFrame::HLine);
|
||||||
@@ -181,7 +177,6 @@ public:
|
|||||||
root->addWidget(headerSep);
|
root->addWidget(headerSep);
|
||||||
root->addWidget(splitter, 1);
|
root->addWidget(splitter, 1);
|
||||||
|
|
||||||
connect(m_newBtn, &QPushButton::clicked, this, &ProvidersPageWidget::onNewClicked);
|
|
||||||
m_filterDebounce = new QTimer(this);
|
m_filterDebounce = new QTimer(this);
|
||||||
m_filterDebounce->setSingleShot(true);
|
m_filterDebounce->setSingleShot(true);
|
||||||
m_filterDebounce->setInterval(100);
|
m_filterDebounce->setInterval(100);
|
||||||
@@ -248,9 +243,9 @@ private slots:
|
|||||||
header->setPalette(hp);
|
header->setPalette(hp);
|
||||||
header->setContentsMargins(8, 4, 8, 4);
|
header->setContentsMargins(8, 4, 8, 4);
|
||||||
header->setAutoFillBackground(true);
|
header->setAutoFillBackground(true);
|
||||||
const bool dark = isDarkPalette(palette());
|
header->setStyleSheet(
|
||||||
const QString bg = dark ? QStringLiteral("#262626") : QStringLiteral("#f0f0f0");
|
QStringLiteral("QLabel { background:%1; }")
|
||||||
header->setStyleSheet(QStringLiteral("QLabel { background:%1; }").arg(bg));
|
.arg(themeFor(palette()).listHeaderBg));
|
||||||
m_listLayout->insertWidget(m_listLayout->count() - 1, header);
|
m_listLayout->insertWidget(m_listLayout->count() - 1, header);
|
||||||
|
|
||||||
std::vector<const Providers::ProviderInstance *> sorted;
|
std::vector<const Providers::ProviderInstance *> sorted;
|
||||||
@@ -316,57 +311,6 @@ private slots:
|
|||||||
populateDetail(inst->name);
|
populateDetail(inst->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onNewClicked()
|
|
||||||
{
|
|
||||||
if (!m_factory)
|
|
||||||
return;
|
|
||||||
NewProviderDialog dlg(m_factory->knownClientApis(), this);
|
|
||||||
if (dlg.exec() != QDialog::Accepted)
|
|
||||||
return;
|
|
||||||
Providers::ProviderInstance inst;
|
|
||||||
inst.name = dlg.providerName();
|
|
||||||
inst.clientApi = dlg.providerType();
|
|
||||||
inst.description = dlg.description();
|
|
||||||
inst.url = dlg.url();
|
|
||||||
inst.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(inst.name);
|
|
||||||
|
|
||||||
if (inst.name.isEmpty()) {
|
|
||||||
QMessageBox::warning(this, tr("New provider"), tr("Name cannot be empty."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (m_factory->instanceByName(inst.name)) {
|
|
||||||
QMessageBox::warning(this, tr("New provider"),
|
|
||||||
tr("An instance named '%1' already exists.").arg(inst.name));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const QString validation = Providers::ProviderInstance::validate(
|
|
||||||
inst, m_factory->knownClientApis());
|
|
||||||
if (!validation.isEmpty()) {
|
|
||||||
QMessageBox::warning(this, tr("New provider"), validation);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const QString softWarning = Providers::ProviderInstance::warnings(inst);
|
|
||||||
if (!softWarning.isEmpty()) {
|
|
||||||
if (QMessageBox::warning(this, tr("New provider"),
|
|
||||||
softWarning + QStringLiteral("\n\n")
|
|
||||||
+ tr("Save anyway?"),
|
|
||||||
QMessageBox::Yes | QMessageBox::No,
|
|
||||||
QMessageBox::No)
|
|
||||||
!= QMessageBox::Yes)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
QString writeErr;
|
|
||||||
if (Providers::ProviderInstanceWriter::writeToUserDir(
|
|
||||||
inst, /*previousPath=*/QString{}, &writeErr).isEmpty()) {
|
|
||||||
QMessageBox::warning(this, tr("New provider"), writeErr);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (m_secrets && !dlg.apiKey().isEmpty())
|
|
||||||
m_secrets->writeKey(inst.apiKeyRef, dlg.apiKey());
|
|
||||||
m_factory->reload();
|
|
||||||
selectInstance(inst.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
void onDuplicateClicked()
|
void onDuplicateClicked()
|
||||||
{
|
{
|
||||||
if (!m_factory || m_currentName.isEmpty())
|
if (!m_factory || m_currentName.isEmpty())
|
||||||
@@ -589,7 +533,6 @@ private:
|
|||||||
QPointer<ProvidersPageNavigator> m_navigator;
|
QPointer<ProvidersPageNavigator> m_navigator;
|
||||||
|
|
||||||
QLabel *m_titleLabel = nullptr;
|
QLabel *m_titleLabel = nullptr;
|
||||||
QPushButton *m_newBtn = nullptr;
|
|
||||||
QLineEdit *m_filterEdit = nullptr;
|
QLineEdit *m_filterEdit = nullptr;
|
||||||
|
|
||||||
QScrollArea *m_listScroll = nullptr;
|
QScrollArea *m_listScroll = nullptr;
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist";
|
|||||||
// Provider Settings Page ID
|
// Provider Settings Page ID
|
||||||
const char QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID[] = "QodeAssist.7ProviderSettingsPageId";
|
const char QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID[] = "QodeAssist.7ProviderSettingsPageId";
|
||||||
|
|
||||||
|
// Agents Settings Page ID
|
||||||
|
const char QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID[] = "QodeAssist.8AgentsSettingsPageId";
|
||||||
|
|
||||||
// Provider API Keys
|
// Provider API Keys
|
||||||
const char OPEN_ROUTER_API_KEY[] = "QodeAssist.openRouterApiKey";
|
const char OPEN_ROUTER_API_KEY[] = "QodeAssist.openRouterApiKey";
|
||||||
const char OPEN_ROUTER_API_KEY_HISTORY[] = "QodeAssist.openRouterApiKeyHistory";
|
const char OPEN_ROUTER_API_KEY_HISTORY[] = "QodeAssist.openRouterApiKeyHistory";
|
||||||
|
|||||||
52
settings/SettingsTheme.hpp
Normal file
52
settings/SettingsTheme.hpp
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QFont>
|
||||||
|
#include <QFontDatabase>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
struct Theme
|
||||||
|
{
|
||||||
|
bool dark = false;
|
||||||
|
QString listHeaderBg;
|
||||||
|
QString rowSeparator;
|
||||||
|
QString rowSelectedBg;
|
||||||
|
QString codeBg;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline bool isDarkPalette(const QPalette &p)
|
||||||
|
{
|
||||||
|
return p.color(QPalette::Window).lightness() < 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline Theme themeFor(const QPalette &p)
|
||||||
|
{
|
||||||
|
const bool dark = isDarkPalette(p);
|
||||||
|
if (dark)
|
||||||
|
return {true,
|
||||||
|
QStringLiteral("#262626"),
|
||||||
|
QStringLiteral("#3a3a3a"),
|
||||||
|
QStringLiteral("#2c4060"),
|
||||||
|
QStringLiteral("#1f1f1f")};
|
||||||
|
return {false,
|
||||||
|
QStringLiteral("#f0f0f0"),
|
||||||
|
QStringLiteral("#dcdcdc"),
|
||||||
|
QStringLiteral("#cfe2ff"),
|
||||||
|
QStringLiteral("#f4f4f4")};
|
||||||
|
}
|
||||||
|
|
||||||
|
inline QFont monospaceFont(int pixelSize = 11)
|
||||||
|
{
|
||||||
|
QFont f = QFontDatabase::systemFont(QFontDatabase::FixedFont);
|
||||||
|
f.setStyleHint(QFont::Monospace);
|
||||||
|
if (pixelSize > 0)
|
||||||
|
f.setPixelSize(pixelSize);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
100
settings/SettingsUiBuilders.cpp
Normal file
100
settings/SettingsUiBuilders.cpp
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "SettingsUiBuilders.hpp"
|
||||||
|
|
||||||
|
#include "SettingsTheme.hpp"
|
||||||
|
|
||||||
|
#include <QFont>
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
void applyMutedSmallCaps(QLabel *label)
|
||||||
|
{
|
||||||
|
QFont f = label->font();
|
||||||
|
f.setPixelSize(10);
|
||||||
|
f.setLetterSpacing(QFont::AbsoluteSpacing, 0.4);
|
||||||
|
label->setFont(f);
|
||||||
|
QPalette p = label->palette();
|
||||||
|
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
|
||||||
|
label->setPalette(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
QLabel *makeSectionHeader(const QString &title, QWidget *parent)
|
||||||
|
{
|
||||||
|
auto *header = new QLabel(title.toUpper(), parent);
|
||||||
|
applyMutedSmallCaps(header);
|
||||||
|
header->setContentsMargins(8, 4, 8, 4);
|
||||||
|
header->setAutoFillBackground(true);
|
||||||
|
const Theme theme = themeFor(parent ? parent->palette() : QPalette());
|
||||||
|
header->setStyleSheet(
|
||||||
|
QStringLiteral("QLabel { background:%1; border-top:1px solid %2;"
|
||||||
|
" border-bottom:1px solid %2; }")
|
||||||
|
.arg(theme.listHeaderBg, theme.rowSeparator));
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLabel *makeHintLabel(const QString &text, QWidget *parent)
|
||||||
|
{
|
||||||
|
auto *h = new QLabel(text, parent);
|
||||||
|
QFont hf = h->font();
|
||||||
|
hf.setPixelSize(11);
|
||||||
|
h->setFont(hf);
|
||||||
|
h->setWordWrap(true);
|
||||||
|
QPalette p = h->palette();
|
||||||
|
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
|
||||||
|
h->setPalette(p);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
QHBoxLayout *singleField(QWidget *w)
|
||||||
|
{
|
||||||
|
auto *lay = new QHBoxLayout;
|
||||||
|
lay->setContentsMargins(0, 0, 0, 0);
|
||||||
|
lay->setSpacing(4);
|
||||||
|
lay->addWidget(w, 1);
|
||||||
|
return lay;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QLabel *makeFormLabel(const QString &text)
|
||||||
|
{
|
||||||
|
auto *l = new QLabel(text);
|
||||||
|
l->setMinimumWidth(96);
|
||||||
|
l->setAlignment(Qt::AlignLeft | Qt::AlignTop);
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
FormBuilder::FormBuilder(QGridLayout *grid, int startRow)
|
||||||
|
: m_grid(grid)
|
||||||
|
, m_row(startRow)
|
||||||
|
{}
|
||||||
|
|
||||||
|
FormBuilder &FormBuilder::row(const QString &label, QLayout *value, const QString &hint)
|
||||||
|
{
|
||||||
|
m_grid->addWidget(makeFormLabel(label), m_row, 0, Qt::AlignTop);
|
||||||
|
auto *holder = new QWidget;
|
||||||
|
holder->setLayout(value);
|
||||||
|
m_grid->addWidget(holder, m_row, 1);
|
||||||
|
++m_row;
|
||||||
|
if (!hint.isEmpty()) {
|
||||||
|
m_grid->addWidget(makeHintLabel(hint), m_row, 1);
|
||||||
|
++m_row;
|
||||||
|
}
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
|
||||||
|
FormBuilder &FormBuilder::row(const QString &label, QWidget *value, const QString &hint)
|
||||||
|
{
|
||||||
|
return row(label, singleField(value), hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
39
settings/SettingsUiBuilders.hpp
Normal file
39
settings/SettingsUiBuilders.hpp
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class QGridLayout;
|
||||||
|
class QHBoxLayout;
|
||||||
|
class QLabel;
|
||||||
|
class QLayout;
|
||||||
|
class QWidget;
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
void applyMutedSmallCaps(QLabel *label);
|
||||||
|
|
||||||
|
QLabel *makeSectionHeader(const QString &title, QWidget *parent);
|
||||||
|
|
||||||
|
QLabel *makeHintLabel(const QString &text, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
QHBoxLayout *singleField(QWidget *w);
|
||||||
|
|
||||||
|
class FormBuilder
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit FormBuilder(QGridLayout *grid, int startRow = 0);
|
||||||
|
|
||||||
|
FormBuilder &row(const QString &label, QLayout *value, const QString &hint = {});
|
||||||
|
FormBuilder &row(const QString &label, QWidget *value, const QString &hint = {});
|
||||||
|
|
||||||
|
int currentRow() const { return m_row; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
QGridLayout *m_grid;
|
||||||
|
int m_row;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
88
settings/TagChip.cpp
Normal file
88
settings/TagChip.cpp
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "TagChip.hpp"
|
||||||
|
|
||||||
|
#include "SettingsTheme.hpp"
|
||||||
|
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QScopedValueRollback>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
TagChip::TagChip(const QString &tag, int count, QWidget *parent)
|
||||||
|
: QFrame(parent)
|
||||||
|
, m_tag(tag)
|
||||||
|
{
|
||||||
|
setObjectName(QStringLiteral("TagChip"));
|
||||||
|
setCursor(Qt::PointingHandCursor);
|
||||||
|
|
||||||
|
m_label = new QLabel(tag, this);
|
||||||
|
m_label->setFont(monospaceFont(11));
|
||||||
|
|
||||||
|
auto *row = new QHBoxLayout(this);
|
||||||
|
row->setContentsMargins(5, 0, 5, 0);
|
||||||
|
row->setSpacing(4);
|
||||||
|
row->addWidget(m_label);
|
||||||
|
|
||||||
|
if (count >= 0) {
|
||||||
|
m_count = new QLabel(QString::number(count), this);
|
||||||
|
QFont cf = m_count->font();
|
||||||
|
cf.setPixelSize(10);
|
||||||
|
m_count->setFont(cf);
|
||||||
|
row->addWidget(m_count);
|
||||||
|
}
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TagChip::setActive(bool on)
|
||||||
|
{
|
||||||
|
if (m_active == on)
|
||||||
|
return;
|
||||||
|
m_active = on;
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TagChip::mouseReleaseEvent(QMouseEvent *event)
|
||||||
|
{
|
||||||
|
if (event->button() == Qt::LeftButton)
|
||||||
|
emit clicked(m_tag);
|
||||||
|
QFrame::mouseReleaseEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TagChip::changeEvent(QEvent *event)
|
||||||
|
{
|
||||||
|
QFrame::changeEvent(event);
|
||||||
|
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TagChip::applyTheme()
|
||||||
|
{
|
||||||
|
if (m_inApplyTheme)
|
||||||
|
return;
|
||||||
|
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||||
|
const Theme theme = themeFor(palette());
|
||||||
|
const QString text = palette().color(QPalette::WindowText).name();
|
||||||
|
const QString mute = palette().color(QPalette::Mid).name();
|
||||||
|
const QString border = m_active ? text : theme.rowSeparator;
|
||||||
|
const QString bg = m_active ? theme.rowSelectedBg : QStringLiteral("transparent");
|
||||||
|
setStyleSheet(QStringLiteral(
|
||||||
|
"#TagChip { background:%1; border:1px solid %2; }")
|
||||||
|
.arg(bg, border));
|
||||||
|
QPalette lp = m_label->palette();
|
||||||
|
lp.setColor(QPalette::WindowText, m_active ? QColor(text) : QColor(mute));
|
||||||
|
m_label->setPalette(lp);
|
||||||
|
if (m_count) {
|
||||||
|
QPalette cp = m_count->palette();
|
||||||
|
cp.setColor(QPalette::WindowText, QColor(mute));
|
||||||
|
m_count->setPalette(cp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
39
settings/TagChip.hpp
Normal file
39
settings/TagChip.hpp
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class QLabel;
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class TagChip : public QFrame
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit TagChip(const QString &tag, int count, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
void setActive(bool on);
|
||||||
|
QString tag() const { return m_tag; }
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void clicked(const QString &tag);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||||
|
void changeEvent(QEvent *event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void applyTheme();
|
||||||
|
|
||||||
|
QString m_tag;
|
||||||
|
bool m_active = false;
|
||||||
|
bool m_inApplyTheme = false;
|
||||||
|
QLabel *m_label = nullptr;
|
||||||
|
QLabel *m_count = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
163
settings/TagFilterStrip.cpp
Normal file
163
settings/TagFilterStrip.cpp
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "TagFilterStrip.hpp"
|
||||||
|
|
||||||
|
#include "SettingsTheme.hpp"
|
||||||
|
#include "SettingsUiBuilders.hpp"
|
||||||
|
#include "TagChip.hpp"
|
||||||
|
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLayoutItem>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QScopedValueRollback>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
TagFilterStrip::TagFilterStrip(QWidget *parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
{
|
||||||
|
setObjectName(QStringLiteral("TagStrip"));
|
||||||
|
setAutoFillBackground(true);
|
||||||
|
m_layout = new QVBoxLayout(this);
|
||||||
|
m_layout->setContentsMargins(8, 6, 8, 6);
|
||||||
|
m_layout->setSpacing(5);
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TagFilterStrip::setAvailableTags(const QMap<QString, int> &countsByTag)
|
||||||
|
{
|
||||||
|
m_counts = countsByTag;
|
||||||
|
QSet<QString> stillExisting;
|
||||||
|
for (auto it = m_counts.cbegin(); it != m_counts.cend(); ++it)
|
||||||
|
stillExisting.insert(it.key());
|
||||||
|
QSet<QString> trimmed;
|
||||||
|
for (const QString &t : m_activeTags)
|
||||||
|
if (stillExisting.contains(t))
|
||||||
|
trimmed.insert(t);
|
||||||
|
const bool activeChanged = trimmed != m_activeTags;
|
||||||
|
if (activeChanged)
|
||||||
|
m_activeTags = trimmed;
|
||||||
|
rebuild();
|
||||||
|
if (activeChanged)
|
||||||
|
emit activeTagsChanged(m_activeTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TagFilterStrip::changeEvent(QEvent *event)
|
||||||
|
{
|
||||||
|
QWidget::changeEvent(event);
|
||||||
|
if (m_inApplyTheme)
|
||||||
|
return;
|
||||||
|
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TagFilterStrip::toggleTag(const QString &tag)
|
||||||
|
{
|
||||||
|
if (m_activeTags.contains(tag))
|
||||||
|
m_activeTags.remove(tag);
|
||||||
|
else
|
||||||
|
m_activeTags.insert(tag);
|
||||||
|
refreshActiveStates();
|
||||||
|
emit activeTagsChanged(m_activeTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TagFilterStrip::refreshActiveStates()
|
||||||
|
{
|
||||||
|
for (auto it = m_chipByTag.cbegin(); it != m_chipByTag.cend(); ++it)
|
||||||
|
it.value()->setActive(m_activeTags.contains(it.key()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TagFilterStrip::applyTheme()
|
||||||
|
{
|
||||||
|
if (m_inApplyTheme)
|
||||||
|
return;
|
||||||
|
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||||
|
const Theme theme = themeFor(palette());
|
||||||
|
setStyleSheet(QStringLiteral("QWidget#TagStrip { background:%1;"
|
||||||
|
" border-bottom:1px solid %2; }")
|
||||||
|
.arg(theme.listHeaderBg, theme.rowSeparator));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TagFilterStrip::rebuild()
|
||||||
|
{
|
||||||
|
while (auto *item = m_layout->takeAt(0)) {
|
||||||
|
if (auto *w = item->widget())
|
||||||
|
w->deleteLater();
|
||||||
|
if (auto *l = item->layout()) {
|
||||||
|
while (auto *sub = l->takeAt(0)) {
|
||||||
|
if (auto *sw = sub->widget())
|
||||||
|
sw->deleteLater();
|
||||||
|
delete sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
m_chipByTag.clear();
|
||||||
|
|
||||||
|
if (m_counts.isEmpty()) {
|
||||||
|
setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVisible(true);
|
||||||
|
|
||||||
|
auto *headerLine = new QHBoxLayout;
|
||||||
|
headerLine->setContentsMargins(0, 0, 0, 0);
|
||||||
|
headerLine->setSpacing(6);
|
||||||
|
auto *title = new QLabel(tr("FILTER BY TAG"), this);
|
||||||
|
applyMutedSmallCaps(title);
|
||||||
|
headerLine->addWidget(title);
|
||||||
|
headerLine->addStretch(1);
|
||||||
|
if (!m_activeTags.isEmpty()) {
|
||||||
|
auto *clear = new QLabel(QStringLiteral("<a href=\"#\">%1</a>").arg(tr("clear")), this);
|
||||||
|
connect(clear, &QLabel::linkActivated, this, [this](const QString &) {
|
||||||
|
if (m_activeTags.isEmpty())
|
||||||
|
return;
|
||||||
|
m_activeTags.clear();
|
||||||
|
refreshActiveStates();
|
||||||
|
emit activeTagsChanged(m_activeTags);
|
||||||
|
});
|
||||||
|
headerLine->addWidget(clear);
|
||||||
|
}
|
||||||
|
m_layout->addLayout(headerLine);
|
||||||
|
|
||||||
|
std::vector<std::pair<QString, int>> sorted;
|
||||||
|
sorted.reserve(m_counts.size());
|
||||||
|
for (auto it = m_counts.cbegin(); it != m_counts.cend(); ++it)
|
||||||
|
sorted.emplace_back(it.key(), it.value());
|
||||||
|
std::sort(sorted.begin(), sorted.end(),
|
||||||
|
[](const auto &a, const auto &b) {
|
||||||
|
if (a.second != b.second)
|
||||||
|
return a.second > b.second;
|
||||||
|
return a.first.localeAwareCompare(b.first) < 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
auto *grid = new QGridLayout;
|
||||||
|
grid->setContentsMargins(0, 0, 0, 0);
|
||||||
|
grid->setHorizontalSpacing(3);
|
||||||
|
grid->setVerticalSpacing(3);
|
||||||
|
int col = 0, gridRow = 0;
|
||||||
|
for (const auto &[tag, count] : sorted) {
|
||||||
|
auto *chip = new TagChip(tag, count, this);
|
||||||
|
chip->setActive(m_activeTags.contains(tag));
|
||||||
|
connect(chip, &TagChip::clicked, this, &TagFilterStrip::toggleTag);
|
||||||
|
grid->addWidget(chip, gridRow, col, Qt::AlignLeft);
|
||||||
|
m_chipByTag.insert(tag, chip);
|
||||||
|
if (++col >= 4) {
|
||||||
|
col = 0;
|
||||||
|
++gridRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grid->setColumnStretch(4, 1);
|
||||||
|
m_layout->addLayout(grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
46
settings/TagFilterStrip.hpp
Normal file
46
settings/TagFilterStrip.hpp
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QMap>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QString>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
class QVBoxLayout;
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class TagChip;
|
||||||
|
|
||||||
|
class TagFilterStrip : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit TagFilterStrip(QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
void setAvailableTags(const QMap<QString, int> &countsByTag);
|
||||||
|
const QSet<QString> &activeTags() const { return m_activeTags; }
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void activeTagsChanged(const QSet<QString> &tags);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void changeEvent(QEvent *event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void rebuild();
|
||||||
|
void refreshActiveStates();
|
||||||
|
void applyTheme();
|
||||||
|
void toggleTag(const QString &tag);
|
||||||
|
|
||||||
|
QMap<QString, int> m_counts;
|
||||||
|
QSet<QString> m_activeTags;
|
||||||
|
QVBoxLayout *m_layout = nullptr;
|
||||||
|
QHash<QString, TagChip *> m_chipByTag;
|
||||||
|
bool m_inApplyTheme = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
94
sources/agents/Agent.cpp
Normal file
94
sources/agents/Agent.cpp
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "Agent.hpp"
|
||||||
|
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "JsonPromptTemplate.hpp"
|
||||||
|
#include "PromptTemplate.hpp"
|
||||||
|
#include "Provider.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist {
|
||||||
|
|
||||||
|
using Providers::Provider;
|
||||||
|
using Templates::JsonPromptTemplate;
|
||||||
|
using Templates::PromptTemplate;
|
||||||
|
|
||||||
|
QString AgentConfig::validate(const AgentConfig &config)
|
||||||
|
{
|
||||||
|
if (config.name.isEmpty())
|
||||||
|
return QStringLiteral("Agent config has no name");
|
||||||
|
if (config.schemaVersion > AgentConfig::kSupportedSchemaVersion) {
|
||||||
|
return QStringLiteral(
|
||||||
|
"Agent config '%1' declares schema_version %2 but this plugin "
|
||||||
|
"supports at most %3 — update QodeAssist to use this profile")
|
||||||
|
.arg(config.name)
|
||||||
|
.arg(config.schemaVersion)
|
||||||
|
.arg(AgentConfig::kSupportedSchemaVersion);
|
||||||
|
}
|
||||||
|
if (config.providerInstance.isEmpty())
|
||||||
|
return QStringLiteral("Agent config '%1' has no provider_instance").arg(config.name);
|
||||||
|
if (config.model.isEmpty())
|
||||||
|
return QStringLiteral("Agent config '%1' has no model").arg(config.name);
|
||||||
|
if (config.endpoint.isEmpty())
|
||||||
|
return QStringLiteral("Agent config '%1' has no endpoint").arg(config.name);
|
||||||
|
if (config.messageFormat.isEmpty()) {
|
||||||
|
return QStringLiteral("Agent config '%1' has no [template].message_format")
|
||||||
|
.arg(config.name);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Agent::Agent(AgentConfig config, Providers::Provider *providerOwned, QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_config(std::move(config))
|
||||||
|
, m_provider(providerOwned)
|
||||||
|
{
|
||||||
|
m_invalidReason = AgentConfig::validate(m_config);
|
||||||
|
if (!m_invalidReason.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!m_provider) {
|
||||||
|
m_invalidReason
|
||||||
|
= QStringLiteral("Agent '%1' was constructed without a provider").arg(m_config.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_provider->setParent(this);
|
||||||
|
|
||||||
|
QString tmplErr;
|
||||||
|
m_promptTemplate = JsonPromptTemplate::fromConfig(m_config, &tmplErr);
|
||||||
|
if (!m_promptTemplate) {
|
||||||
|
m_invalidReason = tmplErr.isEmpty()
|
||||||
|
? QStringLiteral("Failed to build prompt template for agent '%1'")
|
||||||
|
.arg(m_config.name)
|
||||||
|
: tmplErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Agent::~Agent() = default;
|
||||||
|
|
||||||
|
PromptTemplate *Agent::promptTemplate() noexcept
|
||||||
|
{
|
||||||
|
return m_promptTemplate.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
const PromptTemplate *Agent::promptTemplate() const noexcept
|
||||||
|
{
|
||||||
|
return m_promptTemplate.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
QFuture<QList<QString>> Agent::installedModels()
|
||||||
|
{
|
||||||
|
Q_ASSERT_X(thread() == QThread::currentThread(), Q_FUNC_INFO,
|
||||||
|
"Agent::installedModels called from non-owning thread; "
|
||||||
|
"the underlying BaseClient is not thread-safe and must be "
|
||||||
|
"accessed from the Agent's owner thread");
|
||||||
|
|
||||||
|
if (!m_provider) {
|
||||||
|
return QtFuture::makeReadyValueFuture(QList<QString>{});
|
||||||
|
}
|
||||||
|
return m_provider->getInstalledModels(m_provider->url());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist
|
||||||
53
sources/agents/Agent.hpp
Normal file
53
sources/agents/Agent.hpp
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QFuture>
|
||||||
|
#include <QList>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "AgentConfig.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist {
|
||||||
|
|
||||||
|
namespace Providers {
|
||||||
|
class Provider;
|
||||||
|
}
|
||||||
|
namespace Templates {
|
||||||
|
class JsonPromptTemplate;
|
||||||
|
class PromptTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Agent : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_DISABLE_COPY_MOVE(Agent)
|
||||||
|
public:
|
||||||
|
Agent(AgentConfig config, Providers::Provider *providerOwned, QObject *parent = nullptr);
|
||||||
|
~Agent() override;
|
||||||
|
|
||||||
|
const AgentConfig &config() const noexcept { return m_config; }
|
||||||
|
|
||||||
|
Providers::Provider *provider() noexcept { return m_provider; }
|
||||||
|
const Providers::Provider *provider() const noexcept { return m_provider; }
|
||||||
|
|
||||||
|
Templates::PromptTemplate *promptTemplate() noexcept;
|
||||||
|
const Templates::PromptTemplate *promptTemplate() const noexcept;
|
||||||
|
|
||||||
|
bool isValid() const noexcept { return m_invalidReason.isEmpty(); }
|
||||||
|
QString invalidReason() const { return m_invalidReason; }
|
||||||
|
|
||||||
|
QFuture<QList<QString>> installedModels();
|
||||||
|
|
||||||
|
private:
|
||||||
|
AgentConfig m_config;
|
||||||
|
std::unique_ptr<Templates::JsonPromptTemplate> m_promptTemplate; // owned
|
||||||
|
Providers::Provider *m_provider = nullptr; // child of this
|
||||||
|
QString m_invalidReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist
|
||||||
57
sources/agents/AgentConfig.hpp
Normal file
57
sources/agents/AgentConfig.hpp
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
namespace QodeAssist {
|
||||||
|
|
||||||
|
struct AgentConfig
|
||||||
|
{
|
||||||
|
static constexpr int kSupportedSchemaVersion = 1;
|
||||||
|
int schemaVersion = 1;
|
||||||
|
QString name;
|
||||||
|
QString description;
|
||||||
|
QString providerInstance;
|
||||||
|
QString model;
|
||||||
|
QString endpoint;
|
||||||
|
QString role;
|
||||||
|
QStringList tags;
|
||||||
|
|
||||||
|
struct Match
|
||||||
|
{
|
||||||
|
QStringList filePatterns;
|
||||||
|
QStringList pathPatterns;
|
||||||
|
QStringList projectNames;
|
||||||
|
|
||||||
|
[[nodiscard]] bool isEmpty() const noexcept
|
||||||
|
{
|
||||||
|
return filePatterns.isEmpty()
|
||||||
|
&& pathPatterns.isEmpty()
|
||||||
|
&& projectNames.isEmpty();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Match match;
|
||||||
|
|
||||||
|
bool enableThinking = false;
|
||||||
|
bool enableTools = false;
|
||||||
|
|
||||||
|
QString messageFormat;
|
||||||
|
QJsonObject sampling;
|
||||||
|
QJsonObject thinking;
|
||||||
|
QString context;
|
||||||
|
QString extendsName;
|
||||||
|
bool abstract = false;
|
||||||
|
bool hidden = false;
|
||||||
|
|
||||||
|
QString sourcePath;
|
||||||
|
bool overridesBundled = false;
|
||||||
|
bool isUserSource() const { return !sourcePath.startsWith(QLatin1StringView{":/"}); }
|
||||||
|
|
||||||
|
static QString validate(const AgentConfig &config);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist
|
||||||
223
sources/agents/AgentFactory.cpp
Normal file
223
sources/agents/AgentFactory.cpp
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "AgentFactory.hpp"
|
||||||
|
|
||||||
|
#include <QLoggingCategory>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include <coreplugin/icore.h>
|
||||||
|
|
||||||
|
#include "Agent.hpp"
|
||||||
|
#include "AgentLoader.hpp"
|
||||||
|
#include "Provider.hpp"
|
||||||
|
#include "ProviderFactory.hpp"
|
||||||
|
#include "Logger.hpp"
|
||||||
|
#include "ProviderSecretsStore.hpp"
|
||||||
|
#include "ProviderInstance.hpp"
|
||||||
|
#include "ProviderInstanceFactory.hpp"
|
||||||
|
|
||||||
|
static inline void initAgentsResource() { Q_INIT_RESOURCE(agents); }
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
Q_LOGGING_CATEGORY(agentFactoryLog, "qodeassist.agentfactory")
|
||||||
|
|
||||||
|
QString agentQrcPrefix() { return QStringLiteral(":/agents"); }
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
namespace QodeAssist {
|
||||||
|
|
||||||
|
AgentFactory::AgentFactory(
|
||||||
|
Providers::ProviderInstanceFactory *instanceFactory,
|
||||||
|
Providers::ProviderSecretsStore *secrets,
|
||||||
|
QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_instanceFactory(instanceFactory)
|
||||||
|
, m_secrets(secrets)
|
||||||
|
{
|
||||||
|
::initAgentsResource();
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentFactory::~AgentFactory() = default;
|
||||||
|
|
||||||
|
QString AgentFactory::userAgentsDir()
|
||||||
|
{
|
||||||
|
return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/agents"))
|
||||||
|
.toFSPathString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentFactory::reload()
|
||||||
|
{
|
||||||
|
Q_ASSERT(thread() == QThread::currentThread());
|
||||||
|
clear();
|
||||||
|
|
||||||
|
auto result = Agents::AgentLoader::load(agentQrcPrefix(), userAgentsDir());
|
||||||
|
for (const QString &err : result.errors)
|
||||||
|
LOG_MESSAGE(QString("[Agents] error: %1").arg(err));
|
||||||
|
for (const QString &warn : result.warnings)
|
||||||
|
LOG_MESSAGE(QString("[Agents] warning: %1").arg(warn));
|
||||||
|
LOG_MESSAGE(QString("[Agents] Loaded %1 profiles (qrc=%2, user=%3)")
|
||||||
|
.arg(result.configs.size())
|
||||||
|
.arg(agentQrcPrefix(), userAgentsDir()));
|
||||||
|
|
||||||
|
for (auto &cfg : result.configs) {
|
||||||
|
LOG_MESSAGE(QString("[Agents] Loaded: %1").arg(cfg.name));
|
||||||
|
registerConfig(std::move(cfg));
|
||||||
|
}
|
||||||
|
m_errors = std::move(result.errors);
|
||||||
|
m_warnings = std::move(result.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentFactory::registerConfig(AgentConfig config)
|
||||||
|
{
|
||||||
|
Q_ASSERT(thread() == QThread::currentThread());
|
||||||
|
|
||||||
|
const QString error = AgentConfig::validate(config);
|
||||||
|
if (!error.isEmpty()) {
|
||||||
|
qCWarning(agentFactoryLog).noquote() << "Rejected agent config:" << error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto it = m_indexByName.constFind(config.name);
|
||||||
|
if (it != m_indexByName.constEnd()) {
|
||||||
|
m_configs[it.value()] = std::move(config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_indexByName.insert(config.name, static_cast<qsizetype>(m_configs.size()));
|
||||||
|
m_configs.push_back(std::move(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
const AgentConfig *AgentFactory::configByName(const QString &name) const
|
||||||
|
{
|
||||||
|
const auto it = m_indexByName.constFind(name);
|
||||||
|
if (it == m_indexByName.constEnd())
|
||||||
|
return nullptr;
|
||||||
|
return &m_configs[it.value()];
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList AgentFactory::configNames() const
|
||||||
|
{
|
||||||
|
QStringList out;
|
||||||
|
out.reserve(static_cast<qsizetype>(m_configs.size()));
|
||||||
|
for (const auto &c : m_configs) {
|
||||||
|
if (c.hidden) continue;
|
||||||
|
out.append(c.name);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
Providers::Provider *buildProviderForAgent(
|
||||||
|
const AgentConfig &cfg,
|
||||||
|
Providers::ProviderInstanceFactory *instanceFactory,
|
||||||
|
Providers::ProviderSecretsStore *secrets,
|
||||||
|
QString *errorOut)
|
||||||
|
{
|
||||||
|
if (!instanceFactory) {
|
||||||
|
if (errorOut) {
|
||||||
|
*errorOut = QStringLiteral(
|
||||||
|
"Agent '%1' cannot be built — no ProviderInstanceFactory was wired "
|
||||||
|
"into AgentFactory")
|
||||||
|
.arg(cfg.name);
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const Providers::ProviderInstance *inst
|
||||||
|
= instanceFactory->instanceByName(cfg.providerInstance);
|
||||||
|
if (!inst) {
|
||||||
|
if (errorOut) {
|
||||||
|
*errorOut = QStringLiteral(
|
||||||
|
"Agent '%1' references unknown provider instance '%2'")
|
||||||
|
.arg(cfg.name, cfg.providerInstance);
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const QString validation = Providers::ProviderInstance::validate(
|
||||||
|
*inst, Providers::ProviderFactory::knownNames());
|
||||||
|
if (!validation.isEmpty()) {
|
||||||
|
if (errorOut)
|
||||||
|
*errorOut = validation;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
Providers::Provider *provider = Providers::ProviderFactory::create(inst->clientApi, nullptr);
|
||||||
|
if (!provider) {
|
||||||
|
if (errorOut) {
|
||||||
|
*errorOut = QStringLiteral("Client API '%1' is not registered (instance '%2')")
|
||||||
|
.arg(inst->clientApi, inst->name);
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
provider->setUrl(inst->url);
|
||||||
|
if (secrets && !inst->apiKeyRef.isEmpty())
|
||||||
|
provider->setApiKey(secrets->readKeySync(inst->apiKeyRef));
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Agent *AgentFactory::create(const QString &name, QObject *parent, QString *errorOut) const
|
||||||
|
{
|
||||||
|
const AgentConfig *cfg = configByName(name);
|
||||||
|
if (!cfg) {
|
||||||
|
if (errorOut)
|
||||||
|
*errorOut = QStringLiteral("Agent '%1' is not registered").arg(name);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
Providers::Provider *provider = buildProviderForAgent(
|
||||||
|
*cfg, m_instanceFactory.data(), m_secrets.data(), errorOut);
|
||||||
|
if (!provider)
|
||||||
|
return nullptr;
|
||||||
|
auto agent = std::make_unique<Agent>(*cfg, provider, /*parent=*/nullptr);
|
||||||
|
if (!agent->isValid()) {
|
||||||
|
if (errorOut)
|
||||||
|
*errorOut = agent->invalidReason();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
agent->setParent(parent);
|
||||||
|
return agent.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
Agent *AgentFactory::createFromFile(
|
||||||
|
const QString &tomlPath, QObject *parent, QString *errorOut) const
|
||||||
|
{
|
||||||
|
QString parseErr;
|
||||||
|
QStringList warnings;
|
||||||
|
auto cfgOpt = Agents::AgentLoader::parseFile(tomlPath, &parseErr, &warnings);
|
||||||
|
if (!cfgOpt) {
|
||||||
|
if (errorOut) *errorOut = parseErr;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
Providers::Provider *provider = buildProviderForAgent(
|
||||||
|
*cfgOpt, m_instanceFactory.data(), m_secrets.data(), errorOut);
|
||||||
|
if (!provider)
|
||||||
|
return nullptr;
|
||||||
|
auto agent = std::make_unique<Agent>(std::move(*cfgOpt), provider, /*parent=*/nullptr);
|
||||||
|
if (!agent->isValid()) {
|
||||||
|
if (errorOut) *errorOut = agent->invalidReason();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
agent->setParent(parent);
|
||||||
|
return agent.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AgentFactory::clear()
|
||||||
|
{
|
||||||
|
Q_ASSERT(thread() == QThread::currentThread());
|
||||||
|
m_configs.clear();
|
||||||
|
m_indexByName.clear();
|
||||||
|
m_errors.clear();
|
||||||
|
m_warnings.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Providers::ProviderInstanceFactory *AgentFactory::instanceFactory() const noexcept
|
||||||
|
{
|
||||||
|
return m_instanceFactory.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
Providers::ProviderSecretsStore *AgentFactory::secretsStore() const noexcept
|
||||||
|
{
|
||||||
|
return m_secrets.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist
|
||||||
67
sources/agents/AgentFactory.hpp
Normal file
67
sources/agents/AgentFactory.hpp
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include "AgentConfig.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist {
|
||||||
|
|
||||||
|
class Agent;
|
||||||
|
|
||||||
|
namespace Providers {
|
||||||
|
class ProviderInstanceFactory;
|
||||||
|
class ProviderSecretsStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AgentFactory : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_DISABLE_COPY_MOVE(AgentFactory)
|
||||||
|
public:
|
||||||
|
explicit AgentFactory(
|
||||||
|
Providers::ProviderInstanceFactory *instanceFactory = nullptr,
|
||||||
|
Providers::ProviderSecretsStore *secrets = nullptr,
|
||||||
|
QObject *parent = nullptr);
|
||||||
|
~AgentFactory() override;
|
||||||
|
|
||||||
|
void reload();
|
||||||
|
|
||||||
|
[[nodiscard]] static QString userAgentsDir();
|
||||||
|
|
||||||
|
[[nodiscard]] const AgentConfig *configByName(const QString &name) const;
|
||||||
|
[[nodiscard]] QStringList configNames() const;
|
||||||
|
[[nodiscard]] const std::vector<AgentConfig> &configs() const noexcept { return m_configs; }
|
||||||
|
|
||||||
|
Agent *create(const QString &name, QObject *parent, QString *errorOut = nullptr) const;
|
||||||
|
|
||||||
|
Agent *createFromFile(
|
||||||
|
const QString &tomlPath, QObject *parent, QString *errorOut = nullptr) const;
|
||||||
|
|
||||||
|
[[nodiscard]] QStringList lastLoadErrors() const { return m_errors; }
|
||||||
|
[[nodiscard]] QStringList lastLoadWarnings() const { return m_warnings; }
|
||||||
|
|
||||||
|
void registerConfig(AgentConfig config);
|
||||||
|
void clear();
|
||||||
|
|
||||||
|
[[nodiscard]] Providers::ProviderInstanceFactory *instanceFactory() const noexcept;
|
||||||
|
[[nodiscard]] Providers::ProviderSecretsStore *secretsStore() const noexcept;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<AgentConfig> m_configs;
|
||||||
|
QHash<QString, qsizetype> m_indexByName;
|
||||||
|
QStringList m_errors;
|
||||||
|
QStringList m_warnings;
|
||||||
|
QPointer<Providers::ProviderInstanceFactory> m_instanceFactory;
|
||||||
|
QPointer<Providers::ProviderSecretsStore> m_secrets;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist
|
||||||
262
sources/agents/AgentLoader.cpp
Normal file
262
sources/agents/AgentLoader.cpp
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "AgentLoader.hpp"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <QSet>
|
||||||
|
|
||||||
|
#include <toml++/toml.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace QodeAssist::Agents {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QJsonValue tomlToJson(const toml::node &node)
|
||||||
|
{
|
||||||
|
if (auto *table = node.as_table()) {
|
||||||
|
QJsonObject obj;
|
||||||
|
for (const auto &[key, value] : *table) {
|
||||||
|
obj.insert(QString::fromStdString(std::string{key.str()}), tomlToJson(value));
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (auto *array = node.as_array()) {
|
||||||
|
QJsonArray arr;
|
||||||
|
for (const auto &item : *array) {
|
||||||
|
arr.append(tomlToJson(item));
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
if (auto *str = node.as_string()) {
|
||||||
|
return QString::fromStdString(str->get());
|
||||||
|
}
|
||||||
|
if (auto *integer = node.as_integer()) {
|
||||||
|
return static_cast<qint64>(integer->get());
|
||||||
|
}
|
||||||
|
if (auto *floating = node.as_floating_point()) {
|
||||||
|
return floating->get();
|
||||||
|
}
|
||||||
|
if (auto *boolean = node.as_boolean()) {
|
||||||
|
return boolean->get();
|
||||||
|
}
|
||||||
|
return QJsonValue::Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject deepMerge(const QJsonObject &base, const QJsonObject &overlay)
|
||||||
|
{
|
||||||
|
QJsonObject result = base;
|
||||||
|
for (auto it = overlay.constBegin(); it != overlay.constEnd(); ++it) {
|
||||||
|
const QJsonValue baseVal = result.value(it.key());
|
||||||
|
const QJsonValue overlayVal = it.value();
|
||||||
|
if (baseVal.isObject() && overlayVal.isObject()) {
|
||||||
|
result[it.key()] = deepMerge(baseVal.toObject(), overlayVal.toObject());
|
||||||
|
} else {
|
||||||
|
result[it.key()] = overlayVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString readUtf8(const QString &path, QString *error)
|
||||||
|
{
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
if (error) *error = QStringLiteral("Cannot open: %1").arg(path);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return QString::fromUtf8(f.readAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<QJsonObject> parseTomlFile(const QString &path, QString *error)
|
||||||
|
{
|
||||||
|
QString readErr;
|
||||||
|
const QString contents = readUtf8(path, &readErr);
|
||||||
|
if (!readErr.isEmpty()) {
|
||||||
|
if (error) *error = readErr;
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
toml::table tbl;
|
||||||
|
try {
|
||||||
|
tbl = toml::parse(contents.toStdString(), path.toStdString());
|
||||||
|
} catch (const toml::parse_error &e) {
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << e;
|
||||||
|
if (error) {
|
||||||
|
*error = QStringLiteral("TOML parse error in %1: %2")
|
||||||
|
.arg(path, QString::fromStdString(oss.str()));
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return tomlToJson(tbl).toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList stringArray(const QJsonValue &v)
|
||||||
|
{
|
||||||
|
QStringList out;
|
||||||
|
if (!v.isArray()) return out;
|
||||||
|
for (const auto &elem : v.toArray()) {
|
||||||
|
if (elem.isString()) out.append(elem.toString());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentConfig configFromMerged(const QJsonObject &obj)
|
||||||
|
{
|
||||||
|
AgentConfig cfg;
|
||||||
|
cfg.schemaVersion = obj.value("schema_version").toInt(1);
|
||||||
|
cfg.name = obj.value("name").toString();
|
||||||
|
cfg.description = obj.value("description").toString();
|
||||||
|
cfg.providerInstance = obj.value("provider_instance").toString();
|
||||||
|
cfg.model = obj.value("model").toString();
|
||||||
|
cfg.endpoint = obj.value("endpoint").toString();
|
||||||
|
cfg.role = obj.value("role").toString();
|
||||||
|
cfg.context = obj.value("context").toString();
|
||||||
|
cfg.enableThinking = obj.value("enable_thinking").toBool(false);
|
||||||
|
cfg.enableTools = obj.value("enable_tools").toBool(false);
|
||||||
|
cfg.tags = stringArray(obj.value("tags"));
|
||||||
|
|
||||||
|
const QJsonObject matchObj = obj.value("match").toObject();
|
||||||
|
cfg.match.filePatterns = stringArray(matchObj.value("file_patterns"));
|
||||||
|
cfg.match.pathPatterns = stringArray(matchObj.value("path_patterns"));
|
||||||
|
cfg.match.projectNames = stringArray(matchObj.value("project_names"));
|
||||||
|
|
||||||
|
cfg.extendsName = obj.value("extends").toString();
|
||||||
|
cfg.abstract = obj.value("abstract").toBool(false);
|
||||||
|
cfg.hidden = obj.value("hidden").toBool(false);
|
||||||
|
|
||||||
|
const QJsonObject tpl = obj.value("template").toObject();
|
||||||
|
cfg.messageFormat = tpl.value("message_format").toString();
|
||||||
|
cfg.sampling = tpl.value("sampling").toObject();
|
||||||
|
cfg.thinking = tpl.value("thinking").toObject();
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RawEntry
|
||||||
|
{
|
||||||
|
QJsonObject obj;
|
||||||
|
QString filePath;
|
||||||
|
bool overridesBundled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr int kMaxExtendsDepth = 32;
|
||||||
|
|
||||||
|
QJsonObject resolveExtends(
|
||||||
|
const QString &name,
|
||||||
|
const QHash<QString, RawEntry> &raw,
|
||||||
|
QSet<QString> &visiting,
|
||||||
|
QStringList &errors,
|
||||||
|
int depth = 0)
|
||||||
|
{
|
||||||
|
if (depth > kMaxExtendsDepth) {
|
||||||
|
errors.append(QStringLiteral("Agent extends chain too deep (>%1) at '%2'")
|
||||||
|
.arg(kMaxExtendsDepth)
|
||||||
|
.arg(name));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (visiting.contains(name)) {
|
||||||
|
errors.append(QStringLiteral("Cyclic 'extends' involving agent '%1'").arg(name));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!raw.contains(name)) {
|
||||||
|
errors.append(QStringLiteral("Unknown parent agent '%1'").arg(name));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
visiting.insert(name);
|
||||||
|
|
||||||
|
QJsonObject self = raw.value(name).obj;
|
||||||
|
const QString parent = self.value("extends").toString();
|
||||||
|
if (!parent.isEmpty()) {
|
||||||
|
const QJsonObject parentMerged
|
||||||
|
= resolveExtends(parent, raw, visiting, errors, depth + 1);
|
||||||
|
QJsonObject merged = deepMerge(parentMerged, self);
|
||||||
|
merged["name"] = name;
|
||||||
|
if (self.contains("abstract"))
|
||||||
|
merged["abstract"] = self.value("abstract");
|
||||||
|
else
|
||||||
|
merged.remove("abstract");
|
||||||
|
self = merged;
|
||||||
|
}
|
||||||
|
visiting.remove(name);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::optional<AgentConfig> AgentLoader::parseFile(
|
||||||
|
const QString &path, QString *error, QStringList * /*warnings*/)
|
||||||
|
{
|
||||||
|
auto objOpt = parseTomlFile(path, error);
|
||||||
|
if (!objOpt) return std::nullopt;
|
||||||
|
AgentConfig cfg = configFromMerged(*objOpt);
|
||||||
|
cfg.sourcePath = path;
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QString &userDir)
|
||||||
|
{
|
||||||
|
LoadResult result;
|
||||||
|
QHash<QString, RawEntry> raw;
|
||||||
|
|
||||||
|
auto scan = [&](const QString &dir, bool isUserLayer) {
|
||||||
|
if (dir.isEmpty()) return;
|
||||||
|
QDir d(dir);
|
||||||
|
if (!d.exists()) return;
|
||||||
|
const QStringList files = d.entryList({"*.toml"}, QDir::Files);
|
||||||
|
for (const QString &fname : files) {
|
||||||
|
const QString fullPath = d.filePath(fname);
|
||||||
|
QString err;
|
||||||
|
auto objOpt = parseTomlFile(fullPath, &err);
|
||||||
|
if (!objOpt) {
|
||||||
|
result.errors.append(err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString name = objOpt->value("name").toString();
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
result.errors.append(QStringLiteral("Agent at %1 has no 'name'").arg(fullPath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bool overrides = isUserLayer && raw.contains(name);
|
||||||
|
raw.insert(name, {*objOpt, fullPath, overrides});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
scan(qrcPrefix, /*isUserLayer=*/false);
|
||||||
|
scan(userDir, /*isUserLayer=*/true);
|
||||||
|
|
||||||
|
for (auto it = raw.constBegin(); it != raw.constEnd(); ++it) {
|
||||||
|
const QString &name = it.key();
|
||||||
|
|
||||||
|
QSet<QString> visiting;
|
||||||
|
const QJsonObject merged = resolveExtends(name, raw, visiting, result.errors);
|
||||||
|
if (merged.isEmpty()) continue;
|
||||||
|
|
||||||
|
AgentConfig cfg = configFromMerged(merged);
|
||||||
|
cfg.sourcePath = it.value().filePath;
|
||||||
|
cfg.overridesBundled = it.value().overridesBundled;
|
||||||
|
|
||||||
|
if (cfg.abstract) continue;
|
||||||
|
|
||||||
|
const QString validation = AgentConfig::validate(cfg);
|
||||||
|
if (!validation.isEmpty()) {
|
||||||
|
result.errors.append(validation);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.configs.push_back(std::move(cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::sort(result.configs.begin(), result.configs.end(),
|
||||||
|
[](const AgentConfig &a, const AgentConfig &b) { return a.name < b.name; });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Agents
|
||||||
30
sources/agents/AgentLoader.hpp
Normal file
30
sources/agents/AgentLoader.hpp
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "AgentConfig.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Agents {
|
||||||
|
|
||||||
|
class AgentLoader
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
struct LoadResult
|
||||||
|
{
|
||||||
|
std::vector<AgentConfig> configs;
|
||||||
|
QStringList errors;
|
||||||
|
QStringList warnings;
|
||||||
|
};
|
||||||
|
|
||||||
|
static LoadResult load(const QString &qrcPrefix, const QString &userDir);
|
||||||
|
|
||||||
|
static std::optional<AgentConfig> parseFile(
|
||||||
|
const QString &path, QString *error, QStringList *warnings = nullptr);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Agents
|
||||||
85
sources/agents/AgentRouter.cpp
Normal file
85
sources/agents/AgentRouter.cpp
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "AgentRouter.hpp"
|
||||||
|
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
|
||||||
|
#include "AgentFactory.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::AgentRouter {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool matchesAnyGlob(const QStringList &patterns, const QString &subject)
|
||||||
|
{
|
||||||
|
if (subject.isEmpty())
|
||||||
|
return false;
|
||||||
|
for (const QString &pat : patterns) {
|
||||||
|
const QRegularExpression re(
|
||||||
|
QRegularExpression::anchoredPattern(
|
||||||
|
QRegularExpression::wildcardToRegularExpression(
|
||||||
|
pat, QRegularExpression::NonPathWildcardConversion)),
|
||||||
|
QRegularExpression::CaseInsensitiveOption);
|
||||||
|
if (re.isValid() && re.match(subject).hasMatch())
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool matchesFilePatterns(const QStringList &patterns, const QString &filePath)
|
||||||
|
{
|
||||||
|
if (patterns.isEmpty())
|
||||||
|
return true;
|
||||||
|
if (filePath.isEmpty())
|
||||||
|
return false;
|
||||||
|
const QString name = QFileInfo(filePath).fileName();
|
||||||
|
return matchesAnyGlob(patterns, name) || matchesAnyGlob(patterns, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool matchesPathPatterns(const QStringList &patterns, const QString &filePath)
|
||||||
|
{
|
||||||
|
if (patterns.isEmpty())
|
||||||
|
return true;
|
||||||
|
if (filePath.isEmpty())
|
||||||
|
return false;
|
||||||
|
return matchesAnyGlob(patterns, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool matchesProjectNames(const QStringList &names, const QString &projectName)
|
||||||
|
{
|
||||||
|
if (names.isEmpty())
|
||||||
|
return true; // dimension unconstrained
|
||||||
|
if (projectName.isEmpty())
|
||||||
|
return false;
|
||||||
|
// Project names are user-facing identifiers, not paths — case
|
||||||
|
// sensitive comparison matches what ProjectExplorer hands us.
|
||||||
|
return names.contains(projectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool matches(const AgentConfig::Match &m, const Context &ctx)
|
||||||
|
{
|
||||||
|
if (m.isEmpty())
|
||||||
|
return true; // explicit catch-all
|
||||||
|
return matchesFilePatterns(m.filePatterns, ctx.filePath)
|
||||||
|
&& matchesPathPatterns(m.pathPatterns, ctx.filePath)
|
||||||
|
&& matchesProjectNames(m.projectNames, ctx.projectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString pickAgent(
|
||||||
|
const QStringList &roster, const Context &ctx, const AgentFactory &factory)
|
||||||
|
{
|
||||||
|
for (const QString &name : roster) {
|
||||||
|
const AgentConfig *cfg = factory.configByName(name);
|
||||||
|
if (!cfg)
|
||||||
|
continue; // stale roster entry — silently skip
|
||||||
|
if (matches(cfg->match, ctx))
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::AgentRouter
|
||||||
30
sources/agents/AgentRouter.hpp
Normal file
30
sources/agents/AgentRouter.hpp
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include "AgentConfig.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist {
|
||||||
|
|
||||||
|
class AgentFactory;
|
||||||
|
|
||||||
|
namespace AgentRouter {
|
||||||
|
|
||||||
|
struct Context
|
||||||
|
{
|
||||||
|
QString filePath;
|
||||||
|
QString projectName;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] bool matches(const AgentConfig::Match &match, const Context &ctx);
|
||||||
|
|
||||||
|
[[nodiscard]] QString pickAgent(
|
||||||
|
const QStringList &roster, const Context &ctx, const AgentFactory &factory);
|
||||||
|
|
||||||
|
} // namespace AgentRouter
|
||||||
|
|
||||||
|
} // namespace QodeAssist
|
||||||
30
sources/agents/CMakeLists.txt
Normal file
30
sources/agents/CMakeLists.txt
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
add_library(Agents STATIC
|
||||||
|
AgentConfig.hpp
|
||||||
|
Agent.hpp Agent.cpp
|
||||||
|
AgentLoader.hpp AgentLoader.cpp
|
||||||
|
AgentFactory.hpp AgentFactory.cpp
|
||||||
|
AgentRouter.hpp AgentRouter.cpp
|
||||||
|
ContextRenderer.hpp ContextRenderer.cpp
|
||||||
|
agents.qrc
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(Agents
|
||||||
|
PUBLIC
|
||||||
|
Qt::Core
|
||||||
|
Qt::Network
|
||||||
|
QtCreator::Core
|
||||||
|
QtCreator::Utils
|
||||||
|
LLMQore
|
||||||
|
pantor::inja
|
||||||
|
ProvidersConfig
|
||||||
|
Common
|
||||||
|
Providers
|
||||||
|
Templates
|
||||||
|
PRIVATE
|
||||||
|
QodeAssistLogger
|
||||||
|
tomlplusplus::tomlplusplus
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(Agents
|
||||||
|
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
)
|
||||||
209
sources/agents/ContextRenderer.cpp
Normal file
209
sources/agents/ContextRenderer.cpp
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ContextRenderer.hpp"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
#include <inja/inja.hpp>
|
||||||
|
|
||||||
|
namespace QodeAssist::Templates::ContextRenderer {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString substituteVars(const QString &src, const Bindings &b)
|
||||||
|
{
|
||||||
|
QString out = src;
|
||||||
|
if (!b.projectDir.isEmpty())
|
||||||
|
out.replace(QStringLiteral("${PROJECT_DIR}"), b.projectDir);
|
||||||
|
if (!b.homeDir.isEmpty())
|
||||||
|
out.replace(QStringLiteral("${HOME}"), b.homeDir);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPathAllowed(const QString &requestedPath, const Bindings &b)
|
||||||
|
{
|
||||||
|
const QString target = QDir::cleanPath(requestedPath);
|
||||||
|
|
||||||
|
auto isUnder = [&target](const QString &root) {
|
||||||
|
if (root.isEmpty()) return false;
|
||||||
|
const QString cleanRoot = QDir::cleanPath(root);
|
||||||
|
if (target == cleanRoot) return true;
|
||||||
|
return target.startsWith(cleanRoot + QLatin1Char('/'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUnder(b.projectDir)) return true;
|
||||||
|
if (!b.homeDir.isEmpty() && isUnder(b.homeDir + QStringLiteral("/qodeassist")))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void registerReadFile(inja::Environment &env, const Bindings &b)
|
||||||
|
{
|
||||||
|
const Bindings capturedBindings = b;
|
||||||
|
env.add_callback("read_file", 1, [capturedBindings](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
const std::string raw = args.at(0)->get<std::string>();
|
||||||
|
QString path = QString::fromStdString(raw);
|
||||||
|
|
||||||
|
if (!capturedBindings.projectDir.isEmpty())
|
||||||
|
path.replace(QStringLiteral("${PROJECT_DIR}"), capturedBindings.projectDir);
|
||||||
|
if (!capturedBindings.homeDir.isEmpty())
|
||||||
|
path.replace(QStringLiteral("${HOME}"), capturedBindings.homeDir);
|
||||||
|
|
||||||
|
if (!isPathAllowed(path, capturedBindings)) {
|
||||||
|
qWarning("[QodeAssist] context.read_file: path not in allowed roots: %s",
|
||||||
|
qUtf8Printable(path));
|
||||||
|
return std::string{};
|
||||||
|
}
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||||
|
return std::string{};
|
||||||
|
return f.readAll().toStdString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QString expandAndResolvePath(const QString &raw, const Bindings &b)
|
||||||
|
{
|
||||||
|
QString p = raw;
|
||||||
|
if (!b.projectDir.isEmpty())
|
||||||
|
p.replace(QStringLiteral("${PROJECT_DIR}"), b.projectDir);
|
||||||
|
if (!b.homeDir.isEmpty())
|
||||||
|
p.replace(QStringLiteral("${HOME}"), b.homeDir);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
void registerFileExists(inja::Environment &env, const Bindings &b)
|
||||||
|
{
|
||||||
|
const Bindings caps = b;
|
||||||
|
env.add_callback("file_exists", 1, [caps](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
const QString p = expandAndResolvePath(
|
||||||
|
QString::fromStdString(args.at(0)->get<std::string>()), caps);
|
||||||
|
if (!isPathAllowed(p, caps))
|
||||||
|
return false;
|
||||||
|
return QFileInfo::exists(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void registerReadDir(inja::Environment &env, const Bindings &b)
|
||||||
|
{
|
||||||
|
const Bindings caps = b;
|
||||||
|
|
||||||
|
env.add_callback("read_dir", 1, [caps](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
const QString p = expandAndResolvePath(
|
||||||
|
QString::fromStdString(args.at(0)->get<std::string>()), caps);
|
||||||
|
if (!isPathAllowed(p, caps)) {
|
||||||
|
qWarning("[QodeAssist] context.read_dir: path not in allowed roots: %s",
|
||||||
|
qUtf8Printable(p));
|
||||||
|
return nlohmann::json::array();
|
||||||
|
}
|
||||||
|
QDir dir(p);
|
||||||
|
if (!dir.exists())
|
||||||
|
return nlohmann::json::array();
|
||||||
|
nlohmann::json out = nlohmann::json::array();
|
||||||
|
const QStringList entries = dir.entryList(
|
||||||
|
QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||||||
|
for (const QString &name : entries)
|
||||||
|
out.push_back(name.toStdString());
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void registerStringHelpers(inja::Environment &env)
|
||||||
|
{
|
||||||
|
env.add_callback("head_lines", 2, [](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
const QString text = QString::fromStdString(args.at(0)->get<std::string>());
|
||||||
|
const int n = args.at(1)->get<int>();
|
||||||
|
if (n <= 0)
|
||||||
|
return std::string{};
|
||||||
|
const QStringList lines = text.split('\n');
|
||||||
|
const int take = std::min<int>(n, lines.size());
|
||||||
|
QStringList head;
|
||||||
|
head.reserve(take);
|
||||||
|
for (int i = 0; i < take; ++i)
|
||||||
|
head.append(lines.at(i));
|
||||||
|
return head.join('\n').toStdString();
|
||||||
|
});
|
||||||
|
|
||||||
|
env.add_callback("basename", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
return QFileInfo(QString::fromStdString(args.at(0)->get<std::string>()))
|
||||||
|
.fileName()
|
||||||
|
.toStdString();
|
||||||
|
});
|
||||||
|
env.add_callback("dirname", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
return QFileInfo(QString::fromStdString(args.at(0)->get<std::string>()))
|
||||||
|
.path()
|
||||||
|
.toStdString();
|
||||||
|
});
|
||||||
|
env.add_callback("ext", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
return QFileInfo(QString::fromStdString(args.at(0)->get<std::string>()))
|
||||||
|
.suffix()
|
||||||
|
.toStdString();
|
||||||
|
});
|
||||||
|
|
||||||
|
env.add_callback("lower", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
return QString::fromStdString(args.at(0)->get<std::string>()).toLower().toStdString();
|
||||||
|
});
|
||||||
|
env.add_callback("upper", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
return QString::fromStdString(args.at(0)->get<std::string>()).toUpper().toStdString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void registerSandbox(inja::Environment &env)
|
||||||
|
{
|
||||||
|
|
||||||
|
env.set_search_included_templates_in_files(false);
|
||||||
|
env.set_include_callback(
|
||||||
|
[](const std::filesystem::path &, const std::string &name) -> inja::Template {
|
||||||
|
throw inja::FileError(
|
||||||
|
"include is disabled in QodeAssist context: '" + name + "'");
|
||||||
|
});
|
||||||
|
|
||||||
|
env.set_line_statement("@@@inja@@@");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QString render(const QString &templateSource, const Bindings &bindings, QString *error)
|
||||||
|
{
|
||||||
|
if (templateSource.isEmpty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const QString substituted = substituteVars(templateSource, bindings);
|
||||||
|
|
||||||
|
inja::Environment env;
|
||||||
|
registerSandbox(env);
|
||||||
|
registerReadFile(env, bindings);
|
||||||
|
registerFileExists(env, bindings);
|
||||||
|
registerReadDir(env, bindings);
|
||||||
|
registerStringHelpers(env);
|
||||||
|
|
||||||
|
inja::Template tpl;
|
||||||
|
try {
|
||||||
|
tpl = env.parse(substituted.toStdString());
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
if (error) {
|
||||||
|
*error = QStringLiteral("Failed to parse context jinja: %1")
|
||||||
|
.arg(QString::fromUtf8(e.what()));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const std::string rendered = env.render(tpl, nlohmann::json::object());
|
||||||
|
return QString::fromStdString(rendered);
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
if (error) {
|
||||||
|
*error = QStringLiteral("Failed to render context jinja: %1")
|
||||||
|
.arg(QString::fromUtf8(e.what()));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Templates::ContextRenderer
|
||||||
19
sources/agents/ContextRenderer.hpp
Normal file
19
sources/agents/ContextRenderer.hpp
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace QodeAssist::Templates::ContextRenderer {
|
||||||
|
|
||||||
|
struct Bindings
|
||||||
|
{
|
||||||
|
QString projectDir;
|
||||||
|
QString homeDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
QString render(const QString &templateSource, const Bindings &bindings,
|
||||||
|
QString *error = nullptr);
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Templates::ContextRenderer
|
||||||
9
sources/agents/agents.qrc
Normal file
9
sources/agents/agents.qrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/agents">
|
||||||
|
<file>ollama_base_chat.toml</file>
|
||||||
|
<file>ollama_base_fim.toml</file>
|
||||||
|
<file>ollama_gemma4_e4b_chat.toml</file>
|
||||||
|
<file>ollama_codellama_7b_code_fim.toml</file>
|
||||||
|
<file>ollama_codellama_13b_qml_fim.toml</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
44
sources/agents/ollama_base_chat.toml
Normal file
44
sources/agents/ollama_base_chat.toml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "Ollama Base Chat"
|
||||||
|
description = "Shared base for Ollama /api/chat profiles."
|
||||||
|
|
||||||
|
abstract = true
|
||||||
|
|
||||||
|
provider_instance = "Ollama (Native)"
|
||||||
|
endpoint = "/api/chat"
|
||||||
|
|
||||||
|
tags = ["ollama", "local"]
|
||||||
|
|
||||||
|
[template]
|
||||||
|
message_format = """
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{%- if existsIn(ctx, "system_prompt") %}
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": {{ tojson(ctx.system_prompt) }}
|
||||||
|
}{% if length(ctx.history) > 0 %},{% endif %}
|
||||||
|
{%- endif %}
|
||||||
|
{%- for msg in ctx.history %}
|
||||||
|
{
|
||||||
|
"role": {{ tojson(msg.role) }},
|
||||||
|
"content": {{ tojson(msg.content) }}{% if existsIn(msg, "images") %},
|
||||||
|
"images": [
|
||||||
|
{%- for img in msg.images %}
|
||||||
|
{{ tojson(img.data) }}{% if not loop.is_last %},{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
]{% endif %}
|
||||||
|
}{% if not loop.is_last %},{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
[template.sampling]
|
||||||
|
stream = true
|
||||||
|
|
||||||
|
[template.sampling.options]
|
||||||
|
num_predict = 2048
|
||||||
|
temperature = 0.7
|
||||||
|
keep_alive = "5m"
|
||||||
32
sources/agents/ollama_base_fim.toml
Normal file
32
sources/agents/ollama_base_fim.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "Ollama FIM Base"
|
||||||
|
description = "Shared base for Ollama native FIM (/api/generate) profiles."
|
||||||
|
|
||||||
|
abstract = true
|
||||||
|
|
||||||
|
provider_instance = "Ollama (Native)"
|
||||||
|
endpoint = "/api/generate"
|
||||||
|
|
||||||
|
tags = ["ollama", "local", "fim"]
|
||||||
|
|
||||||
|
[template]
|
||||||
|
message_format = """
|
||||||
|
{
|
||||||
|
"prompt": {{ tojson(ctx.prefix) }},
|
||||||
|
"suffix": {{ tojson(ctx.suffix) }}
|
||||||
|
{%- if existsIn(ctx, "system_prompt") %},
|
||||||
|
"system": {{ tojson(ctx.system_prompt) }}
|
||||||
|
{%- endif %}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
[template.sampling]
|
||||||
|
stream = true
|
||||||
|
|
||||||
|
[template.sampling.options]
|
||||||
|
num_predict = 512
|
||||||
|
temperature = 0.2
|
||||||
|
top_p = 0.9
|
||||||
|
keep_alive = "5m"
|
||||||
|
stop = ["<EOT>"]
|
||||||
40
sources/agents/ollama_codellama_13b_qml_fim.toml
Normal file
40
sources/agents/ollama_codellama_13b_qml_fim.toml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "Qt CodeLlama 13B QML FIM"
|
||||||
|
description = "Local Qt-Company-tuned CodeLlama 13B for QML FIM completion."
|
||||||
|
|
||||||
|
provider_instance = "Ollama (Native)"
|
||||||
|
endpoint = "/api/generate"
|
||||||
|
|
||||||
|
model = "theqtcompany/codellama-13b-qml:latest"
|
||||||
|
|
||||||
|
tags = ["fim", "ollama", "local", "codellama", "qml", "qt"]
|
||||||
|
|
||||||
|
[match]
|
||||||
|
file_patterns = ["*.qml"]
|
||||||
|
|
||||||
|
[template]
|
||||||
|
message_format = """
|
||||||
|
{
|
||||||
|
"prompt": {%- if existsIn(ctx, "suffix") and length(ctx.suffix) > 0 -%}
|
||||||
|
{{ tojson("<SUF>" + ctx.suffix + "<PRE>" + ctx.prefix + "<MID>") }}
|
||||||
|
{%- else -%}
|
||||||
|
{{ tojson("<PRE>" + ctx.prefix + "<MID>") }}
|
||||||
|
{%- endif %}
|
||||||
|
{%- if existsIn(ctx, "system_prompt") %},
|
||||||
|
"system": {{ tojson(ctx.system_prompt) }}
|
||||||
|
{%- endif %}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
[template.sampling]
|
||||||
|
stream = true
|
||||||
|
|
||||||
|
[template.sampling.options]
|
||||||
|
num_predict = 500
|
||||||
|
temperature = 0
|
||||||
|
top_p = 1
|
||||||
|
repeat_penalty = 1.05
|
||||||
|
keep_alive = "5m"
|
||||||
|
|
||||||
|
stop = ["<SUF>", "<PRE>", "</PRE>", "</SUF>", "< EOT >", "\\end", "<MID>", "</MID>", "##"]
|
||||||
34
sources/agents/ollama_codellama_7b_code_fim.toml
Normal file
34
sources/agents/ollama_codellama_7b_code_fim.toml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "CodeLlama 7B Code FIM"
|
||||||
|
description = "Local CodeLlama 7B (code variant) on Ollama, FIM completion via PRE/SUF/MID markers."
|
||||||
|
|
||||||
|
provider_instance = "Ollama (Native)"
|
||||||
|
endpoint = "/api/generate"
|
||||||
|
|
||||||
|
model = "codellama:7b-code"
|
||||||
|
|
||||||
|
tags = ["fim", "ollama", "local", "codellama"]
|
||||||
|
|
||||||
|
[match]
|
||||||
|
file_patterns = ["*.cpp", "*.cc", "*.cxx", "*.c", "*.h", "*.hpp", "*.hxx", "*.inl"]
|
||||||
|
|
||||||
|
[template]
|
||||||
|
message_format = """
|
||||||
|
{
|
||||||
|
"prompt": {{ tojson("<PRE> " + ctx.prefix + " <SUF>" + ctx.suffix + " <MID>") }}
|
||||||
|
{%- if existsIn(ctx, "system_prompt") %},
|
||||||
|
"system": {{ tojson(ctx.system_prompt) }}
|
||||||
|
{%- endif %}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
[template.sampling]
|
||||||
|
stream = true
|
||||||
|
|
||||||
|
[template.sampling.options]
|
||||||
|
num_predict = 512
|
||||||
|
temperature = 0.2
|
||||||
|
top_p = 0.9
|
||||||
|
keep_alive = "5m"
|
||||||
|
stop = ["<EOT>", "<PRE>", "<SUF>", "<MID>"]
|
||||||
36
sources/agents/ollama_gemma4_e4b_chat.toml
Normal file
36
sources/agents/ollama_gemma4_e4b_chat.toml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "Ollama gemma4:e4b Chat"
|
||||||
|
extends = "Ollama Base Chat"
|
||||||
|
|
||||||
|
description = "Local Gemma 4 E4B on Ollama /api/chat — coding chat assistant."
|
||||||
|
|
||||||
|
model = "gemma4:e4b"
|
||||||
|
|
||||||
|
role = """
|
||||||
|
You are a helpful coding assistant integrated into Qt Creator.
|
||||||
|
Answer concisely. When the user shares code, prefer concrete diffs or
|
||||||
|
minimal patches over rewriting whole files. Use markdown code blocks
|
||||||
|
with language tags so the IDE can render them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enable_thinking = true
|
||||||
|
enable_tools = true
|
||||||
|
|
||||||
|
tags = ["chat", "ollama", "local", "gemma"]
|
||||||
|
|
||||||
|
context = """
|
||||||
|
{%- set readme = read_file("${PROJECT_DIR}/README.md") -%}
|
||||||
|
|
||||||
|
{%- if length(readme) > 0 %}
|
||||||
|
## Project README.md
|
||||||
|
{{ readme }}
|
||||||
|
{%- endif %}
|
||||||
|
"""
|
||||||
|
|
||||||
|
[template.sampling.options]
|
||||||
|
num_predict = 4096
|
||||||
|
temperature = 1
|
||||||
|
top_k = 64
|
||||||
|
top_p = 0.95
|
||||||
|
num_ctx = 8192
|
||||||
11
sources/common/CMakeLists.txt
Normal file
11
sources/common/CMakeLists.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
add_library(Common INTERFACE)
|
||||||
|
|
||||||
|
target_sources(Common INTERFACE
|
||||||
|
ContextData.hpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(Common INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
|
target_link_libraries(Common INTERFACE
|
||||||
|
Qt::Core
|
||||||
|
)
|
||||||
79
sources/common/ContextData.hpp
Normal file
79
sources/common/ContextData.hpp
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVector>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
namespace QodeAssist::Templates {
|
||||||
|
|
||||||
|
struct ContentBlockEntry
|
||||||
|
{
|
||||||
|
enum class Kind {
|
||||||
|
Text,
|
||||||
|
Thinking,
|
||||||
|
RedactedThinking,
|
||||||
|
ToolUse,
|
||||||
|
ToolResult,
|
||||||
|
Image,
|
||||||
|
};
|
||||||
|
|
||||||
|
Kind kind = Kind::Text;
|
||||||
|
|
||||||
|
QString text; // Text
|
||||||
|
QString thinking; // Thinking
|
||||||
|
QString signature; // Thinking / RedactedThinking
|
||||||
|
QString toolUseId; // ToolUse / ToolResult
|
||||||
|
QString toolName; // ToolUse
|
||||||
|
QJsonObject toolInput; // ToolUse
|
||||||
|
QString result; // ToolResult
|
||||||
|
QString imageData; // Image (base64 or url)
|
||||||
|
QString mediaType; // Image
|
||||||
|
bool isImageUrl = false;
|
||||||
|
|
||||||
|
bool operator==(const ContentBlockEntry &) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Message
|
||||||
|
{
|
||||||
|
QString role;
|
||||||
|
QVector<ContentBlockEntry> blocks;
|
||||||
|
|
||||||
|
// Convenience for callers that only need a single text block.
|
||||||
|
static Message text(const QString &role, const QString &text)
|
||||||
|
{
|
||||||
|
Message m;
|
||||||
|
m.role = role;
|
||||||
|
ContentBlockEntry e;
|
||||||
|
e.kind = ContentBlockEntry::Kind::Text;
|
||||||
|
e.text = text;
|
||||||
|
m.blocks.append(std::move(e));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator==(const Message &) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FileMetadata
|
||||||
|
{
|
||||||
|
QString filePath;
|
||||||
|
QString content;
|
||||||
|
bool operator==(const FileMetadata &) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ContextData
|
||||||
|
{
|
||||||
|
std::optional<QString> systemPrompt = std::nullopt;
|
||||||
|
std::optional<QString> prefix = std::nullopt;
|
||||||
|
std::optional<QString> suffix = std::nullopt;
|
||||||
|
std::optional<QString> fileContext = std::nullopt;
|
||||||
|
std::optional<QVector<Message>> history = std::nullopt;
|
||||||
|
std::optional<QList<FileMetadata>> filesMetadata = std::nullopt;
|
||||||
|
|
||||||
|
bool operator==(const ContextData &) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Templates
|
||||||
12
sources/external/CMakeLists.txt
vendored
12
sources/external/CMakeLists.txt
vendored
@@ -1,5 +1,17 @@
|
|||||||
include(FetchContent)
|
include(FetchContent)
|
||||||
|
|
||||||
|
set(INJA_BUILD_TESTS OFF CACHE INTERNAL "")
|
||||||
|
set(INJA_INSTALL OFF CACHE INTERNAL "")
|
||||||
|
set(INJA_EXPORT OFF CACHE INTERNAL "")
|
||||||
|
set(BUILD_BENCHMARK OFF CACHE INTERNAL "")
|
||||||
|
set(COVERALLS OFF CACHE INTERNAL "")
|
||||||
|
FetchContent_Declare(inja
|
||||||
|
GIT_REPOSITORY https://github.com/pantor/inja.git
|
||||||
|
GIT_TAG v3.5.0
|
||||||
|
GIT_SHALLOW TRUE
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(inja)
|
||||||
|
|
||||||
FetchContent_Declare(tomlplusplus
|
FetchContent_Declare(tomlplusplus
|
||||||
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
|
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
|
||||||
GIT_TAG v3.4.0
|
GIT_TAG v3.4.0
|
||||||
|
|||||||
22
sources/providers/CMakeLists.txt
Normal file
22
sources/providers/CMakeLists.txt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
add_library(Providers STATIC
|
||||||
|
ProviderID.hpp
|
||||||
|
Provider.hpp Provider.cpp
|
||||||
|
ProviderFactory.hpp ProviderFactory.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(Providers
|
||||||
|
PUBLIC
|
||||||
|
Qt::Core
|
||||||
|
Qt::Network
|
||||||
|
QtCreator::Core
|
||||||
|
QtCreator::Utils
|
||||||
|
LLMQore
|
||||||
|
Common
|
||||||
|
PRIVATE
|
||||||
|
QodeAssistLogger
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(Providers
|
||||||
|
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
PRIVATE ${CMAKE_SOURCE_DIR}/sources/templates
|
||||||
|
)
|
||||||
86
sources/providers/Provider.cpp
Normal file
86
sources/providers/Provider.cpp
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "Provider.hpp"
|
||||||
|
|
||||||
|
#include "PromptTemplate.hpp"
|
||||||
|
|
||||||
|
#include <LLMQore/BaseClient.hpp>
|
||||||
|
#include <LLMQore/ToolsManager.hpp>
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
|
||||||
|
#include <Logger.hpp>
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
Provider::Provider(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{}
|
||||||
|
|
||||||
|
bool Provider::prepareRequest(
|
||||||
|
QJsonObject &request,
|
||||||
|
PromptTemplate *prompt,
|
||||||
|
const ContextData &context,
|
||||||
|
bool isToolsEnabled,
|
||||||
|
bool isThinkingEnabled)
|
||||||
|
{
|
||||||
|
if (!prompt) {
|
||||||
|
LOG_MESSAGE(QString("Provider '%1': null template").arg(name()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prompt->isSupportProvider(providerID())) {
|
||||||
|
LOG_MESSAGE(QString("Template '%1' doesn't support provider '%2'")
|
||||||
|
.arg(prompt->name(), name()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prompt->buildFullRequest(request, context, isThinkingEnabled)) {
|
||||||
|
LOG_MESSAGE(
|
||||||
|
QString("Provider '%1': template '%2' failed to build request")
|
||||||
|
.arg(name(), prompt->name()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isToolsEnabled) {
|
||||||
|
const auto toolsDefinitions = toolsManager()->getToolsDefinitions();
|
||||||
|
if (!toolsDefinitions.isEmpty()) {
|
||||||
|
request["tools"] = toolsDefinitions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
RequestID Provider::sendRequest(
|
||||||
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||||
|
{
|
||||||
|
auto *c = client();
|
||||||
|
|
||||||
|
c->setUrl(url.toString());
|
||||||
|
c->setApiKey(apiKey());
|
||||||
|
|
||||||
|
auto requestId = c->sendMessage(payload, endpoint);
|
||||||
|
|
||||||
|
LOG_MESSAGE(
|
||||||
|
QString("%1: Sending request %2 to %3%4").arg(name(), requestId, url.toString(), endpoint));
|
||||||
|
LOG_MESSAGE(
|
||||||
|
QString("%1: Payload:\n%2")
|
||||||
|
.arg(name(), QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Indented))));
|
||||||
|
|
||||||
|
return requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Provider::cancelRequest(const RequestID &requestId)
|
||||||
|
{
|
||||||
|
LOG_MESSAGE(QString("%1: Cancelling request %2").arg(name(), requestId));
|
||||||
|
client()->cancelRequest(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
::LLMQore::ToolsManager *Provider::toolsManager() const
|
||||||
|
{
|
||||||
|
return client()->tools();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
80
sources/providers/Provider.hpp
Normal file
80
sources/providers/Provider.hpp
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QFlags>
|
||||||
|
#include <QFuture>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <utils/environment.h>
|
||||||
|
|
||||||
|
#include "ContextData.hpp"
|
||||||
|
#include "ProviderID.hpp"
|
||||||
|
#include "LLMQore/BaseClient.hpp"
|
||||||
|
|
||||||
|
namespace LLMQore {
|
||||||
|
class BaseClient;
|
||||||
|
class ToolsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Templates {
|
||||||
|
class PromptTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
class QJsonObject;
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
using Templates::ContextData;
|
||||||
|
using Templates::PromptTemplate;
|
||||||
|
using LLMQore::RequestID;
|
||||||
|
|
||||||
|
enum class ProviderCapability {
|
||||||
|
Tools = 0x1,
|
||||||
|
Thinking = 0x2,
|
||||||
|
Image = 0x4,
|
||||||
|
ModelListing = 0x8,
|
||||||
|
};
|
||||||
|
Q_DECLARE_FLAGS(ProviderCapabilities, ProviderCapability)
|
||||||
|
Q_DECLARE_OPERATORS_FOR_FLAGS(ProviderCapabilities)
|
||||||
|
|
||||||
|
class Provider : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_DISABLE_COPY_MOVE(Provider)
|
||||||
|
public:
|
||||||
|
explicit Provider(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
virtual ~Provider() = default;
|
||||||
|
|
||||||
|
virtual QString name() const = 0;
|
||||||
|
|
||||||
|
virtual QString url() const { return m_url; }
|
||||||
|
virtual QString apiKey() const { return m_apiKey; }
|
||||||
|
void setUrl(const QString &url) { m_url = url; }
|
||||||
|
void setApiKey(const QString &apiKey) { m_apiKey = apiKey; }
|
||||||
|
|
||||||
|
[[nodiscard]] virtual bool prepareRequest(
|
||||||
|
QJsonObject &request,
|
||||||
|
PromptTemplate *prompt,
|
||||||
|
const ContextData &context,
|
||||||
|
bool isToolsEnabled,
|
||||||
|
bool isThinkingEnabled);
|
||||||
|
virtual QFuture<QList<QString>> getInstalledModels(const QString &url) = 0;
|
||||||
|
virtual ProviderID providerID() const = 0;
|
||||||
|
virtual ProviderCapabilities capabilities() const { return {}; }
|
||||||
|
|
||||||
|
virtual ::LLMQore::BaseClient *client() const = 0;
|
||||||
|
|
||||||
|
virtual RequestID sendRequest(
|
||||||
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint);
|
||||||
|
void cancelRequest(const RequestID &requestId);
|
||||||
|
::LLMQore::ToolsManager *toolsManager() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_url;
|
||||||
|
QString m_apiKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
43
sources/providers/ProviderFactory.cpp
Normal file
43
sources/providers/ProviderFactory.cpp
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ProviderFactory.hpp"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers::ProviderFactory {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QHash<QString, FactoryFn> &table()
|
||||||
|
{
|
||||||
|
static QHash<QString, FactoryFn> t;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void registerType(const QString &name, FactoryFn fn)
|
||||||
|
{
|
||||||
|
if (name.isEmpty() || !fn) return;
|
||||||
|
table().insert(name, std::move(fn));
|
||||||
|
}
|
||||||
|
|
||||||
|
Provider *create(const QString &name, QObject *parent)
|
||||||
|
{
|
||||||
|
auto it = table().constFind(name);
|
||||||
|
if (it == table().constEnd()) return nullptr;
|
||||||
|
return it.value()(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList knownNames()
|
||||||
|
{
|
||||||
|
return table().keys();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear()
|
||||||
|
{
|
||||||
|
table().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers::ProviderFactory
|
||||||
25
sources/providers/ProviderFactory.hpp
Normal file
25
sources/providers/ProviderFactory.hpp
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
class Provider;
|
||||||
|
|
||||||
|
namespace ProviderFactory {
|
||||||
|
|
||||||
|
using FactoryFn = std::function<Provider *(QObject *parent)>;
|
||||||
|
|
||||||
|
void registerType(const QString &name, FactoryFn fn);
|
||||||
|
Provider *create(const QString &name, QObject *parent);
|
||||||
|
QStringList knownNames();
|
||||||
|
void clear(); // for tests / shutdown
|
||||||
|
|
||||||
|
} // namespace ProviderFactory
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
22
sources/providers/ProviderID.hpp
Normal file
22
sources/providers/ProviderID.hpp
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
enum class ProviderID : int {
|
||||||
|
Any,
|
||||||
|
Ollama,
|
||||||
|
LMStudio,
|
||||||
|
Claude,
|
||||||
|
OpenAI,
|
||||||
|
OpenAICompatible,
|
||||||
|
OpenAIResponses,
|
||||||
|
MistralAI,
|
||||||
|
OpenRouter,
|
||||||
|
GoogleAI,
|
||||||
|
LlamaCpp,
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
17
sources/templates/CMakeLists.txt
Normal file
17
sources/templates/CMakeLists.txt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
add_library(Templates STATIC
|
||||||
|
PromptTemplate.hpp
|
||||||
|
JsonPromptTemplate.hpp JsonPromptTemplate.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(Templates
|
||||||
|
PUBLIC
|
||||||
|
Qt::Core
|
||||||
|
Common
|
||||||
|
Providers
|
||||||
|
pantor::inja
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(Templates
|
||||||
|
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
PRIVATE ${CMAKE_SOURCE_DIR}/sources/agents
|
||||||
|
)
|
||||||
336
sources/templates/JsonPromptTemplate.cpp
Normal file
336
sources/templates/JsonPromptTemplate.cpp
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "JsonPromptTemplate.hpp"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
#include "AgentConfig.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Templates {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
nlohmann::json buildContextJson(const ContextData &context)
|
||||||
|
{
|
||||||
|
nlohmann::json ctx = nlohmann::json::object();
|
||||||
|
|
||||||
|
if (context.systemPrompt) {
|
||||||
|
ctx["system_prompt"] = context.systemPrompt->toStdString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.prefix) {
|
||||||
|
ctx["prefix"] = context.prefix->toStdString();
|
||||||
|
}
|
||||||
|
if (context.suffix) {
|
||||||
|
ctx["suffix"] = context.suffix->toStdString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.filesMetadata && !context.filesMetadata->isEmpty()) {
|
||||||
|
nlohmann::json files = nlohmann::json::array();
|
||||||
|
for (const auto &file : context.filesMetadata.value()) {
|
||||||
|
nlohmann::json fj = nlohmann::json::object();
|
||||||
|
fj["file_path"] = file.filePath.toStdString();
|
||||||
|
fj["content"] = file.content.toStdString();
|
||||||
|
files.push_back(std::move(fj));
|
||||||
|
}
|
||||||
|
ctx["files_metadata"] = std::move(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json history = nlohmann::json::array();
|
||||||
|
if (context.history) {
|
||||||
|
for (const auto &msg : context.history.value()) {
|
||||||
|
nlohmann::json mj = nlohmann::json::object();
|
||||||
|
mj["role"] = msg.role.toStdString();
|
||||||
|
|
||||||
|
nlohmann::json blocks = nlohmann::json::array();
|
||||||
|
QString flatContent;
|
||||||
|
nlohmann::json flatImages = nlohmann::json::array();
|
||||||
|
|
||||||
|
for (const auto &b : msg.blocks) {
|
||||||
|
nlohmann::json bj = nlohmann::json::object();
|
||||||
|
switch (b.kind) {
|
||||||
|
case ContentBlockEntry::Kind::Text:
|
||||||
|
bj["type"] = "text";
|
||||||
|
bj["text"] = b.text.toStdString();
|
||||||
|
flatContent += b.text;
|
||||||
|
break;
|
||||||
|
case ContentBlockEntry::Kind::Thinking:
|
||||||
|
bj["type"] = "thinking";
|
||||||
|
bj["thinking"] = b.thinking.toStdString();
|
||||||
|
bj["signature"] = b.signature.toStdString();
|
||||||
|
break;
|
||||||
|
case ContentBlockEntry::Kind::RedactedThinking:
|
||||||
|
bj["type"] = "redacted_thinking";
|
||||||
|
bj["data"] = b.signature.toStdString();
|
||||||
|
break;
|
||||||
|
case ContentBlockEntry::Kind::ToolUse: {
|
||||||
|
bj["type"] = "tool_use";
|
||||||
|
bj["id"] = b.toolUseId.toStdString();
|
||||||
|
bj["name"] = b.toolName.toStdString();
|
||||||
|
const std::string inputStr
|
||||||
|
= QJsonDocument(b.toolInput).toJson(QJsonDocument::Compact).toStdString();
|
||||||
|
nlohmann::json parsedInput
|
||||||
|
= nlohmann::json::parse(inputStr, nullptr, /*allow_exceptions=*/false);
|
||||||
|
if (parsedInput.is_discarded()) {
|
||||||
|
if (!b.toolInput.isEmpty()) {
|
||||||
|
qWarning("[QodeAssist] tool_use '%s' has unparseable input "
|
||||||
|
"(serialized as null): %s",
|
||||||
|
qUtf8Printable(b.toolName),
|
||||||
|
inputStr.c_str());
|
||||||
|
}
|
||||||
|
parsedInput = nullptr;
|
||||||
|
}
|
||||||
|
bj["input"] = std::move(parsedInput);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ContentBlockEntry::Kind::ToolResult:
|
||||||
|
bj["type"] = "tool_result";
|
||||||
|
bj["tool_use_id"] = b.toolUseId.toStdString();
|
||||||
|
bj["content"] = b.result.toStdString();
|
||||||
|
break;
|
||||||
|
case ContentBlockEntry::Kind::Image:
|
||||||
|
bj["type"] = "image";
|
||||||
|
bj["data"] = b.imageData.toStdString();
|
||||||
|
bj["media_type"] = b.mediaType.toStdString();
|
||||||
|
bj["is_url"] = b.isImageUrl;
|
||||||
|
{
|
||||||
|
nlohmann::json ij = nlohmann::json::object();
|
||||||
|
ij["data"] = b.imageData.toStdString();
|
||||||
|
ij["media_type"] = b.mediaType.toStdString();
|
||||||
|
ij["is_url"] = b.isImageUrl;
|
||||||
|
flatImages.push_back(std::move(ij));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
blocks.push_back(std::move(bj));
|
||||||
|
}
|
||||||
|
|
||||||
|
mj["content"] = flatContent.toStdString();
|
||||||
|
if (!flatImages.empty())
|
||||||
|
mj["images"] = std::move(flatImages);
|
||||||
|
mj["content_blocks"] = std::move(blocks);
|
||||||
|
|
||||||
|
history.push_back(std::move(mj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx["history"] = std::move(history);
|
||||||
|
|
||||||
|
nlohmann::json data = nlohmann::json::object();
|
||||||
|
data["ctx"] = std::move(ctx);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
void registerStandardCallbacks(inja::Environment &env)
|
||||||
|
{
|
||||||
|
// Sandbox: disable filesystem reads from `{% include %}` and reject
|
||||||
|
// any include callback. User-authored templates run with full
|
||||||
|
// process privileges, so they must not slurp arbitrary files via
|
||||||
|
// include directives. File reads happen only through
|
||||||
|
// ContextManager-provided callbacks (e.g. read_file()).
|
||||||
|
env.set_search_included_templates_in_files(false);
|
||||||
|
env.set_include_callback(
|
||||||
|
[](const std::filesystem::path &, const std::string &name) -> inja::Template {
|
||||||
|
throw inja::FileError(
|
||||||
|
"include is disabled in QodeAssist templates: '" + name + "'");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable inja's `##` line-statement shorthand — collides with
|
||||||
|
// Markdown headings inside template bodies. Same rationale as in
|
||||||
|
// ContextRenderer; retarget to an unreachable sentinel.
|
||||||
|
env.set_line_statement("@@@inja@@@");
|
||||||
|
|
||||||
|
env.add_callback("tojson", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
return args.at(0)->dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
env.add_callback("strip_signature_suffix", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
std::string content = args.at(0)->get<std::string>();
|
||||||
|
const std::string marker = "\n[Signature: ";
|
||||||
|
const auto pos = content.find(marker);
|
||||||
|
if (pos != std::string::npos) {
|
||||||
|
content = content.substr(0, pos);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
});
|
||||||
|
|
||||||
|
env.add_callback("filter_skip_role", 2, [](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
const nlohmann::json &history = *args.at(0);
|
||||||
|
const std::string role = args.at(1)->get<std::string>();
|
||||||
|
nlohmann::json result = nlohmann::json::array();
|
||||||
|
for (const auto &msg : history) {
|
||||||
|
if (msg.contains("role") && msg["role"].get<std::string>() == role) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push_back(msg);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
env.add_callback("filter_skip_empty_thinking", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
const nlohmann::json &history = *args.at(0);
|
||||||
|
nlohmann::json result = nlohmann::json::array();
|
||||||
|
for (const auto &msg : history) {
|
||||||
|
const bool isThinking = msg.value("is_thinking", false);
|
||||||
|
const std::string sig = msg.value("signature", "");
|
||||||
|
if (isThinking && sig.empty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push_back(msg);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
env.add_callback(
|
||||||
|
"filter_skip_empty_parts_thinking", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||||
|
const nlohmann::json &history = *args.at(0);
|
||||||
|
nlohmann::json result = nlohmann::json::array();
|
||||||
|
for (const auto &msg : history) {
|
||||||
|
const bool isThinking = msg.value("is_thinking", false);
|
||||||
|
const std::string content = msg.value("content", "");
|
||||||
|
const std::string sig = msg.value("signature", "");
|
||||||
|
if (isThinking && content.empty() && sig.empty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push_back(msg);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::unique_ptr<JsonPromptTemplate> JsonPromptTemplate::fromConfig(
|
||||||
|
const AgentConfig &cfg, QString *error)
|
||||||
|
{
|
||||||
|
auto setError = [&error](const QString &msg) {
|
||||||
|
if (error) *error = msg;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cfg.messageFormat.isEmpty()) {
|
||||||
|
setError(QStringLiteral("Agent '%1' has empty message_format").arg(cfg.name));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto tpl = std::unique_ptr<JsonPromptTemplate>(new JsonPromptTemplate);
|
||||||
|
tpl->m_name = cfg.name;
|
||||||
|
tpl->m_description = cfg.description;
|
||||||
|
tpl->m_sampling = cfg.sampling;
|
||||||
|
tpl->m_thinking = cfg.thinking;
|
||||||
|
|
||||||
|
registerStandardCallbacks(tpl->m_env);
|
||||||
|
try {
|
||||||
|
tpl->m_template = tpl->m_env.parse(cfg.messageFormat.toStdString());
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
setError(QStringLiteral("Failed to parse jinja for '%1': %2")
|
||||||
|
.arg(cfg.name, QString::fromUtf8(e.what())));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<QJsonObject> JsonPromptTemplate::renderBody(const ContextData &context) const
|
||||||
|
{
|
||||||
|
const nlohmann::json data = buildContextJson(context);
|
||||||
|
|
||||||
|
std::string rendered;
|
||||||
|
try {
|
||||||
|
std::lock_guard<std::mutex> lock(m_renderMutex);
|
||||||
|
rendered = m_env.render(m_template, data);
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
qWarning("[QodeAssist] Template '%s' render failed: %s",
|
||||||
|
qUtf8Printable(m_name),
|
||||||
|
e.what());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonParseError err;
|
||||||
|
const QJsonDocument doc
|
||||||
|
= QJsonDocument::fromJson(QByteArray::fromStdString(rendered), &err);
|
||||||
|
constexpr std::size_t kMaxRenderedLogChars = 500;
|
||||||
|
const std::string truncated = rendered.size() > kMaxRenderedLogChars
|
||||||
|
? rendered.substr(0, kMaxRenderedLogChars) + "... [truncated]"
|
||||||
|
: rendered;
|
||||||
|
if (err.error != QJsonParseError::NoError) {
|
||||||
|
qWarning("[QodeAssist] Template '%s' produced invalid JSON at offset %d: %s\n"
|
||||||
|
"--- raw output (truncated) ---\n%s",
|
||||||
|
qUtf8Printable(m_name),
|
||||||
|
err.offset,
|
||||||
|
qUtf8Printable(err.errorString()),
|
||||||
|
truncated.c_str());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (!doc.isObject()) {
|
||||||
|
qWarning("[QodeAssist] Template '%s' rendered a non-object JSON value (truncated):\n%s",
|
||||||
|
qUtf8Printable(m_name),
|
||||||
|
truncated.c_str());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return doc.object();
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool mergeRenderedBody(QJsonObject &request, const std::optional<QJsonObject> &body)
|
||||||
|
{
|
||||||
|
if (!body)
|
||||||
|
return false;
|
||||||
|
for (auto it = body->constBegin(); it != body->constEnd(); ++it) {
|
||||||
|
request.insert(it.key(), it.value());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void deepMergeInto(QJsonObject &base, const QJsonObject &overlay)
|
||||||
|
{
|
||||||
|
for (auto it = overlay.constBegin(); it != overlay.constEnd(); ++it) {
|
||||||
|
const QJsonValue baseVal = base.value(it.key());
|
||||||
|
const QJsonValue overlayVal = it.value();
|
||||||
|
if (baseVal.isObject() && overlayVal.isObject()) {
|
||||||
|
QJsonObject merged = baseVal.toObject();
|
||||||
|
deepMergeInto(merged, overlayVal.toObject());
|
||||||
|
base[it.key()] = merged;
|
||||||
|
} else {
|
||||||
|
base[it.key()] = overlayVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void JsonPromptTemplate::prepareRequest(QJsonObject &request, const ContextData &context) const
|
||||||
|
{
|
||||||
|
mergeRenderedBody(request, renderBody(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool JsonPromptTemplate::buildFullRequest(
|
||||||
|
QJsonObject &request,
|
||||||
|
const ContextData &context,
|
||||||
|
bool thinkingEnabled) const
|
||||||
|
{
|
||||||
|
if (!mergeRenderedBody(request, renderBody(context)))
|
||||||
|
return false;
|
||||||
|
applySampling(request, thinkingEnabled);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void JsonPromptTemplate::applySampling(QJsonObject &request, bool thinkingEnabled) const
|
||||||
|
{
|
||||||
|
// Merge order: sampling provides defaults → body wins for its own
|
||||||
|
// keys → thinking overrides win on top.
|
||||||
|
QJsonObject merged = m_sampling;
|
||||||
|
deepMergeInto(merged, request);
|
||||||
|
|
||||||
|
if (thinkingEnabled && !m_thinking.isEmpty()) {
|
||||||
|
deepMergeInto(merged, m_thinking.value("overrides").toObject());
|
||||||
|
deepMergeInto(merged, m_thinking.value("request_block").toObject());
|
||||||
|
}
|
||||||
|
|
||||||
|
request = std::move(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Templates
|
||||||
75
sources/templates/JsonPromptTemplate.hpp
Normal file
75
sources/templates/JsonPromptTemplate.hpp
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include <inja/inja.hpp>
|
||||||
|
|
||||||
|
#include "PromptTemplate.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist {
|
||||||
|
struct AgentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Templates {
|
||||||
|
|
||||||
|
// Renderer for the request-body jinja template embedded in an
|
||||||
|
// AgentConfig. One per Agent — built inline from the config (no shared
|
||||||
|
// template registry, no model/provider filtering).
|
||||||
|
class JsonPromptTemplate : public PromptTemplate
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// Build a renderer from an already-parsed agent config. Compiles
|
||||||
|
// the jinja source via inja once. On failure returns nullptr and
|
||||||
|
// populates `*error` (existing content preserved; pass nullptr to
|
||||||
|
// discard).
|
||||||
|
static std::unique_ptr<JsonPromptTemplate> fromConfig(
|
||||||
|
const AgentConfig &cfg, QString *error = nullptr);
|
||||||
|
|
||||||
|
QString name() const override { return m_name; }
|
||||||
|
QString description() const override { return m_description; }
|
||||||
|
|
||||||
|
// Standalone-template filters are gone — each template is built for
|
||||||
|
// exactly one agent, so it always matches its owner's provider/model.
|
||||||
|
bool isSupportProvider(Providers::ProviderID) const override { return true; }
|
||||||
|
bool isSupportModel(const QString &) const override { return true; }
|
||||||
|
PromptShape shape() const override { return PromptShape::Chat; }
|
||||||
|
|
||||||
|
void prepareRequest(QJsonObject &request, const ContextData &context) const override;
|
||||||
|
|
||||||
|
[[nodiscard]] bool buildFullRequest(
|
||||||
|
QJsonObject &request,
|
||||||
|
const ContextData &context,
|
||||||
|
bool thinkingEnabled = false) const override;
|
||||||
|
|
||||||
|
const QJsonObject &sampling() const { return m_sampling; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
JsonPromptTemplate() = default;
|
||||||
|
|
||||||
|
std::optional<QJsonObject> renderBody(const ContextData &context) const;
|
||||||
|
void applySampling(QJsonObject &request, bool thinkingEnabled) const;
|
||||||
|
|
||||||
|
QString m_name;
|
||||||
|
QString m_description;
|
||||||
|
|
||||||
|
// m_env is populated once in fromConfig() and never mutated again.
|
||||||
|
// It is `mutable` only because inja::Environment::render() is not a
|
||||||
|
// const member; m_renderMutex serialises those render() calls since
|
||||||
|
// inja's render path is not internally re-entrant on one Environment.
|
||||||
|
mutable inja::Environment m_env;
|
||||||
|
inja::Template m_template;
|
||||||
|
mutable std::mutex m_renderMutex;
|
||||||
|
|
||||||
|
QJsonObject m_sampling;
|
||||||
|
QJsonObject m_thinking;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Templates
|
||||||
50
sources/templates/PromptTemplate.hpp
Normal file
50
sources/templates/PromptTemplate.hpp
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "ContextData.hpp"
|
||||||
|
#include "ProviderID.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Templates {
|
||||||
|
|
||||||
|
using Providers::ProviderID;
|
||||||
|
|
||||||
|
enum class PromptShape {
|
||||||
|
Chat,
|
||||||
|
Fim,
|
||||||
|
};
|
||||||
|
|
||||||
|
class PromptTemplate
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
PromptTemplate() = default;
|
||||||
|
virtual ~PromptTemplate() = default;
|
||||||
|
|
||||||
|
PromptTemplate(const PromptTemplate &) = delete;
|
||||||
|
PromptTemplate &operator=(const PromptTemplate &) = delete;
|
||||||
|
PromptTemplate(PromptTemplate &&) = delete;
|
||||||
|
PromptTemplate &operator=(PromptTemplate &&) = delete;
|
||||||
|
|
||||||
|
virtual QString name() const = 0;
|
||||||
|
virtual void prepareRequest(QJsonObject &request, const ContextData &context) const = 0;
|
||||||
|
virtual QString description() const = 0;
|
||||||
|
virtual bool isSupportProvider(ProviderID id) const = 0;
|
||||||
|
virtual PromptShape shape() const { return PromptShape::Chat; }
|
||||||
|
|
||||||
|
virtual bool isSupportModel(const QString & /*modelName*/) const { return true; }
|
||||||
|
|
||||||
|
[[nodiscard]] virtual bool buildFullRequest(
|
||||||
|
QJsonObject &request,
|
||||||
|
const ContextData &context,
|
||||||
|
bool /*thinkingEnabled*/ = false) const
|
||||||
|
{
|
||||||
|
prepareRequest(request, context);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} // namespace QodeAssist::Templates
|
||||||
Reference in New Issue
Block a user