feat: Add agents and agents settings

This commit is contained in:
Petr Mironychev
2026-05-26 12:30:11 +02:00
parent 51ebe3e523
commit 97236c6069
70 changed files with 4308 additions and 296 deletions

View File

@@ -41,9 +41,13 @@ add_definitions(
add_subdirectory(sources/external)
add_subdirectory(sources/tomlSerializer)
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(settings)
add_subdirectory(logger)
add_subdirectory(sources/providersConfig)
add_subdirectory(UIControls)
add_subdirectory(ChatView)
@@ -72,6 +76,7 @@ add_qtc_plugin(QodeAssist
LLMQore
PluginLLMCore
ProvidersConfig
Agents
Skills
QodeAssistChatViewplugin
SOURCES

View File

@@ -16,7 +16,7 @@ Provider::Provider(QObject *parent)
: QObject(parent)
{}
RequestID Provider::sendRequest(
LLMQore::RequestID Provider::sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
{
auto *c = client();
@@ -35,7 +35,7 @@ RequestID Provider::sendRequest(
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));
client()->cancelRequest(requestId);

View File

@@ -11,6 +11,7 @@
#include "ContextData.hpp"
#include "PromptTemplate.hpp"
#include "LLMQore/BaseClient.hpp"
#include "RequestType.hpp"
namespace LLMQore {
@@ -56,9 +57,9 @@ public:
virtual ::LLMQore::BaseClient *client() const = 0;
virtual QString apiKey() const = 0;
virtual RequestID sendRequest(
virtual LLMQore::RequestID sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint);
void cancelRequest(const RequestID &requestId);
void cancelRequest(const LLMQore::RequestID &requestId);
::LLMQore::ToolsManager *toolsManager() const;
};

View File

@@ -9,5 +9,4 @@ namespace QodeAssist::PluginLLMCore {
enum RequestType { CodeCompletion, Chat, Embedding, QuickRefactoring };
using RequestID = QString;
}

View File

@@ -139,14 +139,9 @@ PluginLLMCore::ProviderCapabilities GoogleAIProvider::capabilities() const
| PluginLLMCore::ProviderCapability::ModelListing;
}
PluginLLMCore::RequestID GoogleAIProvider::sendRequest(
LLMQore::RequestID GoogleAIProvider::sendRequest(
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;
if (cleaned.contains("model")) {
m_client->setModel(cleaned["model"].toString());

View File

@@ -28,7 +28,7 @@ public:
PluginLLMCore::ProviderID providerID() const override;
PluginLLMCore::ProviderCapabilities capabilities() const override;
PluginLLMCore::RequestID sendRequest(
LLMQore::RequestID sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
::LLMQore::BaseClient *client() const override;

View File

@@ -105,7 +105,7 @@ void LMStudioProvider::prepareRequest(
}
}
PluginLLMCore::RequestID LMStudioProvider::sendRequest(
LLMQore::RequestID LMStudioProvider::sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
{
return PluginLLMCore::Provider::sendRequest(

View File

@@ -30,7 +30,7 @@ public:
::LLMQore::BaseClient *client() const override;
QString apiKey() const override;
PluginLLMCore::RequestID sendRequest(
LLMQore::RequestID sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
private:

View File

@@ -118,7 +118,7 @@ QFuture<QList<QString>> LMStudioResponsesProvider::getInstalledModels(const QStr
return m_client->listModels();
}
PluginLLMCore::RequestID LMStudioResponsesProvider::sendRequest(
LLMQore::RequestID LMStudioResponsesProvider::sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
{
return PluginLLMCore::Provider::sendRequest(

View File

@@ -30,7 +30,7 @@ public:
::LLMQore::BaseClient *client() const override;
QString apiKey() const override;
PluginLLMCore::RequestID sendRequest(
LLMQore::RequestID sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
private:

View File

@@ -97,7 +97,7 @@ QFuture<QList<QString>> OllamaCompatProvider::getInstalledModels(const QString &
return m_client->listModels();
}
PluginLLMCore::RequestID OllamaCompatProvider::sendRequest(
LLMQore::RequestID OllamaCompatProvider::sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
{
const QString effectiveEndpoint

View File

@@ -30,7 +30,7 @@ public:
::LLMQore::BaseClient *client() const override;
QString apiKey() const override;
PluginLLMCore::RequestID sendRequest(
LLMQore::RequestID sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
private:

View File

@@ -54,6 +54,7 @@
#include "settings/GeneralSettings.hpp"
#include "settings/ProjectSettingsPanel.hpp"
#ifdef QODEASSIST_EXPERIMENTAL
#include "settings/AgentsSettingsPage.hpp"
#include "settings/ProvidersSettingsPage.hpp"
#endif
#include "settings/QuickRefactorSettings.hpp"
@@ -63,6 +64,8 @@
#include "ProviderInstanceFactory.hpp"
#include "ProviderLauncher.hpp"
#include "ProviderSecretsStore.hpp"
#include <AgentFactory.hpp>
#endif
#include "templates/Templates.hpp"
#include "widgets/CustomInstructionsManager.hpp"
@@ -214,6 +217,11 @@ public:
m_providerSecretsStore,
m_providerLauncher,
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
m_mcpServerManager = new Mcp::McpServerManager(this);
@@ -516,6 +524,9 @@ private:
QPointer<Providers::ProviderLauncher> m_providerLauncher;
QPointer<Settings::ProvidersPageNavigator> m_providersPageNavigator;
std::unique_ptr<Core::IOptionsPage> m_providersOptionsPage;
QPointer<AgentFactory> m_agentFactory;
QPointer<Settings::AgentsPageNavigator> m_agentsPageNavigator;
std::unique_ptr<Core::IOptionsPage> m_agentsOptionsPage;
#endif
};

View 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

View 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

View 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

View 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
View 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

View 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
View 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

View 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

View 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"

View 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

View File

@@ -17,16 +17,23 @@ add_library(QodeAssistSettings STATIC
ProviderSettings.hpp ProviderSettings.cpp
ProviderNameMigration.hpp
ProvidersSettingsPage.hpp ProvidersSettingsPage.cpp
ProvidersSettingsHelpers.hpp
SettingsTheme.hpp
SettingsUiBuilders.hpp SettingsUiBuilders.cpp
SectionBox.hpp SectionBox.cpp
TagChip.hpp TagChip.cpp
ProviderListItem.hpp ProviderListItem.cpp
ProviderDetailPane.hpp ProviderDetailPane.cpp
NewProviderDialog.hpp NewProviderDialog.cpp
PluginUpdater.hpp PluginUpdater.cpp
UpdateDialog.hpp UpdateDialog.cpp
AgentRole.hpp AgentRole.cpp
AgentRoleDialog.hpp AgentRoleDialog.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
@@ -38,6 +45,7 @@ target_link_libraries(QodeAssistSettings
QtCreator::Utils
QodeAssistLogger
ProvidersConfig
Agents
Skills
)
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

View File

@@ -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

View File

@@ -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

View File

@@ -18,8 +18,9 @@
#include <solutions/terminal/terminalview.h>
#include "ProviderInstanceWriter.hpp"
#include "ProvidersSettingsHelpers.hpp"
#include "SectionBox.hpp"
#include "SettingsTheme.hpp"
#include "SettingsUiBuilders.hpp"
namespace QodeAssist::Settings {
@@ -112,15 +113,12 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
identityGrid->setContentsMargins(0, 0, 0, 0);
identityGrid->setHorizontalSpacing(8);
identityGrid->setVerticalSpacing(4);
int identityRow = 0;
identityRow = addFormRow(identityGrid, identityRow, tr("Name:"),
singleField(m_nameEdit));
identityRow = addFormRow(identityGrid, identityRow, tr("Client API:"),
singleField(m_typeEdit),
tr("The client API this provider speaks. "
"Cannot be changed after creation."));
identityRow = addFormRow(identityGrid, identityRow, tr("Description:"),
singleField(m_descriptionEdit));
FormBuilder(identityGrid)
.row(tr("Name:"), m_nameEdit)
.row(tr("Client API:"), m_typeEdit,
tr("The client API this provider speaks. "
"Cannot be changed after creation."))
.row(tr("Description:"), m_descriptionEdit);
identitySection->bodyLayout()->addLayout(identityGrid);
auto *endpointSection = new SectionBox(tr("Endpoint"), this);
@@ -130,11 +128,9 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
endpointGrid->setContentsMargins(0, 0, 0, 0);
endpointGrid->setHorizontalSpacing(8);
endpointGrid->setVerticalSpacing(4);
int endpointRow = 0;
endpointRow = addFormRow(endpointGrid, endpointRow, tr("URL:"),
singleField(m_urlEdit),
tr("Base URL. Agents append their endpoint path "
"(e.g. /chat/completions) to this."));
FormBuilder(endpointGrid).row(tr("URL:"), m_urlEdit,
tr("Base URL. Agents append their endpoint path "
"(e.g. /chat/completions) to this."));
endpointSection->bodyLayout()->addLayout(endpointGrid);
m_samplePreview = new QLabel(this);
@@ -176,14 +172,7 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
});
connect(m_apiKeyClearBtn, &QPushButton::clicked, this,
[this] { emit apiKeyClearRequested(); });
m_keyHint = new QLabel(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);
m_keyHint = makeHintLabel(QString{}, this);
auto *keyRow = new QHBoxLayout;
keyRow->setContentsMargins(0, 0, 0, 0);
@@ -197,9 +186,9 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
credGrid->setContentsMargins(0, 0, 0, 0);
credGrid->setHorizontalSpacing(8);
credGrid->setVerticalSpacing(4);
int credRow = 0;
credRow = addFormRow(credGrid, credRow, tr("API key:"), keyRow);
credGrid->addWidget(m_keyHint, credRow, 1);
FormBuilder credForm(credGrid);
credForm.row(tr("API key:"), keyRow);
credGrid->addWidget(m_keyHint, credForm.currentRow(), 1);
credSection->bodyLayout()->addLayout(credGrid);
m_launchSection = new SectionBox(tr("Launch"), this);
@@ -483,12 +472,10 @@ Providers::ProviderInstance ProviderDetailPane::collectEdits() const
void ProviderDetailPane::applyPreviewPalette()
{
const bool dark = isDarkPalette(palette());
const QString bg = dark ? QStringLiteral("#1f1f1f") : QStringLiteral("#f4f4f4");
const QString bd = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
const Theme theme = themeFor(palette());
m_samplePreview->setStyleSheet(QStringLiteral(
"QLabel { background:%1; border:1px solid %2; }")
.arg(bg, bd));
.arg(theme.codeBg, theme.rowSeparator));
}
void ProviderDetailPane::applyTerminalPalette()

View File

@@ -10,7 +10,7 @@
#include <QVBoxLayout>
#include "ProviderInstance.hpp"
#include "ProvidersSettingsHelpers.hpp"
#include "SettingsTheme.hpp"
namespace QodeAssist::Settings {
@@ -99,12 +99,11 @@ void ProviderListItem::applyTheme()
if (m_inApplyTheme)
return;
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
const bool dark = isDarkPalette(palette());
const QString sep = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
const QString sel = dark ? QStringLiteral("#2c4060") : QStringLiteral("#cfe2ff");
const Theme theme = themeFor(palette());
setStyleSheet(QStringLiteral(
"#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

View File

@@ -227,6 +227,8 @@ public:
}
};
#ifndef QODEASSIST_EXPERIMENTAL
const ProviderSettingsPage providerSettingsPage;
#endif
} // namespace QodeAssist::Settings

View File

@@ -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

View File

@@ -28,7 +28,6 @@
#include <QTimer>
#include <QVBoxLayout>
#include "NewProviderDialog.hpp"
#include "ProviderDetailPane.hpp"
#include "ProviderInstance.hpp"
#include "ProviderInstanceFactory.hpp"
@@ -36,8 +35,8 @@
#include "ProviderLauncher.hpp"
#include "ProviderListItem.hpp"
#include "ProviderSecretsStore.hpp"
#include "ProvidersSettingsHelpers.hpp"
#include "SettingsConstants.hpp"
#include "SettingsTheme.hpp"
namespace QodeAssist::Settings {
@@ -80,13 +79,10 @@ public:
tf.setPixelSize(13);
m_titleLabel->setFont(tf);
m_newBtn = new QPushButton(tr("+ New provider…"), this);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(8);
headerRow->addWidget(m_titleLabel, 1);
headerRow->addWidget(m_newBtn);
auto *headerSep = new QFrame(this);
headerSep->setFrameShape(QFrame::HLine);
@@ -181,7 +177,6 @@ public:
root->addWidget(headerSep);
root->addWidget(splitter, 1);
connect(m_newBtn, &QPushButton::clicked, this, &ProvidersPageWidget::onNewClicked);
m_filterDebounce = new QTimer(this);
m_filterDebounce->setSingleShot(true);
m_filterDebounce->setInterval(100);
@@ -248,9 +243,9 @@ private slots:
header->setPalette(hp);
header->setContentsMargins(8, 4, 8, 4);
header->setAutoFillBackground(true);
const bool dark = isDarkPalette(palette());
const QString bg = dark ? QStringLiteral("#262626") : QStringLiteral("#f0f0f0");
header->setStyleSheet(QStringLiteral("QLabel { background:%1; }").arg(bg));
header->setStyleSheet(
QStringLiteral("QLabel { background:%1; }")
.arg(themeFor(palette()).listHeaderBg));
m_listLayout->insertWidget(m_listLayout->count() - 1, header);
std::vector<const Providers::ProviderInstance *> sorted;
@@ -316,57 +311,6 @@ private slots:
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()
{
if (!m_factory || m_currentName.isEmpty())
@@ -589,7 +533,6 @@ private:
QPointer<ProvidersPageNavigator> m_navigator;
QLabel *m_titleLabel = nullptr;
QPushButton *m_newBtn = nullptr;
QLineEdit *m_filterEdit = nullptr;
QScrollArea *m_listScroll = nullptr;

View File

@@ -138,6 +138,9 @@ const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist";
// Provider Settings Page ID
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
const char OPEN_ROUTER_API_KEY[] = "QodeAssist.openRouterApiKey";
const char OPEN_ROUTER_API_KEY_HISTORY[] = "QodeAssist.openRouterApiKeyHistory";

View 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

View 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

View 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
View 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
View 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
View 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

View 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
View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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}
)

View 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

View 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

View 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>

View 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"

View 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>"]

View 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>", "##"]

View 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>"]

View 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

View 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
)

View 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

View File

@@ -1,5 +1,17 @@
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
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
GIT_TAG v3.4.0

View 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
)

View 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

View 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

View 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

View 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

View 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

View 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
)

View 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

View 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

View 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