feat: Add settings page for providers (#353)

This commit is contained in:
Petr Mironychev
2026-05-21 19:30:32 +02:00
committed by GitHub
parent ca3baa7597
commit e193d1e1fa
45 changed files with 3835 additions and 2 deletions

View File

@@ -2,6 +2,10 @@ cmake_minimum_required(VERSION 3.16)
project(QodeAssist) project(QodeAssist)
option(QODEASSIST_EXPERIMENTAL
"Enable experimental features" OFF)
message(STATUS "QodeAssist experimental features: ${QODEASSIST_EXPERIMENTAL}")
set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON) set(CMAKE_AUTOUIC ON)
@@ -34,11 +38,13 @@ add_definitions(
-DQODEASSIST_QT_CREATOR_VERSION_PATCH=${QODEASSIST_QT_CREATOR_VERSION_PATCH} -DQODEASSIST_QT_CREATOR_VERSION_PATCH=${QODEASSIST_QT_CREATOR_VERSION_PATCH}
) )
add_subdirectory(sources/external/llmqore) add_subdirectory(sources/external)
add_subdirectory(sources/tomlSerializer)
add_subdirectory(sources/skills) add_subdirectory(sources/skills)
add_subdirectory(pluginllmcore) add_subdirectory(pluginllmcore)
add_subdirectory(settings) add_subdirectory(settings)
add_subdirectory(logger) add_subdirectory(logger)
add_subdirectory(sources/providersConfig)
add_subdirectory(UIControls) add_subdirectory(UIControls)
add_subdirectory(ChatView) add_subdirectory(ChatView)
add_subdirectory(context) add_subdirectory(context)
@@ -65,6 +71,7 @@ add_qtc_plugin(QodeAssist
QtCreator::CPlusPlus QtCreator::CPlusPlus
LLMQore LLMQore
PluginLLMCore PluginLLMCore
ProvidersConfig
Skills Skills
QodeAssistChatViewplugin QodeAssistChatViewplugin
SOURCES SOURCES
@@ -160,6 +167,10 @@ add_qtc_plugin(QodeAssist
settings/McpClientsListAspect.hpp settings/McpClientsListAspect.cpp settings/McpClientsListAspect.hpp settings/McpClientsListAspect.cpp
) )
if(QODEASSIST_EXPERIMENTAL)
target_compile_definitions(QodeAssist PRIVATE QODEASSIST_EXPERIMENTAL)
endif()
get_target_property(QtCreatorCorePath QtCreator::Core LOCATION) get_target_property(QtCreatorCorePath QtCreator::Core LOCATION)
find_program(QtCreatorExecutable find_program(QtCreatorExecutable
NAMES NAMES

View File

@@ -1,6 +1,8 @@
// Copyright (C) 2024-2026 Petr Mironychev // Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
#include <memory>
#include "QodeAssistConstants.hpp" #include "QodeAssistConstants.hpp"
#include "QodeAssisttr.h" #include "QodeAssisttr.h"
#include "settings/PluginUpdater.hpp" #include "settings/PluginUpdater.hpp"
@@ -10,6 +12,7 @@
#include <coreplugin/actionmanager/actionmanager.h> #include <coreplugin/actionmanager/actionmanager.h>
#include <coreplugin/actionmanager/command.h> #include <coreplugin/actionmanager/command.h>
#include <coreplugin/coreconstants.h> #include <coreplugin/coreconstants.h>
#include <coreplugin/dialogs/ioptionspage.h>
#include <coreplugin/editormanager/documentmodel.h> #include <coreplugin/editormanager/documentmodel.h>
#include <coreplugin/editormanager/editormanager.h> #include <coreplugin/editormanager/editormanager.h>
#include <coreplugin/icontext.h> #include <coreplugin/icontext.h>
@@ -50,8 +53,17 @@
#include "settings/ChatAssistantSettings.hpp" #include "settings/ChatAssistantSettings.hpp"
#include "settings/GeneralSettings.hpp" #include "settings/GeneralSettings.hpp"
#include "settings/ProjectSettingsPanel.hpp" #include "settings/ProjectSettingsPanel.hpp"
#ifdef QODEASSIST_EXPERIMENTAL
#include "settings/ProvidersSettingsPage.hpp"
#endif
#include "settings/QuickRefactorSettings.hpp" #include "settings/QuickRefactorSettings.hpp"
#include "settings/SettingsConstants.hpp" #include "settings/SettingsConstants.hpp"
#ifdef QODEASSIST_EXPERIMENTAL
#include "ProviderInstanceFactory.hpp"
#include "ProviderLauncher.hpp"
#include "ProviderSecretsStore.hpp"
#endif
#include "templates/Templates.hpp" #include "templates/Templates.hpp"
#include "widgets/CustomInstructionsManager.hpp" #include "widgets/CustomInstructionsManager.hpp"
#include "widgets/QuickRefactorDialog.hpp" #include "widgets/QuickRefactorDialog.hpp"
@@ -192,6 +204,18 @@ public:
Settings::setupProjectPanel(); Settings::setupProjectPanel();
ConfigurationManager::instance().init(); ConfigurationManager::instance().init();
#ifdef QODEASSIST_EXPERIMENTAL
m_providerInstanceFactory = new Providers::ProviderInstanceFactory(this);
m_providerSecretsStore = new Providers::ProviderSecretsStore(this);
m_providerLauncher = new Providers::ProviderLauncher(this);
m_providersPageNavigator = new Settings::ProvidersPageNavigator(this);
m_providersOptionsPage = Settings::createProvidersSettingsPage(
m_providerInstanceFactory,
m_providerSecretsStore,
m_providerLauncher,
m_providersPageNavigator);
#endif
m_mcpServerManager = new Mcp::McpServerManager(this); m_mcpServerManager = new Mcp::McpServerManager(this);
m_mcpServerManager->init(); m_mcpServerManager->init();
@@ -482,10 +506,17 @@ private:
QPointer<PluginUpdater> m_updater; QPointer<PluginUpdater> m_updater;
UpdateStatusWidget *m_statusWidget{nullptr}; UpdateStatusWidget *m_statusWidget{nullptr};
QString m_lastRefactorInstructions; QString m_lastRefactorInstructions;
QScopedPointer<Chat::ChatView> m_chatView; std::unique_ptr<Chat::ChatView> m_chatView;
QPointer<Mcp::McpServerManager> m_mcpServerManager; QPointer<Mcp::McpServerManager> m_mcpServerManager;
QPointer<QQmlEngine> m_engine; QPointer<QQmlEngine> m_engine;
QPointer<Skills::SkillsManager> m_skillsManager; QPointer<Skills::SkillsManager> m_skillsManager;
#ifdef QODEASSIST_EXPERIMENTAL
QPointer<Providers::ProviderInstanceFactory> m_providerInstanceFactory;
QPointer<Providers::ProviderSecretsStore> m_providerSecretsStore;
QPointer<Providers::ProviderLauncher> m_providerLauncher;
QPointer<Settings::ProvidersPageNavigator> m_providersPageNavigator;
std::unique_ptr<Core::IOptionsPage> m_providersOptionsPage;
#endif
}; };
} // namespace QodeAssist::Internal } // namespace QodeAssist::Internal

View File

@@ -16,6 +16,12 @@ add_library(QodeAssistSettings STATIC
ProjectSettingsPanel.hpp ProjectSettingsPanel.cpp ProjectSettingsPanel.hpp ProjectSettingsPanel.cpp
ProviderSettings.hpp ProviderSettings.cpp ProviderSettings.hpp ProviderSettings.cpp
ProviderNameMigration.hpp ProviderNameMigration.hpp
ProvidersSettingsPage.hpp ProvidersSettingsPage.cpp
ProvidersSettingsHelpers.hpp
SectionBox.hpp SectionBox.cpp
ProviderListItem.hpp ProviderListItem.cpp
ProviderDetailPane.hpp ProviderDetailPane.cpp
NewProviderDialog.hpp NewProviderDialog.cpp
PluginUpdater.hpp PluginUpdater.cpp PluginUpdater.hpp PluginUpdater.cpp
UpdateDialog.hpp UpdateDialog.cpp UpdateDialog.hpp UpdateDialog.cpp
AgentRole.hpp AgentRole.cpp AgentRole.hpp AgentRole.cpp
@@ -31,6 +37,7 @@ target_link_libraries(QodeAssistSettings
QtCreator::Core QtCreator::Core
QtCreator::Utils QtCreator::Utils
QodeAssistLogger QodeAssistLogger
ProvidersConfig
Skills Skills
) )
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

