refactor: Remove all experimental code

This commit is contained in:
Petr Mironychev
2026-07-13 09:37:04 +02:00
parent 34ce787320
commit adef7972ed
145 changed files with 0 additions and 13468 deletions

View File

@@ -1,12 +1,2 @@
add_subdirectory(external)
add_subdirectory(tomlSerializer)
add_subdirectory(skills)
add_subdirectory(common)
add_subdirectory(providers)
add_subdirectory(templates)
add_subdirectory(agents)
add_subdirectory(providersConfig)
if(QODEASSIST_EXPERIMENTAL)
add_subdirectory(settings)
endif()

View File

@@ -1,95 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "Agent.hpp"
#include <QThread>
#include "JsonPromptTemplate.hpp"
#include "PromptTemplate.hpp"
#include "Provider.hpp"
namespace QodeAssist {
using Providers::Provider;
using Templates::JsonPromptTemplate;
using Templates::PromptTemplate;
QString AgentConfig::validate(const AgentConfig &config)
{
if (config.name.isEmpty())
return QStringLiteral("Agent config has no name");
if (config.schemaVersion > AgentConfig::kSupportedSchemaVersion) {
return QStringLiteral(
"Agent config '%1' declares schema_version %2 but this plugin "
"supports at most %3 — update QodeAssist to use this profile")
.arg(config.name)
.arg(config.schemaVersion)
.arg(AgentConfig::kSupportedSchemaVersion);
}
if (config.providerInstance.isEmpty())
return QStringLiteral("Agent config '%1' has no provider_instance").arg(config.name);
if (config.model.isEmpty())
return QStringLiteral("Agent config '%1' has no model").arg(config.name);
if (config.endpoint.isEmpty())
return QStringLiteral("Agent config '%1' has no endpoint").arg(config.name);
if (config.messageFormat.isEmpty()) {
return QStringLiteral("Agent config '%1' has no [template].message_format")
.arg(config.name);
}
return {};
}
Agent::Agent(AgentConfig config, Providers::Provider *providerOwned, QObject *parent)
: QObject(parent)
, m_config(std::move(config))
, m_provider(providerOwned)
{
m_invalidReason = AgentConfig::validate(m_config);
if (!m_invalidReason.isEmpty())
return;
if (!m_provider) {
m_invalidReason
= QStringLiteral("Agent '%1' was constructed without a provider").arg(m_config.name);
return;
}
m_provider->setParent(this);
QString tmplErr;
m_promptTemplate = JsonPromptTemplate::fromConfig(m_config, &tmplErr);
if (!m_promptTemplate) {
m_invalidReason = tmplErr.isEmpty()
? QStringLiteral("Failed to build prompt template for agent '%1'")
.arg(m_config.name)
: tmplErr;
}
}
Agent::~Agent() = default;
PromptTemplate *Agent::promptTemplate() noexcept
{
return m_promptTemplate.get();
}
const PromptTemplate *Agent::promptTemplate() const noexcept
{
return m_promptTemplate.get();
}
QFuture<QList<QString>> Agent::installedModels()
{
Q_ASSERT_X(thread() == QThread::currentThread(), Q_FUNC_INFO,
"Agent::installedModels called from non-owning thread; "
"the underlying BaseClient is not thread-safe and must be "
"accessed from the Agent's owner thread");
if (!m_provider) {
return QtFuture::makeReadyValueFuture(QList<QString>{});
}
return m_provider->getInstalledModels(m_provider->url());
}
} // namespace QodeAssist

View File

@@ -1,54 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <memory>
#include <QFuture>
#include <QList>
#include <QObject>
#include <QString>
#include "AgentConfig.hpp"
namespace QodeAssist {
namespace Providers {
class Provider;
}
namespace Templates {
class JsonPromptTemplate;
class PromptTemplate;
}
class Agent : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(Agent)
public:
Agent(AgentConfig config, Providers::Provider *providerOwned, QObject *parent = nullptr);
~Agent() override;
const AgentConfig &config() const noexcept { return m_config; }
Providers::Provider *provider() noexcept { return m_provider; }
const Providers::Provider *provider() const noexcept { return m_provider; }
Templates::PromptTemplate *promptTemplate() noexcept;
const Templates::PromptTemplate *promptTemplate() const noexcept;
bool isValid() const noexcept { return m_invalidReason.isEmpty(); }
QString invalidReason() const { return m_invalidReason; }
QFuture<QList<QString>> installedModels();
private:
AgentConfig m_config;
std::unique_ptr<Templates::JsonPromptTemplate> m_promptTemplate; // owned
Providers::Provider *m_provider = nullptr; // child of this
QString m_invalidReason;
};
} // namespace QodeAssist

View File

@@ -1,58 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QJsonObject>
#include <QString>
#include <QStringList>
namespace QodeAssist {
struct AgentConfig
{
static constexpr int kSupportedSchemaVersion = 1;
int schemaVersion = 1;
QString name;
QString description;
QString providerInstance;
QString model;
QString endpoint;
QString role;
QStringList tags;
struct Match
{
QStringList filePatterns;
QStringList pathPatterns;
QStringList projectNames;
[[nodiscard]] bool isEmpty() const noexcept
{
return filePatterns.isEmpty()
&& pathPatterns.isEmpty()
&& projectNames.isEmpty();
}
};
Match match;
bool enableThinking = false;
bool enableTools = false;
QString messageFormat;
QJsonObject sampling;
QJsonObject thinking;
QString context;
QString extendsName;
bool abstract = false;
bool hidden = false;
QString sourcePath;
bool overridesBundled = false;
bool isUserSource() const { return !sourcePath.startsWith(QLatin1StringView{":/"}); }
static QString validate(const AgentConfig &config);
};
} // namespace QodeAssist

View File

@@ -1,224 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentFactory.hpp"
#include <QLoggingCategory>
#include <QThread>
#include <coreplugin/icore.h>
#include "Agent.hpp"
#include "AgentLoader.hpp"
#include "Provider.hpp"
#include "ProviderFactory.hpp"
#include "Logger.hpp"
#include "ProviderSecretsStore.hpp"
#include "ProviderInstance.hpp"
#include "ProviderInstanceFactory.hpp"
static inline void initAgentsResource() { Q_INIT_RESOURCE(agents); }
namespace {
Q_LOGGING_CATEGORY(agentFactoryLog, "qodeassist.agentfactory")
QString agentQrcPrefix() { return QStringLiteral(":/agents"); }
} // namespace
namespace QodeAssist {
AgentFactory::AgentFactory(
Providers::ProviderInstanceFactory *instanceFactory,
Providers::ProviderSecretsStore *secrets,
QObject *parent)
: QObject(parent)
, m_instanceFactory(instanceFactory)
, m_secrets(secrets)
{
::initAgentsResource();
reload();
}
AgentFactory::~AgentFactory() = default;
QString AgentFactory::userAgentsDir()
{
return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/agents"))
.toFSPathString();
}
void AgentFactory::reload()
{
Q_ASSERT(thread() == QThread::currentThread());
clear();
auto result = Agents::AgentLoader::load(agentQrcPrefix(), userAgentsDir());
for (const QString &err : result.errors)
LOG_MESSAGE(QString("[Agents] error: %1").arg(err));
for (const QString &warn : result.warnings)
LOG_MESSAGE(QString("[Agents] warning: %1").arg(warn));
LOG_MESSAGE(QString("[Agents] Loaded %1 profiles (qrc=%2, user=%3)")
.arg(result.configs.size())
.arg(agentQrcPrefix(), userAgentsDir()));
for (auto &cfg : result.configs) {
LOG_MESSAGE(QString("[Agents] Loaded: %1").arg(cfg.name));
registerConfig(std::move(cfg));
}
m_errors = std::move(result.errors);
m_warnings = std::move(result.warnings);
}
void AgentFactory::registerConfig(AgentConfig config)
{
Q_ASSERT(thread() == QThread::currentThread());
const QString error = AgentConfig::validate(config);
if (!error.isEmpty()) {
qCWarning(agentFactoryLog).noquote() << "Rejected agent config:" << error;
return;
}
const auto it = m_indexByName.constFind(config.name);
if (it != m_indexByName.constEnd()) {
m_configs[it.value()] = std::move(config);
return;
}
m_indexByName.insert(config.name, static_cast<qsizetype>(m_configs.size()));
m_configs.push_back(std::move(config));
}
const AgentConfig *AgentFactory::configByName(const QString &name) const
{
const auto it = m_indexByName.constFind(name);
if (it == m_indexByName.constEnd())
return nullptr;
return &m_configs[it.value()];
}
QStringList AgentFactory::configNames() const
{
QStringList out;
out.reserve(static_cast<qsizetype>(m_configs.size()));
for (const auto &c : m_configs) {
if (c.hidden) continue;
out.append(c.name);
}
return out;
}
namespace {
Providers::Provider *buildProviderForAgent(
const AgentConfig &cfg,
Providers::ProviderInstanceFactory *instanceFactory,
Providers::ProviderSecretsStore *secrets,
QString *errorOut)
{
if (!instanceFactory) {
if (errorOut) {
*errorOut = QStringLiteral(
"Agent '%1' cannot be built — no ProviderInstanceFactory was wired "
"into AgentFactory")
.arg(cfg.name);
}
return nullptr;
}
const Providers::ProviderInstance *inst
= instanceFactory->instanceByName(cfg.providerInstance);
if (!inst) {
if (errorOut) {
*errorOut = QStringLiteral(
"Agent '%1' references unknown provider instance '%2'")
.arg(cfg.name, cfg.providerInstance);
}
return nullptr;
}
const QString validation = Providers::ProviderInstance::validate(
*inst, Providers::ProviderFactory::knownNames());
if (!validation.isEmpty()) {
if (errorOut)
*errorOut = validation;
return nullptr;
}
Providers::Provider *provider = Providers::ProviderFactory::create(inst->clientApi, nullptr);
if (!provider) {
if (errorOut) {
*errorOut = QStringLiteral("Client API '%1' is not registered (instance '%2')")
.arg(inst->clientApi, inst->name);
}
return nullptr;
}
provider->setUrl(inst->url);
if (secrets && !inst->apiKeyRef.isEmpty())
provider->setApiKey(secrets->readKeySync(inst->apiKeyRef));
return provider;
}
} // namespace
Agent *AgentFactory::create(const QString &name, QObject *parent, QString *errorOut) const
{
const AgentConfig *cfg = configByName(name);
if (!cfg) {
if (errorOut)
*errorOut = QStringLiteral("Agent '%1' is not registered").arg(name);
return nullptr;
}
Providers::Provider *provider = buildProviderForAgent(
*cfg, m_instanceFactory.data(), m_secrets.data(), errorOut);
if (!provider)
return nullptr;
auto agent = std::make_unique<Agent>(*cfg, provider, /*parent=*/nullptr);
if (!agent->isValid()) {
if (errorOut)
*errorOut = agent->invalidReason();
return nullptr;
}
agent->setParent(parent);
return agent.release();
}
Agent *AgentFactory::createFromFile(
const QString &tomlPath, QObject *parent, QString *errorOut) const
{
QString parseErr;
QStringList warnings;
auto cfgOpt = Agents::AgentLoader::parseFile(tomlPath, &parseErr, &warnings);
if (!cfgOpt) {
if (errorOut) *errorOut = parseErr;
return nullptr;
}
Providers::Provider *provider = buildProviderForAgent(
*cfgOpt, m_instanceFactory.data(), m_secrets.data(), errorOut);
if (!provider)
return nullptr;
auto agent = std::make_unique<Agent>(std::move(*cfgOpt), provider, /*parent=*/nullptr);
if (!agent->isValid()) {
if (errorOut) *errorOut = agent->invalidReason();
return nullptr;
}
agent->setParent(parent);
return agent.release();
}
void AgentFactory::clear()
{
Q_ASSERT(thread() == QThread::currentThread());
m_configs.clear();
m_indexByName.clear();
m_errors.clear();
m_warnings.clear();
}
Providers::ProviderInstanceFactory *AgentFactory::instanceFactory() const noexcept
{
return m_instanceFactory.data();
}
Providers::ProviderSecretsStore *AgentFactory::secretsStore() const noexcept
{
return m_secrets.data();
}
} // namespace QodeAssist

View File

@@ -1,68 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <vector>
#include <QHash>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
#include "AgentConfig.hpp"
namespace QodeAssist {
class Agent;
namespace Providers {
class ProviderInstanceFactory;
class ProviderSecretsStore;
}
class AgentFactory : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(AgentFactory)
public:
explicit AgentFactory(
Providers::ProviderInstanceFactory *instanceFactory = nullptr,
Providers::ProviderSecretsStore *secrets = nullptr,
QObject *parent = nullptr);
~AgentFactory() override;
void reload();
[[nodiscard]] static QString userAgentsDir();
[[nodiscard]] const AgentConfig *configByName(const QString &name) const;
[[nodiscard]] QStringList configNames() const;
[[nodiscard]] const std::vector<AgentConfig> &configs() const noexcept { return m_configs; }
Agent *create(const QString &name, QObject *parent, QString *errorOut = nullptr) const;
Agent *createFromFile(
const QString &tomlPath, QObject *parent, QString *errorOut = nullptr) const;
[[nodiscard]] QStringList lastLoadErrors() const { return m_errors; }
[[nodiscard]] QStringList lastLoadWarnings() const { return m_warnings; }
void registerConfig(AgentConfig config);
void clear();
[[nodiscard]] Providers::ProviderInstanceFactory *instanceFactory() const noexcept;
[[nodiscard]] Providers::ProviderSecretsStore *secretsStore() const noexcept;
private:
std::vector<AgentConfig> m_configs;
QHash<QString, qsizetype> m_indexByName;
QStringList m_errors;
QStringList m_warnings;
QPointer<Providers::ProviderInstanceFactory> m_instanceFactory;
QPointer<Providers::ProviderSecretsStore> m_secrets;
};
} // namespace QodeAssist

View File

@@ -1,263 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentLoader.hpp"
#include <QDir>
#include <QFile>
#include <QHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QSet>
#include <toml++/toml.hpp>
#include <algorithm>
#include <sstream>
namespace QodeAssist::Agents {
namespace {
QJsonValue tomlToJson(const toml::node &node)
{
if (auto *table = node.as_table()) {
QJsonObject obj;
for (const auto &[key, value] : *table) {
obj.insert(QString::fromStdString(std::string{key.str()}), tomlToJson(value));
}
return obj;
}
if (auto *array = node.as_array()) {
QJsonArray arr;
for (const auto &item : *array) {
arr.append(tomlToJson(item));
}
return arr;
}
if (auto *str = node.as_string()) {
return QString::fromStdString(str->get());
}
if (auto *integer = node.as_integer()) {
return static_cast<qint64>(integer->get());
}
if (auto *floating = node.as_floating_point()) {
return floating->get();
}
if (auto *boolean = node.as_boolean()) {
return boolean->get();
}
return QJsonValue::Null;
}
QJsonObject deepMerge(const QJsonObject &base, const QJsonObject &overlay)
{
QJsonObject result = base;
for (auto it = overlay.constBegin(); it != overlay.constEnd(); ++it) {
const QJsonValue baseVal = result.value(it.key());
const QJsonValue overlayVal = it.value();
if (baseVal.isObject() && overlayVal.isObject()) {
result[it.key()] = deepMerge(baseVal.toObject(), overlayVal.toObject());
} else {
result[it.key()] = overlayVal;
}
}
return result;
}
QString readUtf8(const QString &path, QString *error)
{
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
if (error) *error = QStringLiteral("Cannot open: %1").arg(path);
return {};
}
return QString::fromUtf8(f.readAll());
}
std::optional<QJsonObject> parseTomlFile(const QString &path, QString *error)
{
QString readErr;
const QString contents = readUtf8(path, &readErr);
if (!readErr.isEmpty()) {
if (error) *error = readErr;
return std::nullopt;
}
toml::table tbl;
try {
tbl = toml::parse(contents.toStdString(), path.toStdString());
} catch (const toml::parse_error &e) {
std::ostringstream oss;
oss << e;
if (error) {
*error = QStringLiteral("TOML parse error in %1: %2")
.arg(path, QString::fromStdString(oss.str()));
}
return std::nullopt;
}
return tomlToJson(tbl).toObject();
}
QStringList stringArray(const QJsonValue &v)
{
QStringList out;
if (!v.isArray()) return out;
for (const auto &elem : v.toArray()) {
if (elem.isString()) out.append(elem.toString());
}
return out;
}
AgentConfig configFromMerged(const QJsonObject &obj)
{
AgentConfig cfg;
cfg.schemaVersion = obj.value("schema_version").toInt(1);
cfg.name = obj.value("name").toString();
cfg.description = obj.value("description").toString();
cfg.providerInstance = obj.value("provider_instance").toString();
cfg.model = obj.value("model").toString();
cfg.endpoint = obj.value("endpoint").toString();
cfg.role = obj.value("role").toString();
cfg.context = obj.value("context").toString();
cfg.enableThinking = obj.value("enable_thinking").toBool(false);
cfg.enableTools = obj.value("enable_tools").toBool(false);
cfg.tags = stringArray(obj.value("tags"));
const QJsonObject matchObj = obj.value("match").toObject();
cfg.match.filePatterns = stringArray(matchObj.value("file_patterns"));
cfg.match.pathPatterns = stringArray(matchObj.value("path_patterns"));
cfg.match.projectNames = stringArray(matchObj.value("project_names"));
cfg.extendsName = obj.value("extends").toString();
cfg.abstract = obj.value("abstract").toBool(false);
cfg.hidden = obj.value("hidden").toBool(false);
const QJsonObject tpl = obj.value("template").toObject();
cfg.messageFormat = tpl.value("message_format").toString();
cfg.sampling = tpl.value("sampling").toObject();
cfg.thinking = tpl.value("thinking").toObject();
return cfg;
}
struct RawEntry
{
QJsonObject obj;
QString filePath;
bool overridesBundled = false;
};
constexpr int kMaxExtendsDepth = 32;
QJsonObject resolveExtends(
const QString &name,
const QHash<QString, RawEntry> &raw,
QSet<QString> &visiting,
QStringList &errors,
int depth = 0)
{
if (depth > kMaxExtendsDepth) {
errors.append(QStringLiteral("Agent extends chain too deep (>%1) at '%2'")
.arg(kMaxExtendsDepth)
.arg(name));
return {};
}
if (visiting.contains(name)) {
errors.append(QStringLiteral("Cyclic 'extends' involving agent '%1'").arg(name));
return {};
}
if (!raw.contains(name)) {
errors.append(QStringLiteral("Unknown parent agent '%1'").arg(name));
return {};
}
visiting.insert(name);
QJsonObject self = raw.value(name).obj;
const QString parent = self.value("extends").toString();
if (!parent.isEmpty()) {
const QJsonObject parentMerged
= resolveExtends(parent, raw, visiting, errors, depth + 1);
QJsonObject merged = deepMerge(parentMerged, self);
merged["name"] = name;
if (self.contains("abstract"))
merged["abstract"] = self.value("abstract");
else
merged.remove("abstract");
self = merged;
}
visiting.remove(name);
return self;
}
} // namespace
std::optional<AgentConfig> AgentLoader::parseFile(
const QString &path, QString *error, QStringList * /*warnings*/)
{
auto objOpt = parseTomlFile(path, error);
if (!objOpt) return std::nullopt;
AgentConfig cfg = configFromMerged(*objOpt);
cfg.sourcePath = path;
return cfg;
}
AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QString &userDir)
{
LoadResult result;
QHash<QString, RawEntry> raw;
auto scan = [&](const QString &dir, bool isUserLayer) {
if (dir.isEmpty()) return;
QDir d(dir);
if (!d.exists()) return;
const QStringList files = d.entryList({"*.toml"}, QDir::Files);
for (const QString &fname : files) {
const QString fullPath = d.filePath(fname);
QString err;
auto objOpt = parseTomlFile(fullPath, &err);
if (!objOpt) {
result.errors.append(err);
continue;
}
const QString name = objOpt->value("name").toString();
if (name.isEmpty()) {
result.errors.append(QStringLiteral("Agent at %1 has no 'name'").arg(fullPath));
continue;
}
const bool overrides = isUserLayer && raw.contains(name);
raw.insert(name, {*objOpt, fullPath, overrides});
}
};
scan(qrcPrefix, /*isUserLayer=*/false);
scan(userDir, /*isUserLayer=*/true);
for (auto it = raw.constBegin(); it != raw.constEnd(); ++it) {
const QString &name = it.key();
QSet<QString> visiting;
const QJsonObject merged = resolveExtends(name, raw, visiting, result.errors);
if (merged.isEmpty()) continue;
AgentConfig cfg = configFromMerged(merged);
cfg.sourcePath = it.value().filePath;
cfg.overridesBundled = it.value().overridesBundled;
if (cfg.abstract) continue;
const QString validation = AgentConfig::validate(cfg);
if (!validation.isEmpty()) {
result.errors.append(validation);
continue;
}
result.configs.push_back(std::move(cfg));
}
std::sort(result.configs.begin(), result.configs.end(),
[](const AgentConfig &a, const AgentConfig &b) { return a.name < b.name; });
return result;
}
} // namespace QodeAssist::Agents

View File

@@ -1,31 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QString>
#include <QStringList>
#include <vector>
#include "AgentConfig.hpp"
namespace QodeAssist::Agents {
class AgentLoader
{
public:
struct LoadResult
{
std::vector<AgentConfig> configs;
QStringList errors;
QStringList warnings;
};
static LoadResult load(const QString &qrcPrefix, const QString &userDir);
static std::optional<AgentConfig> parseFile(
const QString &path, QString *error, QStringList *warnings = nullptr);
};
} // namespace QodeAssist::Agents

View File

@@ -1,86 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentRouter.hpp"
#include <QFileInfo>
#include <QRegularExpression>
#include "AgentFactory.hpp"
namespace QodeAssist::AgentRouter {
namespace {
bool matchesAnyGlob(const QStringList &patterns, const QString &subject)
{
if (subject.isEmpty())
return false;
for (const QString &pat : patterns) {
const QRegularExpression re(
QRegularExpression::anchoredPattern(
QRegularExpression::wildcardToRegularExpression(
pat, QRegularExpression::NonPathWildcardConversion)),
QRegularExpression::CaseInsensitiveOption);
if (re.isValid() && re.match(subject).hasMatch())
return true;
}
return false;
}
bool matchesFilePatterns(const QStringList &patterns, const QString &filePath)
{
if (patterns.isEmpty())
return true;
if (filePath.isEmpty())
return false;
const QString name = QFileInfo(filePath).fileName();
return matchesAnyGlob(patterns, name) || matchesAnyGlob(patterns, filePath);
}
bool matchesPathPatterns(const QStringList &patterns, const QString &filePath)
{
if (patterns.isEmpty())
return true;
if (filePath.isEmpty())
return false;
return matchesAnyGlob(patterns, filePath);
}
bool matchesProjectNames(const QStringList &names, const QString &projectName)
{
if (names.isEmpty())
return true; // dimension unconstrained
if (projectName.isEmpty())
return false;
// Project names are user-facing identifiers, not paths — case
// sensitive comparison matches what ProjectExplorer hands us.
return names.contains(projectName);
}
} // namespace
bool matches(const AgentConfig::Match &m, const Context &ctx)
{
if (m.isEmpty())
return true; // explicit catch-all
return matchesFilePatterns(m.filePatterns, ctx.filePath)
&& matchesPathPatterns(m.pathPatterns, ctx.filePath)
&& matchesProjectNames(m.projectNames, ctx.projectName);
}
QString pickAgent(
const QStringList &roster, const Context &ctx, const AgentFactory &factory)
{
for (const QString &name : roster) {
const AgentConfig *cfg = factory.configByName(name);
if (!cfg)
continue; // stale roster entry — silently skip
if (matches(cfg->match, ctx))
return name;
}
return {};
}
} // namespace QodeAssist::AgentRouter

View File

@@ -1,31 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QString>
#include <QStringList>
#include "AgentConfig.hpp"
namespace QodeAssist {
class AgentFactory;
namespace AgentRouter {
struct Context
{
QString filePath;
QString projectName;
};
[[nodiscard]] bool matches(const AgentConfig::Match &match, const Context &ctx);
[[nodiscard]] QString pickAgent(
const QStringList &roster, const Context &ctx, const AgentFactory &factory);
} // namespace AgentRouter
} // namespace QodeAssist

