mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-05-30 02:49:12 -04:00
feat: Add settings page for providers (#353)
This commit is contained in:
10
sources/external/CMakeLists.txt
vendored
Normal file
10
sources/external/CMakeLists.txt
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
include(FetchContent)
|
||||
|
||||
FetchContent_Declare(tomlplusplus
|
||||
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
|
||||
GIT_TAG v3.4.0
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(tomlplusplus)
|
||||
|
||||
add_subdirectory(llmqore)
|
||||
27
sources/providersConfig/CMakeLists.txt
Normal file
27
sources/providersConfig/CMakeLists.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
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}
|
||||
)
|
||||
51
sources/providersConfig/ProviderInstance.cpp
Normal file
51
sources/providersConfig/ProviderInstance.cpp
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
57
sources/providersConfig/ProviderInstance.hpp
Normal file
57
sources/providersConfig/ProviderInstance.hpp
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
198
sources/providersConfig/ProviderInstanceFactory.cpp
Normal file
198
sources/providersConfig/ProviderInstanceFactory.cpp
Normal file
@@ -0,0 +1,198 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
67
sources/providersConfig/ProviderInstanceFactory.hpp
Normal file
67
sources/providersConfig/ProviderInstanceFactory.hpp
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <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
|
||||
270
sources/providersConfig/ProviderInstanceLoader.cpp
Normal file
270
sources/providersConfig/ProviderInstanceLoader.cpp
Normal file
@@ -0,0 +1,270 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
32
sources/providersConfig/ProviderInstanceLoader.hpp
Normal file
32
sources/providersConfig/ProviderInstanceLoader.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
165
sources/providersConfig/ProviderInstanceWriter.cpp
Normal file
165
sources/providersConfig/ProviderInstanceWriter.cpp
Normal file
@@ -0,0 +1,165 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
27
sources/providersConfig/ProviderInstanceWriter.hpp
Normal file
27
sources/providersConfig/ProviderInstanceWriter.hpp
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
637
sources/providersConfig/ProviderLauncher.cpp
Normal file
637
sources/providersConfig/ProviderLauncher.cpp
Normal file
@@ -0,0 +1,637 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
82
sources/providersConfig/ProviderLauncher.hpp
Normal file
82
sources/providersConfig/ProviderLauncher.hpp
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
75
sources/providersConfig/ProviderSecretsStore.cpp
Normal file
75
sources/providersConfig/ProviderSecretsStore.cpp
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
30
sources/providersConfig/ProviderSecretsStore.hpp
Normal file
30
sources/providersConfig/ProviderSecretsStore.hpp
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
8
sources/providersConfig/claude.toml
Normal file
8
sources/providersConfig/claude.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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"
|
||||
8
sources/providersConfig/codestral.toml
Normal file
8
sources/providersConfig/codestral.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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"
|
||||
8
sources/providersConfig/googleai.toml
Normal file
8
sources/providersConfig/googleai.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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"
|
||||
8
sources/providersConfig/llamacpp.toml
Normal file
8
sources/providersConfig/llamacpp.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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"
|
||||
8
sources/providersConfig/lmstudio_chat.toml
Normal file
8
sources/providersConfig/lmstudio_chat.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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)"
|
||||
8
sources/providersConfig/lmstudio_responses.toml
Normal file
8
sources/providersConfig/lmstudio_responses.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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)"
|
||||
8
sources/providersConfig/mistral.toml
Normal file
8
sources/providersConfig/mistral.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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"
|
||||
8
sources/providersConfig/ollama_compat.toml
Normal file
8
sources/providersConfig/ollama_compat.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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)"
|
||||
8
sources/providersConfig/ollama_native.toml
Normal file
8
sources/providersConfig/ollama_native.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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)"
|
||||
8
sources/providersConfig/openai_chat.toml
Normal file
8
sources/providersConfig/openai_chat.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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)"
|
||||
8
sources/providersConfig/openai_compat.toml
Normal file
8
sources/providersConfig/openai_compat.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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"
|
||||
8
sources/providersConfig/openai_responses.toml
Normal file
8
sources/providersConfig/openai_responses.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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)"
|
||||
8
sources/providersConfig/openrouter.toml
Normal file
8
sources/providersConfig/openrouter.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
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"
|
||||
17
sources/providersConfig/provider_instances.qrc
Normal file
17
sources/providersConfig/provider_instances.qrc
Normal file
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
12
sources/tomlSerializer/CMakeLists.txt
Normal file
12
sources/tomlSerializer/CMakeLists.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
add_library(TomlSerializer STATIC
|
||||
TomlWriter.hpp TomlWriter.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(TomlSerializer
|
||||
PUBLIC
|
||||
Qt::Core
|
||||
)
|
||||
|
||||
target_include_directories(TomlSerializer
|
||||
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
)
|
||||
135
sources/tomlSerializer/TomlWriter.cpp
Normal file
135
sources/tomlSerializer/TomlWriter.cpp
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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
|
||||
47
sources/tomlSerializer/TomlWriter.hpp
Normal file
47
sources/tomlSerializer/TomlWriter.hpp
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#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