View File

@@ -0,0 +1,81 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "NewProviderDialog.hpp"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>
namespace QodeAssist::Settings {
NewProviderDialog::NewProviderDialog(const QStringList &types, QWidget *parent)
: QDialog(parent)
{
setWindowTitle(tr("New provider"));
setMinimumWidth(520);
auto *intro = new QLabel(
tr("A provider binds a client API to a URL and API key. "
"Agents reference providers by name."),
this);
intro->setWordWrap(true);
QPalette ip = intro->palette();
ip.setColor(QPalette::WindowText, ip.color(QPalette::Mid));
intro->setPalette(ip);
m_typeCombo = new QComboBox(this);
m_typeCombo->addItems(types);
m_typeCombo->setEditable(false);
m_nameEdit = new QLineEdit(this);
m_nameEdit->setPlaceholderText(tr("Shown in the providers list and referenced by agents."));
m_urlEdit = new QLineEdit(this);
m_urlEdit->setPlaceholderText(QStringLiteral("https://api.example.com"));
m_descriptionEdit = new QLineEdit(this);
m_descriptionEdit->setPlaceholderText(tr("Optional — what this provider is for."));
m_apiKeyEdit = new QLineEdit(this);
m_apiKeyEdit->setEchoMode(QLineEdit::Password);
m_apiKeyEdit->setPlaceholderText(tr("(stored — leave blank to set later)"));
auto *form = new QFormLayout;
form->addRow(tr("Client API:"), m_typeCombo);
form->addRow(tr("Name:"), m_nameEdit);
form->addRow(tr("URL:"), m_urlEdit);
form->addRow(tr("Description:"), m_descriptionEdit);
form->addRow(tr("API key:"), m_apiKeyEdit);
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Cancel | QDialogButtonBox::Ok, this);
buttons->button(QDialogButtonBox::Ok)->setText(tr("Create"));
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *root = new QVBoxLayout(this);
root->addWidget(intro);
root->addLayout(form);
root->addWidget(buttons);
connect(m_typeCombo, &QComboBox::currentTextChanged, this, [this](const QString &type) {
if (m_nameEdit->text().isEmpty())
m_nameEdit->setText(type);
});
if (!types.isEmpty() && m_nameEdit->text().isEmpty())
m_nameEdit->setText(types.front());
}
QString NewProviderDialog::providerType() const { return m_typeCombo->currentText(); }
QString NewProviderDialog::providerName() const { return m_nameEdit->text().trimmed(); }
QString NewProviderDialog::url() const { return m_urlEdit->text().trimmed(); }
QString NewProviderDialog::description() const { return m_descriptionEdit->text().trimmed(); }
QString NewProviderDialog::apiKey() const { return m_apiKeyEdit->text(); }
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,34 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDialog>
#include <QStringList>
class QComboBox;
class QLineEdit;
namespace QodeAssist::Settings {
class NewProviderDialog : public QDialog
{
Q_OBJECT
public:
explicit NewProviderDialog(const QStringList &types, QWidget *parent = nullptr);
QString providerType() const;
QString providerName() const;
QString url() const;
QString description() const;
QString apiKey() const;
private:
QComboBox *m_typeCombo = nullptr;
QLineEdit *m_nameEdit = nullptr;
QLineEdit *m_urlEdit = nullptr;
QLineEdit *m_descriptionEdit = nullptr;
QLineEdit *m_apiKeyEdit = nullptr;
};
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,527 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProviderDetailPane.hpp"
#include <array>
#include <QFrame>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QToolButton>
#include <QVBoxLayout>
#include <solutions/terminal/terminalview.h>
#include "ProviderInstanceWriter.hpp"
#include "ProvidersSettingsHelpers.hpp"
#include "SectionBox.hpp"
namespace QodeAssist::Settings {
ProviderDetailPane::ProviderDetailPane(QWidget *parent)
: QWidget(parent)
{
m_nameLabel = new QLabel(this);
QFont nf = m_nameLabel->font();
nf.setBold(true);
nf.setPixelSize(15);
m_nameLabel->setFont(nf);
m_sourcePathLabel = new QLabel(this);
m_sourcePathLabel->setFont(monospaceFont(11));
QPalette spp = m_sourcePathLabel->palette();
spp.setColor(QPalette::WindowText, spp.color(QPalette::Mid));
m_sourcePathLabel->setPalette(spp);
m_editBtn = new QPushButton(tr("Edit…"), this);
m_editBtn->setDefault(true);
m_openInEditorBtn = new QPushButton(tr("Open in editor"), this);
m_openInEditorBtn->setToolTip(
tr("Open this provider's TOML file in Qt Creator. "
"Bundled providers are read-only — duplicate first."));
m_dupBtn = new QPushButton(tr("Duplicate…"), this);
m_deleteBtn = new QPushButton(tr("Delete"), this);
m_cancelBtn = new QPushButton(tr("Cancel"), this);
m_saveBtn = new QPushButton(tr("Save"), this);
m_saveBtn->setDefault(true);
m_cancelBtn->hide();
m_saveBtn->hide();
connect(m_editBtn, &QPushButton::clicked, this, [this] { setEditing(true); });
connect(m_cancelBtn, &QPushButton::clicked, this, [this] {
setEditing(false);
populate(m_current, m_currentHasStoredKey);
});
connect(m_saveBtn, &QPushButton::clicked, this, [this] {
emit saveRequested(collectEdits());
});
connect(m_openInEditorBtn, &QPushButton::clicked, this,
[this] { emit openInEditorRequested(m_current.sourcePath); });
connect(m_dupBtn, &QPushButton::clicked, this, [this] { emit duplicateRequested(); });
connect(m_deleteBtn, &QPushButton::clicked, this, [this] { emit deleteRequested(); });
auto *btnBar = new QHBoxLayout;
btnBar->setContentsMargins(0, 0, 0, 0);
btnBar->setSpacing(4);
btnBar->addWidget(m_editBtn);
btnBar->addWidget(m_openInEditorBtn);
btnBar->addWidget(m_dupBtn);
btnBar->addWidget(m_deleteBtn);
btnBar->addWidget(m_cancelBtn);
btnBar->addWidget(m_saveBtn);
auto *titleRow = new QHBoxLayout;
titleRow->setContentsMargins(0, 0, 0, 0);
titleRow->setSpacing(8);
titleRow->addWidget(m_nameLabel);
titleRow->addStretch(1);
auto *headerLeft = new QVBoxLayout;
headerLeft->setContentsMargins(0, 0, 0, 0);
headerLeft->setSpacing(2);
headerLeft->addLayout(titleRow);
headerLeft->addWidget(m_sourcePathLabel);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(8);
headerRow->addLayout(headerLeft, 1);
headerRow->addLayout(btnBar);
auto *headerSep = new QFrame(this);
headerSep->setFrameShape(QFrame::HLine);
headerSep->setFrameShadow(QFrame::Sunken);
m_descriptionLabel = new QLabel(this);
m_descriptionLabel->setWordWrap(true);
m_descriptionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
auto *identitySection = new SectionBox(tr("Identity"), this);
m_nameEdit = new QLineEdit(this);
m_typeEdit = new QLineEdit(this);
m_typeEdit->setReadOnly(true);
m_descriptionEdit = new QPlainTextEdit(this);
m_descriptionEdit->setMaximumHeight(60);
m_descriptionEdit->setReadOnly(true);
auto *identityGrid = new QGridLayout;
identityGrid->setContentsMargins(0, 0, 0, 0);
identityGrid->setHorizontalSpacing(8);
identityGrid->setVerticalSpacing(4);
int identityRow = 0;
identityRow = addFormRow(identityGrid, identityRow, tr("Name:"),
singleField(m_nameEdit));
identityRow = addFormRow(identityGrid, identityRow, tr("Client API:"),
singleField(m_typeEdit),
tr("The client API this provider speaks. "
"Cannot be changed after creation."));
identityRow = addFormRow(identityGrid, identityRow, tr("Description:"),
singleField(m_descriptionEdit));
identitySection->bodyLayout()->addLayout(identityGrid);
auto *endpointSection = new SectionBox(tr("Endpoint"), this);
m_urlEdit = new QLineEdit(this);
m_urlEdit->setFont(monospaceFont(11));
auto *endpointGrid = new QGridLayout;
endpointGrid->setContentsMargins(0, 0, 0, 0);
endpointGrid->setHorizontalSpacing(8);
endpointGrid->setVerticalSpacing(4);
int endpointRow = 0;
endpointRow = addFormRow(endpointGrid, endpointRow, tr("URL:"),
singleField(m_urlEdit),
tr("Base URL. Agents append their endpoint path "
"(e.g. /chat/completions) to this."));
endpointSection->bodyLayout()->addLayout(endpointGrid);
m_samplePreview = new QLabel(this);
m_samplePreview->setFont(monospaceFont(11));
m_samplePreview->setTextInteractionFlags(Qt::TextSelectableByMouse);
m_samplePreview->setWordWrap(true);
m_samplePreview->setContentsMargins(6, 4, 6, 4);
m_samplePreview->setAutoFillBackground(true);
endpointSection->bodyLayout()->addWidget(m_samplePreview);
auto *credSection = new SectionBox(tr("Credentials"), this);
m_apiKeyEdit = new QLineEdit(this);
m_apiKeyEdit->setEchoMode(QLineEdit::Password);
m_apiKeyEdit->setPlaceholderText(tr("Enter API key…"));
m_revealKeyBtn = new QToolButton(this);
m_revealKeyBtn->setText(QStringLiteral("👁"));
m_revealKeyBtn->setCheckable(true);
m_revealKeyBtn->setToolTip(tr("Show / hide API key"));
connect(m_revealKeyBtn, &QToolButton::toggled, this, [this](bool on) {
m_apiKeyEdit->setEchoMode(on ? QLineEdit::Normal : QLineEdit::Password);
});
m_apiKeySaveBtn = new QPushButton(tr("Save key"), this);
m_apiKeySaveBtn->setEnabled(false);
m_apiKeyClearBtn = new QPushButton(tr("Clear"), this);
m_apiKeyClearBtn->setToolTip(tr("Erase the stored API key for this provider"));
connect(m_apiKeyEdit, &QLineEdit::textChanged, this, [this](const QString &t) {
m_apiKeySaveBtn->setEnabled(!t.isEmpty());
});
connect(m_apiKeyEdit, &QLineEdit::returnPressed, this, [this] {
if (!m_apiKeyEdit->text().isEmpty())
m_apiKeySaveBtn->click();
});
connect(m_apiKeySaveBtn, &QPushButton::clicked, this, [this] {
const QString key = m_apiKeyEdit->text();
if (key.isEmpty())
return;
emit apiKeySaveRequested(key);
m_apiKeyEdit->clear();
});
connect(m_apiKeyClearBtn, &QPushButton::clicked, this,
[this] { emit apiKeyClearRequested(); });
m_keyHint = new QLabel(this);
QFont khf = m_keyHint->font();
khf.setPixelSize(11);
m_keyHint->setFont(khf);
m_keyHint->setWordWrap(true);
QPalette khp = m_keyHint->palette();
khp.setColor(QPalette::WindowText, khp.color(QPalette::Mid));
m_keyHint->setPalette(khp);
auto *keyRow = new QHBoxLayout;
keyRow->setContentsMargins(0, 0, 0, 0);
keyRow->setSpacing(4);
keyRow->addWidget(m_apiKeyEdit, 1);
keyRow->addWidget(m_revealKeyBtn);
keyRow->addWidget(m_apiKeySaveBtn);
keyRow->addWidget(m_apiKeyClearBtn);
auto *credGrid = new QGridLayout;
credGrid->setContentsMargins(0, 0, 0, 0);
credGrid->setHorizontalSpacing(8);
credGrid->setVerticalSpacing(4);
int credRow = 0;
credRow = addFormRow(credGrid, credRow, tr("API key:"), keyRow);
credGrid->addWidget(m_keyHint, credRow, 1);
credSection->bodyLayout()->addLayout(credGrid);
m_launchSection = new SectionBox(tr("Launch"), this);
m_launchEmptyHint = new QLabel(this);
m_launchEmptyHint->setWordWrap(true);
QPalette lehp = m_launchEmptyHint->palette();
lehp.setColor(QPalette::WindowText, lehp.color(QPalette::Mid));
m_launchEmptyHint->setPalette(lehp);
m_launchCmdLabel = new QLabel(this);
m_launchCmdLabel->setFont(monospaceFont(11));
m_launchCmdLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
m_launchCmdLabel->setWordWrap(true);
m_launchStatusPill = new QLabel(tr("idle"), this);
m_startBtn = new QPushButton(tr("Start"), this);
m_stopBtn = new QPushButton(tr("Stop"), this);
m_restartBtn = new QPushButton(tr("Restart"), this);
connect(m_startBtn, &QPushButton::clicked, this,
[this] { emit launchStartRequested(m_current.name); });
connect(m_stopBtn, &QPushButton::clicked, this,
[this] { emit launchStopRequested(m_current.name); });
connect(m_restartBtn, &QPushButton::clicked, this,
[this] { emit launchRestartRequested(m_current.name); });
auto *launchBtnRow = new QHBoxLayout;
launchBtnRow->setContentsMargins(0, 0, 0, 0);
launchBtnRow->setSpacing(6);
launchBtnRow->addWidget(m_launchStatusPill);
launchBtnRow->addStretch(1);
launchBtnRow->addWidget(m_startBtn);
launchBtnRow->addWidget(m_stopBtn);
launchBtnRow->addWidget(m_restartBtn);
m_launchTerminalToggle = new QToolButton(this);
m_launchTerminalToggle->setText(tr("▸ Show launch terminal"));
m_launchTerminalToggle->setCursor(Qt::PointingHandCursor);
m_launchTerminalToggle->setAutoRaise(true);
m_launchTerminalToggle->setCheckable(true);
m_launchTerminal = new TerminalSolution::TerminalView(this);
{
QFont termFont(TerminalSolution::defaultFontFamily());
const int sz = TerminalSolution::defaultFontSize();
if (sz > 0)
termFont.setPixelSize(sz);
termFont.setStyleHint(QFont::Monospace);
m_launchTerminal->setFont(termFont);
applyTerminalPalette();
}
m_launchTerminal->setMinimumHeight(180);
m_launchTerminal->setVisible(false);
connect(m_launchTerminalToggle, &QToolButton::toggled, this, [this](bool on) {
m_launchTerminal->setVisible(on);
m_launchTerminalToggle->setText(
on ? tr("▾ Hide launch terminal") : tr("▸ Show launch terminal"));
});
m_launchSection->bodyLayout()->addWidget(m_launchEmptyHint);
m_launchSection->bodyLayout()->addWidget(m_launchCmdLabel);
m_launchSection->bodyLayout()->addLayout(launchBtnRow);
m_launchSection->bodyLayout()->addWidget(m_launchTerminalToggle, 0, Qt::AlignLeft);
m_launchSection->bodyLayout()->addWidget(m_launchTerminal);
m_rawToggle = new QToolButton(this);
m_rawToggle->setText(tr("▸ Show raw TOML"));
m_rawToggle->setCursor(Qt::PointingHandCursor);
m_rawToggle->setAutoRaise(true);
m_rawToggle->setCheckable(true);
m_rawToml = new QPlainTextEdit(this);
m_rawToml->setReadOnly(true);
m_rawToml->setFont(monospaceFont(11));
m_rawToml->setMinimumHeight(120);
m_rawToml->setVisible(false);
connect(m_rawToggle, &QToolButton::toggled, this, [this](bool on) {
m_rawToml->setVisible(on);
m_rawToggle->setText(on ? tr("▾ Hide raw TOML") : tr("▸ Show raw TOML"));
});
auto *root = new QVBoxLayout(this);
root->setContentsMargins(12, 12, 12, 12);
root->setSpacing(10);
root->addLayout(headerRow);
root->addWidget(headerSep);
root->addWidget(m_descriptionLabel);
root->addWidget(identitySection);
root->addWidget(endpointSection);
root->addWidget(credSection);
root->addWidget(m_launchSection);
root->addWidget(m_rawToggle, 0, Qt::AlignLeft);
root->addWidget(m_rawToml);
root->addStretch(1);
clear();
}
void ProviderDetailPane::populate(const Providers::ProviderInstance &inst, bool hasStoredKey)
{
m_current = inst;
m_currentHasStoredKey = hasStoredKey;
const bool isUser = inst.isUserSource();
const bool needsKey = !inst.apiKeyRef.isEmpty();
m_nameLabel->setText(inst.name);
m_sourcePathLabel->setText(inst.sourcePath);
m_descriptionLabel->setText(
inst.description.isEmpty() ? tr("No description provided.") : inst.description);
m_nameEdit->setText(inst.name);
m_typeEdit->setText(inst.clientApi);
m_descriptionEdit->setPlainText(inst.description);
m_urlEdit->setText(inst.url);
m_apiKeyEdit->clear();
m_apiKeyEdit->setEnabled(needsKey);
m_apiKeySaveBtn->setEnabled(false);
m_apiKeyClearBtn->setEnabled(needsKey && hasStoredKey);
m_revealKeyBtn->setEnabled(needsKey);
m_revealKeyBtn->setChecked(false);
if (!needsKey) {
m_apiKeyEdit->setPlaceholderText(tr("— not required (local provider)"));
m_keyHint->setText(tr("This provider type does not use a key."));
} else if (hasStoredKey) {
m_apiKeyEdit->setPlaceholderText(tr("Stored — enter a new key to replace it."));
m_keyHint->setText(tr("A key is stored. Type a new key and press Save key to "
"replace it, or Clear to erase it."));
} else {
m_apiKeyEdit->setPlaceholderText(tr("Enter API key…"));
m_keyHint->setText(tr("No key stored yet. Type a key and press Save key."));
}
m_samplePreview->setText(
QStringLiteral("# sample request line\nPOST %1/<agent endpoint>").arg(inst.url));
applyPreviewPalette();
m_deleteBtn->setEnabled(isUser);
m_dupBtn->setEnabled(true);
m_editBtn->setVisible(isUser);
m_openInEditorBtn->setEnabled(isUser);
setEditing(false);
QString toml = QStringLiteral("# %1\n").arg(inst.sourcePath);
toml += Providers::ProviderInstanceWriter::toToml(inst);
m_rawToml->setPlainText(toml);
}
void ProviderDetailPane::clear()
{
m_current = {};
m_nameLabel->setText(tr("Select a provider"));
m_sourcePathLabel->clear();
m_descriptionLabel->clear();
m_nameEdit->clear();
m_typeEdit->clear();
m_descriptionEdit->clear();
m_urlEdit->clear();
m_apiKeyEdit->clear();
m_apiKeyEdit->setEnabled(false);
m_apiKeySaveBtn->setEnabled(false);
m_apiKeyClearBtn->setEnabled(false);
m_revealKeyBtn->setEnabled(false);
m_samplePreview->clear();
m_rawToml->clear();
m_editBtn->setVisible(false);
m_dupBtn->setEnabled(false);
m_deleteBtn->setEnabled(false);
m_openInEditorBtn->setEnabled(false);
}
void ProviderDetailPane::refreshKeyStatus(bool hasStoredKey)
{
m_currentHasStoredKey = hasStoredKey;
const bool needsKey = !m_current.apiKeyRef.isEmpty();
m_apiKeyClearBtn->setEnabled(needsKey && hasStoredKey);
if (!needsKey)
return;
if (hasStoredKey) {
m_apiKeyEdit->setPlaceholderText(tr("Stored — enter a new key to replace it."));
m_keyHint->setText(tr("A key is stored. Type a new key and press Save key to "
"replace it, or Clear to erase it."));
} else {
m_apiKeyEdit->setPlaceholderText(tr("Enter API key…"));
m_keyHint->setText(tr("No key stored yet. Type a key and press Save key."));
}
}
void ProviderDetailPane::setLaunchState(
Providers::ProviderLauncher::State st, const QString &lastError)
{
const bool hasLaunch = !m_current.launch.isEmpty();
m_launchSection->setVisible(true);
m_launchEmptyHint->setVisible(!hasLaunch);
m_launchCmdLabel->setVisible(hasLaunch);
m_startBtn->setVisible(hasLaunch);
m_stopBtn->setVisible(hasLaunch);
m_restartBtn->setVisible(hasLaunch);
m_launchStatusPill->setVisible(hasLaunch);
m_launchTerminalToggle->setVisible(hasLaunch);
if (!hasLaunch) {
m_launchEmptyHint->setText(tr(
"No [launch] block. This provider is treated as external — "
"the plugin will not spawn or supervise any process. "
"Add a [launch] block to the TOML to have the plugin manage "
"a local server here."));
m_launchCmdLabel->clear();
m_launchTerminal->clearContents();
return;
}
const QString detachedNote = m_current.launch.detach
? tr(" <span style='color:gray'>(detached — survives Qt Creator restart)</span>")
: QString();
m_launchCmdLabel->setText(
QStringLiteral("<b>%1</b> %2%3")
.arg(m_current.launch.command.toHtmlEscaped(),
m_current.launch.args.join(QLatin1Char(' ')).toHtmlEscaped(),
detachedNote));
QString statusText;
switch (st) {
case Providers::ProviderLauncher::Idle: statusText = tr("idle"); break;
case Providers::ProviderLauncher::Starting: statusText = tr("starting…"); break;
case Providers::ProviderLauncher::Probing: statusText = tr("probing…"); break;
case Providers::ProviderLauncher::Ready: statusText = tr("ready"); break;
case Providers::ProviderLauncher::Stopping: statusText = tr("stopping…"); break;
case Providers::ProviderLauncher::Failed:
statusText = lastError.isEmpty() ? tr("failed")
: tr("failed — %1").arg(lastError);
break;
}
m_launchStatusPill->setText(statusText);
const bool running = st == Providers::ProviderLauncher::Starting
|| st == Providers::ProviderLauncher::Probing
|| st == Providers::ProviderLauncher::Ready;
m_startBtn->setEnabled(!running && st != Providers::ProviderLauncher::Stopping);
m_stopBtn->setEnabled(running);
m_restartBtn->setEnabled(running || st == Providers::ProviderLauncher::Failed);
}
void ProviderDetailPane::resetLaunchTerminal(const QByteArray &scrollback)
{
m_launchTerminal->clearContents();
if (!scrollback.isEmpty())
m_launchTerminal->writeToTerminal(scrollback, true);
}
void ProviderDetailPane::appendLaunchBytes(const QByteArray &chunk)
{
m_launchTerminal->writeToTerminal(chunk, true);
}
void ProviderDetailPane::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange) {
applyPreviewPalette();
applyTerminalPalette();
}
}
void ProviderDetailPane::setEditing(bool on)
{
m_editing = on;
m_nameEdit->setReadOnly(!on);
m_descriptionEdit->setReadOnly(!on);
m_urlEdit->setReadOnly(!on);
m_editBtn->setVisible(!on && m_current.isUserSource());
m_dupBtn->setVisible(!on);
m_deleteBtn->setVisible(!on);
m_cancelBtn->setVisible(on);
m_saveBtn->setVisible(on);
}
Providers::ProviderInstance ProviderDetailPane::collectEdits() const
{
Providers::ProviderInstance out = m_current;
out.name = m_nameEdit->text().trimmed();
out.description = m_descriptionEdit->toPlainText().trimmed();
out.url = m_urlEdit->text().trimmed();
return out;
}
void ProviderDetailPane::applyPreviewPalette()
{
const bool dark = isDarkPalette(palette());
const QString bg = dark ? QStringLiteral("#1f1f1f") : QStringLiteral("#f4f4f4");
const QString bd = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
m_samplePreview->setStyleSheet(QStringLiteral(
"QLabel { background:%1; border:1px solid %2; }")
.arg(bg, bd));
}
void ProviderDetailPane::applyTerminalPalette()
{
if (!m_launchTerminal)
return;
const QPalette pal = palette();
const bool dark = isDarkPalette(pal);
const std::array<QColor, 16> ansi = dark
? std::array<QColor, 16>{
QColor("#000000"), QColor("#cd3131"), QColor("#0dbc79"),
QColor("#e5e510"), QColor("#2472c8"), QColor("#bc3fbc"),
QColor("#11a8cd"), QColor("#e5e5e5"),
QColor("#666666"), QColor("#f14c4c"), QColor("#23d18b"),
QColor("#f5f543"), QColor("#3b8eea"), QColor("#d670d6"),
QColor("#29b8db"), QColor("#ffffff"),
}
: std::array<QColor, 16>{
QColor("#000000"), QColor("#c91b00"), QColor("#00c200"),
QColor("#c7c400"), QColor("#0037da"), QColor("#c930c7"),
QColor("#00c5c7"), QColor("#c7c7c7"),
QColor("#676767"), QColor("#ff6d67"), QColor("#5ff967"),
QColor("#fefb67"), QColor("#6871ff"), QColor("#ff76ff"),
QColor("#5ffdff"), QColor("#ffffff"),
};
std::array<QColor, 20> colors{};
for (int i = 0; i < 16; ++i)
colors[i] = ansi[i];
colors[16] = pal.color(QPalette::Text);
colors[17] = pal.color(QPalette::Base);
colors[18] = pal.color(QPalette::Highlight);
colors[19] = dark ? QColor("#5a5a40") : QColor("#fff59d");
m_launchTerminal->setColors(colors);
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,101 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QWidget>
#include "ProviderInstance.hpp"
#include "ProviderLauncher.hpp"
class QLabel;
class QLineEdit;
class QPlainTextEdit;
class QPushButton;
class QToolButton;
namespace TerminalSolution {
class TerminalView;
}
namespace QodeAssist::Settings {
class SectionBox;
class ProviderDetailPane : public QWidget
{
Q_OBJECT
public:
explicit ProviderDetailPane(QWidget *parent = nullptr);
void populate(const Providers::ProviderInstance &inst, bool hasStoredKey);
void clear();
void refreshKeyStatus(bool hasStoredKey);
void setLaunchState(Providers::ProviderLauncher::State st, const QString &lastError);
void resetLaunchTerminal(const QByteArray &scrollback);
void appendLaunchBytes(const QByteArray &chunk);
QString currentName() const { return m_current.name; }
signals:
void saveRequested(const Providers::ProviderInstance &edited);
void duplicateRequested();
void deleteRequested();
void apiKeySaveRequested(const QString &newKey);
void apiKeyClearRequested();
void launchStartRequested(const QString &providerName);
void launchStopRequested(const QString &providerName);
void launchRestartRequested(const QString &providerName);
void openInEditorRequested(const QString &sourcePath);
protected:
void changeEvent(QEvent *event) override;
private:
void setEditing(bool on);
Providers::ProviderInstance collectEdits() const;
void applyPreviewPalette();
void applyTerminalPalette();
bool m_editing = false;
bool m_currentHasStoredKey = false;
Providers::ProviderInstance m_current;
QLabel *m_nameLabel = nullptr;
QLabel *m_sourcePathLabel = nullptr;
QPushButton *m_editBtn = nullptr;
QPushButton *m_openInEditorBtn = nullptr;
QPushButton *m_dupBtn = nullptr;
QPushButton *m_deleteBtn = nullptr;
QPushButton *m_cancelBtn = nullptr;
QPushButton *m_saveBtn = nullptr;
QLabel *m_descriptionLabel = nullptr;
QLineEdit *m_nameEdit = nullptr;
QLineEdit *m_typeEdit = nullptr;
QPlainTextEdit *m_descriptionEdit = nullptr;
QLineEdit *m_urlEdit = nullptr;
QLabel *m_samplePreview = nullptr;
QLineEdit *m_apiKeyEdit = nullptr;
QToolButton *m_revealKeyBtn = nullptr;
QLabel *m_keyHint = nullptr;
QPushButton *m_apiKeySaveBtn = nullptr;
QPushButton *m_apiKeyClearBtn = nullptr;
SectionBox *m_launchSection = nullptr;
QLabel *m_launchEmptyHint = nullptr;
QLabel *m_launchCmdLabel = nullptr;
QLabel *m_launchStatusPill = nullptr;
QPushButton *m_startBtn = nullptr;
QPushButton *m_stopBtn = nullptr;
QPushButton *m_restartBtn = nullptr;
QToolButton *m_launchTerminalToggle = nullptr;
TerminalSolution::TerminalView *m_launchTerminal = nullptr;
QToolButton *m_rawToggle = nullptr;
QPlainTextEdit *m_rawToml = nullptr;
};
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,110 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProviderListItem.hpp"
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QScopedValueRollback>
#include <QVBoxLayout>
#include "ProviderInstance.hpp"
#include "ProvidersSettingsHelpers.hpp"
namespace QodeAssist::Settings {
ProviderListItem::ProviderListItem(
const Providers::ProviderInstance &inst, QWidget *parent)
: QFrame(parent)
, m_name(inst.name)
{
setObjectName(QStringLiteral("ProvListItem"));
setFrameShape(QFrame::NoFrame);
setAutoFillBackground(true);
setCursor(Qt::PointingHandCursor);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(6);
m_statusDot = new QLabel(QStringLiteral(""), this);
QFont df = m_statusDot->font();
df.setPixelSize(11);
m_statusDot->setFont(df);
m_statusDot->setStyleSheet(QStringLiteral("color: %1;").arg(statusColor(Status::Unknown)));
m_nameLabel = new QLabel(inst.name, this);
QFont nf = m_nameLabel->font();
nf.setBold(true);
nf.setPixelSize(12);
m_nameLabel->setFont(nf);
headerRow->addWidget(m_statusDot, 0, Qt::AlignVCenter);
headerRow->addWidget(m_nameLabel, 1);
m_urlLabel = new QLabel(inst.url, this);
m_urlLabel->setFont(monospaceFont(10));
QPalette up = m_urlLabel->palette();
up.setColor(QPalette::WindowText, up.color(QPalette::Mid));
m_urlLabel->setPalette(up);
m_urlLabel->setContentsMargins(17, 0, 0, 0);
auto *outer = new QVBoxLayout(this);
outer->setContentsMargins(8, 6, 8, 6);
outer->setSpacing(2);
outer->addLayout(headerRow);
outer->addWidget(m_urlLabel);
applyTheme();
}
void ProviderListItem::setStatus(Status s)
{
m_status = s;
m_statusDot->setStyleSheet(QStringLiteral("color: %1;").arg(statusColor(s)));
}
void ProviderListItem::setSelected(bool s)
{
if (m_selected == s)
return;
m_selected = s;
applyTheme();
}
void ProviderListItem::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
emit clicked(m_name);
QFrame::mouseReleaseEvent(event);
}
void ProviderListItem::changeEvent(QEvent *event)
{
QFrame::changeEvent(event);
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
applyTheme();
}
QString ProviderListItem::statusColor(Status s)
{
switch (s) {
case Status::Ok: return QStringLiteral("#3a8a4f");
case Status::Fail: return QStringLiteral("#c94a4a");
case Status::Unknown: return QStringLiteral("#888888");
}
return QStringLiteral("#888888");
}
void ProviderListItem::applyTheme()
{
if (m_inApplyTheme)
return;
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
const bool dark = isDarkPalette(palette());
const QString sep = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
const QString sel = dark ? QStringLiteral("#2c4060") : QStringLiteral("#cfe2ff");
setStyleSheet(QStringLiteral(
"#ProvListItem { background:%1; border-top: 1px solid %2; }")
.arg(m_selected ? sel : QStringLiteral("transparent"), sep));
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,50 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QFrame>
class QLabel;
class QMouseEvent;
namespace QodeAssist::Providers {
struct ProviderInstance;
}
namespace QodeAssist::Settings {
class ProviderListItem : public QFrame
{
Q_OBJECT
public:
enum class Status : int { Unknown, Ok, Fail };
explicit ProviderListItem(
const Providers::ProviderInstance &inst, QWidget *parent = nullptr);
void setStatus(Status s);
void setSelected(bool s);
QString providerName() const { return m_name; }
signals:
void clicked(const QString &name);
protected:
void mouseReleaseEvent(QMouseEvent *event) override;
void changeEvent(QEvent *event) override;
private:
static QString statusColor(Status s);
void applyTheme();
QString m_name;
Status m_status = Status::Unknown;
bool m_selected = false;
bool m_inApplyTheme = false;
QLabel *m_statusDot = nullptr;
QLabel *m_nameLabel = nullptr;
QLabel *m_urlLabel = nullptr;
};
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,64 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QFont>
#include <QFontDatabase>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPalette>
#include <QString>
#include <QWidget>
namespace QodeAssist::Settings {
inline QFont monospaceFont(int pixelSize = 11)
{
QFont f = QFontDatabase::systemFont(QFontDatabase::FixedFont);
f.setStyleHint(QFont::Monospace);
if (pixelSize > 0)
f.setPixelSize(pixelSize);
return f;
}
inline bool isDarkPalette(const QPalette &p)
{
return p.color(QPalette::Window).lightness() < 128;
}
inline int addFormRow(
QGridLayout *grid, int row, const QString &label, QLayout *value, const QString &hint = {})
{
auto *l = new QLabel(label);
l->setMinimumWidth(96);
l->setAlignment(Qt::AlignLeft | Qt::AlignTop);
grid->addWidget(l, row, 0, Qt::AlignTop);
auto *holder = new QWidget;
holder->setLayout(value);
grid->addWidget(holder, row, 1);
if (hint.isEmpty())
return row + 1;
auto *h = new QLabel(hint);
QFont hf = h->font();
hf.setPixelSize(11);
h->setFont(hf);
h->setWordWrap(true);
QPalette p = h->palette();
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
h->setPalette(p);
grid->addWidget(h, row + 1, 1);
return row + 2;
}
inline QHBoxLayout *singleField(QWidget *w)
{
auto *lay = new QHBoxLayout;
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(4);
lay->addWidget(w, 1);
return lay;
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,674 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProvidersSettingsPage.hpp"
#include <algorithm>
#include <vector>
#include <coreplugin/dialogs/ioptionspage.h>
#include <coreplugin/editormanager/editormanager.h>
#include <coreplugin/icore.h>
#include <utils/filepath.h>
#include <QDialog>
#include <QFile>
#include <QFileInfo>
#include <QFrame>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPointer>
#include <QPushButton>
#include <QScrollArea>
#include <QSplitter>
#include <QTimer>
#include <QVBoxLayout>
#include "NewProviderDialog.hpp"
#include "ProviderDetailPane.hpp"
#include "ProviderInstance.hpp"
#include "ProviderInstanceFactory.hpp"
#include "ProviderInstanceWriter.hpp"
#include "ProviderLauncher.hpp"
#include "ProviderListItem.hpp"
#include "ProviderSecretsStore.hpp"
#include "ProvidersSettingsHelpers.hpp"
#include "SettingsConstants.hpp"
namespace QodeAssist::Settings {
ProvidersPageNavigator::ProvidersPageNavigator(QObject *parent)
: QObject(parent)
{}
void ProvidersPageNavigator::requestSelectInstance(const QString &name)
{
m_pending = name;
emit selectInstanceRequested(name);
}
QString ProvidersPageNavigator::takePendingSelection()
{
QString p = m_pending;
m_pending.clear();
return p;
}
namespace {
class ProvidersPageWidget : public Core::IOptionsPageWidget
{
Q_OBJECT
public:
ProvidersPageWidget(
Providers::ProviderInstanceFactory *factory,
Providers::ProviderSecretsStore *secrets,
Providers::ProviderLauncher *launcher,
ProvidersPageNavigator *navigator)
: m_factory(factory)
, m_secrets(secrets)
, m_launcher(launcher)
, m_navigator(navigator)
{
m_titleLabel = new QLabel(tr("Providers"), this);
QFont tf = m_titleLabel->font();
tf.setBold(true);
tf.setPixelSize(13);
m_titleLabel->setFont(tf);
m_newBtn = new QPushButton(tr("+ New provider…"), this);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(8);
headerRow->addWidget(m_titleLabel, 1);
headerRow->addWidget(m_newBtn);
auto *headerSep = new QFrame(this);
headerSep->setFrameShape(QFrame::HLine);
headerSep->setFrameShadow(QFrame::Sunken);
m_filterEdit = new QLineEdit(this);
m_filterEdit->setPlaceholderText(tr("Filter providers…"));
m_listScroll = new QScrollArea(this);
m_listScroll->setWidgetResizable(true);
m_listScroll->setFrameShape(QFrame::NoFrame);
m_listContent = new QWidget(this);
m_listLayout = new QVBoxLayout(m_listContent);
m_listLayout->setContentsMargins(0, 0, 0, 0);
m_listLayout->setSpacing(0);
m_listLayout->addStretch(1);
m_listScroll->setWidget(m_listContent);
auto *leftBox = new QFrame(this);
leftBox->setFrameShape(QFrame::StyledPanel);
auto *leftLay = new QVBoxLayout(leftBox);
leftLay->setContentsMargins(0, 0, 0, 0);
leftLay->setSpacing(0);
auto *filterRow = new QHBoxLayout;
filterRow->setContentsMargins(6, 6, 6, 6);
filterRow->addWidget(m_filterEdit, 1);
leftLay->addLayout(filterRow);
leftLay->addWidget(m_listScroll, 1);
m_detailPane = new ProviderDetailPane(this);
connect(m_detailPane, &ProviderDetailPane::saveRequested,
this, &ProvidersPageWidget::onSaveEdited);
connect(m_detailPane, &ProviderDetailPane::duplicateRequested,
this, &ProvidersPageWidget::onDuplicateClicked);
connect(m_detailPane, &ProviderDetailPane::deleteRequested,
this, &ProvidersPageWidget::onRemoveClicked);
connect(m_detailPane, &ProviderDetailPane::apiKeySaveRequested,
this, &ProvidersPageWidget::onApiKeySave);
connect(m_detailPane, &ProviderDetailPane::apiKeyClearRequested,
this, &ProvidersPageWidget::onApiKeyClear);
connect(m_detailPane, &ProviderDetailPane::launchStartRequested,
this, &ProvidersPageWidget::onLaunchStart);
connect(m_detailPane, &ProviderDetailPane::launchStopRequested,
this, &ProvidersPageWidget::onLaunchStop);
connect(m_detailPane, &ProviderDetailPane::launchRestartRequested,
this, &ProvidersPageWidget::onLaunchRestart);
connect(m_detailPane, &ProviderDetailPane::openInEditorRequested,
this, [this](const QString &path) {
if (path.isEmpty() || path.startsWith(QLatin1String(":/"))) {
QMessageBox::information(
this, tr("Open in editor"),
tr("Bundled providers are read-only. "
"Use Duplicate to create an editable user copy first."));
return;
}
Core::EditorManager::openEditor(Utils::FilePath::fromString(path));
});
if (m_launcher) {
connect(m_launcher.data(), &Providers::ProviderLauncher::stateChanged,
this, [this](const QString &name,
Providers::ProviderLauncher::State newState) {
if (name == m_currentName)
refreshDetailLaunch();
const ProviderListItem::Status status = rowStatusFromState(newState);
for (auto *row : m_rows) {
if (row->providerName() == name)
row->setStatus(status);
}
});
connect(m_launcher.data(), &Providers::ProviderLauncher::bytesReceived,
this, [this](const QString &name, const QByteArray &chunk) {
if (name == m_currentName)
m_detailPane->appendLaunchBytes(chunk);
});
}
m_detailScroll = new QScrollArea(this);
m_detailScroll->setWidgetResizable(true);
m_detailScroll->setFrameShape(QFrame::StyledPanel);
m_detailScroll->setWidget(m_detailPane);
auto *splitter = new QSplitter(Qt::Horizontal, this);
splitter->addWidget(leftBox);
splitter->addWidget(m_detailScroll);
splitter->setStretchFactor(0, 0);
splitter->setStretchFactor(1, 1);
splitter->setSizes({320, 700});
auto *root = new QVBoxLayout(this);
root->setContentsMargins(8, 8, 8, 8);
root->setSpacing(6);
root->addLayout(headerRow);
root->addWidget(headerSep);
root->addWidget(splitter, 1);
connect(m_newBtn, &QPushButton::clicked, this, &ProvidersPageWidget::onNewClicked);
m_filterDebounce = new QTimer(this);
m_filterDebounce->setSingleShot(true);
m_filterDebounce->setInterval(100);
connect(m_filterDebounce, &QTimer::timeout, this,
&ProvidersPageWidget::rebuildList);
connect(m_filterEdit, &QLineEdit::textChanged, this,
[this](const QString &) { m_filterDebounce->start(); });
if (m_factory) {
connect(m_factory.data(),
&Providers::ProviderInstanceFactory::instancesReloaded,
this, &ProvidersPageWidget::rebuildList);
}
if (m_navigator) {
connect(m_navigator.data(),
&ProvidersPageNavigator::selectInstanceRequested,
this, &ProvidersPageWidget::selectInstance);
}
rebuildList();
const QString pending
= m_navigator ? m_navigator->takePendingSelection() : QString{};
if (!pending.isEmpty())
selectInstance(pending);
else if (m_factory && !m_factory->instances().empty())
selectInstance(m_factory->instances().front().name);
}
void apply() final {}
private slots:
void rebuildList()
{
if (!m_factory)
return;
while (m_listLayout->count() > 0) {
QLayoutItem *item = m_listLayout->takeAt(0);
if (auto *w = item->widget())
w->deleteLater();
delete item;
}
m_rows.clear();
m_listLayout->addStretch(1); // re-add trailing stretch
const QString filter = m_filterEdit->text().trimmed().toLower();
auto matches = [&](const Providers::ProviderInstance &inst) {
if (filter.isEmpty())
return true;
return inst.name.toLower().contains(filter)
|| inst.clientApi.toLower().contains(filter)
|| inst.url.toLower().contains(filter);
};
auto addSection = [&](const QString &title, bool userSection) {
auto *header = new QLabel(title.toUpper(), m_listContent);
QFont hf = header->font();
hf.setPixelSize(10);
hf.setLetterSpacing(QFont::AbsoluteSpacing, 0.5);
header->setFont(hf);
QPalette hp = header->palette();
hp.setColor(QPalette::WindowText, hp.color(QPalette::Mid));
header->setPalette(hp);
header->setContentsMargins(8, 4, 8, 4);
header->setAutoFillBackground(true);
const bool dark = isDarkPalette(palette());
const QString bg = dark ? QStringLiteral("#262626") : QStringLiteral("#f0f0f0");
header->setStyleSheet(QStringLiteral("QLabel { background:%1; }").arg(bg));
m_listLayout->insertWidget(m_listLayout->count() - 1, header);
std::vector<const Providers::ProviderInstance *> sorted;
for (const auto &inst : m_factory->instances()) {
if (inst.isUserSource() != userSection)
continue;
if (!matches(inst))
continue;
sorted.push_back(&inst);
}
std::sort(sorted.begin(), sorted.end(),
[](const Providers::ProviderInstance *a,
const Providers::ProviderInstance *b) {
return a->name.compare(b->name, Qt::CaseInsensitive) < 0;
});
int shown = 0;
for (const auto *inst : sorted) {
auto *row = new ProviderListItem(*inst, m_listContent);
connect(row, &ProviderListItem::clicked,
this, &ProvidersPageWidget::selectInstance);
if (m_launcher)
row->setStatus(rowStatusFromLauncher(inst->name));
m_rows.append(row);
m_listLayout->insertWidget(m_listLayout->count() - 1, row);
++shown;
}
if (shown == 0) {
auto *empty = new QLabel(
userSection ? tr("No user instances yet.")
: tr("No bundled instances loaded."),
m_listContent);
empty->setContentsMargins(10, 6, 10, 6);
QPalette ep = empty->palette();
ep.setColor(QPalette::WindowText, ep.color(QPalette::Mid));
empty->setPalette(ep);
m_listLayout->insertWidget(m_listLayout->count() - 1, empty);
}
};
addSection(tr("User"), true);
addSection(tr("Bundled"), false);
for (auto *row : m_rows)
row->setSelected(row->providerName() == m_currentName);
if (!m_currentName.isEmpty())
populateDetail(m_currentName);
else
m_detailPane->clear();
}
void selectInstance(const QString &name)
{
if (name.isEmpty())
return;
const auto *inst = m_factory ? m_factory->instanceByName(name) : nullptr;
if (!inst)
return;
m_currentName = inst->name;
for (auto *row : m_rows)
row->setSelected(row->providerName() == inst->name);
populateDetail(inst->name);
}
void onNewClicked()
{
if (!m_factory)
return;
NewProviderDialog dlg(m_factory->knownClientApis(), this);
if (dlg.exec() != QDialog::Accepted)
return;
Providers::ProviderInstance inst;
inst.name = dlg.providerName();
inst.clientApi = dlg.providerType();
inst.description = dlg.description();
inst.url = dlg.url();
inst.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(inst.name);
if (inst.name.isEmpty()) {
QMessageBox::warning(this, tr("New provider"), tr("Name cannot be empty."));
return;
}
if (m_factory->instanceByName(inst.name)) {
QMessageBox::warning(this, tr("New provider"),
tr("An instance named '%1' already exists.").arg(inst.name));
return;
}
const QString validation = Providers::ProviderInstance::validate(
inst, m_factory->knownClientApis());
if (!validation.isEmpty()) {
QMessageBox::warning(this, tr("New provider"), validation);
return;
}
const QString softWarning = Providers::ProviderInstance::warnings(inst);
if (!softWarning.isEmpty()) {
if (QMessageBox::warning(this, tr("New provider"),
softWarning + QStringLiteral("\n\n")
+ tr("Save anyway?"),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No)
!= QMessageBox::Yes)
return;
}
QString writeErr;
if (Providers::ProviderInstanceWriter::writeToUserDir(
inst, /*previousPath=*/QString{}, &writeErr).isEmpty()) {
QMessageBox::warning(this, tr("New provider"), writeErr);
return;
}
if (m_secrets && !dlg.apiKey().isEmpty())
m_secrets->writeKey(inst.apiKeyRef, dlg.apiKey());
m_factory->reload();
selectInstance(inst.name);
}
void onDuplicateClicked()
{
if (!m_factory || m_currentName.isEmpty())
return;
const Providers::ProviderInstance *srcPtr
= m_factory->instanceByName(m_currentName);
if (!srcPtr)
return;
const Providers::ProviderInstance srcCopy = *srcPtr;
bool ok = false;
const QString name = QInputDialog::getText(
this, tr("Duplicate provider"),
tr("Name for the new provider:"), QLineEdit::Normal,
QStringLiteral("%1 (copy)").arg(srcCopy.name), &ok);
if (!ok || name.trimmed().isEmpty())
return;
if (m_factory->instanceByName(name.trimmed())) {
QMessageBox::warning(this, tr("Duplicate provider"),
tr("An instance named '%1' already exists.").arg(name.trimmed()));
return;
}
Providers::ProviderInstance copy = srcCopy;
copy.name = name.trimmed();
copy.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(copy.name);
copy.sourcePath.clear();
copy.overridesBundled = false;
QString writeErr;
if (Providers::ProviderInstanceWriter::writeToUserDir(
copy, /*previousPath=*/QString{}, &writeErr).isEmpty()) {
QMessageBox::warning(this, tr("Duplicate provider"), writeErr);
return;
}
m_factory->reload();
selectInstance(copy.name);
}
void onRemoveClicked()
{
if (!m_factory || m_currentName.isEmpty())
return;
const Providers::ProviderInstance *instPtr
= m_factory->instanceByName(m_currentName);
if (!instPtr || !instPtr->isUserSource())
return;
const QString instName = instPtr->name;
const QString sourcePath = instPtr->sourcePath;
if (QMessageBox::question(
this, tr("Delete provider"),
tr("Delete user provider '%1'?\n\nFile: %2").arg(instName, sourcePath))
!= QMessageBox::Yes)
return;
if (!QFile::remove(sourcePath)) {
QMessageBox::warning(this, tr("Delete provider"),
tr("Failed to delete file:\n%1").arg(sourcePath));
return;
}
m_currentName.clear();
m_factory->reload();
m_detailPane->clear();
}
void onSaveEdited(const Providers::ProviderInstance &edited)
{
if (!m_factory)
return;
Providers::ProviderInstance e = edited;
if (e.name.isEmpty()) {
QMessageBox::warning(this, tr("Save"), tr("Name cannot be empty."));
return;
}
const auto *prior = m_factory->instanceByName(m_currentName);
const QString priorRef = prior ? prior->apiKeyRef : QString{};
const QString priorName = prior ? prior->name : QString{};
const bool nameChanged = !priorName.isEmpty() && priorName != e.name;
if (e.apiKeyRef.isEmpty() || (nameChanged && e.apiKeyRef == priorRef))
e.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(e.name);
const QString validation = Providers::ProviderInstance::validate(
e, m_factory->knownClientApis());
if (!validation.isEmpty()) {
QMessageBox::warning(this, tr("Save"), validation);
return;
}
if (nameChanged) {
const auto *clash = m_factory->instanceByName(e.name);
if (clash) {
QMessageBox::warning(this, tr("Save"),
tr("An instance named '%1' already exists.").arg(e.name));
return;
}
}
const QString softWarning = Providers::ProviderInstance::warnings(e);
if (!softWarning.isEmpty()) {
if (QMessageBox::warning(this, tr("Save"),
softWarning + QStringLiteral("\n\n")
+ tr("Save anyway?"),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No)
!= QMessageBox::Yes)
return;
}
const QString previousPath
= (prior && prior->isUserSource()) ? prior->sourcePath : QString{};
QString writeErr;
const QString writtenPath = Providers::ProviderInstanceWriter::writeToUserDir(
e, previousPath, &writeErr);
if (writtenPath.isEmpty()) {
QMessageBox::warning(this, tr("Save"), writeErr);
return;
}
if (!previousPath.isEmpty()
&& QFileInfo(writtenPath).absoluteFilePath()
!= QFileInfo(previousPath).absoluteFilePath()) {
if (!QFile::remove(previousPath)) {
QMessageBox::warning(
this, tr("Save"),
tr("Saved to:\n%1\n\nbut could not remove the old file:\n%2\n\n"
"Two provider files now describe this instance — delete the "
"old file manually to avoid a duplicate-name error.")
.arg(writtenPath, previousPath));
}
}
if (m_secrets && !priorRef.isEmpty() && priorRef != e.apiKeyRef) {
const QString carried = m_secrets->readKeySync(priorRef);
if (!carried.isEmpty())
m_secrets->writeKey(e.apiKeyRef, carried);
m_secrets->eraseKey(priorRef);
}
m_factory->reload();
selectInstance(e.name);
}
void onApiKeySave(const QString &newKey)
{
if (!m_factory || !m_secrets || m_currentName.isEmpty() || newKey.isEmpty())
return;
const auto *inst = m_factory->instanceByName(m_currentName);
if (!inst || inst->apiKeyRef.isEmpty())
return;
m_secrets->writeKey(inst->apiKeyRef, newKey);
m_detailPane->refreshKeyStatus(true);
}
void onApiKeyClear()
{
if (!m_factory || !m_secrets || m_currentName.isEmpty())
return;
const Providers::ProviderInstance *instPtr
= m_factory->instanceByName(m_currentName);
if (!instPtr || instPtr->apiKeyRef.isEmpty())
return;
const QString instName = instPtr->name;
const QString apiKeyRef = instPtr->apiKeyRef;
if (QMessageBox::question(
this, tr("Clear API key"),
tr("Erase the stored API key for '%1'?").arg(instName))
!= QMessageBox::Yes)
return;
m_secrets->eraseKey(apiKeyRef);
m_detailPane->refreshKeyStatus(false);
}
void onLaunchStart(const QString &name)
{
if (!m_factory || !m_launcher)
return;
const auto *inst = m_factory->instanceByName(name);
if (!inst || inst->launch.isEmpty())
return;
m_launcher->start(name, inst->launch);
}
void onLaunchStop(const QString &name)
{
if (!m_launcher)
return;
m_launcher->stop(name);
}
void onLaunchRestart(const QString &name)
{
if (!m_factory || !m_launcher)
return;
const auto *inst = m_factory->instanceByName(name);
if (!inst || inst->launch.isEmpty())
return;
m_launcher->restart(name, inst->launch);
}
private:
void populateDetail(const QString &name)
{
if (!m_factory)
return;
const auto *inst = m_factory->instanceByName(name);
if (!inst) {
m_detailPane->clear();
return;
}
const bool hasStoredKey
= m_secrets && !inst->apiKeyRef.isEmpty() && m_secrets->hasKey(inst->apiKeyRef);
m_detailPane->populate(*inst, hasStoredKey);
if (m_launcher) {
m_detailPane->setLaunchState(
m_launcher->state(inst->name),
m_launcher->lastError(inst->name));
m_detailPane->resetLaunchTerminal(m_launcher->scrollback(inst->name));
} else {
m_detailPane->setLaunchState(Providers::ProviderLauncher::Idle, {});
m_detailPane->resetLaunchTerminal({});
}
}
QPointer<Providers::ProviderInstanceFactory> m_factory;
QPointer<Providers::ProviderSecretsStore> m_secrets;
QPointer<ProvidersPageNavigator> m_navigator;
QLabel *m_titleLabel = nullptr;
QPushButton *m_newBtn = nullptr;
QLineEdit *m_filterEdit = nullptr;
QScrollArea *m_listScroll = nullptr;
QWidget *m_listContent = nullptr;
QVBoxLayout *m_listLayout = nullptr;
QList<ProviderListItem *> m_rows;
QScrollArea *m_detailScroll = nullptr;
ProviderDetailPane *m_detailPane = nullptr;
QString m_currentName;
QPointer<Providers::ProviderLauncher> m_launcher;
QTimer *m_filterDebounce = nullptr;
void refreshDetailLaunch()
{
if (!m_launcher || m_currentName.isEmpty())
return;
m_detailPane->setLaunchState(
m_launcher->state(m_currentName),
m_launcher->lastError(m_currentName));
}
static ProviderListItem::Status rowStatusFromState(
Providers::ProviderLauncher::State state)
{
switch (state) {
case Providers::ProviderLauncher::Ready:
return ProviderListItem::Status::Ok;
case Providers::ProviderLauncher::Failed:
return ProviderListItem::Status::Fail;
case Providers::ProviderLauncher::Idle:
case Providers::ProviderLauncher::Starting:
case Providers::ProviderLauncher::Probing:
case Providers::ProviderLauncher::Stopping:
return ProviderListItem::Status::Unknown;
}
return ProviderListItem::Status::Unknown;
}
ProviderListItem::Status rowStatusFromLauncher(const QString &name) const
{
if (!m_launcher)
return ProviderListItem::Status::Unknown;
return rowStatusFromState(m_launcher->state(name));
}
};
class ProvidersOptionsPage : public Core::IOptionsPage
{
public:
ProvidersOptionsPage(
Providers::ProviderInstanceFactory *factory,
Providers::ProviderSecretsStore *secrets,
Providers::ProviderLauncher *launcher,
ProvidersPageNavigator *navigator)
{
setId(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID);
setDisplayName(QObject::tr("Providers"));
setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY);
setWidgetCreator([factory, secrets, launcher, navigator] {
return new ProvidersPageWidget(factory, secrets, launcher, navigator);
});
}
};
} // namespace
std::unique_ptr<Core::IOptionsPage> createProvidersSettingsPage(
Providers::ProviderInstanceFactory *instanceFactory,
Providers::ProviderSecretsStore *secrets,
Providers::ProviderLauncher *launcher,
ProvidersPageNavigator *navigator)
{
return std::make_unique<ProvidersOptionsPage>(
instanceFactory, secrets, launcher, navigator);
}
} // namespace QodeAssist::Settings
#include "ProvidersSettingsPage.moc"

View File

@@ -0,0 +1,43 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <memory>
#include <QObject>
#include <QString>
namespace Core { class IOptionsPage; }
namespace QodeAssist::Providers {
class ProviderInstanceFactory;
class ProviderSecretsStore;
class ProviderLauncher;
}
namespace QodeAssist::Settings {
class ProvidersPageNavigator : public QObject
{
Q_OBJECT
public:
explicit ProvidersPageNavigator(QObject *parent = nullptr);
void requestSelectInstance(const QString &name);
QString takePendingSelection();
signals:
void selectInstanceRequested(const QString &name);
private:
QString m_pending;
};
std::unique_ptr<Core::IOptionsPage> createProvidersSettingsPage(
Providers::ProviderInstanceFactory *instanceFactory,
Providers::ProviderSecretsStore *secrets,
Providers::ProviderLauncher *launcher,
ProvidersPageNavigator *navigator);
} // namespace QodeAssist::Settings

31
settings/SectionBox.cpp Normal file
View File

@@ -0,0 +1,31 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SectionBox.hpp"
#include <QLabel>
#include <QVBoxLayout>
namespace QodeAssist::Settings {
SectionBox::SectionBox(const QString &title, QWidget *parent)
: QWidget(parent)
{
m_title = new QLabel(title, this);
QFont tf = m_title->font();
tf.setBold(true);
m_title->setFont(tf);
m_body = new QWidget(this);
m_bodyLayout = new QVBoxLayout(m_body);
m_bodyLayout->setContentsMargins(0, 0, 0, 0);
m_bodyLayout->setSpacing(4);
auto *outer = new QVBoxLayout(this);
outer->setContentsMargins(0, 4, 0, 4);
outer->setSpacing(4);
outer->addWidget(m_title);
outer->addWidget(m_body, 1);
}
} // namespace QodeAssist::Settings

26
settings/SectionBox.hpp Normal file
View File

@@ -0,0 +1,26 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QWidget>
class QLabel;
class QVBoxLayout;
namespace QodeAssist::Settings {
class SectionBox : public QWidget
{
public:
explicit SectionBox(const QString &title, QWidget *parent = nullptr);
QVBoxLayout *bodyLayout() const { return m_bodyLayout; }
private:
QLabel *m_title = nullptr;
QWidget *m_body = nullptr;
QVBoxLayout *m_bodyLayout = nullptr;
};
} // namespace QodeAssist::Settings

10
sources/external/CMakeLists.txt vendored Normal file
View 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)

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

View 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

View 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

View 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

View File

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

View File

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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

View 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