View File

@@ -1,30 +0,0 @@
add_library(Agents STATIC
AgentConfig.hpp
Agent.hpp Agent.cpp
AgentLoader.hpp AgentLoader.cpp
AgentFactory.hpp AgentFactory.cpp
AgentRouter.hpp AgentRouter.cpp
ContextRenderer.hpp ContextRenderer.cpp
agents.qrc
)
target_link_libraries(Agents
PUBLIC
Qt::Core
Qt::Network
QtCreator::Core
QtCreator::Utils
LLMQore
pantor::inja
ProvidersConfig
Common
Providers
Templates
PRIVATE
QodeAssistLogger
tomlplusplus::tomlplusplus
)
target_include_directories(Agents
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
)

View File

@@ -1,210 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ContextRenderer.hpp"
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStringList>
#include <filesystem>
#include <inja/inja.hpp>
namespace QodeAssist::Templates::ContextRenderer {
namespace {
QString substituteVars(const QString &src, const Bindings &b)
{
QString out = src;
if (!b.projectDir.isEmpty())
out.replace(QStringLiteral("${PROJECT_DIR}"), b.projectDir);
if (!b.homeDir.isEmpty())
out.replace(QStringLiteral("${HOME}"), b.homeDir);
return out;
}
bool isPathAllowed(const QString &requestedPath, const Bindings &b)
{
const QString target = QDir::cleanPath(requestedPath);
auto isUnder = [&target](const QString &root) {
if (root.isEmpty()) return false;
const QString cleanRoot = QDir::cleanPath(root);
if (target == cleanRoot) return true;
return target.startsWith(cleanRoot + QLatin1Char('/'));
};
if (isUnder(b.projectDir)) return true;
if (!b.homeDir.isEmpty() && isUnder(b.homeDir + QStringLiteral("/qodeassist")))
return true;
return false;
}
void registerReadFile(inja::Environment &env, const Bindings &b)
{
const Bindings capturedBindings = b;
env.add_callback("read_file", 1, [capturedBindings](inja::Arguments &args) -> nlohmann::json {
const std::string raw = args.at(0)->get<std::string>();
QString path = QString::fromStdString(raw);
if (!capturedBindings.projectDir.isEmpty())
path.replace(QStringLiteral("${PROJECT_DIR}"), capturedBindings.projectDir);
if (!capturedBindings.homeDir.isEmpty())
path.replace(QStringLiteral("${HOME}"), capturedBindings.homeDir);
if (!isPathAllowed(path, capturedBindings)) {
qWarning("[QodeAssist] context.read_file: path not in allowed roots: %s",
qUtf8Printable(path));
return std::string{};
}
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
return std::string{};
return f.readAll().toStdString();
});
}
QString expandAndResolvePath(const QString &raw, const Bindings &b)
{
QString p = raw;
if (!b.projectDir.isEmpty())
p.replace(QStringLiteral("${PROJECT_DIR}"), b.projectDir);
if (!b.homeDir.isEmpty())
p.replace(QStringLiteral("${HOME}"), b.homeDir);
return p;
}
void registerFileExists(inja::Environment &env, const Bindings &b)
{
const Bindings caps = b;
env.add_callback("file_exists", 1, [caps](inja::Arguments &args) -> nlohmann::json {
const QString p = expandAndResolvePath(
QString::fromStdString(args.at(0)->get<std::string>()), caps);
if (!isPathAllowed(p, caps))
return false;
return QFileInfo::exists(p);
});
}
void registerReadDir(inja::Environment &env, const Bindings &b)
{
const Bindings caps = b;
env.add_callback("read_dir", 1, [caps](inja::Arguments &args) -> nlohmann::json {
const QString p = expandAndResolvePath(
QString::fromStdString(args.at(0)->get<std::string>()), caps);
if (!isPathAllowed(p, caps)) {
qWarning("[QodeAssist] context.read_dir: path not in allowed roots: %s",
qUtf8Printable(p));
return nlohmann::json::array();
}
QDir dir(p);
if (!dir.exists())
return nlohmann::json::array();
nlohmann::json out = nlohmann::json::array();
const QStringList entries = dir.entryList(
QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
for (const QString &name : entries)
out.push_back(name.toStdString());
return out;
});
}
void registerStringHelpers(inja::Environment &env)
{
env.add_callback("head_lines", 2, [](inja::Arguments &args) -> nlohmann::json {
const QString text = QString::fromStdString(args.at(0)->get<std::string>());
const int n = args.at(1)->get<int>();
if (n <= 0)
return std::string{};
const QStringList lines = text.split('\n');
const int take = std::min<int>(n, lines.size());
QStringList head;
head.reserve(take);
for (int i = 0; i < take; ++i)
head.append(lines.at(i));
return head.join('\n').toStdString();
});
env.add_callback("basename", 1, [](inja::Arguments &args) -> nlohmann::json {
return QFileInfo(QString::fromStdString(args.at(0)->get<std::string>()))
.fileName()
.toStdString();
});
env.add_callback("dirname", 1, [](inja::Arguments &args) -> nlohmann::json {
return QFileInfo(QString::fromStdString(args.at(0)->get<std::string>()))
.path()
.toStdString();
});
env.add_callback("ext", 1, [](inja::Arguments &args) -> nlohmann::json {
return QFileInfo(QString::fromStdString(args.at(0)->get<std::string>()))
.suffix()
.toStdString();
});
env.add_callback("lower", 1, [](inja::Arguments &args) -> nlohmann::json {
return QString::fromStdString(args.at(0)->get<std::string>()).toLower().toStdString();
});
env.add_callback("upper", 1, [](inja::Arguments &args) -> nlohmann::json {
return QString::fromStdString(args.at(0)->get<std::string>()).toUpper().toStdString();
});
}
void registerSandbox(inja::Environment &env)
{
env.set_search_included_templates_in_files(false);
env.set_include_callback(
[](const std::filesystem::path &, const std::string &name) -> inja::Template {
throw inja::FileError(
"include is disabled in QodeAssist context: '" + name + "'");
});
env.set_line_statement("@@@inja@@@");
}
} // namespace
QString render(const QString &templateSource, const Bindings &bindings, QString *error)
{
if (templateSource.isEmpty())
return {};
const QString substituted = substituteVars(templateSource, bindings);
inja::Environment env;
registerSandbox(env);
registerReadFile(env, bindings);
registerFileExists(env, bindings);
registerReadDir(env, bindings);
registerStringHelpers(env);
inja::Template tpl;
try {
tpl = env.parse(substituted.toStdString());
} catch (const std::exception &e) {
if (error) {
*error = QStringLiteral("Failed to parse context jinja: %1")
.arg(QString::fromUtf8(e.what()));
}
return {};
}
try {
const std::string rendered = env.render(tpl, nlohmann::json::object());
return QString::fromStdString(rendered);
} catch (const std::exception &e) {
if (error) {
*error = QStringLiteral("Failed to render context jinja: %1")
.arg(QString::fromUtf8(e.what()));
}
return {};
}
}
} // namespace QodeAssist::Templates::ContextRenderer

View File

@@ -1,20 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QString>
namespace QodeAssist::Templates::ContextRenderer {
struct Bindings
{
QString projectDir;
QString homeDir;
};
QString render(const QString &templateSource, const Bindings &bindings,
QString *error = nullptr);
} // namespace QodeAssist::Templates::ContextRenderer

View File

@@ -1,9 +0,0 @@
<RCC>
<qresource prefix="/agents">
<file>ollama_base_chat.toml</file>
<file>ollama_base_fim.toml</file>
<file>ollama_gemma4_e4b_chat.toml</file>
<file>ollama_codellama_7b_code_fim.toml</file>
<file>ollama_codellama_13b_qml_fim.toml</file>
</qresource>
</RCC>

View File

@@ -1,44 +0,0 @@
schema_version = 1
name = "Ollama Base Chat"
description = "Shared base for Ollama /api/chat profiles."
abstract = true
provider_instance = "Ollama (Native)"
endpoint = "/api/chat"
tags = ["ollama", "local"]
[template]
message_format = """
{
"messages": [
{%- if existsIn(ctx, "system_prompt") %}
{
"role": "system",
"content": {{ tojson(ctx.system_prompt) }}
}{% if length(ctx.history) > 0 %},{% endif %}
{%- endif %}
{%- for msg in ctx.history %}
{
"role": {{ tojson(msg.role) }},
"content": {{ tojson(msg.content) }}{% if existsIn(msg, "images") %},
"images": [
{%- for img in msg.images %}
{{ tojson(img.data) }}{% if not loop.is_last %},{% endif %}
{%- endfor %}
]{% endif %}
}{% if not loop.is_last %},{% endif %}
{%- endfor %}
]
}
"""
[template.sampling]
stream = true
[template.sampling.options]
num_predict = 2048
temperature = 0.7
keep_alive = "5m"

View File

@@ -1,32 +0,0 @@
schema_version = 1
name = "Ollama FIM Base"
description = "Shared base for Ollama native FIM (/api/generate) profiles."
abstract = true
provider_instance = "Ollama (Native)"
endpoint = "/api/generate"
tags = ["ollama", "local", "fim"]
[template]
message_format = """
{
"prompt": {{ tojson(ctx.prefix) }},
"suffix": {{ tojson(ctx.suffix) }}
{%- if existsIn(ctx, "system_prompt") %},
"system": {{ tojson(ctx.system_prompt) }}
{%- endif %}
}
"""
[template.sampling]
stream = true
[template.sampling.options]
num_predict = 512
temperature = 0.2
top_p = 0.9
keep_alive = "5m"
stop = ["<EOT>"]

View File

@@ -1,40 +0,0 @@
schema_version = 1
name = "Qt CodeLlama 13B QML FIM"
description = "Local Qt-Company-tuned CodeLlama 13B for QML FIM completion."
provider_instance = "Ollama (Native)"
endpoint = "/api/generate"
model = "theqtcompany/codellama-13b-qml:latest"
tags = ["fim", "ollama", "local", "codellama", "qml", "qt"]
[match]
file_patterns = ["*.qml"]
[template]
message_format = """
{
"prompt": {%- if existsIn(ctx, "suffix") and length(ctx.suffix) > 0 -%}
{{ tojson("<SUF>" + ctx.suffix + "<PRE>" + ctx.prefix + "<MID>") }}
{%- else -%}
{{ tojson("<PRE>" + ctx.prefix + "<MID>") }}
{%- endif %}
{%- if existsIn(ctx, "system_prompt") %},
"system": {{ tojson(ctx.system_prompt) }}
{%- endif %}
}
"""
[template.sampling]
stream = true
[template.sampling.options]
num_predict = 500
temperature = 0
top_p = 1
repeat_penalty = 1.05
keep_alive = "5m"
stop = ["<SUF>", "<PRE>", "</PRE>", "</SUF>", "< EOT >", "\\end", "<MID>", "</MID>", "##"]

View File

@@ -1,34 +0,0 @@
schema_version = 1
name = "CodeLlama 7B Code FIM"
description = "Local CodeLlama 7B (code variant) on Ollama, FIM completion via PRE/SUF/MID markers."
provider_instance = "Ollama (Native)"
endpoint = "/api/generate"
model = "codellama:7b-code"
tags = ["fim", "ollama", "local", "codellama"]
[match]
file_patterns = ["*.cpp", "*.cc", "*.cxx", "*.c", "*.h", "*.hpp", "*.hxx", "*.inl"]
[template]
message_format = """
{
"prompt": {{ tojson("<PRE> " + ctx.prefix + " <SUF>" + ctx.suffix + " <MID>") }}
{%- if existsIn(ctx, "system_prompt") %},
"system": {{ tojson(ctx.system_prompt) }}
{%- endif %}
}
"""
[template.sampling]
stream = true
[template.sampling.options]
num_predict = 512
temperature = 0.2
top_p = 0.9
keep_alive = "5m"
stop = ["<EOT>", "<PRE>", "<SUF>", "<MID>"]

View File

@@ -1,36 +0,0 @@
schema_version = 1
name = "Ollama gemma4:e4b Chat"
extends = "Ollama Base Chat"
description = "Local Gemma 4 E4B on Ollama /api/chat — coding chat assistant."
model = "gemma4:e4b"
role = """
You are a helpful coding assistant integrated into Qt Creator.
Answer concisely. When the user shares code, prefer concrete diffs or
minimal patches over rewriting whole files. Use markdown code blocks
with language tags so the IDE can render them.
"""
enable_thinking = true
enable_tools = true
tags = ["chat", "ollama", "local", "gemma"]
context = """
{%- set readme = read_file("${PROJECT_DIR}/README.md") -%}
{%- if length(readme) > 0 %}
## Project README.md
{{ readme }}
{%- endif %}
"""
[template.sampling.options]
num_predict = 4096
temperature = 1
top_k = 64
top_p = 0.95
num_ctx = 8192

View File

@@ -1,11 +0,0 @@
add_library(Common INTERFACE)
target_sources(Common INTERFACE
ContextData.hpp
)
target_include_directories(Common INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(Common INTERFACE
Qt::Core
)

View File

@@ -1,80 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QJsonObject>
#include <QString>
#include <QVector>
#include <optional>
namespace QodeAssist::Templates {
struct ContentBlockEntry
{
enum class Kind {
Text,
Thinking,
RedactedThinking,
ToolUse,
ToolResult,
Image,
};
Kind kind = Kind::Text;
QString text; // Text
QString thinking; // Thinking
QString signature; // Thinking / RedactedThinking
QString toolUseId; // ToolUse / ToolResult
QString toolName; // ToolUse
QJsonObject toolInput; // ToolUse
QString result; // ToolResult
QString imageData; // Image (base64 or url)
QString mediaType; // Image
bool isImageUrl = false;
bool operator==(const ContentBlockEntry &) const = default;
};
struct Message
{
QString role;
QVector<ContentBlockEntry> blocks;
// Convenience for callers that only need a single text block.
static Message text(const QString &role, const QString &text)
{
Message m;
m.role = role;
ContentBlockEntry e;
e.kind = ContentBlockEntry::Kind::Text;
e.text = text;
m.blocks.append(std::move(e));
return m;
}
bool operator==(const Message &) const = default;
};
struct FileMetadata
{
QString filePath;
QString content;
bool operator==(const FileMetadata &) const = default;
};
struct ContextData
{
std::optional<QString> systemPrompt = std::nullopt;
std::optional<QString> prefix = std::nullopt;
std::optional<QString> suffix = std::nullopt;
std::optional<QString> fileContext = std::nullopt;
std::optional<QVector<Message>> history = std::nullopt;
std::optional<QList<FileMetadata>> filesMetadata = std::nullopt;
bool operator==(const ContextData &) const = default;
};
} // namespace QodeAssist::Templates

View File

@@ -1,22 +0,0 @@
add_library(Providers STATIC
ProviderID.hpp
Provider.hpp Provider.cpp
ProviderFactory.hpp ProviderFactory.cpp
)
target_link_libraries(Providers
PUBLIC
Qt::Core
Qt::Network
QtCreator::Core
QtCreator::Utils
LLMQore
Common
PRIVATE
QodeAssistLogger
)
target_include_directories(Providers
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
PRIVATE ${CMAKE_SOURCE_DIR}/sources/templates
)

View File

@@ -1,87 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "Provider.hpp"
#include "PromptTemplate.hpp"
#include <LLMQore/BaseClient.hpp>
#include <LLMQore/ToolsManager.hpp>
#include <QJsonArray>
#include <QJsonDocument>
#include <Logger.hpp>
namespace QodeAssist::Providers {
Provider::Provider(QObject *parent)
: QObject(parent)
{}
bool Provider::prepareRequest(
QJsonObject &request,
PromptTemplate *prompt,
const ContextData &context,
bool isToolsEnabled,
bool isThinkingEnabled)
{
if (!prompt) {
LOG_MESSAGE(QString("Provider '%1': null template").arg(name()));
return false;
}
if (!prompt->isSupportProvider(providerID())) {
LOG_MESSAGE(QString("Template '%1' doesn't support provider '%2'")
.arg(prompt->name(), name()));
return false;
}
if (!prompt->buildFullRequest(request, context, isThinkingEnabled)) {
LOG_MESSAGE(
QString("Provider '%1': template '%2' failed to build request")
.arg(name(), prompt->name()));
return false;
}
if (isToolsEnabled) {
const auto toolsDefinitions = toolsManager()->getToolsDefinitions();
if (!toolsDefinitions.isEmpty()) {
request["tools"] = toolsDefinitions;
}
}
return true;
}
RequestID Provider::sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
{
auto *c = client();
c->setUrl(url.toString());
c->setApiKey(apiKey());
auto requestId = c->sendMessage(payload, endpoint);
LOG_MESSAGE(
QString("%1: Sending request %2 to %3%4").arg(name(), requestId, url.toString(), endpoint));
LOG_MESSAGE(
QString("%1: Payload:\n%2")
.arg(name(), QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Indented))));
return requestId;
}
void Provider::cancelRequest(const RequestID &requestId)
{
LOG_MESSAGE(QString("%1: Cancelling request %2").arg(name(), requestId));
client()->cancelRequest(requestId);
}
::LLMQore::ToolsManager *Provider::toolsManager() const
{
return client()->tools();
}
} // namespace QodeAssist::Providers

View File

@@ -1,81 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QFlags>
#include <QFuture>
#include <QObject>
#include <QString>
#include <utils/environment.h>
#include "ContextData.hpp"
#include "ProviderID.hpp"
#include "LLMQore/BaseClient.hpp"
namespace LLMQore {
class BaseClient;
class ToolsManager;
}
namespace QodeAssist::Templates {
class PromptTemplate;
}
class QJsonObject;
namespace QodeAssist::Providers {
using Templates::ContextData;
using Templates::PromptTemplate;
using LLMQore::RequestID;
enum class ProviderCapability {
Tools = 0x1,
Thinking = 0x2,
Image = 0x4,
ModelListing = 0x8,
};
Q_DECLARE_FLAGS(ProviderCapabilities, ProviderCapability)
Q_DECLARE_OPERATORS_FOR_FLAGS(ProviderCapabilities)
class Provider : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(Provider)
public:
explicit Provider(QObject *parent = nullptr);
virtual ~Provider() = default;
virtual QString name() const = 0;
virtual QString url() const { return m_url; }
virtual QString apiKey() const { return m_apiKey; }
void setUrl(const QString &url) { m_url = url; }
void setApiKey(const QString &apiKey) { m_apiKey = apiKey; }
[[nodiscard]] virtual bool prepareRequest(
QJsonObject &request,
PromptTemplate *prompt,
const ContextData &context,
bool isToolsEnabled,
bool isThinkingEnabled);
virtual QFuture<QList<QString>> getInstalledModels(const QString &url) = 0;
virtual ProviderID providerID() const = 0;
virtual ProviderCapabilities capabilities() const { return {}; }
virtual ::LLMQore::BaseClient *client() const = 0;
virtual RequestID sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint);
void cancelRequest(const RequestID &requestId);
::LLMQore::ToolsManager *toolsManager() const;
private:
QString m_url;
QString m_apiKey;
};
} // namespace QodeAssist::Providers

View File

@@ -1,44 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ProviderFactory.hpp"
#include <QHash>
namespace QodeAssist::Providers::ProviderFactory {
namespace {
QHash<QString, FactoryFn> &table()
{
static QHash<QString, FactoryFn> t;
return t;
}
} // namespace
void registerType(const QString &name, FactoryFn fn)
{
if (name.isEmpty() || !fn) return;
table().insert(name, std::move(fn));
}
Provider *create(const QString &name, QObject *parent)
{
auto it = table().constFind(name);
if (it == table().constEnd()) return nullptr;
return it.value()(parent);
}
QStringList knownNames()
{
return table().keys();
}
void clear()
{
table().clear();
}
} // namespace QodeAssist::Providers::ProviderFactory

View File

@@ -1,26 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QString>
#include <functional>
namespace QodeAssist::Providers {
class Provider;
namespace ProviderFactory {
using FactoryFn = std::function<Provider *(QObject *parent)>;
void registerType(const QString &name, FactoryFn fn);
Provider *create(const QString &name, QObject *parent);
QStringList knownNames();
void clear(); // for tests / shutdown
} // namespace ProviderFactory
} // namespace QodeAssist::Providers

View File

@@ -1,23 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
namespace QodeAssist::Providers {
enum class ProviderID : int {
Any,
Ollama,
LMStudio,
Claude,
OpenAI,
OpenAICompatible,
OpenAIResponses,
MistralAI,
OpenRouter,
GoogleAI,
LlamaCpp,
};
} // namespace QodeAssist::Providers

View File

@@ -1,27 +0,0 @@
add_library(ProvidersConfig STATIC
ProviderInstance.hpp ProviderInstance.cpp
ProviderInstanceLoader.hpp ProviderInstanceLoader.cpp
ProviderInstanceWriter.hpp ProviderInstanceWriter.cpp
ProviderInstanceFactory.hpp ProviderInstanceFactory.cpp
ProviderSecretsStore.hpp ProviderSecretsStore.cpp
ProviderLauncher.hpp ProviderLauncher.cpp
provider_instances.qrc
)
target_link_libraries(ProvidersConfig
PUBLIC
Qt::Core
Qt::Network
QtCreator::Core
QtCreator::Utils
QtCreator::TerminalLib
PRIVATE
QodeAssistLogger
TomlSerializer
tomlplusplus::tomlplusplus
)
target_include_directories(ProvidersConfig
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
)

View File

@@ -1,52 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ProviderInstance.hpp"
#include <QUrl>
namespace QodeAssist::Providers {
QString ProviderInstance::validate(
const ProviderInstance &inst, const QStringList &knownClientApis)
{
if (inst.name.isEmpty())
return QStringLiteral("Provider instance has no name");
if (inst.clientApi.isEmpty())
return QStringLiteral("Provider instance '%1' has no client_api").arg(inst.name);
if (!knownClientApis.isEmpty() && !knownClientApis.contains(inst.clientApi)) {
return QStringLiteral("Provider instance '%1' references unknown client_api '%2'")
.arg(inst.name, inst.clientApi);
}
if (inst.url.isEmpty())
return QStringLiteral("Provider instance '%1' has no URL").arg(inst.name);
const QUrl parsed(inst.url);
if (!parsed.isValid()
|| (parsed.scheme() != QLatin1StringView{"http"}
&& parsed.scheme() != QLatin1StringView{"https"})) {
return QStringLiteral("Provider instance '%1' has an invalid or unsafe URL: %2")
.arg(inst.name, inst.url);
}
return {};
}
QString ProviderInstance::warnings(const ProviderInstance &inst)
{
const QUrl parsed(inst.url);
if (parsed.scheme() == QLatin1StringView{"http"} && !inst.apiKeyRef.isEmpty()) {
const QString host = parsed.host();
const bool isLoopback = host == QLatin1StringView{"localhost"}
|| host == QLatin1StringView{"127.0.0.1"}
|| host == QLatin1StringView{"::1"};
if (!isLoopback) {
return QStringLiteral(
"URL uses plaintext http:// to '%1' but the provider has an API key. "
"Any request will transmit the key unencrypted — prefer https://.")
.arg(host);
}
}
return {};
}
} // namespace QodeAssist::Providers

View File

@@ -1,58 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <chrono>
#include <QHash>
#include <QJsonObject>
#include <QString>
#include <QStringList>
namespace QodeAssist::Providers {
struct LaunchConfig
{
QString command;
QStringList args;
QString cwd;
QHash<QString, QString> env;
QString readyUrl;
std::chrono::seconds readyTimeout{30};
bool autoStart = false;
bool detach = false;
[[nodiscard]] bool isEmpty() const noexcept { return command.isEmpty(); }
};
struct ProviderInstance
{
QString name;
QString clientApi;
QString description;
QString url;
QString apiKeyRef;
QJsonObject extras;
LaunchConfig launch;
QString extendsName;
bool abstract = false;
QString sourcePath;
bool overridesBundled = false;
[[nodiscard]] bool isUserSource() const
{
return !sourcePath.startsWith(QLatin1StringView{":/"});
}
[[nodiscard]] static QString validate(
const ProviderInstance &inst, const QStringList &knownClientApis);
[[nodiscard]] static QString warnings(const ProviderInstance &inst);
};
} // namespace QodeAssist::Providers

