mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-07-20 10:10:56 -04:00
refactor: Remove all experimental code
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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>"]
|
||||
@@ -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>", "##"]
|
||||
@@ -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>"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -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"
|
||||
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -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"
|
||||
@@ -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)"
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 ¤tName,
|
||||
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 ¤tName)
|
||||
{
|
||||
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
|
||||
@@ -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 ¤tName,
|
||||
AgentFactory *agentFactory = nullptr,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
QString selectedName() const { return m_selectedName; }
|
||||
|
||||
private:
|
||||
void rebuild(const QString ¤tName);
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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 §ion)
|
||||
{
|
||||
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 ¤t) {
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user