From 97236c60692714c76cc7a5cd1a4ee075f067b1da Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Tue, 26 May 2026 12:30:11 +0200 Subject: [PATCH] feat: Add agents and agents settings --- CMakeLists.txt | 7 +- pluginllmcore/Provider.cpp | 4 +- pluginllmcore/Provider.hpp | 5 +- pluginllmcore/RequestType.hpp | 1 - providers/GoogleAIProvider.cpp | 7 +- providers/GoogleAIProvider.hpp | 2 +- providers/LMStudioProvider.cpp | 2 +- providers/LMStudioProvider.hpp | 2 +- providers/LMStudioResponsesProvider.cpp | 2 +- providers/LMStudioResponsesProvider.hpp | 2 +- providers/OllamaCompatProvider.cpp | 2 +- providers/OllamaCompatProvider.hpp | 2 +- qodeassist.cpp | 11 + settings/AgentDetailPane.cpp | 475 ++++++++++++++++++ settings/AgentDetailPane.hpp | 91 ++++ settings/AgentDuplicator.cpp | 120 +++++ settings/AgentDuplicator.hpp | 26 + settings/AgentListItem.cpp | 127 +++++ settings/AgentListItem.hpp | 44 ++ settings/AgentListPane.cpp | 236 +++++++++ settings/AgentListPane.hpp | 62 +++ settings/AgentsSettingsPage.cpp | 280 +++++++++++ settings/AgentsSettingsPage.hpp | 38 ++ settings/CMakeLists.txt | 12 +- settings/NewProviderDialog.cpp | 81 --- settings/NewProviderDialog.hpp | 34 -- settings/ProviderDetailPane.cpp | 47 +- settings/ProviderListItem.cpp | 9 +- settings/ProviderSettings.cpp | 2 + settings/ProvidersSettingsHelpers.hpp | 64 --- settings/ProvidersSettingsPage.cpp | 65 +-- settings/SettingsConstants.hpp | 3 + settings/SettingsTheme.hpp | 52 ++ settings/SettingsUiBuilders.cpp | 100 ++++ settings/SettingsUiBuilders.hpp | 39 ++ settings/TagChip.cpp | 88 ++++ settings/TagChip.hpp | 39 ++ settings/TagFilterStrip.cpp | 163 ++++++ settings/TagFilterStrip.hpp | 46 ++ sources/agents/Agent.cpp | 94 ++++ sources/agents/Agent.hpp | 53 ++ sources/agents/AgentConfig.hpp | 57 +++ sources/agents/AgentFactory.cpp | 223 ++++++++ sources/agents/AgentFactory.hpp | 67 +++ sources/agents/AgentLoader.cpp | 262 ++++++++++ sources/agents/AgentLoader.hpp | 30 ++ sources/agents/AgentRouter.cpp | 85 ++++ sources/agents/AgentRouter.hpp | 30 ++ sources/agents/CMakeLists.txt | 30 ++ sources/agents/ContextRenderer.cpp | 209 ++++++++ sources/agents/ContextRenderer.hpp | 19 + sources/agents/agents.qrc | 9 + sources/agents/ollama_base_chat.toml | 44 ++ sources/agents/ollama_base_fim.toml | 32 ++ .../agents/ollama_codellama_13b_qml_fim.toml | 40 ++ .../agents/ollama_codellama_7b_code_fim.toml | 34 ++ sources/agents/ollama_gemma4_e4b_chat.toml | 36 ++ sources/common/CMakeLists.txt | 11 + sources/common/ContextData.hpp | 79 +++ sources/external/CMakeLists.txt | 12 + sources/providers/CMakeLists.txt | 22 + sources/providers/Provider.cpp | 86 ++++ sources/providers/Provider.hpp | 80 +++ sources/providers/ProviderFactory.cpp | 43 ++ sources/providers/ProviderFactory.hpp | 25 + sources/providers/ProviderID.hpp | 22 + sources/templates/CMakeLists.txt | 17 + sources/templates/JsonPromptTemplate.cpp | 336 +++++++++++++ sources/templates/JsonPromptTemplate.hpp | 75 +++ sources/templates/PromptTemplate.hpp | 50 ++ 70 files changed, 4308 insertions(+), 296 deletions(-) create mode 100644 settings/AgentDetailPane.cpp create mode 100644 settings/AgentDetailPane.hpp create mode 100644 settings/AgentDuplicator.cpp create mode 100644 settings/AgentDuplicator.hpp create mode 100644 settings/AgentListItem.cpp create mode 100644 settings/AgentListItem.hpp create mode 100644 settings/AgentListPane.cpp create mode 100644 settings/AgentListPane.hpp create mode 100644 settings/AgentsSettingsPage.cpp create mode 100644 settings/AgentsSettingsPage.hpp delete mode 100644 settings/NewProviderDialog.cpp delete mode 100644 settings/NewProviderDialog.hpp delete mode 100644 settings/ProvidersSettingsHelpers.hpp create mode 100644 settings/SettingsTheme.hpp create mode 100644 settings/SettingsUiBuilders.cpp create mode 100644 settings/SettingsUiBuilders.hpp create mode 100644 settings/TagChip.cpp create mode 100644 settings/TagChip.hpp create mode 100644 settings/TagFilterStrip.cpp create mode 100644 settings/TagFilterStrip.hpp create mode 100644 sources/agents/Agent.cpp create mode 100644 sources/agents/Agent.hpp create mode 100644 sources/agents/AgentConfig.hpp create mode 100644 sources/agents/AgentFactory.cpp create mode 100644 sources/agents/AgentFactory.hpp create mode 100644 sources/agents/AgentLoader.cpp create mode 100644 sources/agents/AgentLoader.hpp create mode 100644 sources/agents/AgentRouter.cpp create mode 100644 sources/agents/AgentRouter.hpp create mode 100644 sources/agents/CMakeLists.txt create mode 100644 sources/agents/ContextRenderer.cpp create mode 100644 sources/agents/ContextRenderer.hpp create mode 100644 sources/agents/agents.qrc create mode 100644 sources/agents/ollama_base_chat.toml create mode 100644 sources/agents/ollama_base_fim.toml create mode 100644 sources/agents/ollama_codellama_13b_qml_fim.toml create mode 100644 sources/agents/ollama_codellama_7b_code_fim.toml create mode 100644 sources/agents/ollama_gemma4_e4b_chat.toml create mode 100644 sources/common/CMakeLists.txt create mode 100644 sources/common/ContextData.hpp create mode 100644 sources/providers/CMakeLists.txt create mode 100644 sources/providers/Provider.cpp create mode 100644 sources/providers/Provider.hpp create mode 100644 sources/providers/ProviderFactory.cpp create mode 100644 sources/providers/ProviderFactory.hpp create mode 100644 sources/providers/ProviderID.hpp create mode 100644 sources/templates/CMakeLists.txt create mode 100644 sources/templates/JsonPromptTemplate.cpp create mode 100644 sources/templates/JsonPromptTemplate.hpp create mode 100644 sources/templates/PromptTemplate.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b5a4ee1..df7660b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/pluginllmcore/Provider.cpp b/pluginllmcore/Provider.cpp index a86806c..a1bd745 100644 --- a/pluginllmcore/Provider.cpp +++ b/pluginllmcore/Provider.cpp @@ -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); diff --git a/pluginllmcore/Provider.hpp b/pluginllmcore/Provider.hpp index b0929ab..6e23cb9 100644 --- a/pluginllmcore/Provider.hpp +++ b/pluginllmcore/Provider.hpp @@ -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; }; diff --git a/pluginllmcore/RequestType.hpp b/pluginllmcore/RequestType.hpp index 8baa569..c44b5f9 100644 --- a/pluginllmcore/RequestType.hpp +++ b/pluginllmcore/RequestType.hpp @@ -9,5 +9,4 @@ namespace QodeAssist::PluginLLMCore { enum RequestType { CodeCompletion, Chat, Embedding, QuickRefactoring }; -using RequestID = QString; } diff --git a/providers/GoogleAIProvider.cpp b/providers/GoogleAIProvider.cpp index 3d9d09a..2b3b849 100644 --- a/providers/GoogleAIProvider.cpp +++ b/providers/GoogleAIProvider.cpp @@ -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()); diff --git a/providers/GoogleAIProvider.hpp b/providers/GoogleAIProvider.hpp index 32f9a97..7a103b9 100644 --- a/providers/GoogleAIProvider.hpp +++ b/providers/GoogleAIProvider.hpp @@ -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; diff --git a/providers/LMStudioProvider.cpp b/providers/LMStudioProvider.cpp index 77e36d7..ee2402b 100644 --- a/providers/LMStudioProvider.cpp +++ b/providers/LMStudioProvider.cpp @@ -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( diff --git a/providers/LMStudioProvider.hpp b/providers/LMStudioProvider.hpp index 5015a86..52061e3 100644 --- a/providers/LMStudioProvider.hpp +++ b/providers/LMStudioProvider.hpp @@ -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: diff --git a/providers/LMStudioResponsesProvider.cpp b/providers/LMStudioResponsesProvider.cpp index 5560689..059f7e2 100644 --- a/providers/LMStudioResponsesProvider.cpp +++ b/providers/LMStudioResponsesProvider.cpp @@ -118,7 +118,7 @@ QFuture> 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( diff --git a/providers/LMStudioResponsesProvider.hpp b/providers/LMStudioResponsesProvider.hpp index e1b0f65..0564255 100644 --- a/providers/LMStudioResponsesProvider.hpp +++ b/providers/LMStudioResponsesProvider.hpp @@ -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: diff --git a/providers/OllamaCompatProvider.cpp b/providers/OllamaCompatProvider.cpp index f929d5f..1b7f77f 100644 --- a/providers/OllamaCompatProvider.cpp +++ b/providers/OllamaCompatProvider.cpp @@ -97,7 +97,7 @@ QFuture> 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 diff --git a/providers/OllamaCompatProvider.hpp b/providers/OllamaCompatProvider.hpp index b51b34b..60c69fd 100644 --- a/providers/OllamaCompatProvider.hpp +++ b/providers/OllamaCompatProvider.hpp @@ -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: diff --git a/qodeassist.cpp b/qodeassist.cpp index de3847d..5665246 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -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 #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 m_providerLauncher; QPointer m_providersPageNavigator; std::unique_ptr m_providersOptionsPage; + QPointer m_agentFactory; + QPointer m_agentsPageNavigator; + std::unique_ptr m_agentsOptionsPage; #endif }; diff --git a/settings/AgentDetailPane.cpp b/settings/AgentDetailPane.cpp new file mode 100644 index 0000000..1e7f148 --- /dev/null +++ b/settings/AgentDetailPane.cpp @@ -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 +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 diff --git a/settings/AgentDetailPane.hpp b/settings/AgentDetailPane.hpp new file mode 100644 index 0000000..56169bd --- /dev/null +++ b/settings/AgentDetailPane.hpp @@ -0,0 +1,91 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include + +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 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 diff --git a/settings/AgentDuplicator.cpp b/settings/AgentDuplicator.cpp new file mode 100644 index 0000000..c4caf1a --- /dev/null +++ b/settings/AgentDuplicator.cpp @@ -0,0 +1,120 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "AgentDuplicator.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +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 diff --git a/settings/AgentDuplicator.hpp b/settings/AgentDuplicator.hpp new file mode 100644 index 0000000..3b3947a --- /dev/null +++ b/settings/AgentDuplicator.hpp @@ -0,0 +1,26 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +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 diff --git a/settings/AgentListItem.cpp b/settings/AgentListItem.cpp new file mode 100644 index 0000000..20fe873 --- /dev/null +++ b/settings/AgentListItem.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +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 &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 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 diff --git a/settings/AgentListItem.hpp b/settings/AgentListItem.hpp new file mode 100644 index 0000000..c79a2e8 --- /dev/null +++ b/settings/AgentListItem.hpp @@ -0,0 +1,44 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include + +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 &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 m_chips; +}; + +} // namespace QodeAssist::Settings diff --git a/settings/AgentListPane.cpp b/settings/AgentListPane.cpp new file mode 100644 index 0000000..1fdfcad --- /dev/null +++ b/settings/AgentListPane.cpp @@ -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 +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 &) { 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 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 AgentListPane::visibleAgents() const +{ + std::vector 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 &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 userAgents; + std::vector 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 newRows; + auto *content = new QWidget; + content->setAutoFillBackground(true); + auto *contentLayout = new QVBoxLayout(content); + contentLayout->setContentsMargins(0, 0, 0, 0); + contentLayout->setSpacing(0); + + const QSet &activeTags = m_tagStrip->activeTags(); + auto addAgents = [&](const std::vector &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 diff --git a/settings/AgentListPane.hpp b/settings/AgentListPane.hpp new file mode 100644 index 0000000..17649b0 --- /dev/null +++ b/settings/AgentListPane.hpp @@ -0,0 +1,62 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#include + +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 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 m_rows; + QString m_currentName; +}; + +} // namespace QodeAssist::Settings diff --git a/settings/AgentsSettingsPage.cpp b/settings/AgentsSettingsPage.cpp new file mode 100644 index 0000000..f3a60b2 --- /dev/null +++ b/settings/AgentsSettingsPage.cpp @@ -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 +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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 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 createAgentsSettingsPage( + AgentFactory *agentFactory, AgentsPageNavigator *navigator) +{ + return std::make_unique(agentFactory, navigator); +} + +} // namespace QodeAssist::Settings + +#include "AgentsSettingsPage.moc" diff --git a/settings/AgentsSettingsPage.hpp b/settings/AgentsSettingsPage.hpp new file mode 100644 index 0000000..b200856 --- /dev/null +++ b/settings/AgentsSettingsPage.hpp @@ -0,0 +1,38 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include + +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 createAgentsSettingsPage( + AgentFactory *agentFactory, AgentsPageNavigator *navigator); + +} // namespace QodeAssist::Settings diff --git a/settings/CMakeLists.txt b/settings/CMakeLists.txt index a3cdf2f..e311bc4 100644 --- a/settings/CMakeLists.txt +++ b/settings/CMakeLists.txt @@ -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}) diff --git a/settings/NewProviderDialog.cpp b/settings/NewProviderDialog.cpp deleted file mode 100644 index 7209835..0000000 --- a/settings/NewProviderDialog.cpp +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later - -#include "NewProviderDialog.hpp" - -#include -#include -#include -#include -#include -#include -#include - -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 diff --git a/settings/NewProviderDialog.hpp b/settings/NewProviderDialog.hpp deleted file mode 100644 index 93bbd80..0000000 --- a/settings/NewProviderDialog.hpp +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include -#include - -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 diff --git a/settings/ProviderDetailPane.cpp b/settings/ProviderDetailPane.cpp index b284308..ab78492 100644 --- a/settings/ProviderDetailPane.cpp +++ b/settings/ProviderDetailPane.cpp @@ -18,8 +18,9 @@ #include #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() diff --git a/settings/ProviderListItem.cpp b/settings/ProviderListItem.cpp index ed09202..c217d33 100644 --- a/settings/ProviderListItem.cpp +++ b/settings/ProviderListItem.cpp @@ -10,7 +10,7 @@ #include #include "ProviderInstance.hpp" -#include "ProvidersSettingsHelpers.hpp" +#include "SettingsTheme.hpp" namespace QodeAssist::Settings { @@ -99,12 +99,11 @@ void ProviderListItem::applyTheme() if (m_inApplyTheme) return; QScopedValueRollback 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 diff --git a/settings/ProviderSettings.cpp b/settings/ProviderSettings.cpp index 9ecc224..c0df2f9 100644 --- a/settings/ProviderSettings.cpp +++ b/settings/ProviderSettings.cpp @@ -227,6 +227,8 @@ public: } }; +#ifndef QODEASSIST_EXPERIMENTAL const ProviderSettingsPage providerSettingsPage; +#endif } // namespace QodeAssist::Settings diff --git a/settings/ProvidersSettingsHelpers.hpp b/settings/ProvidersSettingsHelpers.hpp deleted file mode 100644 index 9ee958a..0000000 --- a/settings/ProvidersSettingsHelpers.hpp +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -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 diff --git a/settings/ProvidersSettingsPage.cpp b/settings/ProvidersSettingsPage.cpp index cf41d86..b1ff9d8 100644 --- a/settings/ProvidersSettingsPage.cpp +++ b/settings/ProvidersSettingsPage.cpp @@ -28,7 +28,6 @@ #include #include -#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 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 m_navigator; QLabel *m_titleLabel = nullptr; - QPushButton *m_newBtn = nullptr; QLineEdit *m_filterEdit = nullptr; QScrollArea *m_listScroll = nullptr; diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index 5e592da..6ceb0ed 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -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"; diff --git a/settings/SettingsTheme.hpp b/settings/SettingsTheme.hpp new file mode 100644 index 0000000..ee4bb2c --- /dev/null +++ b/settings/SettingsTheme.hpp @@ -0,0 +1,52 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +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 diff --git a/settings/SettingsUiBuilders.cpp b/settings/SettingsUiBuilders.cpp new file mode 100644 index 0000000..83c92dc --- /dev/null +++ b/settings/SettingsUiBuilders.cpp @@ -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 +#include +#include +#include +#include +#include + +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 diff --git a/settings/SettingsUiBuilders.hpp b/settings/SettingsUiBuilders.hpp new file mode 100644 index 0000000..188325c --- /dev/null +++ b/settings/SettingsUiBuilders.hpp @@ -0,0 +1,39 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +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 diff --git a/settings/TagChip.cpp b/settings/TagChip.cpp new file mode 100644 index 0000000..9d870ba --- /dev/null +++ b/settings/TagChip.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +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 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 diff --git a/settings/TagChip.hpp b/settings/TagChip.hpp new file mode 100644 index 0000000..4f03fab --- /dev/null +++ b/settings/TagChip.hpp @@ -0,0 +1,39 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +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 diff --git a/settings/TagFilterStrip.cpp b/settings/TagFilterStrip.cpp new file mode 100644 index 0000000..fbb37df --- /dev/null +++ b/settings/TagFilterStrip.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 &countsByTag) +{ + m_counts = countsByTag; + QSet stillExisting; + for (auto it = m_counts.cbegin(); it != m_counts.cend(); ++it) + stillExisting.insert(it.key()); + QSet 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 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("%1").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> 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 diff --git a/settings/TagFilterStrip.hpp b/settings/TagFilterStrip.hpp new file mode 100644 index 0000000..2009fee --- /dev/null +++ b/settings/TagFilterStrip.hpp @@ -0,0 +1,46 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +class QVBoxLayout; + +namespace QodeAssist::Settings { + +class TagChip; + +class TagFilterStrip : public QWidget +{ + Q_OBJECT +public: + explicit TagFilterStrip(QWidget *parent = nullptr); + + void setAvailableTags(const QMap &countsByTag); + const QSet &activeTags() const { return m_activeTags; } + +signals: + void activeTagsChanged(const QSet &tags); + +protected: + void changeEvent(QEvent *event) override; + +private: + void rebuild(); + void refreshActiveStates(); + void applyTheme(); + void toggleTag(const QString &tag); + + QMap m_counts; + QSet m_activeTags; + QVBoxLayout *m_layout = nullptr; + QHash m_chipByTag; + bool m_inApplyTheme = false; +}; + +} // namespace QodeAssist::Settings diff --git a/sources/agents/Agent.cpp b/sources/agents/Agent.cpp new file mode 100644 index 0000000..c5e534c --- /dev/null +++ b/sources/agents/Agent.cpp @@ -0,0 +1,94 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "Agent.hpp" + +#include + +#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> 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{}); + } + return m_provider->getInstalledModels(m_provider->url()); +} + +} // namespace QodeAssist diff --git a/sources/agents/Agent.hpp b/sources/agents/Agent.hpp new file mode 100644 index 0000000..4ac3cb2 --- /dev/null +++ b/sources/agents/Agent.hpp @@ -0,0 +1,53 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include +#include + +#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> installedModels(); + +private: + AgentConfig m_config; + std::unique_ptr m_promptTemplate; // owned + Providers::Provider *m_provider = nullptr; // child of this + QString m_invalidReason; +}; + +} // namespace QodeAssist diff --git a/sources/agents/AgentConfig.hpp b/sources/agents/AgentConfig.hpp new file mode 100644 index 0000000..7716d6f --- /dev/null +++ b/sources/agents/AgentConfig.hpp @@ -0,0 +1,57 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +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 diff --git a/sources/agents/AgentFactory.cpp b/sources/agents/AgentFactory.cpp new file mode 100644 index 0000000..e0ae99a --- /dev/null +++ b/sources/agents/AgentFactory.cpp @@ -0,0 +1,223 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "AgentFactory.hpp" + +#include +#include + +#include + +#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(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(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(*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(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 diff --git a/sources/agents/AgentFactory.hpp b/sources/agents/AgentFactory.hpp new file mode 100644 index 0000000..a29cb1e --- /dev/null +++ b/sources/agents/AgentFactory.hpp @@ -0,0 +1,67 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include +#include +#include + +#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 &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 m_configs; + QHash m_indexByName; + QStringList m_errors; + QStringList m_warnings; + QPointer m_instanceFactory; + QPointer m_secrets; +}; + +} // namespace QodeAssist diff --git a/sources/agents/AgentLoader.cpp b/sources/agents/AgentLoader.cpp new file mode 100644 index 0000000..d24c4a2 --- /dev/null +++ b/sources/agents/AgentLoader.cpp @@ -0,0 +1,262 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "AgentLoader.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +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(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 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 &raw, + QSet &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 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 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 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 diff --git a/sources/agents/AgentLoader.hpp b/sources/agents/AgentLoader.hpp new file mode 100644 index 0000000..2570de8 --- /dev/null +++ b/sources/agents/AgentLoader.hpp @@ -0,0 +1,30 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "AgentConfig.hpp" + +namespace QodeAssist::Agents { + +class AgentLoader +{ +public: + struct LoadResult + { + std::vector configs; + QStringList errors; + QStringList warnings; + }; + + static LoadResult load(const QString &qrcPrefix, const QString &userDir); + + static std::optional parseFile( + const QString &path, QString *error, QStringList *warnings = nullptr); +}; + +} // namespace QodeAssist::Agents diff --git a/sources/agents/AgentRouter.cpp b/sources/agents/AgentRouter.cpp new file mode 100644 index 0000000..b57fd19 --- /dev/null +++ b/sources/agents/AgentRouter.cpp @@ -0,0 +1,85 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "AgentRouter.hpp" + +#include +#include + +#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 diff --git a/sources/agents/AgentRouter.hpp b/sources/agents/AgentRouter.hpp new file mode 100644 index 0000000..14954c2 --- /dev/null +++ b/sources/agents/AgentRouter.hpp @@ -0,0 +1,30 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#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 diff --git a/sources/agents/CMakeLists.txt b/sources/agents/CMakeLists.txt new file mode 100644 index 0000000..c9a8586 --- /dev/null +++ b/sources/agents/CMakeLists.txt @@ -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} +) diff --git a/sources/agents/ContextRenderer.cpp b/sources/agents/ContextRenderer.cpp new file mode 100644 index 0000000..fcb5892 --- /dev/null +++ b/sources/agents/ContextRenderer.cpp @@ -0,0 +1,209 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ContextRenderer.hpp" + +#include +#include +#include +#include +#include + +#include + +#include + +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(); + 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()), 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()), 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()); + const int n = args.at(1)->get(); + if (n <= 0) + return std::string{}; + const QStringList lines = text.split('\n'); + const int take = std::min(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())) + .fileName() + .toStdString(); + }); + env.add_callback("dirname", 1, [](inja::Arguments &args) -> nlohmann::json { + return QFileInfo(QString::fromStdString(args.at(0)->get())) + .path() + .toStdString(); + }); + env.add_callback("ext", 1, [](inja::Arguments &args) -> nlohmann::json { + return QFileInfo(QString::fromStdString(args.at(0)->get())) + .suffix() + .toStdString(); + }); + + env.add_callback("lower", 1, [](inja::Arguments &args) -> nlohmann::json { + return QString::fromStdString(args.at(0)->get()).toLower().toStdString(); + }); + env.add_callback("upper", 1, [](inja::Arguments &args) -> nlohmann::json { + return QString::fromStdString(args.at(0)->get()).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 diff --git a/sources/agents/ContextRenderer.hpp b/sources/agents/ContextRenderer.hpp new file mode 100644 index 0000000..5def432 --- /dev/null +++ b/sources/agents/ContextRenderer.hpp @@ -0,0 +1,19 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +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 diff --git a/sources/agents/agents.qrc b/sources/agents/agents.qrc new file mode 100644 index 0000000..8bf4c2d --- /dev/null +++ b/sources/agents/agents.qrc @@ -0,0 +1,9 @@ + + + ollama_base_chat.toml + ollama_base_fim.toml + ollama_gemma4_e4b_chat.toml + ollama_codellama_7b_code_fim.toml + ollama_codellama_13b_qml_fim.toml + + diff --git a/sources/agents/ollama_base_chat.toml b/sources/agents/ollama_base_chat.toml new file mode 100644 index 0000000..5c89228 --- /dev/null +++ b/sources/agents/ollama_base_chat.toml @@ -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" diff --git a/sources/agents/ollama_base_fim.toml b/sources/agents/ollama_base_fim.toml new file mode 100644 index 0000000..72b9d2f --- /dev/null +++ b/sources/agents/ollama_base_fim.toml @@ -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 = [""] diff --git a/sources/agents/ollama_codellama_13b_qml_fim.toml b/sources/agents/ollama_codellama_13b_qml_fim.toml new file mode 100644 index 0000000..b7538a9 --- /dev/null +++ b/sources/agents/ollama_codellama_13b_qml_fim.toml @@ -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("" + ctx.suffix + "
" + ctx.prefix + "") }}
+  {%- else -%}
+    {{ tojson("
" + ctx.prefix + "") }}
+  {%- 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 = ["", "
", "
", "
", "< EOT >", "\\end", "", "", "##"] diff --git a/sources/agents/ollama_codellama_7b_code_fim.toml b/sources/agents/ollama_codellama_7b_code_fim.toml new file mode 100644 index 0000000..235ef4a --- /dev/null +++ b/sources/agents/ollama_codellama_7b_code_fim.toml @@ -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("
 " + ctx.prefix + " " + 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 = ["", "
", "", ""]
diff --git a/sources/agents/ollama_gemma4_e4b_chat.toml b/sources/agents/ollama_gemma4_e4b_chat.toml
new file mode 100644
index 0000000..e41fe22
--- /dev/null
+++ b/sources/agents/ollama_gemma4_e4b_chat.toml
@@ -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
diff --git a/sources/common/CMakeLists.txt b/sources/common/CMakeLists.txt
new file mode 100644
index 0000000..fdb1a7b
--- /dev/null
+++ b/sources/common/CMakeLists.txt
@@ -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
+)
diff --git a/sources/common/ContextData.hpp b/sources/common/ContextData.hpp
new file mode 100644
index 0000000..c8719db
--- /dev/null
+++ b/sources/common/ContextData.hpp
@@ -0,0 +1,79 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include 
+#include 
+#include 
+#include 
+
+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 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 systemPrompt = std::nullopt;
+    std::optional prefix = std::nullopt;
+    std::optional suffix = std::nullopt;
+    std::optional fileContext = std::nullopt;
+    std::optional> history = std::nullopt;
+    std::optional> filesMetadata = std::nullopt;
+
+    bool operator==(const ContextData &) const = default;
+};
+
+} // namespace QodeAssist::Templates
diff --git a/sources/external/CMakeLists.txt b/sources/external/CMakeLists.txt
index 67776a8..b2872cc 100644
--- a/sources/external/CMakeLists.txt
+++ b/sources/external/CMakeLists.txt
@@ -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
diff --git a/sources/providers/CMakeLists.txt b/sources/providers/CMakeLists.txt
new file mode 100644
index 0000000..ce2e329
--- /dev/null
+++ b/sources/providers/CMakeLists.txt
@@ -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
+)
diff --git a/sources/providers/Provider.cpp b/sources/providers/Provider.cpp
new file mode 100644
index 0000000..53393e8
--- /dev/null
+++ b/sources/providers/Provider.cpp
@@ -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 
+#include 
+
+#include 
+#include 
+
+#include 
+
+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
diff --git a/sources/providers/Provider.hpp b/sources/providers/Provider.hpp
new file mode 100644
index 0000000..ffef41d
--- /dev/null
+++ b/sources/providers/Provider.hpp
@@ -0,0 +1,80 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#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> 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
diff --git a/sources/providers/ProviderFactory.cpp b/sources/providers/ProviderFactory.cpp
new file mode 100644
index 0000000..3e9dd2f
--- /dev/null
+++ b/sources/providers/ProviderFactory.cpp
@@ -0,0 +1,43 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "ProviderFactory.hpp"
+
+#include 
+
+namespace QodeAssist::Providers::ProviderFactory {
+
+namespace {
+
+QHash &table()
+{
+    static QHash 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
diff --git a/sources/providers/ProviderFactory.hpp b/sources/providers/ProviderFactory.hpp
new file mode 100644
index 0000000..b67f8c4
--- /dev/null
+++ b/sources/providers/ProviderFactory.hpp
@@ -0,0 +1,25 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include 
+#include 
+#include 
+
+namespace QodeAssist::Providers {
+
+class Provider;
+
+namespace ProviderFactory {
+
+using FactoryFn = std::function;
+
+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
diff --git a/sources/providers/ProviderID.hpp b/sources/providers/ProviderID.hpp
new file mode 100644
index 0000000..57e628e
--- /dev/null
+++ b/sources/providers/ProviderID.hpp
@@ -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
diff --git a/sources/templates/CMakeLists.txt b/sources/templates/CMakeLists.txt
new file mode 100644
index 0000000..4a7c81d
--- /dev/null
+++ b/sources/templates/CMakeLists.txt
@@ -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
+)
diff --git a/sources/templates/JsonPromptTemplate.cpp b/sources/templates/JsonPromptTemplate.cpp
new file mode 100644
index 0000000..e4ee736
--- /dev/null
+++ b/sources/templates/JsonPromptTemplate.cpp
@@ -0,0 +1,336 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "JsonPromptTemplate.hpp"
+
+#include 
+#include 
+#include 
+
+#include 
+
+#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();
+        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();
+        nlohmann::json result = nlohmann::json::array();
+        for (const auto &msg : history) {
+            if (msg.contains("role") && msg["role"].get() == 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::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(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 JsonPromptTemplate::renderBody(const ContextData &context) const
+{
+    const nlohmann::json data = buildContextJson(context);
+
+    std::string rendered;
+    try {
+        std::lock_guard 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 &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
diff --git a/sources/templates/JsonPromptTemplate.hpp b/sources/templates/JsonPromptTemplate.hpp
new file mode 100644
index 0000000..734beb8
--- /dev/null
+++ b/sources/templates/JsonPromptTemplate.hpp
@@ -0,0 +1,75 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+
+#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 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 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
diff --git a/sources/templates/PromptTemplate.hpp b/sources/templates/PromptTemplate.hpp
new file mode 100644
index 0000000..cdb8141
--- /dev/null
+++ b/sources/templates/PromptTemplate.hpp
@@ -0,0 +1,50 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include 
+#include 
+#include 
+
+#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