View File

@@ -1,199 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ProviderInstanceFactory.hpp"
#include <QDir>
#include <QFileInfo>
#include <QFileSystemWatcher>
#include <QLoggingCategory>
#include <QSet>
#include <QThread>
#include <QTimer>
#include <coreplugin/icore.h>
#include <algorithm>
#include "ProviderInstanceLoader.hpp"
#include "Logger.hpp"
static inline void initProviderInstancesResource()
{
Q_INIT_RESOURCE(provider_instances);
}
namespace {
Q_LOGGING_CATEGORY(providerInstanceFactoryLog, "qodeassist.providerinstancefactory")
QString instanceQrcPrefix() { return QStringLiteral(":/provider-instances"); }
} // namespace
namespace QodeAssist::Providers {
ProviderInstanceFactory::ProviderInstanceFactory(QObject *parent)
: QObject(parent)
{
::initProviderInstancesResource();
m_watcher = new QFileSystemWatcher(this);
m_reloadDebounce = new QTimer(this);
m_reloadDebounce->setSingleShot(true);
m_reloadDebounce->setInterval(150);
connect(m_reloadDebounce, &QTimer::timeout, this, [this] { reload(); });
auto kick = [this](const QString &) { m_reloadDebounce->start(); };
connect(m_watcher, &QFileSystemWatcher::fileChanged, this, kick);
connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, kick);
reload();
}
ProviderInstanceFactory::~ProviderInstanceFactory() = default;
QString ProviderInstanceFactory::userInstancesDir()
{
return Core::ICore::userResourcePath(
QStringLiteral("qodeassist/config/providers")).toFSPathString();
}
void ProviderInstanceFactory::reload()
{
Q_ASSERT_X(QThread::currentThread() == thread(),
Q_FUNC_INFO, "ProviderInstanceFactory must be used from its owner thread");
clear();
auto result = ProviderInstanceLoader::load(instanceQrcPrefix(), userInstancesDir());
for (const QString &err : result.errors)
LOG_MESSAGE(QString("[ProviderInstances] error: %1").arg(err));
for (const QString &warn : result.warnings)
LOG_MESSAGE(QString("[ProviderInstances] warning: %1").arg(warn));
LOG_MESSAGE(QString("[ProviderInstances] Loaded %1 instances (qrc=%2, user=%3)")
.arg(result.instances.size())
.arg(instanceQrcPrefix(), userInstancesDir()));
for (auto &inst : result.instances) {
LOG_MESSAGE(QString("[ProviderInstances] Loaded: %1 (client_api=%2, url=%3)")
.arg(inst.name, inst.clientApi, inst.url));
m_instances.push_back(std::move(inst));
}
m_errors = std::move(result.errors);
m_warnings = std::move(result.warnings);
rebuildIndexes();
rewatchUserDir();
emit instancesReloaded();
}
void ProviderInstanceFactory::rebuildIndexes()
{
m_nameIndex.clear();
m_instanceNamesCache.clear();
m_knownClientApisCache.clear();
m_nameIndex.reserve(static_cast<qsizetype>(m_instances.size()));
m_instanceNamesCache.reserve(static_cast<qsizetype>(m_instances.size()));
std::sort(m_instances.begin(), m_instances.end(),
[](const ProviderInstance &a, const ProviderInstance &b) {
return a.name.compare(b.name, Qt::CaseInsensitive) < 0;
});
QSet<QString> seenApis;
for (qsizetype i = 0; i < static_cast<qsizetype>(m_instances.size()); ++i) {
const ProviderInstance &inst = m_instances[i];
m_nameIndex.insert(inst.name.toCaseFolded(), i);
m_instanceNamesCache.append(inst.name);
if (!seenApis.contains(inst.clientApi)) {
seenApis.insert(inst.clientApi);
m_knownClientApisCache.append(inst.clientApi);
}
}
std::sort(m_knownClientApisCache.begin(), m_knownClientApisCache.end(),
[](const QString &a, const QString &b) {
return a.compare(b, Qt::CaseInsensitive) < 0;
});
}
void ProviderInstanceFactory::rewatchUserDir()
{
if (!m_watcher)
return;
const QStringList stale = m_watcher->files() + m_watcher->directories();
if (!stale.isEmpty())
m_watcher->removePaths(stale);
const QString userDir = userInstancesDir();
QDir().mkpath(userDir);
m_watcher->addPath(userDir);
QDir d(userDir);
for (const QFileInfo &fi : d.entryInfoList({"*.toml"}, QDir::Files))
m_watcher->addPath(fi.absoluteFilePath());
}
void ProviderInstanceFactory::registerInstance(ProviderInstance instance)
{
Q_ASSERT_X(QThread::currentThread() == thread(),
Q_FUNC_INFO, "ProviderInstanceFactory must be used from its owner thread");
const QString validation = ProviderInstance::validate(instance, knownClientApis());
if (!validation.isEmpty()) {
qCWarning(providerInstanceFactoryLog).noquote()
<< "Refusing to register provider instance:" << validation;
return;
}
const QString name = instance.name;
for (auto &existing : m_instances) {
if (existing.name == name) {
existing = std::move(instance);
emit instanceChanged(name);
return;
}
}
m_instances.push_back(std::move(instance));
rebuildIndexes();
emit instanceChanged(name);
}
const ProviderInstance *ProviderInstanceFactory::instanceByName(const QString &name) const
{
const auto it = m_nameIndex.constFind(name.toCaseFolded());
if (it == m_nameIndex.constEnd())
return nullptr;
return &m_instances[it.value()];
}
QStringList ProviderInstanceFactory::instanceNames() const
{
return m_instanceNamesCache;
}
QStringList ProviderInstanceFactory::instanceNamesForClientApi(const QString &clientApi) const
{
QStringList out;
for (const auto &inst : m_instances) {
if (inst.clientApi == clientApi)
out.append(inst.name);
}
return out;
}
QStringList ProviderInstanceFactory::knownClientApis() const
{
return m_knownClientApisCache;
}
void ProviderInstanceFactory::clear()
{
Q_ASSERT_X(QThread::currentThread() == thread(),
Q_FUNC_INFO, "ProviderInstanceFactory must be used from its owner thread");
m_instances.clear();
m_nameIndex.clear();
m_instanceNamesCache.clear();
m_knownClientApisCache.clear();
m_errors.clear();
m_warnings.clear();
}
} // namespace QodeAssist::Providers

View File

@@ -1,68 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <vector>
#include <QHash>
#include <QObject>
#include <QString>
#include <QStringList>
#include "ProviderInstance.hpp"
class QFileSystemWatcher;
class QTimer;
namespace QodeAssist::Providers {
class ProviderInstanceFactory : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(ProviderInstanceFactory)
public:
explicit ProviderInstanceFactory(QObject *parent = nullptr);
~ProviderInstanceFactory() override;
void reload();
[[nodiscard]] static QString userInstancesDir();
[[nodiscard]] const ProviderInstance *instanceByName(const QString &name) const;
[[nodiscard]] QStringList instanceNames() const;
[[nodiscard]] QStringList instanceNamesForClientApi(const QString &clientApi) const;
[[nodiscard]] QStringList knownClientApis() const;
[[nodiscard]] const std::vector<ProviderInstance> &instances() const noexcept
{
return m_instances;
}
[[nodiscard]] QStringList lastLoadErrors() const { return m_errors; }
[[nodiscard]] QStringList lastLoadWarnings() const { return m_warnings; }
void registerInstance(ProviderInstance instance);
void clear();
signals:
void instanceChanged(const QString &name);
void instancesReloaded();
private:
void rewatchUserDir();
void rebuildIndexes();
std::vector<ProviderInstance> m_instances;
QHash<QString, qsizetype> m_nameIndex;
QStringList m_instanceNamesCache;
QStringList m_knownClientApisCache;
QStringList m_errors;
QStringList m_warnings;
QFileSystemWatcher *m_watcher = nullptr;
QTimer *m_reloadDebounce = nullptr;
};
} // namespace QodeAssist::Providers

View File

