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)
option(QODEASSIST_EXPERIMENTAL
"Enable experimental features" OFF)
message(STATUS "QodeAssist experimental features: ${QODEASSIST_EXPERIMENTAL}")
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
@@ -34,11 +38,13 @@ add_definitions(
-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(pluginllmcore)
add_subdirectory(settings)
add_subdirectory(logger)
add_subdirectory(sources/providersConfig)
add_subdirectory(UIControls)
add_subdirectory(ChatView)
add_subdirectory(context)
@@ -65,6 +71,7 @@ add_qtc_plugin(QodeAssist
QtCreator::CPlusPlus
LLMQore
PluginLLMCore
ProvidersConfig
Skills
QodeAssistChatViewplugin
SOURCES
@@ -160,6 +167,10 @@ add_qtc_plugin(QodeAssist
settings/McpClientsListAspect.hpp settings/McpClientsListAspect.cpp
)
if(QODEASSIST_EXPERIMENTAL)
target_compile_definitions(QodeAssist PRIVATE QODEASSIST_EXPERIMENTAL)
endif()
get_target_property(QtCreatorCorePath QtCreator::Core LOCATION)
find_program(QtCreatorExecutable
NAMES

View File

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

View File

@@ -16,6 +16,12 @@ add_library(QodeAssistSettings STATIC
ProjectSettingsPanel.hpp ProjectSettingsPanel.cpp
ProviderSettings.hpp ProviderSettings.cpp
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
UpdateDialog.hpp UpdateDialog.cpp
AgentRole.hpp AgentRole.cpp
@@ -31,6 +37,7 @@ target_link_libraries(QodeAssistSettings
QtCreator::Core
QtCreator::Utils
QodeAssistLogger
ProvidersConfig
Skills
)
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