@@ -1,271 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ProviderInstanceLoader.hpp"
#include <QDir>
#include <QFile>
#include <QHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#include <toml++/toml.hpp>
#include <algorithm>
#include <sstream>
namespace QodeAssist::Providers {
namespace {
QJsonValue tomlToJson(const toml::node &node)
{
if (auto *table = node.as_table()) {
QJsonObject obj;
for (const auto &[key, value] : *table) {
obj.insert(QString::fromStdString(std::string{key.str()}), tomlToJson(value));
}
return obj;
}
if (auto *array = node.as_array()) {
QJsonArray arr;
for (const auto &item : *array)
arr.append(tomlToJson(item));
return arr;
}
if (auto *str = node.as_string())
return QString::fromStdString(str->get());
if (auto *integer = node.as_integer())
return static_cast<qint64>(integer->get());
if (auto *floating = node.as_floating_point())
return floating->get();
if (auto *boolean = node.as_boolean())
return boolean->get();
return QJsonValue::Null;
}
QJsonObject deepMerge(const QJsonObject &base, const QJsonObject &overlay)
{
QJsonObject result = base;
for (auto it = overlay.constBegin(); it != overlay.constEnd(); ++it) {
const QJsonValue baseVal = result.value(it.key());
const QJsonValue overlayVal = it.value();
if (baseVal.isObject() && overlayVal.isObject())
result[it.key()] = deepMerge(baseVal.toObject(), overlayVal.toObject());
else
result[it.key()] = overlayVal;
}
return result;
}
QString readUtf8(const QString &path, QString *error)
{
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
if (error)
*error = QStringLiteral("Cannot open: %1").arg(path);
return {};
}
return QString::fromUtf8(f.readAll());
}
std::optional<QJsonObject> parseTomlFile(const QString &path, QString *error)
{
QString readErr;
const QString contents = readUtf8(path, &readErr);
if (!readErr.isEmpty()) {
if (error)
*error = readErr;
return std::nullopt;
}
toml::table tbl;
try {
tbl = toml::parse(contents.toStdString(), path.toStdString());
} catch (const toml::parse_error &e) {
std::ostringstream oss;
oss << e;
if (error) {
*error = QStringLiteral("TOML parse error in %1: %2")
.arg(path, QString::fromStdString(oss.str()));
}
return std::nullopt;
}
return tomlToJson(tbl).toObject();
}
QStringList stringArray(const QJsonValue &v)
{
QStringList out;
if (!v.isArray())
return out;
for (const auto &elem : v.toArray()) {
if (elem.isString())
out.append(elem.toString());
}
return out;
}
LaunchConfig launchConfigFromObject(const QJsonObject &launchObj)
{
LaunchConfig l;
if (launchObj.isEmpty())
return l;
l.command = launchObj.value("command").toString();
l.args = stringArray(launchObj.value("args"));
l.cwd = launchObj.value("cwd").toString();
const QJsonObject envObj = launchObj.value("env").toObject();
for (auto it = envObj.constBegin(); it != envObj.constEnd(); ++it) {
if (it.value().isString())
l.env.insert(it.key(), it.value().toString());
}
l.readyUrl = launchObj.value("ready_url").toString();
if (launchObj.contains("ready_timeout_s")) {
const int raw = launchObj.value("ready_timeout_s")
.toInt(static_cast<int>(l.readyTimeout.count()));
l.readyTimeout = std::chrono::seconds{std::max(1, raw)};
}
l.autoStart = launchObj.value("auto_start").toBool(false);
l.detach = launchObj.value("detach").toBool(false);
return l;
}
ProviderInstance instanceFromMerged(const QJsonObject &obj)
{
ProviderInstance inst;
inst.name = obj.value("name").toString();
inst.clientApi = obj.value("client_api").toString();
inst.description = obj.value("description").toString();
inst.url = obj.value("url").toString();
inst.apiKeyRef = obj.value("api_key_ref").toString();
inst.extras = obj.value("extras").toObject();
inst.launch = launchConfigFromObject(obj.value("launch").toObject());
inst.extendsName = obj.value("extends").toString();
inst.abstract = obj.value("abstract").toBool(false);
return inst;
}
struct RawEntry
{
QJsonObject obj;
QString filePath;
bool overridesBundled = false;
};
constexpr int kMaxExtendsDepth = 16;
QJsonObject resolveExtends(
const QString &name,
const QHash<QString, RawEntry> &raw,
QSet<QString> &visiting,
QStringList &errors,
int depth = 0)
{
if (depth > kMaxExtendsDepth) {
errors.append(QStringLiteral("Provider instance extends chain too deep (>%1) at '%2'")
.arg(kMaxExtendsDepth)
.arg(name));
return {};
}
if (visiting.contains(name)) {
errors.append(QStringLiteral("Cyclic 'extends' involving provider instance '%1'").arg(name));
return {};
}
if (!raw.contains(name)) {
errors.append(QStringLiteral("Unknown parent provider instance '%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);
self.remove("extends");
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<ProviderInstance> ProviderInstanceLoader::parseFile(
const QString &path, QString *error)
{
auto objOpt = parseTomlFile(path, error);
if (!objOpt)
return std::nullopt;
ProviderInstance inst = instanceFromMerged(*objOpt);
inst.sourcePath = path;
return inst;
}
ProviderInstanceLoader::LoadResult ProviderInstanceLoader::load(
const QString &qrcPrefix, const QString &userDir)
{
LoadResult result;
QHash<QString, RawEntry> raw;
auto scan = [&](const QString &dir, bool isUserLayer) {
if (dir.isEmpty())
return;
QDir d(dir);
if (!d.exists())
return;
const QStringList files = d.entryList({"*.toml"}, QDir::Files);
for (const QString &fname : files) {
const QString fullPath = d.filePath(fname);
QString err;
auto objOpt = parseTomlFile(fullPath, &err);
if (!objOpt) {
result.errors.append(err);
continue;
}
const QString name = objOpt->value("name").toString();
if (name.isEmpty()) {
result.errors.append(
QStringLiteral("Provider instance at %1 has no 'name'").arg(fullPath));
continue;
}
const bool overrides = isUserLayer && raw.contains(name);
raw.insert(name, {*objOpt, fullPath, overrides});
}
};
scan(qrcPrefix, /*isUserLayer=*/false);
scan(userDir, /*isUserLayer=*/true);
for (auto it = raw.constBegin(); it != raw.constEnd(); ++it) {
const QString &name = it.key();
QSet<QString> visiting;
const QJsonObject merged = resolveExtends(name, raw, visiting, result.errors);
if (merged.isEmpty())
continue;
ProviderInstance inst = instanceFromMerged(merged);
inst.sourcePath = it.value().filePath;
inst.overridesBundled = it.value().overridesBundled;
if (inst.abstract)
continue;
result.instances.push_back(std::move(inst));
}
std::sort(result.instances.begin(), result.instances.end(),
[](const ProviderInstance &a, const ProviderInstance &b) {
return a.name.compare(b.name, Qt::CaseInsensitive) < 0;
});
return result;
}
} // namespace QodeAssist::Providers

View File

@@ -1,33 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <optional>
#include <vector>
#include <QString>
#include <QStringList>
#include "ProviderInstance.hpp"
namespace QodeAssist::Providers {
class ProviderInstanceLoader
{
public:
struct LoadResult
{
std::vector<ProviderInstance> instances;
QStringList errors;
QStringList warnings;
};
static LoadResult load(const QString &qrcPrefix, const QString &userDir);
static std::optional<ProviderInstance> parseFile(
const QString &path, QString *error);
};
} // namespace QodeAssist::Providers

View File

@@ -1,166 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ProviderInstanceWriter.hpp"
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QSet>
#include "ProviderInstanceFactory.hpp"
#include "TomlWriter.hpp"
namespace QodeAssist::Providers {
namespace {
struct Tr
{
Q_DECLARE_TR_FUNCTIONS(QtC::QodeAssist)
};
constexpr int kIdentityKeyColumn = 11; // longest key: "description"
constexpr int kLaunchKeyColumn = 15; // longest key: "ready_timeout_s"
void writeIdentityBlock(TomlSerializer::TomlWriter &w, const ProviderInstance &inst)
{
w.setKeyColumnWidth(0);
w.writeInt(QStringLiteral("schema_version"), 1);
w.writeBlankLine();
w.setKeyColumnWidth(kIdentityKeyColumn);
w.writeString(QStringLiteral("name"), inst.name);
w.writeString(QStringLiteral("client_api"), inst.clientApi);
if (!inst.description.isEmpty())
w.writeString(QStringLiteral("description"), inst.description);
w.writeBlankLine();
w.writeString(QStringLiteral("url"), inst.url);
if (!inst.apiKeyRef.isEmpty())
w.writeString(QStringLiteral("api_key_ref"), inst.apiKeyRef);
}
void writeExtrasBlock(TomlSerializer::TomlWriter &w, const QJsonObject &extras)
{
w.writeBlankLine();
w.writeTableHeader(QStringLiteral("extras"));
w.setKeyColumnWidth(0);
w.writeJsonPrimitives(extras);
}
void writeLaunchBlock(TomlSerializer::TomlWriter &w, const LaunchConfig &l)
{
w.writeBlankLine();
w.writeTableHeader(QStringLiteral("launch"));
w.setKeyColumnWidth(kLaunchKeyColumn);
w.writeString(QStringLiteral("command"), l.command);
if (!l.args.isEmpty())
w.writeStringArray(QStringLiteral("args"), l.args);
if (!l.cwd.isEmpty())
w.writeString(QStringLiteral("cwd"), l.cwd);
if (!l.readyUrl.isEmpty())
w.writeString(QStringLiteral("ready_url"), l.readyUrl);
w.writeInt(QStringLiteral("ready_timeout_s"), l.readyTimeout.count());
w.writeBool(QStringLiteral("auto_start"), l.autoStart);
w.writeBool(QStringLiteral("detach"), l.detach);
if (!l.env.isEmpty()) {
w.writeBlankLine();
w.writeTableHeader(QStringLiteral("launch.env"));
w.setKeyColumnWidth(0);
w.writeStringDict(l.env);
}
}
} // namespace
QString ProviderInstanceWriter::toToml(const ProviderInstance &inst)
{
TomlSerializer::TomlWriter w;
writeIdentityBlock(w, inst);
if (!inst.extras.isEmpty())
writeExtrasBlock(w, inst.extras);
if (!inst.launch.isEmpty())
writeLaunchBlock(w, inst.launch);
return w.result();
}
QString ProviderInstanceWriter::deriveBaseName(const QString &name)
{
QString baseName;
for (QChar c : name) {
if (c.isLetterOrNumber())
baseName.append(c.toLower());
else if (c == QLatin1Char(' ') || c == QLatin1Char('-') || c == QLatin1Char('_'))
baseName.append(QLatin1Char('_'));
}
while (baseName.startsWith(QLatin1Char('_')))
baseName.remove(0, 1);
while (baseName.endsWith(QLatin1Char('_')))
baseName.chop(1);
if (baseName.isEmpty())
baseName = QStringLiteral("instance");
return baseName;
}
namespace {
constexpr int kMaxCollisionRetries = 1000;
} // namespace
QString ProviderInstanceWriter::pickUserFilePath(
const QString &userDir, const QString &name, const QString &previousPath)
{
const QDir dir(userDir);
const QString base = deriveBaseName(name);
const QString preferred = dir.filePath(base + QLatin1String(".toml"));
if (!previousPath.isEmpty()
&& QFileInfo(previousPath).absolutePath() == dir.absolutePath()
&& QFileInfo(previousPath).absoluteFilePath() == QFileInfo(preferred).absoluteFilePath())
return preferred;
QSet<QString> taken;
for (const QString &existing : dir.entryList({"*.toml"}, QDir::Files))
taken.insert(existing);
if (!taken.contains(base + QLatin1String(".toml")))
return preferred;
for (int i = 2; i < kMaxCollisionRetries; ++i) {
const QString candidate = QStringLiteral("%1_%2.toml").arg(base).arg(i);
if (!taken.contains(candidate))
return dir.filePath(candidate);
}
return {};
}
QString ProviderInstanceWriter::writeToUserDir(
const ProviderInstance &inst, const QString &previousPath, QString *errorOut)
{
const QString userDir = ProviderInstanceFactory::userInstancesDir();
if (!QDir().mkpath(userDir)) {
if (errorOut)
*errorOut = Tr::tr("Cannot create user provider folder:\n%1").arg(userDir);
return {};
}
const QString filePath = pickUserFilePath(userDir, inst.name, previousPath);
if (filePath.isEmpty()) {
if (errorOut)
*errorOut = Tr::tr("Cannot pick a free filename in:\n%1").arg(userDir);
return {};
}
QSaveFile f(filePath);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
if (errorOut)
*errorOut = Tr::tr("Cannot write %1:\n%2").arg(filePath, f.errorString());
return {};
}
const QByteArray bytes = toToml(inst).toUtf8();
if (f.write(bytes) != bytes.size() || !f.commit()) {
if (errorOut)
*errorOut = Tr::tr("Write failed for %1:\n%2").arg(filePath, f.errorString());
return {};
}
return filePath;
}
} // namespace QodeAssist::Providers

View File

@@ -1,28 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QString>
#include "ProviderInstance.hpp"
namespace QodeAssist::Providers {
class ProviderInstanceWriter
{
public:
[[nodiscard]] static QString toToml(const ProviderInstance &inst);
[[nodiscard]] static QString writeToUserDir(
const ProviderInstance &inst,
const QString &previousPath,
QString *errorOut = nullptr);
[[nodiscard]] static QString pickUserFilePath(
const QString &userDir,
const QString &name,
const QString &previousPath);
[[nodiscard]] static QString deriveBaseName(const QString &name);
};
} // namespace QodeAssist::Providers

View File

@@ -1,638 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ProviderLauncher.hpp"
#include <QDir>
#include <QElapsedTimer>
#include <QLoggingCategory>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSslError>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTimer>
#include <QUrl>
#include <utils/commandline.h>
#include <utils/environment.h>
#include <utils/filepath.h>
#include <utils/processinterface.h>
#include <utils/qtcprocess.h>
#include "Logger.hpp"
#include <algorithm>
#include <chrono>
#ifdef Q_OS_WIN
#include <windows.h>
#else
#include <signal.h>
#endif
namespace QodeAssist::Providers {
namespace {
Q_LOGGING_CATEGORY(launcherLog, "qodeassist.providerlauncher")
constexpr std::chrono::milliseconds kProbeInterval{500};
constexpr std::chrono::milliseconds kProbeTransferTimeout{2000};
constexpr std::chrono::milliseconds kAdoptionTransferTimeout{1500};
constexpr std::chrono::milliseconds kStartTimeout{2000};
constexpr int kScrollbackBytesMax = 1 * 1024 * 1024; // 1 MiB cap per slot
} // namespace
struct ProviderLauncher::Slot
{
QString name;
LaunchConfig cfg;
State state = Idle;
Utils::Process *process = nullptr;
qint64 detachedPid = 0;
bool adoptedExternal = false;
bool started = false;
QTimer *probeTimer = nullptr;
QTimer *startTimer = nullptr; // fail-fast timer for QProcess::started
QElapsedTimer probeStart;
QPointer<QNetworkReply> probeReply;
QList<QPointer<QNetworkReply>> oneShotProbes;
int generation = 0;
QString lastError;
QByteArray scrollback;
};
ProviderLauncher::ProviderLauncher(QObject *parent)
: QObject(parent)
, m_nam(new QNetworkAccessManager(this))
{
connect(m_nam, &QNetworkAccessManager::sslErrors, this,
[this](QNetworkReply *reply, const QList<QSslError> &errors) {
QStringList msgs;
msgs.reserve(errors.size());
for (const QSslError &e : errors)
msgs.append(e.errorString());
qCWarning(launcherLog).noquote()
<< "SSL errors on probe to" << reply->url().toString() << ":"
<< msgs.join(QStringLiteral("; "));
});
}
ProviderLauncher::~ProviderLauncher()
{
m_nam->disconnect(this);
for (Slot *slot : m_slots) {
if (slot->cfg.detach) {
if (slot->probeTimer) {
slot->probeTimer->stop();
slot->probeTimer->deleteLater();
slot->probeTimer = nullptr;
}
if (slot->probeReply) {
slot->probeReply->abort();
slot->probeReply->deleteLater();
slot->probeReply.clear();
}
} else {
teardownSlot(slot);
}
delete slot;
}
m_slots.clear();
}
void ProviderLauncher::start(const QString &instanceName, const LaunchConfig &cfg)
{
if (instanceName.isEmpty() || cfg.isEmpty())
return;
Slot *slot = m_slots.value(instanceName, nullptr);
if (slot) {
if (slot->state == Starting || slot->state == Probing || slot->state == Ready) {
slot->cfg = cfg;
return;
}
teardownSlot(slot);
} else {
slot = new Slot;
slot->name = instanceName;
m_slots.insert(instanceName, slot);
}
slot->cfg = cfg;
slot->scrollback.clear();
slot->lastError.clear();
slot->detachedPid = 0;
slot->adoptedExternal = false;
slot->started = false;
++slot->generation;
const int gen = slot->generation;
if (!cfg.readyUrl.isEmpty()) {
changeState(slot, Starting);
const QString name = instanceName;
const QString readyUrl = cfg.readyUrl;
probeOnceAsync(slot, gen, readyUrl, [this, name, readyUrl](bool ok) {
Slot *s = m_slots.value(name, nullptr);
if (!s || s->state != Starting)
return;
if (ok) {
s->adoptedExternal = true;
s->detachedPid = 0;
appendLog(s, QStringLiteral(
"[adopt] %1 is already up — reusing the running process (no pid).")
.arg(readyUrl));
changeState(s, Ready);
return;
}
if (s->cfg.detach)
launchDetached(s);
else
launchProcess(s);
});
return;
}
if (cfg.detach)
launchDetached(slot);
else
launchProcess(slot);
}
void ProviderLauncher::stop(const QString &instanceName)
{
Slot *slot = m_slots.value(instanceName, nullptr);
if (!slot)
return;
if (slot->state == Idle || slot->state == Failed) {
changeState(slot, Idle);
return;
}
changeState(slot, Stopping);
if (slot->cfg.detach) {
const qint64 pid = slot->detachedPid;
const QString readyUrl = slot->cfg.readyUrl;
if (slot->probeTimer) {
slot->probeTimer->stop();
slot->probeTimer->deleteLater();
slot->probeTimer = nullptr;
}
if (slot->probeReply) {
slot->probeReply->abort();
slot->probeReply->deleteLater();
slot->probeReply.clear();
}
slot->detachedPid = 0;
slot->adoptedExternal = false;
if (pid <= 0) {
appendLog(slot, QStringLiteral(
"[stop] no pid recorded (process was adopted via probe) — "
"cannot terminate from the plugin; kill manually if needed."));
changeState(slot, Idle);
return;
}
if (readyUrl.isEmpty()) {
appendLog(slot, QStringLiteral("[stop] SIGTERM pid=%1").arg(pid));
killByPid(pid);
changeState(slot, Idle);
return;
}
const QString name = instanceName;
++slot->generation;
const int gen = slot->generation;
probeOnceAsync(slot, gen, readyUrl, [this, name, pid](bool stillUp) {
Slot *s = m_slots.value(name, nullptr);
if (!s)
return;
if (stillUp) {
appendLog(s, QStringLiteral("[stop] SIGTERM pid=%1").arg(pid));
killByPid(pid);
} else {
appendLog(s, QStringLiteral(
"[stop] pid=%1 no longer responsive on ready_url — "
"skipping kill to avoid hitting a reused PID.").arg(pid));
}
if (s->state == Stopping)
changeState(s, Idle);
});
return;
}
teardownSlot(slot);
changeState(slot, Idle);
}
void ProviderLauncher::restart(const QString &instanceName, const LaunchConfig &cfg)
{
stop(instanceName);
start(instanceName, cfg);
}
ProviderLauncher::State ProviderLauncher::state(const QString &instanceName) const
{
const Slot *slot = m_slots.value(instanceName, nullptr);
return slot ? slot->state : Idle;
}
bool ProviderLauncher::isReady(const QString &instanceName) const
{
return state(instanceName) == Ready;
}
QString ProviderLauncher::lastError(const QString &instanceName) const
{
const Slot *slot = m_slots.value(instanceName, nullptr);
return slot ? slot->lastError : QString{};
}
QByteArray ProviderLauncher::scrollback(const QString &instanceName) const
{
const Slot *slot = m_slots.value(instanceName, nullptr);
return slot ? slot->scrollback : QByteArray{};
}
QStringList ProviderLauncher::activeInstances() const
{
QStringList out;
for (auto it = m_slots.constBegin(); it != m_slots.constEnd(); ++it) {
if (it.value()->state != Idle)
out.append(it.key());
}
std::sort(out.begin(), out.end(),
[](const QString &a, const QString &b) {
return a.compare(b, Qt::CaseInsensitive) < 0;
});
return out;
}
void ProviderLauncher::launchProcess(Slot *slot)
{
const LaunchConfig &cfg = slot->cfg;
const QString command = expandVars(cfg.command, slot);
const QStringList args = expandVars(cfg.args, slot);
const QString cwd = cfg.cwd.isEmpty() ? QDir::homePath() : expandVars(cfg.cwd, slot);
const QString name = slot->name;
auto *proc = new Utils::Process(this);
slot->process = proc;
Utils::Environment env = Utils::Environment::systemEnvironment();
env.set(QStringLiteral("PROVIDER_NAME"), slot->name);
for (auto it = cfg.env.constBegin(); it != cfg.env.constEnd(); ++it)
env.set(it.key(), it.value());
proc->setEnvironment(env);
proc->setWorkingDirectory(Utils::FilePath::fromString(cwd));
proc->setCommand(Utils::CommandLine{Utils::FilePath::fromString(command), args});
proc->setPtyData(Utils::Pty::Data{});
connect(proc, &Utils::Process::readyReadStandardOutput, this, [this, name] {
Slot *s = m_slots.value(name, nullptr);
if (!s || !s->process) return;
const QByteArray chunk = s->process->readAllRawStandardOutput();
if (!chunk.isEmpty()) {
appendScrollback(s, chunk);
emit bytesReceived(s->name, chunk);
}
});
connect(proc, &Utils::Process::readyReadStandardError, this, [this, name] {
Slot *s = m_slots.value(name, nullptr);
if (!s || !s->process) return;
const QByteArray chunk = s->process->readAllRawStandardError();
if (!chunk.isEmpty()) {
appendScrollback(s, chunk);
emit bytesReceived(s->name, chunk);
}
});
connect(proc, &Utils::Process::started, this, [this, name] {
Slot *s = m_slots.value(name, nullptr);
if (!s)
return;
s->started = true;
if (s->startTimer) {
s->startTimer->stop();
s->startTimer->deleteLater();
s->startTimer = nullptr;
}
if (s->state != Starting)
return;
if (s->cfg.readyUrl.isEmpty()) {
changeState(s, Ready);
return;
}
s->probeStart.start();
changeState(s, Probing);
scheduleReadyProbe(s);
});
connect(proc, &Utils::Process::done, this, [this, name] {
Slot *s = m_slots.value(name, nullptr);
if (!s || !s->process) return;
const QByteArray tailOut = s->process->readAllRawStandardOutput();
const QByteArray tailErr = s->process->readAllRawStandardError();
if (!tailOut.isEmpty()) { appendScrollback(s, tailOut); emit bytesReceived(s->name, tailOut); }
if (!tailErr.isEmpty()) { appendScrollback(s, tailErr); emit bytesReceived(s->name, tailErr); }
const int code = s->process->exitCode();
const QProcess::ExitStatus status = s->process->exitStatus();
appendLog(s, QStringLiteral("[exit] code=%1 status=%2")
.arg(code)
.arg(status == QProcess::NormalExit ? "normal" : "crashed"));
const State prev = s->state;
teardownSlot(s);
if (prev != Stopping && code != 0) {
s->lastError = QStringLiteral("Process exited (code %1)").arg(code);
changeState(s, Failed);
} else {
changeState(s, Idle);
}
});
appendLog(slot, QStringLiteral("[spawn] %1 %2")
.arg(command, args.join(QLatin1Char(' '))));
changeState(slot, Starting);
proc->start();
if (slot->startTimer) {
slot->startTimer->stop();
slot->startTimer->deleteLater();
}
slot->startTimer = new QTimer(this);
slot->startTimer->setSingleShot(true);
const QString slotName = slot->name;
connect(slot->startTimer, &QTimer::timeout, this, [this, slotName] {
Slot *s = m_slots.value(slotName, nullptr);
if (!s || s->started || s->state != Starting)
return;
s->lastError = s->process && !s->process->errorString().isEmpty()
? s->process->errorString()
: QStringLiteral("Process failed to start");
appendLog(s, QStringLiteral("[error] %1").arg(s->lastError));
teardownSlot(s);
changeState(s, Failed);
});
slot->startTimer->start(kStartTimeout);
}
void ProviderLauncher::launchDetached(Slot *slot)
{
const LaunchConfig &cfg = slot->cfg;
const QString command = expandVars(cfg.command, slot);
const QStringList args = expandVars(cfg.args, slot);
const QString cwd = cfg.cwd.isEmpty() ? QDir::homePath() : expandVars(cfg.cwd, slot);
appendLog(slot, QStringLiteral("[spawn-detached] %1 %2")
.arg(command, args.join(QLatin1Char(' '))));
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("PROVIDER_NAME"), slot->name);
for (auto it = cfg.env.constBegin(); it != cfg.env.constEnd(); ++it)
env.insert(it.key(), it.value());
QProcess tmp;
tmp.setProgram(command);
tmp.setArguments(args);
tmp.setWorkingDirectory(cwd);
tmp.setProcessEnvironment(env);
tmp.setStandardOutputFile(QProcess::nullDevice());
tmp.setStandardErrorFile(QProcess::nullDevice());
qint64 pid = 0;
const bool ok = tmp.startDetached(&pid);
if (!ok || pid <= 0) {
slot->lastError = tmp.errorString().isEmpty()
? QStringLiteral("Detached spawn failed")
: tmp.errorString();
appendLog(slot, QStringLiteral("[error] %1").arg(slot->lastError));
changeState(slot, Failed);
return;
}
slot->detachedPid = pid;
appendLog(slot, QStringLiteral("[detached] pid=%1 (stdout/stderr discarded)").arg(pid));
if (cfg.readyUrl.isEmpty()) {
changeState(slot, Ready);
return;
}
slot->probeStart.start();
changeState(slot, Probing);
scheduleReadyProbe(slot);
}
void ProviderLauncher::probeOnceAsync(
Slot *slot, int expectedGeneration, const QString &url,
std::function<void(bool)> onResult)
{
QNetworkRequest req(QUrl{url});
req.setTransferTimeout(kAdoptionTransferTimeout);
QNetworkReply *reply = m_nam->get(req);
if (slot)
slot->oneShotProbes.append(QPointer<QNetworkReply>(reply));
const QString name = slot ? slot->name : QString{};
connect(reply, &QNetworkReply::finished, this,
[this, reply, name, expectedGeneration, cb = std::move(onResult)] {
reply->deleteLater();
Slot *s = m_slots.value(name, nullptr);
if (s) {
s->oneShotProbes.removeAll(QPointer<QNetworkReply>(reply));
if (s->generation != expectedGeneration)
return;
}
const int http = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const bool ok = reply->error() == QNetworkReply::NoError && http >= 200 && http < 300;
cb(ok);
});
}
void ProviderLauncher::killByPid(qint64 pid)
{
if (pid <= 0)
return;
#ifdef Q_OS_WIN
HANDLE h = ::OpenProcess(PROCESS_TERMINATE, FALSE, static_cast<DWORD>(pid));
if (h) {
::TerminateProcess(h, 1);
::CloseHandle(h);
}
#else
::kill(static_cast<pid_t>(pid), SIGTERM);
#endif
}
void ProviderLauncher::scheduleReadyProbe(Slot *slot)
{
if (!slot->probeTimer) {
const QString name = slot->name;
slot->probeTimer = new QTimer(this);
slot->probeTimer->setSingleShot(true);
connect(slot->probeTimer, &QTimer::timeout, this, [this, name] {
if (Slot *s = m_slots.value(name, nullptr))
runReadyProbe(s);
});
}
slot->probeTimer->start(kProbeInterval);
}
void ProviderLauncher::runReadyProbe(Slot *slot)
{
if (!slot || slot->state != Probing)
return;
const auto elapsed = std::chrono::milliseconds{slot->probeStart.elapsed()};
if (elapsed > slot->cfg.readyTimeout) {
slot->lastError = QStringLiteral("Ready probe timed out after %1 s")
.arg(slot->cfg.readyTimeout.count());
appendLog(slot, QStringLiteral("[probe] timeout — %1").arg(slot->lastError));
changeState(slot, Failed);
teardownSlot(slot);
return;
}
QNetworkRequest req(QUrl{slot->cfg.readyUrl});
req.setTransferTimeout(kProbeTransferTimeout);
slot->probeReply = m_nam->get(req);
const QString name = slot->name;
connect(slot->probeReply, &QNetworkReply::finished, this, [this, name] {
Slot *s = m_slots.value(name, nullptr);
if (!s || !s->probeReply)
return;
QNetworkReply *reply = s->probeReply;
s->probeReply.clear();
reply->deleteLater();
if (s->state != Probing)
return;
const int http = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() == QNetworkReply::NoError && http >= 200 && http < 300) {
appendLog(s, QStringLiteral("[probe] %1 → %2 OK").arg(s->cfg.readyUrl).arg(http));
changeState(s, Ready);
return;
}
if (reply->error() != QNetworkReply::NoError) {
appendLog(s, QStringLiteral("[probe] %1 → %2")
.arg(s->cfg.readyUrl, reply->errorString()));
}
scheduleReadyProbe(s);
});
}
void ProviderLauncher::teardownSlot(Slot *slot)
{
if (!slot)
return;
++slot->generation;
if (slot->probeTimer) {
slot->probeTimer->stop();
slot->probeTimer->deleteLater();
slot->probeTimer = nullptr;
}
if (slot->startTimer) {
slot->startTimer->stop();
slot->startTimer->deleteLater();
slot->startTimer = nullptr;
}
if (slot->probeReply) {
slot->probeReply->abort();
slot->probeReply->deleteLater();
slot->probeReply.clear();
}
for (const QPointer<QNetworkReply> &probe : slot->oneShotProbes) {
if (probe) {
probe->abort();
probe->deleteLater();
}
}
slot->oneShotProbes.clear();
if (slot->process) {
Utils::Process *p = slot->process;
slot->process = nullptr;
p->disconnect(this);
if (p->state() == QProcess::NotRunning) {
p->deleteLater();
} else {
QObject::connect(p, &Utils::Process::done, p, &QObject::deleteLater);
QTimer::singleShot(std::chrono::seconds{15}, p, &QObject::deleteLater);
p->stop();
}
}
}
void ProviderLauncher::appendLog(Slot *slot, const QString &line)
{
if (line.isEmpty())
return;
const QByteArray bytes = (line + QStringLiteral("\r\n")).toUtf8();
appendScrollback(slot, bytes);
emit bytesReceived(slot->name, bytes);
}
void ProviderLauncher::appendScrollback(Slot *slot, const QByteArray &chunk)
{
if (chunk.isEmpty())
return;
slot->scrollback.append(chunk);
if (slot->scrollback.size() > kScrollbackBytesMax) {
const int over = slot->scrollback.size() - kScrollbackBytesMax;
slot->scrollback.remove(0, over);
}
}
void ProviderLauncher::changeState(Slot *slot, State newState)
{
if (slot->state == newState)
return;
slot->state = newState;
const QString name = slot->name;
qCDebug(launcherLog).noquote() << name << "→ state" << newState;
emit stateChanged(name, newState);
}
QString ProviderLauncher::expandOne(
const QString &input, const Slot *slot, const QProcessEnvironment &sys)
{
if (!input.contains(QLatin1String("${")))
return input;
QString out = input;
int searchFrom = 0;
while (searchFrom < out.size()) {
const int open = out.indexOf(QLatin1String("${"), searchFrom);
if (open < 0)
break;
const int close = out.indexOf(QLatin1Char('}'), open + 2);
if (close < 0)
break;
const QString key = out.mid(open + 2, close - open - 2);
QString value;
if (slot && slot->cfg.env.contains(key))
value = slot->cfg.env.value(key);
else if (key == QLatin1String("PROVIDER_NAME") && slot)
value = slot->name;
else if (sys.contains(key))
value = sys.value(key);
out.replace(open, close - open + 1, value);
searchFrom = open + value.size();
}
return out;
}
QString ProviderLauncher::expandVars(const QString &input, const Slot *slot) const
{
if (!input.contains(QLatin1String("${")))
return input;
return expandOne(input, slot, QProcessEnvironment::systemEnvironment());
}
QStringList ProviderLauncher::expandVars(const QStringList &args, const Slot *slot) const
{
if (args.isEmpty())
return {};
const QProcessEnvironment sys = QProcessEnvironment::systemEnvironment();
QStringList out;
out.reserve(args.size());
for (const QString &a : args)
out.append(expandOne(a, slot, sys));
return out;
}
} // namespace QodeAssist::Providers

View File

@@ -1,83 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <functional>
#include <QByteArray>
#include <QHash>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
#include "ProviderInstance.hpp"
class QNetworkAccessManager;
class QNetworkReply;
class QProcessEnvironment;
class QTimer;
namespace Utils { class Process; }
namespace QodeAssist::Providers {
class ProviderLauncher : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(ProviderLauncher)
public:
enum State : quint8 {
Idle, // no process running
Starting, // QProcess::start invoked, waiting for it to be alive
Probing, // process alive, polling ready_url
Ready, // ready probe succeeded (or no probe configured)
Stopping, // termination requested; waiting for QProcess to exit
Failed, // process exited unexpectedly or readiness probe gave up
};
Q_ENUM(State)
explicit ProviderLauncher(QObject *parent = nullptr);
~ProviderLauncher() override;
void start(const QString &instanceName, const LaunchConfig &cfg);
void stop(const QString &instanceName);
void restart(const QString &instanceName, const LaunchConfig &cfg);
[[nodiscard]] State state(const QString &instanceName) const;
[[nodiscard]] bool isReady(const QString &instanceName) const;
[[nodiscard]] QString lastError(const QString &instanceName) const;
[[nodiscard]] QByteArray scrollback(const QString &instanceName) const;
[[nodiscard]] QStringList activeInstances() const;
signals:
void stateChanged(const QString &instanceName, State newState);
void bytesReceived(const QString &instanceName, const QByteArray &chunk);
private:
struct Slot;
void teardownSlot(Slot *slot);
void launchProcess(Slot *slot);
void launchDetached(Slot *slot);
void appendScrollback(Slot *slot, const QByteArray &chunk);
void scheduleReadyProbe(Slot *slot);
void runReadyProbe(Slot *slot);
void probeOnceAsync(Slot *slot, int expectedGeneration, const QString &url,
std::function<void(bool)> onResult);
void appendLog(Slot *slot, const QString &line);
void changeState(Slot *slot, State newState);
QString expandVars(const QString &input, const Slot *slot) const;
QStringList expandVars(const QStringList &args, const Slot *slot) const;
static QString expandOne(const QString &input, const Slot *slot,
const QProcessEnvironment &sys);
static void killByPid(qint64 pid);
QHash<QString, Slot *> m_slots;
QNetworkAccessManager *m_nam = nullptr;
};
} // namespace QodeAssist::Providers

View File

@@ -1,76 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "ProviderSecretsStore.hpp"
#include <coreplugin/icore.h>
#include <utils/qtcsettings.h>
namespace QodeAssist::Providers {
namespace {
constexpr auto kGroup = "QodeAssist/Keychain";
Utils::Key settingsKey(const QString &ref)
{
return Utils::Key(QStringLiteral("%1/%2")
.arg(QLatin1StringView(kGroup))
.arg(ref)
.toUtf8());
}
} // namespace
ProviderSecretsStore::ProviderSecretsStore(QObject *parent)
: QObject(parent)
{}
ProviderSecretsStore::~ProviderSecretsStore() = default;
QString ProviderSecretsStore::readKeySync(const QString &ref) const
{
if (ref.isEmpty())
return {};
auto *s = Core::ICore::settings();
if (!s)
return {};
return s->value(settingsKey(ref)).toString();
}
void ProviderSecretsStore::writeKey(const QString &ref, const QString &value)
{
if (ref.isEmpty())
return;
auto *s = Core::ICore::settings();
if (!s)
return;
s->setValue(settingsKey(ref), value);
s->sync();
emit keyChanged(ref);
}
void ProviderSecretsStore::eraseKey(const QString &ref)
{
if (ref.isEmpty())
return;
auto *s = Core::ICore::settings();
if (!s)
return;
s->remove(settingsKey(ref));
s->sync();
emit keyChanged(ref);
}
bool ProviderSecretsStore::hasKey(const QString &ref) const
{
if (ref.isEmpty())
return false;
auto *s = Core::ICore::settings();
if (!s)
return false;
return s->contains(settingsKey(ref));
}
} // namespace QodeAssist::Providers

View File

@@ -1,31 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QObject>
#include <QString>
namespace QodeAssist::Providers {
class ProviderSecretsStore : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(ProviderSecretsStore)
public:
explicit ProviderSecretsStore(QObject *parent = nullptr);
~ProviderSecretsStore() override;
[[nodiscard]] QString readKeySync(const QString &ref) const;
void writeKey(const QString &ref, const QString &value);
void eraseKey(const QString &ref);
[[nodiscard]] bool hasKey(const QString &ref) const;
signals:
void keyChanged(const QString &ref);
};
} // namespace QodeAssist::Providers

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "Claude"
client_api = "Claude"
description = "Anthropic's hosted Claude API."
url = "https://api.anthropic.com"
api_key_ref = "qodeassist/providers/Claude"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "Codestral"
client_api = "Codestral"
description = "Mistral's Codestral FIM-capable code model API."
url = "https://codestral.mistral.ai"
api_key_ref = "qodeassist/providers/Codestral"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "Google AI"
client_api = "Google AI"
description = "Google AI Studio (Gemini) hosted API."
url = "https://generativelanguage.googleapis.com/v1beta"
api_key_ref = "qodeassist/providers/Google AI"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "llama.cpp"
client_api = "llama.cpp"
description = "Local llama.cpp server (llama-server)."
url = "http://localhost:8080"
api_key_ref = "qodeassist/providers/llama.cpp"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "LM Studio (Chat Completions)"
client_api = "LM Studio (Chat Completions)"
description = "Local LM Studio server over the /v1/chat/completions endpoint."
url = "http://localhost:1234"
api_key_ref = "qodeassist/providers/LM Studio (Chat Completions)"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "LM Studio (Responses API)"
client_api = "LM Studio (Responses API)"
description = "Local LM Studio server over the /v1/responses endpoint."
url = "http://localhost:1234"
api_key_ref = "qodeassist/providers/LM Studio (Responses API)"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "Mistral AI"
client_api = "Mistral AI"
description = "Mistral's hosted chat / completions API."
url = "https://api.mistral.ai"
api_key_ref = "qodeassist/providers/Mistral AI"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "Ollama (OpenAI-compatible)"
client_api = "Ollama (OpenAI-compatible)"
description = "Local Ollama daemon spoken to via the OpenAI-compatible /v1 routes."
url = "http://localhost:11434"
api_key_ref = "qodeassist/providers/Ollama (OpenAI-compatible)"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "Ollama (Native)"
client_api = "Ollama (Native)"
description = "Default local Ollama daemon over its native /api/* endpoints."
url = "http://localhost:11434"
api_key_ref = "qodeassist/providers/Ollama (Native)"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "OpenAI (Chat Completions)"
client_api = "OpenAI (Chat Completions)"
description = "OpenAI's hosted /v1/chat/completions endpoint."
url = "https://api.openai.com/v1"
api_key_ref = "qodeassist/providers/OpenAI (Chat Completions)"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "OpenAI Compatible"
client_api = "OpenAI Compatible"
description = "Self-hosted OpenAI-compatible server (vLLM, TGI, ...). Edit the URL to match your deployment."
url = "http://localhost:1234/v1"
api_key_ref = "qodeassist/providers/OpenAI Compatible"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "OpenAI (Responses API)"
client_api = "OpenAI (Responses API)"
description = "OpenAI's hosted /v1/responses endpoint."
url = "https://api.openai.com/v1"
api_key_ref = "qodeassist/providers/OpenAI (Responses API)"

View File

@@ -1,8 +0,0 @@
schema_version = 1
name = "OpenRouter"
client_api = "OpenRouter"
description = "OpenRouter aggregator (https://openrouter.ai)."
url = "https://openrouter.ai/api"
api_key_ref = "qodeassist/providers/OpenRouter"

View File

@@ -1,17 +0,0 @@
<RCC>
<qresource prefix="/provider-instances">
<file>ollama_native.toml</file>
<file>ollama_compat.toml</file>
<file>claude.toml</file>
<file>openai_chat.toml</file>
<file>openai_responses.toml</file>
<file>openai_compat.toml</file>
<file>lmstudio_chat.toml</file>
<file>lmstudio_responses.toml</file>
<file>openrouter.toml</file>
<file>mistral.toml</file>
<file>codestral.toml</file>
<file>googleai.toml</file>
<file>llamacpp.toml</file>
</qresource>
</RCC>

View File

@@ -1,274 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentPipelinesPage.hpp"
#include <coreplugin/dialogs/ioptionspage.h>
#include <coreplugin/icore.h>
#include <QColor>
#include <QFont>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPointer>
#include <QPushButton>
#include <QScrollArea>
#include <QTimer>
#include <QVBoxLayout>
#include "../../Version.hpp"
#include "AgentRosterWidget.hpp"
#include "AgentsSettingsPage.hpp"
#include "Logger.hpp"
#include "PipelinesConfig.hpp"
#include "SettingsConstants.hpp"
#include "SettingsTr.hpp"
#include <AgentFactory.hpp>
namespace QodeAssist::Settings {
AgentPipelinesPageNavigator::AgentPipelinesPageNavigator(QObject *parent)
: QObject(parent)
{}
namespace {
constexpr int kSaveDebounceMs = 300;
struct SlotMeta
{
const char *title;
const char *hint;
};
const SlotMeta kSlotMeta[] = {
{TrConstants::CODE_COMPLETION, TrConstants::SLOT_HINT_CODE_COMPLETION},
{TrConstants::CHAT_ASSISTANT, TrConstants::SLOT_HINT_CHAT_ASSISTANT},
{TrConstants::CHAT_COMPRESSION, TrConstants::SLOT_HINT_CHAT_COMPRESSION},
{TrConstants::QUICK_REFACTOR, TrConstants::SLOT_HINT_QUICK_REFACTOR},
};
class AgentPipelinesPageWidget : public Core::IOptionsPageWidget
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(AgentPipelinesPageWidget)
public:
AgentPipelinesPageWidget(
const QPointer<AgentFactory> &agentFactory,
const QPointer<AgentPipelinesPageNavigator> &navigator,
const QPointer<AgentsPageNavigator> &agentsNavigator)
: m_agentFactory(agentFactory)
, m_navigator(navigator)
, m_agentsNavigator(agentsNavigator)
{
m_titleLabel = new QLabel(Tr::tr(TrConstants::AGENT_PIPELINES), this);
QFont tf = m_titleLabel->font();
tf.setBold(true);
tf.setPixelSize(13);
m_titleLabel->setFont(tf);
m_resetBtn = new QPushButton(Tr::tr(TrConstants::RESET_TO_DEFAULTS), this);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(8);
headerRow->addWidget(m_titleLabel);
headerRow->addStretch(1);
headerRow->addWidget(m_resetBtn);
auto *headerSep = new QFrame(this);
headerSep->setFrameShape(QFrame::HLine);
headerSep->setFrameShadow(QFrame::Sunken);
m_rosters[0] = new AgentRosterWidget(this);
m_rosters[1] = new AgentRosterWidget(this);
m_rosters[2] = new AgentRosterWidget(this);
m_rosters[3] = new AgentRosterWidget(this);
for (int i = 0; i < kRosterCount; ++i)
m_rosters[i]->setSlot(Tr::tr(kSlotMeta[i].title), Tr::tr(kSlotMeta[i].hint), {});
auto *content = new QWidget(this);
auto *contentLay = new QVBoxLayout(content);
contentLay->setContentsMargins(0, 0, 0, 0);
contentLay->setSpacing(12);
for (int i = 0; i < kRosterCount; ++i)
contentLay->addWidget(m_rosters[i]);
contentLay->addStretch(1);
auto *scroll = new QScrollArea(this);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
scroll->setWidget(content);
auto *root = new QVBoxLayout(this);
root->setContentsMargins(8, 8, 8, 8);
root->setSpacing(6);
root->addLayout(headerRow);
root->addWidget(headerSep);
root->addWidget(scroll, 1);
m_saveDebounce = new QTimer(this);
m_saveDebounce->setSingleShot(true);
m_saveDebounce->setInterval(kSaveDebounceMs);
connect(m_saveDebounce, &QTimer::timeout, this, [this]() { persistRosters(); });
loadFromSettings();
connect(m_resetBtn, &QPushButton::clicked, this, &AgentPipelinesPageWidget::onReset);
for (int i = 0; i < kRosterCount; ++i) {
connect(m_rosters[i], &AgentRosterWidget::editAgentRequested, this,
&AgentPipelinesPageWidget::onEditAgent);
connect(m_rosters[i], &AgentRosterWidget::rosterChanged, this,
[this](const QStringList &) { m_saveDebounce->start(); });
}
}
~AgentPipelinesPageWidget() override
{
if (m_saveDebounce && m_saveDebounce->isActive()) {
m_saveDebounce->stop();
persistRosters();
}
}
void apply() final
{
if (m_saveDebounce && m_saveDebounce->isActive())
m_saveDebounce->stop();
persistRosters();
}
private:
static constexpr int kRosterCount = 4;
void persistRosters()
{
PipelineRosters rosters;
rosters.codeCompletion = m_rosters[0]->roster();
rosters.chatAssistant = m_rosters[1]->roster();
rosters.chatCompression = m_rosters[2]->roster();
rosters.quickRefactor = m_rosters[3]->roster();
QString err;
if (!PipelinesConfig::save(rosters, &err)) {
LOG_MESSAGE(QStringLiteral("[Pipelines] save failed (%1): %2")
.arg(PipelinesConfig::filePath(), err));
if (!m_saveErrorShown) {
m_saveErrorShown = true;
QMessageBox::warning(
Core::ICore::dialogParent(),
Tr::tr(TrConstants::AGENT_PIPELINES),
tr("Failed to save pipelines.toml:\n%1\n\n"
"Further save failures will only be logged.")
.arg(err));
}
} else {
m_saveErrorShown = false;
}
}
void onReset()
{
const auto reply = QMessageBox::question(
Core::ICore::dialogParent(),
Tr::tr(TrConstants::RESET_SETTINGS),
Tr::tr(TrConstants::CONFIRMATION),
QMessageBox::Yes | QMessageBox::No);
if (reply != QMessageBox::Yes)
return;
QString err;
if (!PipelinesConfig::save(PipelineRosters::defaults(), &err))
LOG_MESSAGE(QStringLiteral("[Pipelines] failed to reset rosters: %1").arg(err));
m_saveErrorShown = false;
loadFromSettings();
}
void onEditAgent(const QString &name)
{
if (m_agentsNavigator)
m_agentsNavigator->requestSelectAgent(name);
if (m_navigator)
emit m_navigator->editAgentRequested(name);
#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 83)
Core::ICore::showSettings(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID);
#else
Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID);
#endif
}
void loadFromSettings()
{
const PipelinesLoadResult lr = PipelinesConfig::load();
if (lr.status == PipelinesLoadStatus::ParseError
|| lr.status == PipelinesLoadStatus::SchemaError) {
QMessageBox::warning(
Core::ICore::dialogParent(),
Tr::tr(TrConstants::AGENT_PIPELINES),
tr("pipelines.toml has issues — using defaults for affected entries:\n%1\n\n"
"Click OK to continue. Changes you make here will overwrite the file.")
.arg(lr.message));
}
AgentFactory *factory = m_agentFactory.data();
m_rosters[0]->setRoster(lr.rosters.codeCompletion, factory);
m_rosters[1]->setRoster(lr.rosters.chatAssistant, factory);
m_rosters[2]->setRoster(lr.rosters.chatCompression, factory);
m_rosters[3]->setRoster(lr.rosters.quickRefactor, factory);
}
QPointer<AgentFactory> m_agentFactory;
QPointer<AgentPipelinesPageNavigator> m_navigator;
QPointer<AgentsPageNavigator> m_agentsNavigator;
QLabel *m_titleLabel = nullptr;
QPushButton *m_resetBtn = nullptr;
AgentRosterWidget *m_rosters[kRosterCount] = {};
QTimer *m_saveDebounce = nullptr;
bool m_saveErrorShown = false;
};
class AgentPipelinesOptionsPage final : public Core::IOptionsPage
{
public:
AgentPipelinesOptionsPage(
AgentFactory *agentFactory,
AgentPipelinesPageNavigator *navigator,
AgentsPageNavigator *agentsNavigator)
{
setId(Constants::QODE_ASSIST_AGENT_PIPELINES_PAGE_ID);
setDisplayName(Tr::tr(TrConstants::AGENT_PIPELINES));
setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY);
const QPointer<AgentFactory> factoryPtr(agentFactory);
const QPointer<AgentPipelinesPageNavigator> navPtr(navigator);
const QPointer<AgentsPageNavigator> agentsNavPtr(agentsNavigator);
setWidgetCreator([factoryPtr, navPtr, agentsNavPtr] {
return new AgentPipelinesPageWidget(factoryPtr, navPtr, agentsNavPtr);
});
}
};
} // namespace
std::unique_ptr<Core::IOptionsPage> createAgentPipelinesSettingsPage(
AgentFactory *agentFactory,
AgentPipelinesPageNavigator *navigator,
AgentsPageNavigator *agentsNavigator)
{
return std::make_unique<AgentPipelinesOptionsPage>(
agentFactory, navigator, agentsNavigator);
}
} // namespace QodeAssist::Settings
#include "AgentPipelinesPage.moc"

View File

@@ -1,40 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <memory>
#include <QObject>
#include <QString>
namespace Core {
class IOptionsPage;
}
namespace QodeAssist {
class AgentFactory;
}
namespace QodeAssist::Settings {
class AgentsPageNavigator;
class AgentPipelinesPageNavigator : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(AgentPipelinesPageNavigator)
public:
explicit AgentPipelinesPageNavigator(QObject *parent = nullptr);
signals:
void editAgentRequested(const QString &agentName);
};
std::unique_ptr<Core::IOptionsPage> createAgentPipelinesSettingsPage(
AgentFactory *agentFactory,
AgentPipelinesPageNavigator *navigator,
AgentsPageNavigator *agentsNavigator);
} // namespace QodeAssist::Settings

View File

@@ -1,637 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentRosterWidget.hpp"
#include <QApplication>
#include <QColor>
#include <QFontDatabase>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QStyle>
#include <QToolButton>
#include <QVBoxLayout>
#include <coreplugin/icore.h>
#include <Agent.hpp>
#include <AgentConfig.hpp>
#include <AgentFactory.hpp>
#include <AgentRouter.hpp>
#include "AgentSelectionDialog.hpp"
#include "SettingsTr.hpp"
namespace QodeAssist::Settings {
namespace {
enum class PillKind {
Template,
On, // capability on (thinking/tools)
Off, // capability off ("plain")
User, // user-defined agent
Active, // ✓ active
Match, // matched-this-row chip background
Tag, // free-form discoverability tag from AgentConfig::tags
Neutral,
};
struct Theme
{
bool dark = false;
QColor pageBg;
QColor cardBg;
QColor cardBorder;
QColor groupBorder;
QColor rowSeparator;
QColor rowMatchBg;
QColor listHeader;
QColor text;
QColor textSoft;
QColor textMute;
QColor textFaint;
QColor matchChipBg;
QColor matchChipBorder;
QColor matchChipText;
QColor codeBg;
QColor codeBorder;
struct Pill
{
QColor bg, fg, border;
};
Pill pill(PillKind k) const;
};
Theme::Pill Theme::pill(PillKind k) const
{
if (dark) {
switch (k) {
case PillKind::Template: return {{0x2c, 0x3f, 0x5a}, {0xcf, 0xe1, 0xf7}, {0x4a, 0x62, 0x86}};
case PillKind::On: return {{0x2f, 0x45, 0x30}, {0xbc, 0xe0, 0xbd}, {0x4a, 0x6c, 0x4b}};
case PillKind::Off: return {{0x3a, 0x3a, 0x3a}, {0x8a, 0x8a, 0x8a}, {0x4a, 0x4a, 0x4a}};
case PillKind::User: return {{0x4a, 0x3f, 0x24}, {0xe6, 0xcd, 0x92}, {0x6a, 0x5a, 0x30}};
case PillKind::Active: return {{0x3a, 0x6a, 0x28}, {0xff, 0xff, 0xff}, {0x4a, 0x80, 0x38}};
case PillKind::Match: return {{0x27, 0x38, 0x4a}, {0xbd, 0xd7, 0xee}, {0x3f, 0x5a, 0x78}};
case PillKind::Tag: return {{0x2e, 0x2e, 0x3a}, {0xb9, 0xb9, 0xcf}, {0x46, 0x46, 0x5a}};
case PillKind::Neutral: return {{0x3a, 0x3a, 0x3a}, {0xcf, 0xcf, 0xcf}, {0x4a, 0x4a, 0x4a}};
}
} else {
switch (k) {
case PillKind::Template: return {{0xdb, 0xe7, 0xf6}, {0x1f, 0x3f, 0x73}, {0xa8, 0xc1, 0xe0}};
case PillKind::On: return {{0xdb, 0xe9, 0xd3}, {0x2c, 0x5a, 0x1c}, {0xa3, 0xbc, 0x97}};
case PillKind::Off: return {{0xec, 0xec, 0xec}, {0x7a, 0x7a, 0x7a}, {0xc8, 0xc8, 0xc8}};
case PillKind::User: return {{0xf0, 0xe4, 0xcf}, {0x75, 0x54, 0x1a}, {0xcd, 0xb9, 0x8a}};
case PillKind::Active: return {{0x2c, 0x5a, 0x1c}, {0xff, 0xff, 0xff}, {0x2c, 0x5a, 0x1c}};
case PillKind::Match: return {{0xd6, 0xe8, 0xf7}, {0x1a, 0x4a, 0x7a}, {0x8a, 0xb1, 0xd5}};
case PillKind::Tag: return {{0xe7, 0xe7, 0xf2}, {0x46, 0x46, 0x6e}, {0xc1, 0xc1, 0xd5}};
case PillKind::Neutral: return {{0xe3, 0xe3, 0xe3}, {0x3a, 0x3a, 0x3a}, {0xbc, 0xbc, 0xbc}};
}
}
return {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}};
}
Theme themeFor(const QWidget *w)
{
const QPalette pal = w ? w->palette() : QApplication::palette();
const bool dark = pal.color(QPalette::Window).lightness() < 128;
Theme t;
t.dark = dark;
if (dark) {
t.pageBg = {0x2b, 0x2b, 0x2b};
t.cardBg = {0x2f, 0x2f, 0x2f};
t.cardBorder = {0x4a, 0x4a, 0x4a};
t.groupBorder = {0x4a, 0x4a, 0x4a};
t.rowSeparator = {0x3a, 0x3a, 0x3a};
t.rowMatchBg = {0x2d, 0x3d, 0x24};
t.listHeader = {0x25, 0x25, 0x25};
t.text = {0xe6, 0xe6, 0xe6};
t.textSoft = {0xc2, 0xc2, 0xc2};
t.textMute = {0x9a, 0x9a, 0x9a};
t.textFaint = {0x7a, 0x7a, 0x7a};
t.matchChipBg = {0x26, 0x26, 0x26};
t.matchChipBorder = {0x3a, 0x3a, 0x3a};
t.matchChipText = {0xc2, 0xc2, 0xc2};
t.codeBg = {0x26, 0x26, 0x26};
t.codeBorder = {0x3a, 0x3a, 0x3a};
} else {
t.pageBg = {0xed, 0xed, 0xed};
t.cardBg = {0xf8, 0xf8, 0xf8};
t.cardBorder = {0xbd, 0xbd, 0xbd};
t.groupBorder = {0xb8, 0xb8, 0xb8};
t.rowSeparator = {0xe0, 0xe0, 0xe0};
t.rowMatchBg = {0xe8, 0xf3, 0xdf};
t.listHeader = {0xe8, 0xe8, 0xe8};
t.text = {0x1c, 0x1c, 0x1c};
t.textSoft = {0x3a, 0x3a, 0x3a};
t.textMute = {0x5a, 0x5a, 0x5a};
t.textFaint = {0x8a, 0x8a, 0x8a};
t.matchChipBg = {0xea, 0xea, 0xea};
t.matchChipBorder = {0xcf, 0xcf, 0xcf};
t.matchChipText = {0x3a, 0x3a, 0x3a};
t.codeBg = {0xea, 0xea, 0xea};
t.codeBorder = {0xcf, 0xcf, 0xcf};
}
return t;
}
QString hex(const QColor &c)
{
return c.name(QColor::HexRgb);
}
QString pillStyle(const Theme &t, PillKind k)
{
const auto p = t.pill(k);
return QStringLiteral(
"background:%1; color:%2; border:1px solid %3;"
"padding:0px 5px; border-radius:2px;"
"font-size:10px;")
.arg(hex(p.bg), hex(p.fg), hex(p.border));
}
QLabel *makePill(const QString &text, PillKind kind, const Theme &t, QWidget *parent = nullptr)
{
auto *l = new QLabel(text, parent);
l->setStyleSheet(pillStyle(t, kind));
l->setAlignment(Qt::AlignCenter);
return l;
}
struct MatchSummary
{
QString icon;
QString kind;
QString value;
};
MatchSummary summarise(const AgentConfig::Match &m)
{
if (m.isEmpty())
return {QStringLiteral("·"), AgentRosterWidget::tr("fallback"),
AgentRosterWidget::tr("any file")};
if (!m.filePatterns.isEmpty())
return {QStringLiteral("*"), AgentRosterWidget::tr("path"),
m.filePatterns.join(QStringLiteral(", "))};
if (!m.pathPatterns.isEmpty())
return {QStringLiteral("*"), AgentRosterWidget::tr("path"),
m.pathPatterns.join(QStringLiteral(", "))};
if (!m.projectNames.isEmpty())
return {QStringLiteral(""), AgentRosterWidget::tr("project"),
m.projectNames.join(QStringLiteral(", "))};
return {QStringLiteral("·"), AgentRosterWidget::tr("any"), {}};
}
} // namespace
class AgentRosterRow : public QFrame
{
Q_OBJECT
public:
AgentRosterRow(int index,
const QString &name,
const AgentConfig *cfg,
bool active,
bool first,
bool last,
const Theme &theme,
QWidget *parent = nullptr);
signals:
void moveUpRequested(int index);
void moveDownRequested(int index);
void editRequested(int index);
void removeRequested(int index);
private:
QWidget *buildIdentityLine(const QString &displayName,
const QString &model,
bool active,
bool isUser,
const Theme &t);
QWidget *buildMetaLine(const AgentConfig *cfg, bool active, const Theme &t);
QWidget *buildActions(const Theme &t, bool first, bool last);
int m_index;
};
AgentRosterRow::AgentRosterRow(int index,
const QString &name,
const AgentConfig *cfg,
bool active,
bool first,
bool last,
const Theme &theme,
QWidget *parent)
: QFrame(parent), m_index(index)
{
setAutoFillBackground(true);
QPalette pal = palette();
pal.setColor(QPalette::Window, active ? theme.rowMatchBg : theme.cardBg);
setPalette(pal);
setStyleSheet(QStringLiteral("AgentRosterRow { border:0; border-top:%1; }")
.arg(index == 0 ? QStringLiteral("0px")
: QStringLiteral("1px solid %1").arg(hex(theme.rowSeparator))));
auto *outer = new QHBoxLayout(this);
outer->setContentsMargins(8, 6, 8, 6);
outer->setSpacing(8);
auto *moveCol = new QVBoxLayout;
moveCol->setContentsMargins(0, 0, 0, 0);
moveCol->setSpacing(1);
auto makeArrow = [&](const QString &glyph, const QString &tip, bool enabled) {
auto *b = new QToolButton(this);
b->setText(glyph);
b->setToolTip(tip);
b->setEnabled(enabled);
b->setAutoRaise(true);
b->setFixedSize(18, 14);
QFont f = b->font();
f.setPointSizeF(f.pointSizeF() * 0.75);
b->setFont(f);
return b;
};
auto *upBtn = makeArrow(QStringLiteral(""), tr("Move up"), !first);
auto *dnBtn = makeArrow(QStringLiteral(""), tr("Move down"), !last);
moveCol->addWidget(upBtn);
moveCol->addWidget(dnBtn);
outer->addLayout(moveCol);
auto *idxLbl = new QLabel(QStringLiteral("%1.").arg(index + 1), this);
QFont monoFont = QFontDatabase::systemFont(QFontDatabase::FixedFont);
idxLbl->setFont(monoFont);
QPalette idxPal = idxLbl->palette();
idxPal.setColor(QPalette::WindowText, active ? theme.text : theme.textFaint);
idxLbl->setPalette(idxPal);
idxLbl->setFixedWidth(22);
idxLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
outer->addWidget(idxLbl);
auto *body = new QVBoxLayout;
body->setContentsMargins(0, 0, 0, 0);
body->setSpacing(2);
const QString displayName = cfg ? cfg->name : tr("%1 (missing)").arg(name);
const QString model = cfg ? cfg->model : QString();
const bool isUser = cfg && cfg->isUserSource();
body->addWidget(buildIdentityLine(displayName, model, active, isUser, theme));
body->addWidget(buildMetaLine(cfg, active, theme));
outer->addLayout(body, /*stretch*/ 1);
outer->addWidget(buildActions(theme, first, last));
if (cfg && !cfg->description.isEmpty())
setToolTip(cfg->description);
connect(upBtn, &QToolButton::clicked, this, [this]() { emit moveUpRequested(m_index); });
connect(dnBtn, &QToolButton::clicked, this, [this]() { emit moveDownRequested(m_index); });
}
QWidget *AgentRosterRow::buildIdentityLine(const QString &displayName,
const QString &model,
bool active,
bool isUser,
const Theme &t)
{
auto *w = new QWidget(this);
auto *line = new QHBoxLayout(w);
line->setContentsMargins(0, 0, 0, 0);
line->setSpacing(6);
auto *nameLbl = new QLabel(displayName, w);
QFont nameFont = nameLbl->font();
nameFont.setBold(true);
nameLbl->setFont(nameFont);
line->addWidget(nameLbl);
if (!model.isEmpty()) {
auto *modelLbl = new QLabel(model, w);
modelLbl->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
QPalette mp = modelLbl->palette();
mp.setColor(QPalette::WindowText, t.textSoft);
modelLbl->setPalette(mp);
line->addWidget(modelLbl);
}
if (active)
line->addWidget(makePill(QStringLiteral("") + tr("active"), PillKind::Active, t, w));
if (isUser)
line->addWidget(makePill(tr("user"), PillKind::User, t, w));
line->addStretch(1);
return w;
}
QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, const Theme &t)
{
auto *w = new QWidget(this);
auto *line = new QHBoxLayout(w);
line->setContentsMargins(0, 0, 0, 0);
line->setSpacing(4);
if (cfg) {
const auto sm = summarise(cfg->match);
auto *chip = new QLabel(w);
const QColor bg = active ? t.pill(PillKind::Match).bg : t.matchChipBg;
const QColor bd = active ? t.pill(PillKind::Match).border : t.matchChipBorder;
const QColor fg = active ? t.pill(PillKind::Match).fg : t.matchChipText;
QString chipText = QStringLiteral(
"<span style='opacity:0.7'>%1</span> "
"<span style='color:%2'>%3:</span> %4")
.arg(sm.icon,
hex(active ? t.pill(PillKind::Match).fg : t.textFaint),
sm.kind,
sm.value.toHtmlEscaped());
chip->setTextFormat(Qt::RichText);
chip->setText(chipText);
chip->setStyleSheet(QStringLiteral("background:%1; color:%2; border:1px solid %3;"
"padding:0px 6px; border-radius:2px; font-size:11px;")
.arg(hex(bg), hex(fg), hex(bd)));
chip->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
line->addWidget(chip);
auto *arrow = new QLabel(QStringLiteral(""), w);
QPalette ap = arrow->palette();
ap.setColor(QPalette::WindowText, t.textFaint);
arrow->setPalette(ap);
line->addWidget(arrow);
if (!cfg->providerInstance.isEmpty())
line->addWidget(makePill(cfg->providerInstance, PillKind::Template, t, w));
constexpr int kMaxTagPills = 3;
const int tagCount = cfg->tags.size();
for (int i = 0; i < std::min(tagCount, kMaxTagPills); ++i)
line->addWidget(makePill(cfg->tags.at(i), PillKind::Tag, t, w));
if (tagCount > kMaxTagPills) {
auto *more = makePill(QStringLiteral("+%1").arg(tagCount - kMaxTagPills),
PillKind::Tag, t, w);
more->setToolTip(cfg->tags.mid(kMaxTagPills).join(QStringLiteral(", ")));
line->addWidget(more);
}
} else {
auto *missing = new QLabel(tr("agent configuration is no longer available"), w);
QPalette mp = missing->palette();
mp.setColor(QPalette::WindowText, t.textMute);
missing->setPalette(mp);
line->addWidget(missing);
}
line->addStretch(1);
return w;
}
QWidget *AgentRosterRow::buildActions(const Theme &, bool /*first*/, bool /*last*/)
{
auto *w = new QWidget(this);
auto *l = new QHBoxLayout(w);
l->setContentsMargins(0, 0, 0, 0);
l->setSpacing(4);
auto *edit = new QToolButton(w);
edit->setText(tr("Edit…"));
edit->setToolButtonStyle(Qt::ToolButtonTextOnly);
edit->setAutoRaise(false);
edit->setFocusPolicy(Qt::TabFocus);
edit->setMinimumHeight(22);
auto *remove = new QToolButton(w);
remove->setText(QStringLiteral(""));
remove->setToolTip(tr("Remove from list"));
remove->setAutoRaise(false);
remove->setFixedSize(22, 22);
l->addWidget(edit);
l->addWidget(remove);
connect(edit, &QToolButton::clicked, this, [this]() { emit editRequested(m_index); });
connect(remove, &QToolButton::clicked, this, [this]() { emit removeRequested(m_index); });
return w;
}
AgentRosterWidget::AgentRosterWidget(QWidget *parent)
: QWidget(parent)
{
const Theme t = themeFor(this);
auto *outer = new QVBoxLayout(this);
outer->setContentsMargins(0, 0, 0, 0);
outer->setSpacing(6);
auto *titleRow = new QHBoxLayout;
titleRow->setContentsMargins(0, 0, 0, 0);
titleRow->setSpacing(6);
m_accentDot = new QLabel(this);
m_accentDot->setFixedSize(10, 10);
m_titleLabel = new QLabel(this);
QFont tf = m_titleLabel->font();
tf.setBold(true);
m_titleLabel->setFont(tf);
titleRow->addWidget(m_accentDot);
titleRow->addWidget(m_titleLabel);
titleRow->addStretch(1);
outer->addLayout(titleRow);
m_hintLabel = new QLabel(this);
m_hintLabel->setWordWrap(true);
QPalette hp = m_hintLabel->palette();
hp.setColor(QPalette::WindowText, t.textMute);
m_hintLabel->setPalette(hp);
QFont hf = m_hintLabel->font();
hf.setPointSizeF(hf.pointSizeF() * 0.9);
m_hintLabel->setFont(hf);
outer->addWidget(m_hintLabel);
m_rowsFrame = new QFrame(this);
m_rowsFrame->setObjectName(QStringLiteral("rosterCard"));
m_rowsFrame->setStyleSheet(
QStringLiteral("QFrame#rosterCard { background:%1; border:1px solid %2; border-radius:2px; }")
.arg(hex(t.cardBg), hex(t.cardBorder)));
m_rowsLayout = new QVBoxLayout(m_rowsFrame);
m_rowsLayout->setContentsMargins(0, 0, 0, 0);
m_rowsLayout->setSpacing(0);
m_emptyHint = new QLabel(tr("No agents in roster. Click \"Add agent…\" to populate."),
m_rowsFrame);
m_emptyHint->setAlignment(Qt::AlignCenter);
m_emptyHint->setContentsMargins(10, 12, 10, 12);
QFont eh = m_emptyHint->font();
eh.setItalic(true);
m_emptyHint->setFont(eh);
QPalette ep = m_emptyHint->palette();
ep.setColor(QPalette::WindowText, t.textFaint);
m_emptyHint->setPalette(ep);
m_rowsLayout->addWidget(m_emptyHint);
outer->addWidget(m_rowsFrame);
auto *footer = new QHBoxLayout;
footer->setContentsMargins(0, 0, 0, 0);
footer->setSpacing(6);
m_addBtn = new QPushButton(tr("+ Add agent…"), this);
footer->addWidget(m_addBtn);
footer->addStretch(1);
m_footerHint = new QLabel(tr("first matching agent is used"), this);
QPalette fp = m_footerHint->palette();
fp.setColor(QPalette::WindowText, t.textMute);
m_footerHint->setPalette(fp);
QFont ff = m_footerHint->font();
ff.setPointSizeF(ff.pointSizeF() * 0.9);
m_footerHint->setFont(ff);
footer->addWidget(m_footerHint);
outer->addLayout(footer);
connect(m_addBtn, &QPushButton::clicked, this, &AgentRosterWidget::onAddClicked);
}
void AgentRosterWidget::setSlot(const QString &title, const QString &hint, const QColor &accent)
{
m_titleLabel->setText(title);
m_hintLabel->setText(hint);
m_hintLabel->setVisible(!hint.isEmpty());
if (accent.isValid()) {
m_accentDot->setStyleSheet(
QStringLiteral("background:%1; border:1px solid %2;")
.arg(accent.name(), accent.darker(140).name()));
m_accentDot->setVisible(true);
} else {
m_accentDot->setStyleSheet({});
m_accentDot->setVisible(false);
}
}
void AgentRosterWidget::setRoster(const QStringList &names, AgentFactory *factory)
{
m_factory = factory;
m_names = names;
rebuildRows();
}
void AgentRosterWidget::setRoutingContext(const AgentRouter::Context &ctx)
{
m_routingCtx = ctx;
recomputeActive();
rebuildRows();
}
void AgentRosterWidget::recomputeActive()
{
if (!m_factory || m_names.isEmpty()
|| (m_routingCtx.filePath.isEmpty() && m_routingCtx.projectName.isEmpty())) {
m_activeIndex = -1;
return;
}
const QString picked = AgentRouter::pickAgent(m_names, m_routingCtx, *m_factory);
m_activeIndex = picked.isEmpty() ? -1 : m_names.indexOf(picked);
}
void AgentRosterWidget::rebuildRows()
{
// Tear down existing row widgets (keep the empty hint).
while (m_rowsLayout->count() > 0) {
QLayoutItem *it = m_rowsLayout->takeAt(0);
if (auto *w = it->widget()) {
if (w == m_emptyHint)
w->setVisible(false);
else
w->deleteLater();
}
delete it;
}
if (m_names.isEmpty()) {
m_emptyHint->setVisible(true);
m_rowsLayout->addWidget(m_emptyHint);
return;
}
recomputeActive();
const Theme t = themeFor(this);
for (int i = 0; i < m_names.size(); ++i) {
const QString &name = m_names.at(i);
const AgentConfig *cfg = m_factory ? m_factory->configByName(name) : nullptr;
auto *row = new AgentRosterRow(i,
name,
cfg,
i == m_activeIndex,
/*first*/ i == 0,
/*last*/ i == m_names.size() - 1,
t,
m_rowsFrame);
connect(row, &AgentRosterRow::moveUpRequested, this, &AgentRosterWidget::onRowMoveUp);
connect(row, &AgentRosterRow::moveDownRequested, this, &AgentRosterWidget::onRowMoveDown);
connect(row, &AgentRosterRow::editRequested, this, &AgentRosterWidget::onRowEdit);
connect(row, &AgentRosterRow::removeRequested, this, &AgentRosterWidget::onRowRemove);
m_rowsLayout->addWidget(row);
}
}
void AgentRosterWidget::onAddClicked()
{
if (!m_factory)
return;
AgentSelectionDialog dialog(m_factory->configs(),
/*currentName*/ QString{},
m_factory.data(),
Core::ICore::dialogParent());
if (dialog.exec() != QDialog::Accepted)
return;
const QString picked = dialog.selectedName();
if (picked.isEmpty() || m_names.contains(picked))
return;
m_names.append(picked);
rebuildRows();
emit rosterChanged(m_names);
}
void AgentRosterWidget::onRowMoveUp(int index)
{
if (index <= 0 || index >= m_names.size())
return;
m_names.swapItemsAt(index, index - 1);
rebuildRows();
emit rosterChanged(m_names);
}
void AgentRosterWidget::onRowMoveDown(int index)
{
if (index < 0 || index >= m_names.size() - 1)
return;
m_names.swapItemsAt(index, index + 1);
rebuildRows();
emit rosterChanged(m_names);
}
void AgentRosterWidget::onRowRemove(int index)
{
if (index < 0 || index >= m_names.size())
return;
m_names.removeAt(index);
rebuildRows();
emit rosterChanged(m_names);
}
void AgentRosterWidget::onRowEdit(int index)
{
if (index < 0 || index >= m_names.size())
return;
emit editAgentRequested(m_names.at(index));
}
} // namespace QodeAssist::Settings
#include "AgentRosterWidget.moc"

View File

@@ -1,71 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QColor>
#include <QPointer>
#include <QString>
#include <QStringList>
#include <QWidget>
#include <AgentRouter.hpp>
class QLabel;
class QToolButton;
class QPushButton;
class QVBoxLayout;
class QFrame;
namespace QodeAssist {
class AgentFactory;
}
namespace QodeAssist::Settings {
class AgentRosterRow;
class AgentRosterWidget : public QWidget
{
Q_OBJECT
public:
explicit AgentRosterWidget(QWidget *parent = nullptr);
void setSlot(const QString &title, const QString &hint, const QColor &accent);
void setRoster(const QStringList &names, AgentFactory *factory);
[[nodiscard]] QStringList roster() const { return m_names; }
void setRoutingContext(const AgentRouter::Context &ctx);
signals:
void rosterChanged(const QStringList &names);
void editAgentRequested(const QString &agentName);
private:
void rebuildRows();
void recomputeActive();
void onAddClicked();
void onRowMoveUp(int index);
void onRowMoveDown(int index);
void onRowRemove(int index);
void onRowEdit(int index);
QStringList m_names;
QPointer<AgentFactory> m_factory;
AgentRouter::Context m_routingCtx;
int m_activeIndex = -1;
QLabel *m_accentDot = nullptr;
QLabel *m_titleLabel = nullptr;
QLabel *m_hintLabel = nullptr;
QFrame *m_rowsFrame = nullptr;
QVBoxLayout *m_rowsLayout = nullptr;
QLabel *m_emptyHint = nullptr;
QPushButton *m_addBtn = nullptr;
QLabel *m_footerHint = nullptr;
};
} // namespace QodeAssist::Settings

View File

@@ -1,472 +0,0 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentSelectionDialog.hpp"
#include "AgentSlotWidget.hpp"
#include "PipelinesConfig.hpp"
#include "SettingsTr.hpp"
#include <coreplugin/icore.h>
#include <AgentFactory.hpp>
#include <QDialogButtonBox>
#include <QEnterEvent>
#include <QEvent>
#include <QFont>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QMap>
#include <QMouseEvent>
#include <QPushButton>
#include <QScopedValueRollback>
#include <QScrollArea>
#include <QTimer>
#include <QVBoxLayout>
#include <algorithm>
namespace QodeAssist::Settings {
// -- ListRowCard -------------------------------------------------------
ListRowCard::ListRowCard(QWidget *parent)
: QFrame(parent)
{
setObjectName(QStringLiteral("ListRowCard"));
setAttribute(Qt::WA_StyledBackground, true);
setCursor(Qt::PointingHandCursor);
setFrameShape(QFrame::NoFrame);
applyTheme();
}
bool ListRowCard::matches(const QString &needle) const
{
if (needle.isEmpty())
return true;
return m_searchHaystack.contains(needle.toLower());
}
void ListRowCard::setSelected(bool selected)
{
if (m_selected == selected)
return;
m_selected = selected;
applyTheme();
}
void ListRowCard::buildSearchHaystack(const QStringList &parts)
{
m_searchHaystack = parts.join(QLatin1Char(' ')).toLower();
}
void ListRowCard::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
emit clicked();
QFrame::mousePressEvent(event);
}
void ListRowCard::mouseDoubleClickEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
emit activated();
QFrame::mouseDoubleClickEvent(event);
}
void ListRowCard::enterEvent(QEnterEvent *event)
{
QFrame::enterEvent(event);
m_hover = true;
applyTheme();
}
void ListRowCard::leaveEvent(QEvent *event)
{
QFrame::leaveEvent(event);
m_hover = false;
applyTheme();
}
void ListRowCard::changeEvent(QEvent *event)
{
QFrame::changeEvent(event);
if (m_inApplyTheme)
return;
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
applyTheme();
}
void ListRowCard::applyTheme()
{
if (m_inApplyTheme)
return;
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
const auto t = CardStyle::toneFor(CardStyle::isDark(palette()));
QString bg = t.bg;
QString bd = t.cardBd;
if (m_selected) {
bg = t.selectedBg;
bd = t.selectedBd;
} else if (m_hover) {
bg = t.hoverBg;
}
setStyleSheet(QStringLiteral(
"#ListRowCard { background-color: %1; border: 1px solid %2; }")
.arg(bg, bd));
}
// -- AgentRowCard ------------------------------------------------------
AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
: ListRowCard(parent)
{
setItemName(cfg.name);
QStringList haystack{cfg.name, cfg.providerInstance, cfg.model,
cfg.description, cfg.role,
cfg.endpoint};
haystack += cfg.tags;
buildSearchHaystack(haystack);
auto *name = new QLabel(cfg.name, this);
QFont nameFont = name->font();
nameFont.setBold(true);
nameFont.setPixelSize(13);
name->setFont(nameFont);
name->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred);
QLabel *model = nullptr;
if (!cfg.model.isEmpty()) {
model = new QLabel(QStringLiteral("· %1").arg(cfg.model), this);
model->setFont(CardStyle::monoFont(11));
model->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
model->setMinimumWidth(0);
}
Pill *sourcePill = nullptr;
if (cfg.isUserSource()) {
sourcePill = new Pill(
Pill::User,
cfg.overridesBundled ? Tr::tr("Override") : Tr::tr("User"),
this);
}
auto *description = new QLabel(this);
description->setWordWrap(false);
QFont descFont = description->font();
descFont.setItalic(true);
description->setFont(descFont);
description->setText(cfg.description.isEmpty()
? Tr::tr("No description provided.")
: cfg.description);
description->setMinimumWidth(0);
description->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
description->setTextInteractionFlags(Qt::NoTextInteraction);
QStringList endpointParts;
if (!cfg.endpoint.isEmpty())
endpointParts << cfg.endpoint;
endpointParts << (cfg.enableThinking ? Tr::tr("thinking") : Tr::tr("no-thinking"));
endpointParts << (cfg.enableTools ? Tr::tr("tools") : Tr::tr("no-tools"));
auto *endpoint = new QLabel(endpointParts.join(QStringLiteral(" · ")), this);
endpoint->setFont(CardStyle::monoFont(11));
endpoint->setMinimumWidth(0);
endpoint->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
endpoint->setTextInteractionFlags(Qt::NoTextInteraction);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(6);
headerRow->addWidget(name);
if (model)
headerRow->addWidget(model, 1);
else
headerRow->addStretch(1);
if (sourcePill)
headerRow->addWidget(sourcePill);
auto *outer = new QVBoxLayout(this);
outer->setContentsMargins(10, 8, 10, 8);
outer->setSpacing(3);
outer->addLayout(headerRow);
outer->addWidget(description);
outer->addWidget(endpoint);
if (!cfg.tags.isEmpty()) {
constexpr int kMaxTagPills = 4;
auto *tagsRow = new QHBoxLayout;
tagsRow->setContentsMargins(0, 0, 0, 0);
tagsRow->setSpacing(4);
const int tagCount = cfg.tags.size();
for (int i = 0; i < std::min(tagCount, kMaxTagPills); ++i)
tagsRow->addWidget(new Pill(Pill::Tag, cfg.tags.at(i), this));
if (tagCount > kMaxTagPills) {
auto *more = new Pill(
Pill::Tag,
QStringLiteral("+%1").arg(tagCount - kMaxTagPills),
this);
more->setToolTip(cfg.tags.mid(kMaxTagPills).join(QStringLiteral(", ")));
tagsRow->addWidget(more);
}
tagsRow->addStretch(1);
outer->addLayout(tagsRow);
}
const auto t = CardStyle::toneFor(CardStyle::isDark(palette()));
QPalette descPal = description->palette();
descPal.setColor(QPalette::WindowText,
QColor(cfg.description.isEmpty() ? t.textFaint : t.textSoft));
description->setPalette(descPal);
QPalette endPal = endpoint->palette();
endPal.setColor(QPalette::WindowText, QColor(t.textFaint));
endpoint->setPalette(endPal);
if (model) {
QPalette mp = model->palette();
mp.setColor(QPalette::WindowText, QColor(t.textMute));
model->setPalette(mp);
}
QString tooltip;
if (!cfg.description.isEmpty())
tooltip += cfg.description + QStringLiteral("\n\n");
if (!cfg.providerInstance.isEmpty())
tooltip += Tr::tr("Provider instance: %1\n").arg(cfg.providerInstance);
if (!cfg.role.isEmpty())
tooltip += Tr::tr("Role: %1\n").arg(cfg.role);
if (!cfg.endpoint.isEmpty())
tooltip += Tr::tr("Endpoint: %1\n").arg(cfg.endpoint);
setToolTip(tooltip.trimmed());
}
// -- ProviderSection ---------------------------------------------------
ProviderSection::ProviderSection(const QString &name, QWidget *parent)
: QWidget(parent)
{
m_arrow = new QLabel(QStringLiteral(""));
m_label = new QLabel(name);
CardStyle::applySectionFont(m_label);
QFont arrowFont = m_label->font();
arrowFont.setCapitalization(QFont::MixedCase);
m_arrow->setFont(arrowFont);
QPalette ap = m_arrow->palette();
ap.setColor(QPalette::WindowText, ap.color(QPalette::Mid));
m_arrow->setPalette(ap);
m_header = new QFrame;
m_header->setObjectName(QStringLiteral("ProviderHeader"));
m_header->setCursor(Qt::PointingHandCursor);
m_header->setFrameShape(QFrame::NoFrame);
auto *headerLayout = new QHBoxLayout(m_header);
headerLayout->setContentsMargins(2, 4, 2, 2);
headerLayout->setSpacing(6);
headerLayout->addWidget(m_arrow);
headerLayout->addWidget(m_label);
headerLayout->addStretch(1);
m_header->installEventFilter(this);
m_content = new QWidget;
m_contentLayout = new QVBoxLayout(m_content);
m_contentLayout->setContentsMargins(0, 0, 0, 0);
m_contentLayout->setSpacing(4);
m_content->setVisible(false);
m_arrow->setText(QStringLiteral(""));
m_expanded = false;
auto *outer = new QVBoxLayout(this);
outer->setContentsMargins(0, 0, 0, 0);
outer->setSpacing(0);
outer->addWidget(m_header);
outer->addWidget(m_content);
}
void ProviderSection::addCard(ListRowCard *card)
{
m_contentLayout->addWidget(card);
m_cards.append(card);
}
int ProviderSection::applyFilter(const QString &needle)
{
int visible = 0;
for (auto *card : m_cards) {
const bool show = card->matches(needle);
card->setVisible(show);
if (show)
++visible;
}
return visible;
}
void ProviderSection::setExpanded(bool expanded)
{
if (m_expanded == expanded)
return;
m_expanded = expanded;
m_content->setVisible(expanded);
m_arrow->setText(expanded ? QStringLiteral("") : QStringLiteral(""));
}
bool ProviderSection::eventFilter(QObject *watched, QEvent *event)
{
if (watched == m_header && event->type() == QEvent::MouseButtonRelease) {
auto *me = static_cast<QMouseEvent *>(event);
if (me->button() == Qt::LeftButton) {
setExpanded(!m_expanded);
return true;
}
}
return QWidget::eventFilter(watched, event);
}
// -- AgentSelectionDialog ----------------------------------------------
AgentSelectionDialog::AgentSelectionDialog(
const std::vector<AgentConfig> &configs,
const QString &currentName,
AgentFactory *agentFactory,
QWidget *parent)
: QDialog(parent)
, m_agentFactory(agentFactory)
{
setWindowTitle(Tr::tr("Change Agent"));
resize(720, 600);
setSizeGripEnabled(true);
if (!m_agentFactory)
m_localConfigs = configs;
m_filter = new QLineEdit(this);
m_filter->setPlaceholderText(
Tr::tr("Filter by name, provider, model, template, description…"));
m_filter->setClearButtonEnabled(true);
auto *topRow = new QHBoxLayout;
topRow->setContentsMargins(0, 0, 0, 0);
topRow->setSpacing(6);
topRow->addWidget(m_filter, 1);
m_scroll = new QScrollArea(this);
m_scroll->setWidgetResizable(true);
m_scroll->setFrameShape(QFrame::StyledPanel);
m_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
auto *buttons
= new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
m_okButton = buttons->button(QDialogButtonBox::Ok);
m_okButton->setText(Tr::tr("Change"));
m_okButton->setEnabled(false);
auto *layout = new QVBoxLayout(this);
layout->addLayout(topRow);
layout->addWidget(m_scroll);
layout->addWidget(buttons);
rebuild(currentName);
connect(m_filter, &QLineEdit::textChanged, this,
[this](const QString &text) { applyFilter(text); });
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
}
void AgentSelectionDialog::selectCard(ListRowCard *card)
{
if (m_currentCard == card)
return;
if (m_currentCard)
m_currentCard->setSelected(false);
m_currentCard = card;
if (m_currentCard) {
m_currentCard->setSelected(true);
m_selectedName = m_currentCard->itemName();
} else {
m_selectedName.clear();
}
if (m_okButton)
m_okButton->setEnabled(!m_selectedName.isEmpty());
}
void AgentSelectionDialog::rebuild(const QString &currentName)
{
m_sections.clear();
m_currentCard = nullptr;
m_selectedName.clear();
if (m_okButton)
m_okButton->setEnabled(false);
const auto &configs
= m_agentFactory ? m_agentFactory->configs() : m_localConfigs;
auto *content = new QWidget;
auto *contentLayout = new QVBoxLayout(content);
contentLayout->setContentsMargins(12, 12, 12, 12);
contentLayout->setSpacing(6);
QMap<QString, std::vector<const AgentConfig *>> byProvider;
for (const auto &cfg : configs) {
if (cfg.hidden) continue; // hidden profiles stay loaded but don't surface in the picker
const QString key = cfg.providerInstance.isEmpty()
? Tr::tr("(Unknown provider instance)")
: cfg.providerInstance;
byProvider[key].push_back(&cfg);
}
AgentRowCard *toSelect = nullptr;
ProviderSection *sectionToExpand = nullptr;
for (auto it = byProvider.cbegin(); it != byProvider.cend(); ++it) {
auto *section = new ProviderSection(it.key());
auto sortedConfigs = it.value();
std::sort(sortedConfigs.begin(), sortedConfigs.end(),
[](const AgentConfig *a, const AgentConfig *b) { return a->name < b->name; });
for (const AgentConfig *cfg : sortedConfigs) {
auto *card = new AgentRowCard(*cfg);
connect(card, &ListRowCard::clicked, this,
[this, card]() { selectCard(card); });
connect(card, &ListRowCard::activated, this, [this, card]() {
selectCard(card);
accept();
});
section->addCard(card);
if (cfg->name == currentName) {
toSelect = card;
sectionToExpand = section;
}
}
contentLayout->addWidget(section);
m_sections.append(section);
}
contentLayout->addStretch(1);
m_scroll->setWidget(content);
if (sectionToExpand)
sectionToExpand->setExpanded(true);
if (toSelect) {
selectCard(toSelect);
QTimer::singleShot(0, this, [this, toSelect]() {
m_scroll->ensureWidgetVisible(toSelect, 0, 60);
});
}
applyFilter(m_filter ? m_filter->text() : QString());
}
void AgentSelectionDialog::applyFilter(const QString &needle)
{
const QString trimmed = needle.trimmed();
for (auto *section : m_sections) {
const int visible = section->applyFilter(trimmed);
section->setVisible(visible > 0);
if (!trimmed.isEmpty())
section->setExpanded(visible > 0);
}
}
} // namespace QodeAssist::Settings

View File

@@ -1,183 +0,0 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QDialog>
#include <QFont>
#include <QFrame>
#include <QLabel>
#include <QPalette>
#include <QStringList>
#include <vector>
#include <AgentConfig.hpp>
class QLineEdit;
class QPushButton;
class QScrollArea;
class QVBoxLayout;
namespace QodeAssist {
class AgentFactory;
}
namespace QodeAssist::Settings {
namespace CardStyle {
struct Tone
{
QString bg;
QString hoverBg;
QString selectedBg;
QString selectedBd;
QString cardBd;
QString textSoft;
QString textMute;
QString textFaint;
};
inline bool isDark(const QPalette &p)
{
return p.color(QPalette::Window).lightness() < 128;
}
inline Tone toneFor(bool dark)
{
return dark
? Tone{"#333333", "#3a3a3a", "#2a4566", "#3a6fb7", "#4a4a4a",
"#c2c2c2", "#9a9a9a", "#7a7a7a"}
: Tone{"#f6f6f6", "#ececec", "#cfe1f7", "#3a6fb7", "#bdbdbd",
"#3a3a3a", "#5a5a5a", "#8a8a8a"};
}
inline QFont monoFont(int pixelSize)
{
QFont f;
f.setFamilies({QStringLiteral("SF Mono"),
QStringLiteral("Cascadia Code"),
QStringLiteral("Consolas"),
QStringLiteral("Liberation Mono"),
QStringLiteral("Menlo"),
QStringLiteral("Courier New")});
f.setStyleHint(QFont::Monospace);
f.setPixelSize(pixelSize);
return f;
}
inline void applySectionFont(QLabel *l)
{
QFont f = l->font();
f.setPointSizeF(f.pointSizeF() * 0.85);
f.setBold(true);
f.setCapitalization(QFont::AllUppercase);
f.setLetterSpacing(QFont::AbsoluteSpacing, 0.5);
l->setFont(f);
QPalette p = l->palette();
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
l->setPalette(p);
}
} // namespace CardStyle
class ListRowCard : public QFrame
{
Q_OBJECT
public:
QString itemName() const { return m_itemName; }
bool matches(const QString &needle) const;
void setSelected(bool selected);
bool isSelected() const { return m_selected; }
signals:
void clicked();
void activated();
protected:
explicit ListRowCard(QWidget *parent = nullptr);
void setItemName(const QString &name) { m_itemName = name; }
void buildSearchHaystack(const QStringList &parts);
void mousePressEvent(QMouseEvent *event) override;
void mouseDoubleClickEvent(QMouseEvent *event) override;
void enterEvent(QEnterEvent *event) override;
void leaveEvent(QEvent *event) override;
void changeEvent(QEvent *event) override;
private:
void applyTheme();
QString m_itemName;
QString m_searchHaystack;
bool m_selected = false;
bool m_hover = false;
bool m_inApplyTheme = false;
};
class AgentRowCard : public ListRowCard
{
Q_OBJECT
public:
explicit AgentRowCard(const AgentConfig &cfg, QWidget *parent = nullptr);
QString agentName() const { return itemName(); }
};
class ProviderSection : public QWidget
{
Q_OBJECT
public:
explicit ProviderSection(const QString &name, QWidget *parent = nullptr);
void addCard(ListRowCard *card);
void setExpanded(bool expanded);
bool isExpanded() const { return m_expanded; }
const QList<ListRowCard *> &cards() const { return m_cards; }
int applyFilter(const QString &needle); // returns number of visible cards
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
QFrame *m_header = nullptr;
QLabel *m_arrow = nullptr;
QLabel *m_label = nullptr;
QWidget *m_content = nullptr;
QVBoxLayout *m_contentLayout = nullptr;
QList<ListRowCard *> m_cards;
bool m_expanded = true;
};
class AgentSelectionDialog : public QDialog
{
Q_OBJECT
public:
AgentSelectionDialog(
const std::vector<AgentConfig> &configs,
const QString &currentName,
AgentFactory *agentFactory = nullptr,
QWidget *parent = nullptr);
QString selectedName() const { return m_selectedName; }
private:
void rebuild(const QString &currentName);
void selectCard(ListRowCard *card);
void applyFilter(const QString &needle);
QLineEdit *m_filter = nullptr;
QScrollArea *m_scroll = nullptr;
QPushButton *m_okButton = nullptr;
ListRowCard *m_currentCard = nullptr;
QList<ProviderSection *> m_sections;
QString m_selectedName;
AgentFactory *m_agentFactory = nullptr;
std::vector<AgentConfig> m_localConfigs; // fallback when no factory
};
} // namespace QodeAssist::Settings

View File

@@ -1,362 +0,0 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "AgentSlotWidget.hpp"
#include "SettingsTr.hpp"
#include <QEvent>
#include <QFont>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QPalette>
#include <QPushButton>
#include <QScopedValueRollback>
#include <QVBoxLayout>
namespace QodeAssist::Settings {
namespace {
bool isDarkPalette(const QPalette &p)
{
return p.color(QPalette::Window).lightness() < 128;
}
QFont monoFont(int pixelSize)
{
QFont f;
f.setFamilies({QStringLiteral("SF Mono"),
QStringLiteral("Cascadia Code"),
QStringLiteral("Consolas"),
QStringLiteral("Liberation Mono"),
QStringLiteral("Menlo"),
QStringLiteral("Courier New")});
f.setStyleHint(QFont::Monospace);
f.setPixelSize(pixelSize);
return f;
}
struct Tone
{
QString cardBg;
QString cardBd;
QString textSoft;
QString textMute;
QString textFaint;
};
Tone toneFor(bool dark)
{
return dark ? Tone{"#333333", "#4a4a4a", "#c2c2c2", "#9a9a9a", "#7a7a7a"}
: Tone{"#f6f6f6", "#bdbdbd", "#3a3a3a", "#5a5a5a", "#8a8a8a"};
}
struct PillTone
{
QString bg;
QString fg;
QString bd;
};
PillTone pillTone(Pill::Kind kind, bool dark)
{
switch (kind) {
case Pill::Template:
return dark ? PillTone{"#2c3f5a", "#cfe1f7", "#4a6286"}
: PillTone{"#dbe7f6", "#1f3f73", "#a8c1e0"};
case Pill::On:
return dark ? PillTone{"#2f4530", "#bce0bd", "#4a6c4b"}
: PillTone{"#dbe9d3", "#2c5a1c", "#a3bc97"};
case Pill::Off:
return dark ? PillTone{"#3a3a3a", "#8a8a8a", "#4a4a4a"}
: PillTone{"#ececec", "#7a7a7a", "#c8c8c8"};
case Pill::User:
return dark ? PillTone{"#4a3f24", "#e6cd92", "#6a5a30"}
: PillTone{"#f0e4cf", "#75541a", "#cdb98a"};
case Pill::Tag:
return dark ? PillTone{"#2e2e3a", "#b9b9cf", "#46465a"}
: PillTone{"#e7e7f2", "#46466e", "#c1c1d5"};
}
return {};
}
} // namespace
// -- Pill --------------------------------------------------------------
Pill::Pill(Kind kind, const QString &text, QWidget *parent)
: QLabel(text, parent)
, m_kind(kind)
{
setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
setAlignment(Qt::AlignCenter);
QFont f = font();
f.setPixelSize(11);
setFont(f);
applyTheme();
}
void Pill::setKind(Kind kind)
{
if (m_kind == kind)
return;
m_kind = kind;
applyTheme();
}
void Pill::changeEvent(QEvent *event)
{
QLabel::changeEvent(event);
if (m_inApplyTheme)
return;
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
applyTheme();
}
void Pill::applyTheme()
{
if (m_inApplyTheme)
return;
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
const auto t = pillTone(m_kind, isDarkPalette(palette()));
setStyleSheet(QStringLiteral(
"QLabel { background-color: %1; color: %2;"
" border: 1px solid %3; padding: 1px 7px; }")
.arg(t.bg, t.fg, t.bd));
}
// -- AgentSlotWidget ---------------------------------------------------
AgentSlotWidget::AgentSlotWidget(QWidget *parent)
: QWidget(parent)
{
setAttribute(Qt::WA_StyledBackground, true);
setObjectName(QStringLiteral("AgentSlot"));
m_name = new QLabel(this);
QFont nameFont = m_name->font();
nameFont.setBold(true);
m_name->setFont(nameFont);
m_name->setTextInteractionFlags(Qt::TextSelectableByMouse);
m_sourcePill = new Pill(Pill::User, {}, this);
m_sourcePill->hide();
m_changeBtn = new QPushButton(Tr::tr("Change…"), this);
m_changeBtn->setCursor(Qt::PointingHandCursor);
connect(m_changeBtn, &QPushButton::clicked, this, &AgentSlotWidget::changeRequested);
m_editBtn = new QPushButton(Tr::tr("Edit"), this);
m_editBtn->setCursor(Qt::PointingHandCursor);
m_editBtn->setToolTip(Tr::tr("Open this agent in the Agents settings page."));
connect(m_editBtn, &QPushButton::clicked, this, &AgentSlotWidget::editRequested);
auto makeFieldLabel = [this]() {
auto *l = new QLabel(this);
l->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
return l;
};
auto makeMonoValue = [this]() {
auto *l = new QLabel(this);
l->setFont(monoFont(11));
l->setTextInteractionFlags(Qt::TextSelectableByMouse);
l->setMinimumWidth(0);
l->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
return l;
};
auto makePlainValue = [this]() {
auto *l = new QLabel(this);
l->setTextInteractionFlags(Qt::TextSelectableByMouse);
l->setMinimumWidth(0);
l->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
return l;
};
m_modelLabel = makeFieldLabel();
m_modelLabel->setText(Tr::tr("Model"));
m_modelValue = makeMonoValue();
m_urlLabel = makeFieldLabel();
m_urlLabel->setText(Tr::tr("Provider"));
m_urlValue = makeMonoValue();
m_endpointLabel = makeFieldLabel();
m_endpointLabel->setText(Tr::tr("Endpoint"));
m_endpointValue = makeMonoValue();
m_templateLabel = makeFieldLabel();
m_templateLabel->setText(Tr::tr("Template"));
m_templateValue = makePlainValue();
m_description = new QLabel(this);
m_description->setWordWrap(true);
m_description->setTextInteractionFlags(Qt::TextSelectableByMouse);
QFont descFont = m_description->font();
descFont.setItalic(true);
m_description->setFont(descFont);
m_thinkingPill = new Pill(Pill::Off, {}, this);
m_toolsPill = new Pill(Pill::Off, {}, this);
// Header row
auto *nameRow = new QHBoxLayout;
nameRow->setContentsMargins(0, 0, 0, 0);
nameRow->setSpacing(6);
nameRow->addWidget(m_name);
nameRow->addWidget(m_sourcePill);
nameRow->addStretch(1);
auto *buttonsBox = new QHBoxLayout;
buttonsBox->setContentsMargins(0, 0, 0, 0);
buttonsBox->setSpacing(4);
buttonsBox->addWidget(m_editBtn);
buttonsBox->addWidget(m_changeBtn);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(8);
headerRow->addLayout(nameRow, 1);
headerRow->addLayout(buttonsBox, 0);
// Two-column field grid
auto *grid = new QGridLayout;
grid->setContentsMargins(0, 0, 0, 0);
grid->setHorizontalSpacing(8);
grid->setVerticalSpacing(2);
grid->setColumnMinimumWidth(0, 62);
grid->setColumnStretch(0, 0);
grid->setColumnStretch(1, 1);
grid->addWidget(m_modelLabel, 0, 0);
grid->addWidget(m_modelValue, 0, 1);
grid->addWidget(m_urlLabel, 1, 0);
grid->addWidget(m_urlValue, 1, 1);
grid->addWidget(m_endpointLabel, 2, 0);
grid->addWidget(m_endpointValue, 2, 1);
grid->addWidget(m_templateLabel, 3, 0);
grid->addWidget(m_templateValue, 3, 1);
auto *pillsRow = new QHBoxLayout;
pillsRow->setContentsMargins(0, 0, 0, 0);
pillsRow->setSpacing(4);
pillsRow->addWidget(m_thinkingPill);
pillsRow->addWidget(m_toolsPill);
pillsRow->addStretch(1);
auto *outer = new QVBoxLayout(this);
outer->setContentsMargins(10, 10, 10, 10);
outer->setSpacing(6);
outer->addLayout(headerRow);
outer->addLayout(grid);
outer->addWidget(m_description);
outer->addLayout(pillsRow);
applyTheme();
clear();
}
void AgentSlotWidget::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
if (m_inApplyTheme)
return;
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
applyTheme();
}
void AgentSlotWidget::applyTheme()
{
if (m_inApplyTheme)
return;
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
const Tone t = toneFor(isDarkPalette(palette()));
setStyleSheet(QStringLiteral(
"#AgentSlot { background-color: %1; border: 1px solid %2; }")
.arg(t.cardBg, t.cardBd));
auto applyColor = [](QLabel *label, const QString &color) {
QPalette p = label->palette();
p.setColor(QPalette::WindowText, QColor(color));
label->setPalette(p);
};
for (QLabel *l : {m_modelLabel, m_urlLabel, m_endpointLabel, m_templateLabel})
applyColor(l, t.textMute);
applyColor(m_description, t.textSoft);
}
void AgentSlotWidget::setAgentConfig(const AgentConfig &cfg)
{
m_name->setText(cfg.name);
if (cfg.isUserSource()) {
m_sourcePill->setText(cfg.overridesBundled
? Tr::tr("User overrides bundled")
: Tr::tr("User"));
m_sourcePill->show();
} else {
m_sourcePill->hide();
}
m_modelValue->setText(cfg.model);
m_modelValue->setToolTip(cfg.model);
// The agent profile no longer carries a URL — the URL belongs to
// the referenced provider instance and is editable on the Providers
// settings page. Surface the instance name in this row instead.
m_urlValue->setText(cfg.providerInstance);
m_urlValue->setToolTip(cfg.providerInstance);
m_endpointValue->setText(cfg.endpoint);
m_endpointValue->setToolTip(cfg.endpoint);
// Templates are now inline in the agent profile — no separate name to
// surface. Keep the row but show '—' so the layout stays stable.
m_templateLabel->hide();
m_templateValue->hide();
m_description->setText(cfg.description.isEmpty()
? Tr::tr("No description provided.")
: cfg.description);
const Tone t = toneFor(isDarkPalette(palette()));
QPalette descPal = m_description->palette();
descPal.setColor(QPalette::WindowText,
QColor(cfg.description.isEmpty() ? t.textFaint : t.textSoft));
m_description->setPalette(descPal);
m_thinkingPill->setKind(cfg.enableThinking ? Pill::On : Pill::Off);
m_thinkingPill->setText(cfg.enableThinking ? Tr::tr("thinking on")
: Tr::tr("thinking off"));
m_toolsPill->setKind(cfg.enableTools ? Pill::On : Pill::Off);
m_toolsPill->setText(cfg.enableTools ? Tr::tr("tools on")
: Tr::tr("tools off"));
m_thinkingPill->show();
m_toolsPill->show();
m_editBtn->setEnabled(!cfg.name.isEmpty());
}
void AgentSlotWidget::clear()
{
const Tone t = toneFor(isDarkPalette(palette()));
m_name->setText(QStringLiteral("<i style=\"color:%1\">%2</i>")
.arg(t.textFaint, Tr::tr("(no agent selected)")));
m_sourcePill->hide();
for (QLabel *l : {m_modelValue, m_urlValue, m_endpointValue, m_templateValue}) {
l->clear();
l->setToolTip({});
}
m_description->setText({});
m_thinkingPill->hide();
m_toolsPill->hide();
m_editBtn->setEnabled(false);
}
} // namespace QodeAssist::Settings

View File

@@ -1,78 +0,0 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QLabel>
#include <QWidget>
#include <AgentConfig.hpp>
class QPushButton;
namespace QodeAssist::Settings {
class Pill : public QLabel
{
Q_OBJECT
public:
enum Kind { Template, On, Off, User, Tag };
Pill(Kind kind, const QString &text = {}, QWidget *parent = nullptr);
void setKind(Kind kind);
protected:
void changeEvent(QEvent *event) override;
private:
void applyTheme();
Kind m_kind;
bool m_inApplyTheme = false;
};
class AgentSlotWidget : public QWidget
{
Q_OBJECT
public:
explicit AgentSlotWidget(QWidget *parent = nullptr);
void setAgentConfig(const AgentConfig &cfg);
void clear();
QPushButton *changeButton() const { return m_changeBtn; }
signals:
void changeRequested();
void editRequested();
protected:
void changeEvent(QEvent *event) override;
private:
void applyTheme();
QLabel *m_name = nullptr;
Pill *m_sourcePill = nullptr;
QPushButton *m_changeBtn = nullptr;
QPushButton *m_editBtn = nullptr;
QLabel *m_modelLabel = nullptr;
QLabel *m_modelValue = nullptr;
QLabel *m_urlLabel = nullptr;
QLabel *m_urlValue = nullptr;
QLabel *m_endpointLabel = nullptr;
QLabel *m_endpointValue = nullptr;
QLabel *m_templateLabel = nullptr;
QLabel *m_templateValue = nullptr;
QLabel *m_description = nullptr;
Pill *m_thinkingPill = nullptr;
Pill *m_toolsPill = nullptr;
bool m_inApplyTheme = false;
};
} // namespace QodeAssist::Settings

View File

@@ -1,38 +0,0 @@
add_library(QodeAssistAgentPipelines OBJECT
AgentPipelinesPage.hpp AgentPipelinesPage.cpp
PipelinesConfig.hpp PipelinesConfig.cpp
AgentRosterWidget.hpp AgentRosterWidget.cpp
AgentSlotWidget.hpp AgentSlotWidget.cpp
AgentSelectionDialog.hpp AgentSelectionDialog.cpp
)
target_include_directories(QodeAssistAgentPipelines PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/sources/providers
${CMAKE_SOURCE_DIR}/sources/common
${CMAKE_SOURCE_DIR}/sources/agents
${CMAKE_SOURCE_DIR}/settings
${CMAKE_SOURCE_DIR}/logger
)
target_link_libraries(QodeAssistAgentPipelines PRIVATE
Qt::Core
Qt::Gui
Qt::Widgets
Qt::Network
QtCreator::Core
QtCreator::Utils
Agents
Providers
Common
LLMQore
QodeAssistLogger
TomlSerializer
tomlplusplus::tomlplusplus
)
target_compile_definitions(QodeAssistAgentPipelines PRIVATE
QODEASSIST_QT_CREATOR_VERSION_MAJOR=${QODEASSIST_QT_CREATOR_VERSION_MAJOR}
QODEASSIST_QT_CREATOR_VERSION_MINOR=${QODEASSIST_QT_CREATOR_VERSION_MINOR}
QODEASSIST_QT_CREATOR_VERSION_PATCH=${QODEASSIST_QT_CREATOR_VERSION_PATCH}
)

View File

@@ -1,281 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "PipelinesConfig.hpp"
#include <coreplugin/icore.h>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <toml++/toml.hpp>
#include <exception>
#include "Logger.hpp"
#include "TomlWriter.hpp"
#include <AgentFactory.hpp>
namespace QodeAssist::Settings {
namespace {
constexpr const char *kSection = "pipelines";
constexpr const char *kCodeCompletion = "code_completion";
constexpr const char *kChatAssistant = "chat_assistant";
constexpr const char *kChatCompression = "chat_compression";
constexpr const char *kQuickRefactor = "quick_refactor";
constexpr int kMaxAgentNameLen = 256;
QString trimAndCap(const QString &raw)
{
QString s = raw.trimmed();
if (s.size() > kMaxAgentNameLen)
s.truncate(kMaxAgentNameLen);
return s;
}
QStringList toStringList(const toml::node *node, const QString &slotKey, bool *schemaOk)
{
QStringList out;
if (!node)
return out;
const auto *arr = node->as_array();
if (!arr) {
LOG_MESSAGE(QStringLiteral(
"[Pipelines] schema error: '%1' must be an array of strings, got non-array")
.arg(slotKey));
if (schemaOk)
*schemaOk = false;
return out;
}
out.reserve(static_cast<qsizetype>(arr->size()));
for (size_t i = 0; i < arr->size(); ++i) {
const auto &el = (*arr)[i];
if (const auto *s = el.as_string()) {
const QString name = trimAndCap(QString::fromStdString(s->get()));
if (name.isEmpty())
continue;
if (out.contains(name)) {
LOG_MESSAGE(QStringLiteral("[Pipelines] '%1' element #%2 is a duplicate, dropping")
.arg(slotKey)
.arg(i));
continue;
}
out.append(name);
} else {
LOG_MESSAGE(QStringLiteral("[Pipelines] '%1' element #%2 is not a string, dropping")
.arg(slotKey)
.arg(i));
if (schemaOk)
*schemaOk = false;
}
}
return out;
}
void fillMissingFromDefaults(PipelineRosters &r, const toml::table &section)
{
const PipelineRosters defs = PipelineRosters::defaults();
if (!section.contains(kCodeCompletion))
r.codeCompletion = defs.codeCompletion;
if (!section.contains(kChatAssistant))
r.chatAssistant = defs.chatAssistant;
if (!section.contains(kChatCompression))
r.chatCompression = defs.chatCompression;
if (!section.contains(kQuickRefactor))
r.quickRefactor = defs.quickRefactor;
}
} // namespace
PipelineRosters PipelineRosters::defaults()
{
PipelineRosters r;
r.codeCompletion = {QStringLiteral("Ollama Qwen2.5-Coder Completion")};
r.chatAssistant = {QStringLiteral("Ollama Chat")};
r.chatCompression = {QStringLiteral("Ollama Compression")};
r.quickRefactor = {QStringLiteral("Ollama Quick Refactor")};
return r;
}
QString PipelinesConfig::filePath()
{
return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/pipelines.toml"))
.toFSPathString();
}
PipelinesLoadResult PipelinesConfig::load()
{
PipelinesLoadResult result;
const QString path = filePath();
QFile f(path);
if (!f.exists()) {
result.rosters = PipelineRosters::defaults();
result.status = PipelinesLoadStatus::FileMissing;
return result;
}
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
result.rosters = PipelineRosters::defaults();
result.status = PipelinesLoadStatus::ParseError;
result.message = QStringLiteral("cannot open %1: %2").arg(path, f.errorString());
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message));
return result;
}
const QByteArray contents = f.readAll();
f.close();
toml::table tbl;
try {
tbl = toml::parse(std::string_view(contents.constData(),
static_cast<size_t>(contents.size())),
path.toStdString());
} catch (const toml::parse_error &e) {
result.rosters = PipelineRosters::defaults();
result.status = PipelinesLoadStatus::ParseError;
result.message = QStringLiteral("parse error in %1: %2")
.arg(path, QString::fromStdString(std::string(e.description())));
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message));
return result;
} catch (const std::exception &e) {
result.rosters = PipelineRosters::defaults();
result.status = PipelinesLoadStatus::ParseError;
result.message = QStringLiteral("error reading %1: %2")
.arg(path, QString::fromUtf8(e.what()));
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message));
return result;
} catch (...) {
result.rosters = PipelineRosters::defaults();
result.status = PipelinesLoadStatus::ParseError;
result.message = QStringLiteral("unknown error reading %1").arg(path);
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message));
return result;
}
const auto *section = tbl[kSection].as_table();
if (!section) {
LOG_MESSAGE(QStringLiteral("[Pipelines] no [pipelines] section in %1; using defaults")
.arg(path));
result.rosters = PipelineRosters::defaults();
result.status = PipelinesLoadStatus::SchemaError;
result.message = QStringLiteral("missing [pipelines] section");
return result;
}
bool schemaOk = true;
result.rosters.codeCompletion
= toStringList((*section)[kCodeCompletion].node(), kCodeCompletion, &schemaOk);
result.rosters.chatAssistant
= toStringList((*section)[kChatAssistant].node(), kChatAssistant, &schemaOk);
result.rosters.chatCompression
= toStringList((*section)[kChatCompression].node(), kChatCompression, &schemaOk);
result.rosters.quickRefactor
= toStringList((*section)[kQuickRefactor].node(), kQuickRefactor, &schemaOk);
fillMissingFromDefaults(result.rosters, *section);
if (!schemaOk) {
result.status = PipelinesLoadStatus::SchemaError;
result.message = QStringLiteral("some entries had wrong type; see log");
}
return result;
}
bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut)
{
const QString path = filePath();
const QFileInfo info(path);
if (!QDir().mkpath(info.absolutePath())) {
const QString err = QStringLiteral("cannot create configuration directory: %1")
.arg(info.absolutePath());
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(err));
if (errorOut)
*errorOut = err;
return false;
}
TomlSerializer::TomlWriter w;
w.writeComment(QStringLiteral(
"QodeAssist pipelines — slot → ordered list of agent names."));
w.writeComment(QStringLiteral(
"The router walks each list top-down at request time and uses"));
w.writeComment(QStringLiteral("the first matching agent."));
w.writeBlankLine();
w.writeTableHeader(QString::fromUtf8(kSection));
w.writeStringArray(QString::fromUtf8(kCodeCompletion), rosters.codeCompletion);
w.writeStringArray(QString::fromUtf8(kChatAssistant), rosters.chatAssistant);
w.writeStringArray(QString::fromUtf8(kChatCompression), rosters.chatCompression);
w.writeStringArray(QString::fromUtf8(kQuickRefactor), rosters.quickRefactor);
QSaveFile out(path);
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
if (errorOut)
*errorOut = out.errorString();
return false;
}
const QByteArray payload = w.toUtf8();
const qint64 written = out.write(payload);
if (written != payload.size()) {
const QString err = QStringLiteral("short write (%1/%2): %3")
.arg(written)
.arg(payload.size())
.arg(out.errorString());
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(err));
out.cancelWriting();
if (errorOut)
*errorOut = err;
return false;
}
if (!out.commit()) {
if (errorOut)
*errorOut = out.errorString();
return false;
}
return true;
}
bool PipelinesConfig::validate(const QodeAssist::AgentFactory &factory, QString *errorOut)
{
PipelinesLoadResult lr = load();
PipelineRosters &r = lr.rosters;
bool changed = false;
auto fix = [&](QStringList &current) {
QStringList kept;
kept.reserve(current.size());
for (const QString &raw : current) {
const QString name = trimAndCap(raw);
if (name.isEmpty() || kept.contains(name))
continue;
if (factory.configByName(name))
kept.append(name);
}
if (kept != current) {
current = std::move(kept);
changed = true;
}
};
fix(r.codeCompletion);
fix(r.chatAssistant);
fix(r.chatCompression);
fix(r.quickRefactor);
if (!changed && lr.status == PipelinesLoadStatus::Ok)
return true;
QString saveErr;
if (!save(r, &saveErr)) {
const QString msg = QStringLiteral("failed to persist after validation: %1").arg(saveErr);
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(msg));
if (errorOut)
*errorOut = msg;
return false;
}
return true;
}
} // namespace QodeAssist::Settings

View File

@@ -1,48 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QString>
#include <QStringList>
namespace QodeAssist {
class AgentFactory;
}
namespace QodeAssist::Settings {
struct PipelineRosters
{
QStringList codeCompletion;
QStringList chatAssistant;
QStringList chatCompression;
QStringList quickRefactor;
[[nodiscard]] static PipelineRosters defaults();
};
enum class PipelinesLoadStatus { Ok, FileMissing, ParseError, SchemaError };
struct PipelinesLoadResult
{
PipelineRosters rosters;
PipelinesLoadStatus status = PipelinesLoadStatus::Ok;
QString message;
};
class PipelinesConfig
{
public:
[[nodiscard]] static QString filePath();
[[nodiscard]] static PipelinesLoadResult load();
[[nodiscard]] static bool save(const PipelineRosters &rosters, QString *errorOut = nullptr);
[[nodiscard]] static bool validate(
const QodeAssist::AgentFactory &factory, QString *errorOut = nullptr);
};
} // namespace QodeAssist::Settings

View File

@@ -1,17 +0,0 @@
add_library(Templates STATIC
PromptTemplate.hpp
JsonPromptTemplate.hpp JsonPromptTemplate.cpp
)
target_link_libraries(Templates
PUBLIC
Qt::Core
Common
Providers
pantor::inja
)
target_include_directories(Templates
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
PRIVATE ${CMAKE_SOURCE_DIR}/sources/agents
)

View File

@@ -1,337 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "JsonPromptTemplate.hpp"
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <filesystem>
#include "AgentConfig.hpp"
namespace QodeAssist::Templates {
namespace {
nlohmann::json buildContextJson(const ContextData &context)
{
nlohmann::json ctx = nlohmann::json::object();
if (context.systemPrompt) {
ctx["system_prompt"] = context.systemPrompt->toStdString();
}
if (context.prefix) {
ctx["prefix"] = context.prefix->toStdString();
}
if (context.suffix) {
ctx["suffix"] = context.suffix->toStdString();
}
if (context.filesMetadata && !context.filesMetadata->isEmpty()) {
nlohmann::json files = nlohmann::json::array();
for (const auto &file : context.filesMetadata.value()) {
nlohmann::json fj = nlohmann::json::object();
fj["file_path"] = file.filePath.toStdString();
fj["content"] = file.content.toStdString();
files.push_back(std::move(fj));
}
ctx["files_metadata"] = std::move(files);
}
nlohmann::json history = nlohmann::json::array();
if (context.history) {
for (const auto &msg : context.history.value()) {
nlohmann::json mj = nlohmann::json::object();
mj["role"] = msg.role.toStdString();
nlohmann::json blocks = nlohmann::json::array();
QString flatContent;
nlohmann::json flatImages = nlohmann::json::array();
for (const auto &b : msg.blocks) {
nlohmann::json bj = nlohmann::json::object();
switch (b.kind) {
case ContentBlockEntry::Kind::Text:
bj["type"] = "text";
bj["text"] = b.text.toStdString();
flatContent += b.text;
break;
case ContentBlockEntry::Kind::Thinking:
bj["type"] = "thinking";
bj["thinking"] = b.thinking.toStdString();
bj["signature"] = b.signature.toStdString();
break;
case ContentBlockEntry::Kind::RedactedThinking:
bj["type"] = "redacted_thinking";
bj["data"] = b.signature.toStdString();
break;
case ContentBlockEntry::Kind::ToolUse: {
bj["type"] = "tool_use";
bj["id"] = b.toolUseId.toStdString();
bj["name"] = b.toolName.toStdString();
const std::string inputStr
= QJsonDocument(b.toolInput).toJson(QJsonDocument::Compact).toStdString();
nlohmann::json parsedInput
= nlohmann::json::parse(inputStr, nullptr, /*allow_exceptions=*/false);
if (parsedInput.is_discarded()) {
if (!b.toolInput.isEmpty()) {
qWarning("[QodeAssist] tool_use '%s' has unparseable input "
"(serialized as null): %s",
qUtf8Printable(b.toolName),
inputStr.c_str());
}
parsedInput = nullptr;
}
bj["input"] = std::move(parsedInput);
break;
}
case ContentBlockEntry::Kind::ToolResult:
bj["type"] = "tool_result";
bj["tool_use_id"] = b.toolUseId.toStdString();
bj["content"] = b.result.toStdString();
break;
case ContentBlockEntry::Kind::Image:
bj["type"] = "image";
bj["data"] = b.imageData.toStdString();
bj["media_type"] = b.mediaType.toStdString();
bj["is_url"] = b.isImageUrl;
{
nlohmann::json ij = nlohmann::json::object();
ij["data"] = b.imageData.toStdString();
ij["media_type"] = b.mediaType.toStdString();
ij["is_url"] = b.isImageUrl;
flatImages.push_back(std::move(ij));
}
break;
}
blocks.push_back(std::move(bj));
}
mj["content"] = flatContent.toStdString();
if (!flatImages.empty())
mj["images"] = std::move(flatImages);
mj["content_blocks"] = std::move(blocks);
history.push_back(std::move(mj));
}
}
ctx["history"] = std::move(history);
nlohmann::json data = nlohmann::json::object();
data["ctx"] = std::move(ctx);
return data;
}
void registerStandardCallbacks(inja::Environment &env)
{
// Sandbox: disable filesystem reads from `{% include %}` and reject
// any include callback. User-authored templates run with full
// process privileges, so they must not slurp arbitrary files via
// include directives. File reads happen only through
// ContextManager-provided callbacks (e.g. read_file()).
env.set_search_included_templates_in_files(false);
env.set_include_callback(
[](const std::filesystem::path &, const std::string &name) -> inja::Template {
throw inja::FileError(
"include is disabled in QodeAssist templates: '" + name + "'");
});
// Disable inja's `##` line-statement shorthand — collides with
// Markdown headings inside template bodies. Same rationale as in
// ContextRenderer; retarget to an unreachable sentinel.
env.set_line_statement("@@@inja@@@");
env.add_callback("tojson", 1, [](inja::Arguments &args) -> nlohmann::json {
return args.at(0)->dump();
});
env.add_callback("strip_signature_suffix", 1, [](inja::Arguments &args) -> nlohmann::json {
std::string content = args.at(0)->get<std::string>();
const std::string marker = "\n[Signature: ";
const auto pos = content.find(marker);
if (pos != std::string::npos) {
content = content.substr(0, pos);
}
return content;
});
env.add_callback("filter_skip_role", 2, [](inja::Arguments &args) -> nlohmann::json {
const nlohmann::json &history = *args.at(0);
const std::string role = args.at(1)->get<std::string>();
nlohmann::json result = nlohmann::json::array();
for (const auto &msg : history) {
if (msg.contains("role") && msg["role"].get<std::string>() == role) {
continue;
}
result.push_back(msg);
}
return result;
});
env.add_callback("filter_skip_empty_thinking", 1, [](inja::Arguments &args) -> nlohmann::json {
const nlohmann::json &history = *args.at(0);
nlohmann::json result = nlohmann::json::array();
for (const auto &msg : history) {
const bool isThinking = msg.value("is_thinking", false);
const std::string sig = msg.value("signature", "");
if (isThinking && sig.empty()) {
continue;
}
result.push_back(msg);
}
return result;
});
env.add_callback(
"filter_skip_empty_parts_thinking", 1, [](inja::Arguments &args) -> nlohmann::json {
const nlohmann::json &history = *args.at(0);
nlohmann::json result = nlohmann::json::array();
for (const auto &msg : history) {
const bool isThinking = msg.value("is_thinking", false);
const std::string content = msg.value("content", "");
const std::string sig = msg.value("signature", "");
if (isThinking && content.empty() && sig.empty()) {
continue;
}
result.push_back(msg);
}
return result;
});
}
} // namespace
std::unique_ptr<JsonPromptTemplate> JsonPromptTemplate::fromConfig(
const AgentConfig &cfg, QString *error)
{
auto setError = [&error](const QString &msg) {
if (error) *error = msg;
};
if (cfg.messageFormat.isEmpty()) {
setError(QStringLiteral("Agent '%1' has empty message_format").arg(cfg.name));
return nullptr;
}
auto tpl = std::unique_ptr<JsonPromptTemplate>(new JsonPromptTemplate);
tpl->m_name = cfg.name;
tpl->m_description = cfg.description;
tpl->m_sampling = cfg.sampling;
tpl->m_thinking = cfg.thinking;
registerStandardCallbacks(tpl->m_env);
try {
tpl->m_template = tpl->m_env.parse(cfg.messageFormat.toStdString());
} catch (const std::exception &e) {
setError(QStringLiteral("Failed to parse jinja for '%1': %2")
.arg(cfg.name, QString::fromUtf8(e.what())));
return nullptr;
}
return tpl;
}
std::optional<QJsonObject> JsonPromptTemplate::renderBody(const ContextData &context) const
{
const nlohmann::json data = buildContextJson(context);
std::string rendered;
try {
std::lock_guard<std::mutex> lock(m_renderMutex);
rendered = m_env.render(m_template, data);
} catch (const std::exception &e) {
qWarning("[QodeAssist] Template '%s' render failed: %s",
qUtf8Printable(m_name),
e.what());
return std::nullopt;
}
QJsonParseError err;
const QJsonDocument doc
= QJsonDocument::fromJson(QByteArray::fromStdString(rendered), &err);
constexpr std::size_t kMaxRenderedLogChars = 500;
const std::string truncated = rendered.size() > kMaxRenderedLogChars
? rendered.substr(0, kMaxRenderedLogChars) + "... [truncated]"
: rendered;
if (err.error != QJsonParseError::NoError) {
qWarning("[QodeAssist] Template '%s' produced invalid JSON at offset %d: %s\n"
"--- raw output (truncated) ---\n%s",
qUtf8Printable(m_name),
err.offset,
qUtf8Printable(err.errorString()),
truncated.c_str());
return std::nullopt;
}
if (!doc.isObject()) {
qWarning("[QodeAssist] Template '%s' rendered a non-object JSON value (truncated):\n%s",
qUtf8Printable(m_name),
truncated.c_str());
return std::nullopt;
}
return doc.object();
}
namespace {
bool mergeRenderedBody(QJsonObject &request, const std::optional<QJsonObject> &body)
{
if (!body)
return false;
for (auto it = body->constBegin(); it != body->constEnd(); ++it) {
request.insert(it.key(), it.value());
}
return true;
}
void deepMergeInto(QJsonObject &base, const QJsonObject &overlay)
{
for (auto it = overlay.constBegin(); it != overlay.constEnd(); ++it) {
const QJsonValue baseVal = base.value(it.key());
const QJsonValue overlayVal = it.value();
if (baseVal.isObject() && overlayVal.isObject()) {
QJsonObject merged = baseVal.toObject();
deepMergeInto(merged, overlayVal.toObject());
base[it.key()] = merged;
} else {
base[it.key()] = overlayVal;
}
}
}
} // namespace
void JsonPromptTemplate::prepareRequest(QJsonObject &request, const ContextData &context) const
{
mergeRenderedBody(request, renderBody(context));
}
bool JsonPromptTemplate::buildFullRequest(
QJsonObject &request,
const ContextData &context,
bool thinkingEnabled) const
{
if (!mergeRenderedBody(request, renderBody(context)))
return false;
applySampling(request, thinkingEnabled);
return true;
}
void JsonPromptTemplate::applySampling(QJsonObject &request, bool thinkingEnabled) const
{
// Merge order: sampling provides defaults → body wins for its own
// keys → thinking overrides win on top.
QJsonObject merged = m_sampling;
deepMergeInto(merged, request);
if (thinkingEnabled && !m_thinking.isEmpty()) {
deepMergeInto(merged, m_thinking.value("overrides").toObject());
deepMergeInto(merged, m_thinking.value("request_block").toObject());
}
request = std::move(merged);
}
} // namespace QodeAssist::Templates

View File

@@ -1,76 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <memory>
#include <mutex>
#include <optional>
#include <QJsonObject>
#include <QString>
#include <inja/inja.hpp>
#include "PromptTemplate.hpp"
namespace QodeAssist {
struct AgentConfig;
}
namespace QodeAssist::Templates {
// Renderer for the request-body jinja template embedded in an
// AgentConfig. One per Agent — built inline from the config (no shared
// template registry, no model/provider filtering).
class JsonPromptTemplate : public PromptTemplate
{
public:
// Build a renderer from an already-parsed agent config. Compiles
// the jinja source via inja once. On failure returns nullptr and
// populates `*error` (existing content preserved; pass nullptr to
// discard).
static std::unique_ptr<JsonPromptTemplate> fromConfig(
const AgentConfig &cfg, QString *error = nullptr);
QString name() const override { return m_name; }
QString description() const override { return m_description; }
// Standalone-template filters are gone — each template is built for
// exactly one agent, so it always matches its owner's provider/model.
bool isSupportProvider(Providers::ProviderID) const override { return true; }
bool isSupportModel(const QString &) const override { return true; }
PromptShape shape() const override { return PromptShape::Chat; }
void prepareRequest(QJsonObject &request, const ContextData &context) const override;
[[nodiscard]] bool buildFullRequest(
QJsonObject &request,
const ContextData &context,
bool thinkingEnabled = false) const override;
const QJsonObject &sampling() const { return m_sampling; }
private:
JsonPromptTemplate() = default;
std::optional<QJsonObject> renderBody(const ContextData &context) const;
void applySampling(QJsonObject &request, bool thinkingEnabled) const;
QString m_name;
QString m_description;
// m_env is populated once in fromConfig() and never mutated again.
// It is `mutable` only because inja::Environment::render() is not a
// const member; m_renderMutex serialises those render() calls since
// inja's render path is not internally re-entrant on one Environment.
mutable inja::Environment m_env;
inja::Template m_template;
mutable std::mutex m_renderMutex;
QJsonObject m_sampling;
QJsonObject m_thinking;
};
} // namespace QodeAssist::Templates

View File

@@ -1,51 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QJsonObject>
#include <QList>
#include <QString>
#include "ContextData.hpp"
#include "ProviderID.hpp"
namespace QodeAssist::Templates {
using Providers::ProviderID;
enum class PromptShape {
Chat,
Fim,
};
class PromptTemplate
{
public:
PromptTemplate() = default;
virtual ~PromptTemplate() = default;
PromptTemplate(const PromptTemplate &) = delete;
PromptTemplate &operator=(const PromptTemplate &) = delete;
PromptTemplate(PromptTemplate &&) = delete;
PromptTemplate &operator=(PromptTemplate &&) = delete;
virtual QString name() const = 0;
virtual void prepareRequest(QJsonObject &request, const ContextData &context) const = 0;
virtual QString description() const = 0;
virtual bool isSupportProvider(ProviderID id) const = 0;
virtual PromptShape shape() const { return PromptShape::Chat; }
virtual bool isSupportModel(const QString & /*modelName*/) const { return true; }
[[nodiscard]] virtual bool buildFullRequest(
QJsonObject &request,
const ContextData &context,
bool /*thinkingEnabled*/ = false) const
{
prepareRequest(request, context);
return true;
}
};
} // namespace QodeAssist::Templates

View File

@@ -1,12 +0,0 @@
add_library(TomlSerializer STATIC
TomlWriter.hpp TomlWriter.cpp
)
target_link_libraries(TomlSerializer
PUBLIC
Qt::Core
)
target_include_directories(TomlSerializer
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
)

View File

@@ -1,136 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "TomlWriter.hpp"
#include <QJsonValue>
namespace QodeAssist::TomlSerializer {
QString escapeBasic(const QString &s)
{
QString out;
out.reserve(s.size());
for (QChar c : s) {
const ushort u = c.unicode();
switch (u) {
case '\\': out += QLatin1String("\\\\"); break;
case '"': out += QLatin1String("\\\""); break;
case '\b': out += QLatin1String("\\b"); break;
case '\t': out += QLatin1String("\\t"); break;
case '\n': out += QLatin1String("\\n"); break;
case '\f': out += QLatin1String("\\f"); break;
case '\r': out += QLatin1String("\\r"); break;
default:
if (u < 0x20 || u == 0x7f)
out += QStringLiteral("\\u%1").arg(u, 4, 16, QLatin1Char('0'));
else
out += c;
break;
}
}
return out;
}
void TomlWriter::writeBlankLine()
{
m_out += QLatin1Char('\n');
}
void TomlWriter::writeComment(const QString &line)
{
m_out += QLatin1String("# ");
m_out += line;
m_out += QLatin1Char('\n');
}
void TomlWriter::writeTableHeader(const QString &name)
{
m_out += QLatin1Char('[');
m_out += name;
m_out += QLatin1String("]\n");
}
void TomlWriter::writeKeyPrefix(const QString &key)
{
m_out += key;
if (m_keyColumnWidth > key.size())
m_out += QString(m_keyColumnWidth - key.size(), QLatin1Char(' '));
m_out += QLatin1String(" = ");
}
void TomlWriter::writeString(const QString &key, const QString &value)
{
writeKeyPrefix(key);
m_out += QLatin1Char('"');
m_out += escapeBasic(value);
m_out += QLatin1String("\"\n");
}
void TomlWriter::writeBool(const QString &key, bool value)
{
writeKeyPrefix(key);
m_out += value ? QLatin1String("true") : QLatin1String("false");
m_out += QLatin1Char('\n');
}
void TomlWriter::writeInt(const QString &key, qint64 value)
{
writeKeyPrefix(key);
m_out += QString::number(value);
m_out += QLatin1Char('\n');
}
void TomlWriter::writeDouble(const QString &key, double value)
{
writeKeyPrefix(key);
m_out += QString::number(value);
m_out += QLatin1Char('\n');
}
void TomlWriter::writeStringArray(const QString &key, const QStringList &values)
{
writeKeyPrefix(key);
m_out += QLatin1Char('[');
bool first = true;
for (const QString &v : values) {
if (!first)
m_out += QLatin1String(", ");
m_out += QLatin1Char('"');
m_out += escapeBasic(v);
m_out += QLatin1Char('"');
first = false;
}
m_out += QLatin1String("]\n");
}
void TomlWriter::writeJsonPrimitives(const QJsonObject &obj)
{
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) {
const QJsonValue &v = it.value();
switch (v.type()) {
case QJsonValue::String: writeString(it.key(), v.toString()); break;
case QJsonValue::Bool: writeBool(it.key(), v.toBool()); break;
case QJsonValue::Double: {
const double d = v.toDouble();
const qint64 i = static_cast<qint64>(d);
if (static_cast<double>(i) == d)
writeInt(it.key(), i);
else
writeDouble(it.key(), d);
break;
}
default:
break;
}
}
}
void TomlWriter::writeStringDict(const QHash<QString, QString> &dict)
{
for (auto it = dict.constBegin(); it != dict.constEnd(); ++it)
writeString(it.key(), it.value());
}
} // namespace QodeAssist::TomlSerializer

View File

@@ -1,48 +0,0 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#pragma once
#include <QHash>
#include <QJsonObject>
#include <QString>
#include <QStringList>
namespace QodeAssist::TomlSerializer {
[[nodiscard]] QString escapeBasic(const QString &s);
class TomlWriter
{
public:
TomlWriter() = default;
explicit TomlWriter(int keyColumnWidth) : m_keyColumnWidth(keyColumnWidth) {}
void setKeyColumnWidth(int width) { m_keyColumnWidth = width; }
void writeBlankLine();
void writeComment(const QString &line); // "# line\n"
void writeTableHeader(const QString &name); // "[name]\n"
void writeString(const QString &key, const QString &value);
void writeBool(const QString &key, bool value);
void writeInt(const QString &key, qint64 value);
void writeDouble(const QString &key, double value);
void writeStringArray(const QString &key, const QStringList &values);
void writeJsonPrimitives(const QJsonObject &obj);
void writeStringDict(const QHash<QString, QString> &dict);
[[nodiscard]] QString result() const { return m_out; }
[[nodiscard]] QByteArray toUtf8() const { return m_out.toUtf8(); }
private:
void writeKeyPrefix(const QString &key);
QString m_out;
int m_keyColumnWidth = 0;
};
} // namespace QodeAssist::TomlSerializer