From e193d1e1facfb014461cf960014eb6040c16dde2 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Thu, 21 May 2026 19:30:32 +0200 Subject: [PATCH] feat: Add settings page for providers (#353) --- CMakeLists.txt | 13 +- qodeassist.cpp | 33 +- settings/CMakeLists.txt | 7 + settings/NewProviderDialog.cpp | 81 +++ settings/NewProviderDialog.hpp | 34 + settings/ProviderDetailPane.cpp | 527 ++++++++++++++ settings/ProviderDetailPane.hpp | 101 +++ settings/ProviderListItem.cpp | 110 +++ settings/ProviderListItem.hpp | 50 ++ settings/ProvidersSettingsHelpers.hpp | 64 ++ settings/ProvidersSettingsPage.cpp | 674 ++++++++++++++++++ settings/ProvidersSettingsPage.hpp | 43 ++ settings/SectionBox.cpp | 31 + settings/SectionBox.hpp | 26 + sources/external/CMakeLists.txt | 10 + sources/providersConfig/CMakeLists.txt | 27 + sources/providersConfig/ProviderInstance.cpp | 51 ++ sources/providersConfig/ProviderInstance.hpp | 57 ++ .../ProviderInstanceFactory.cpp | 198 +++++ .../ProviderInstanceFactory.hpp | 67 ++ .../ProviderInstanceLoader.cpp | 270 +++++++ .../ProviderInstanceLoader.hpp | 32 + .../ProviderInstanceWriter.cpp | 165 +++++ .../ProviderInstanceWriter.hpp | 27 + sources/providersConfig/ProviderLauncher.cpp | 637 +++++++++++++++++ sources/providersConfig/ProviderLauncher.hpp | 82 +++ .../providersConfig/ProviderSecretsStore.cpp | 75 ++ .../providersConfig/ProviderSecretsStore.hpp | 30 + sources/providersConfig/claude.toml | 8 + sources/providersConfig/codestral.toml | 8 + sources/providersConfig/googleai.toml | 8 + sources/providersConfig/llamacpp.toml | 8 + sources/providersConfig/lmstudio_chat.toml | 8 + .../providersConfig/lmstudio_responses.toml | 8 + sources/providersConfig/mistral.toml | 8 + sources/providersConfig/ollama_compat.toml | 8 + sources/providersConfig/ollama_native.toml | 8 + sources/providersConfig/openai_chat.toml | 8 + sources/providersConfig/openai_compat.toml | 8 + sources/providersConfig/openai_responses.toml | 8 + sources/providersConfig/openrouter.toml | 8 + .../providersConfig/provider_instances.qrc | 17 + sources/tomlSerializer/CMakeLists.txt | 12 + sources/tomlSerializer/TomlWriter.cpp | 135 ++++ sources/tomlSerializer/TomlWriter.hpp | 47 ++ 45 files changed, 3835 insertions(+), 2 deletions(-) create mode 100644 settings/NewProviderDialog.cpp create mode 100644 settings/NewProviderDialog.hpp create mode 100644 settings/ProviderDetailPane.cpp create mode 100644 settings/ProviderDetailPane.hpp create mode 100644 settings/ProviderListItem.cpp create mode 100644 settings/ProviderListItem.hpp create mode 100644 settings/ProvidersSettingsHelpers.hpp create mode 100644 settings/ProvidersSettingsPage.cpp create mode 100644 settings/ProvidersSettingsPage.hpp create mode 100644 settings/SectionBox.cpp create mode 100644 settings/SectionBox.hpp create mode 100644 sources/external/CMakeLists.txt create mode 100644 sources/providersConfig/CMakeLists.txt create mode 100644 sources/providersConfig/ProviderInstance.cpp create mode 100644 sources/providersConfig/ProviderInstance.hpp create mode 100644 sources/providersConfig/ProviderInstanceFactory.cpp create mode 100644 sources/providersConfig/ProviderInstanceFactory.hpp create mode 100644 sources/providersConfig/ProviderInstanceLoader.cpp create mode 100644 sources/providersConfig/ProviderInstanceLoader.hpp create mode 100644 sources/providersConfig/ProviderInstanceWriter.cpp create mode 100644 sources/providersConfig/ProviderInstanceWriter.hpp create mode 100644 sources/providersConfig/ProviderLauncher.cpp create mode 100644 sources/providersConfig/ProviderLauncher.hpp create mode 100644 sources/providersConfig/ProviderSecretsStore.cpp create mode 100644 sources/providersConfig/ProviderSecretsStore.hpp create mode 100644 sources/providersConfig/claude.toml create mode 100644 sources/providersConfig/codestral.toml create mode 100644 sources/providersConfig/googleai.toml create mode 100644 sources/providersConfig/llamacpp.toml create mode 100644 sources/providersConfig/lmstudio_chat.toml create mode 100644 sources/providersConfig/lmstudio_responses.toml create mode 100644 sources/providersConfig/mistral.toml create mode 100644 sources/providersConfig/ollama_compat.toml create mode 100644 sources/providersConfig/ollama_native.toml create mode 100644 sources/providersConfig/openai_chat.toml create mode 100644 sources/providersConfig/openai_compat.toml create mode 100644 sources/providersConfig/openai_responses.toml create mode 100644 sources/providersConfig/openrouter.toml create mode 100644 sources/providersConfig/provider_instances.qrc create mode 100644 sources/tomlSerializer/CMakeLists.txt create mode 100644 sources/tomlSerializer/TomlWriter.cpp create mode 100644 sources/tomlSerializer/TomlWriter.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f283683..b5a4ee1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/qodeassist.cpp b/qodeassist.cpp index ebec0f8..de3847d 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -1,6 +1,8 @@ // Copyright (C) 2024-2026 Petr Mironychev // SPDX-License-Identifier: GPL-3.0-or-later +#include + #include "QodeAssistConstants.hpp" #include "QodeAssisttr.h" #include "settings/PluginUpdater.hpp" @@ -10,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -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 m_updater; UpdateStatusWidget *m_statusWidget{nullptr}; QString m_lastRefactorInstructions; - QScopedPointer m_chatView; + std::unique_ptr m_chatView; QPointer m_mcpServerManager; QPointer m_engine; QPointer m_skillsManager; +#ifdef QODEASSIST_EXPERIMENTAL + QPointer m_providerInstanceFactory; + QPointer m_providerSecretsStore; + QPointer m_providerLauncher; + QPointer m_providersPageNavigator; + std::unique_ptr m_providersOptionsPage; +#endif }; } // namespace QodeAssist::Internal diff --git a/settings/CMakeLists.txt b/settings/CMakeLists.txt index 757bb64..a3cdf2f 100644 --- a/settings/CMakeLists.txt +++ b/settings/CMakeLists.txt @@ -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}) diff --git a/settings/NewProviderDialog.cpp b/settings/NewProviderDialog.cpp new file mode 100644 index 0000000..7209835 --- /dev/null +++ b/settings/NewProviderDialog.cpp @@ -0,0 +1,81 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "NewProviderDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/settings/NewProviderDialog.hpp b/settings/NewProviderDialog.hpp new file mode 100644 index 0000000..93bbd80 --- /dev/null +++ b/settings/NewProviderDialog.hpp @@ -0,0 +1,34 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +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 diff --git a/settings/ProviderDetailPane.cpp b/settings/ProviderDetailPane.cpp new file mode 100644 index 0000000..b284308 --- /dev/null +++ b/settings/ProviderDetailPane.cpp @@ -0,0 +1,527 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ProviderDetailPane.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#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/").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(" (detached — survives Qt Creator restart)") + : QString(); + m_launchCmdLabel->setText( + QStringLiteral("%1 %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 ansi = dark + ? std::array{ + 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("#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 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 diff --git a/settings/ProviderDetailPane.hpp b/settings/ProviderDetailPane.hpp new file mode 100644 index 0000000..7f2f68b --- /dev/null +++ b/settings/ProviderDetailPane.hpp @@ -0,0 +1,101 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#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 diff --git a/settings/ProviderListItem.cpp b/settings/ProviderListItem.cpp new file mode 100644 index 0000000..ed09202 --- /dev/null +++ b/settings/ProviderListItem.cpp @@ -0,0 +1,110 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ProviderListItem.hpp" + +#include +#include +#include +#include +#include + +#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 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 diff --git a/settings/ProviderListItem.hpp b/settings/ProviderListItem.hpp new file mode 100644 index 0000000..4e91206 --- /dev/null +++ b/settings/ProviderListItem.hpp @@ -0,0 +1,50 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +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 diff --git a/settings/ProvidersSettingsHelpers.hpp b/settings/ProvidersSettingsHelpers.hpp new file mode 100644 index 0000000..9ee958a --- /dev/null +++ b/settings/ProvidersSettingsHelpers.hpp @@ -0,0 +1,64 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/settings/ProvidersSettingsPage.cpp b/settings/ProvidersSettingsPage.cpp new file mode 100644 index 0000000..cf41d86 --- /dev/null +++ b/settings/ProvidersSettingsPage.cpp @@ -0,0 +1,674 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ProvidersSettingsPage.hpp" + +#include +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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 m_factory; + QPointer m_secrets; + QPointer 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 m_rows; + + QScrollArea *m_detailScroll = nullptr; + ProviderDetailPane *m_detailPane = nullptr; + + QString m_currentName; + + QPointer 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 createProvidersSettingsPage( + Providers::ProviderInstanceFactory *instanceFactory, + Providers::ProviderSecretsStore *secrets, + Providers::ProviderLauncher *launcher, + ProvidersPageNavigator *navigator) +{ + return std::make_unique( + instanceFactory, secrets, launcher, navigator); +} + +} // namespace QodeAssist::Settings + +#include "ProvidersSettingsPage.moc" diff --git a/settings/ProvidersSettingsPage.hpp b/settings/ProvidersSettingsPage.hpp new file mode 100644 index 0000000..86b59ce --- /dev/null +++ b/settings/ProvidersSettingsPage.hpp @@ -0,0 +1,43 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include + +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 createProvidersSettingsPage( + Providers::ProviderInstanceFactory *instanceFactory, + Providers::ProviderSecretsStore *secrets, + Providers::ProviderLauncher *launcher, + ProvidersPageNavigator *navigator); + +} // namespace QodeAssist::Settings diff --git a/settings/SectionBox.cpp b/settings/SectionBox.cpp new file mode 100644 index 0000000..700413e --- /dev/null +++ b/settings/SectionBox.cpp @@ -0,0 +1,31 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "SectionBox.hpp" + +#include +#include + +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 diff --git a/settings/SectionBox.hpp b/settings/SectionBox.hpp new file mode 100644 index 0000000..d7eb981 --- /dev/null +++ b/settings/SectionBox.hpp @@ -0,0 +1,26 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +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 diff --git a/sources/external/CMakeLists.txt b/sources/external/CMakeLists.txt new file mode 100644 index 0000000..67776a8 --- /dev/null +++ b/sources/external/CMakeLists.txt @@ -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) diff --git a/sources/providersConfig/CMakeLists.txt b/sources/providersConfig/CMakeLists.txt new file mode 100644 index 0000000..ef14835 --- /dev/null +++ b/sources/providersConfig/CMakeLists.txt @@ -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} +) diff --git a/sources/providersConfig/ProviderInstance.cpp b/sources/providersConfig/ProviderInstance.cpp new file mode 100644 index 0000000..5918391 --- /dev/null +++ b/sources/providersConfig/ProviderInstance.cpp @@ -0,0 +1,51 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ProviderInstance.hpp" + +#include + +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 diff --git a/sources/providersConfig/ProviderInstance.hpp b/sources/providersConfig/ProviderInstance.hpp new file mode 100644 index 0000000..c22c2bf --- /dev/null +++ b/sources/providersConfig/ProviderInstance.hpp @@ -0,0 +1,57 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include +#include + +namespace QodeAssist::Providers { + +struct LaunchConfig +{ + QString command; + QStringList args; + QString cwd; + QHash 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 diff --git a/sources/providersConfig/ProviderInstanceFactory.cpp b/sources/providersConfig/ProviderInstanceFactory.cpp new file mode 100644 index 0000000..1965679 --- /dev/null +++ b/sources/providersConfig/ProviderInstanceFactory.cpp @@ -0,0 +1,198 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ProviderInstanceFactory.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#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(m_instances.size())); + m_instanceNamesCache.reserve(static_cast(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 seenApis; + for (qsizetype i = 0; i < static_cast(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 diff --git a/sources/providersConfig/ProviderInstanceFactory.hpp b/sources/providersConfig/ProviderInstanceFactory.hpp new file mode 100644 index 0000000..7f22aff --- /dev/null +++ b/sources/providersConfig/ProviderInstanceFactory.hpp @@ -0,0 +1,67 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include +#include + +#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 &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 m_instances; + QHash 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 diff --git a/sources/providersConfig/ProviderInstanceLoader.cpp b/sources/providersConfig/ProviderInstanceLoader.cpp new file mode 100644 index 0000000..4df7c9a --- /dev/null +++ b/sources/providersConfig/ProviderInstanceLoader.cpp @@ -0,0 +1,270 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ProviderInstanceLoader.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +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(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 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(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 &raw, + QSet &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 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 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 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 diff --git a/sources/providersConfig/ProviderInstanceLoader.hpp b/sources/providersConfig/ProviderInstanceLoader.hpp new file mode 100644 index 0000000..1cc84f5 --- /dev/null +++ b/sources/providersConfig/ProviderInstanceLoader.hpp @@ -0,0 +1,32 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include +#include + +#include "ProviderInstance.hpp" + +namespace QodeAssist::Providers { + +class ProviderInstanceLoader +{ +public: + struct LoadResult + { + std::vector instances; + QStringList errors; + QStringList warnings; + }; + + static LoadResult load(const QString &qrcPrefix, const QString &userDir); + + static std::optional parseFile( + const QString &path, QString *error); +}; + +} // namespace QodeAssist::Providers diff --git a/sources/providersConfig/ProviderInstanceWriter.cpp b/sources/providersConfig/ProviderInstanceWriter.cpp new file mode 100644 index 0000000..fade70b --- /dev/null +++ b/sources/providersConfig/ProviderInstanceWriter.cpp @@ -0,0 +1,165 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ProviderInstanceWriter.hpp" + +#include +#include +#include +#include +#include +#include + +#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 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 diff --git a/sources/providersConfig/ProviderInstanceWriter.hpp b/sources/providersConfig/ProviderInstanceWriter.hpp new file mode 100644 index 0000000..96934dc --- /dev/null +++ b/sources/providersConfig/ProviderInstanceWriter.hpp @@ -0,0 +1,27 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#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 diff --git a/sources/providersConfig/ProviderLauncher.cpp b/sources/providersConfig/ProviderLauncher.cpp new file mode 100644 index 0000000..1825ad8 --- /dev/null +++ b/sources/providersConfig/ProviderLauncher.cpp @@ -0,0 +1,637 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ProviderLauncher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "Logger.hpp" + +#include +#include + +#ifdef Q_OS_WIN +#include +#else +#include +#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 probeReply; + QList> 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 &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 onResult) +{ + QNetworkRequest req(QUrl{url}); + req.setTransferTimeout(kAdoptionTransferTimeout); + QNetworkReply *reply = m_nam->get(req); + if (slot) + slot->oneShotProbes.append(QPointer(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(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(pid)); + if (h) { + ::TerminateProcess(h, 1); + ::CloseHandle(h); + } +#else + ::kill(static_cast(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 &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 diff --git a/sources/providersConfig/ProviderLauncher.hpp b/sources/providersConfig/ProviderLauncher.hpp new file mode 100644 index 0000000..be3f2ae --- /dev/null +++ b/sources/providersConfig/ProviderLauncher.hpp @@ -0,0 +1,82 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#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 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 m_slots; + QNetworkAccessManager *m_nam = nullptr; +}; + +} // namespace QodeAssist::Providers diff --git a/sources/providersConfig/ProviderSecretsStore.cpp b/sources/providersConfig/ProviderSecretsStore.cpp new file mode 100644 index 0000000..bf17f18 --- /dev/null +++ b/sources/providersConfig/ProviderSecretsStore.cpp @@ -0,0 +1,75 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ProviderSecretsStore.hpp" + +#include +#include + +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 diff --git a/sources/providersConfig/ProviderSecretsStore.hpp b/sources/providersConfig/ProviderSecretsStore.hpp new file mode 100644 index 0000000..6974b95 --- /dev/null +++ b/sources/providersConfig/ProviderSecretsStore.hpp @@ -0,0 +1,30 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +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 diff --git a/sources/providersConfig/claude.toml b/sources/providersConfig/claude.toml new file mode 100644 index 0000000..fd6eac1 --- /dev/null +++ b/sources/providersConfig/claude.toml @@ -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" diff --git a/sources/providersConfig/codestral.toml b/sources/providersConfig/codestral.toml new file mode 100644 index 0000000..2f4c75b --- /dev/null +++ b/sources/providersConfig/codestral.toml @@ -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" diff --git a/sources/providersConfig/googleai.toml b/sources/providersConfig/googleai.toml new file mode 100644 index 0000000..4ae0f06 --- /dev/null +++ b/sources/providersConfig/googleai.toml @@ -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" diff --git a/sources/providersConfig/llamacpp.toml b/sources/providersConfig/llamacpp.toml new file mode 100644 index 0000000..3764ae4 --- /dev/null +++ b/sources/providersConfig/llamacpp.toml @@ -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" diff --git a/sources/providersConfig/lmstudio_chat.toml b/sources/providersConfig/lmstudio_chat.toml new file mode 100644 index 0000000..672ed30 --- /dev/null +++ b/sources/providersConfig/lmstudio_chat.toml @@ -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)" diff --git a/sources/providersConfig/lmstudio_responses.toml b/sources/providersConfig/lmstudio_responses.toml new file mode 100644 index 0000000..e2e46fb --- /dev/null +++ b/sources/providersConfig/lmstudio_responses.toml @@ -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)" diff --git a/sources/providersConfig/mistral.toml b/sources/providersConfig/mistral.toml new file mode 100644 index 0000000..4ca6f25 --- /dev/null +++ b/sources/providersConfig/mistral.toml @@ -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" diff --git a/sources/providersConfig/ollama_compat.toml b/sources/providersConfig/ollama_compat.toml new file mode 100644 index 0000000..998053f --- /dev/null +++ b/sources/providersConfig/ollama_compat.toml @@ -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)" diff --git a/sources/providersConfig/ollama_native.toml b/sources/providersConfig/ollama_native.toml new file mode 100644 index 0000000..4e900e7 --- /dev/null +++ b/sources/providersConfig/ollama_native.toml @@ -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)" diff --git a/sources/providersConfig/openai_chat.toml b/sources/providersConfig/openai_chat.toml new file mode 100644 index 0000000..da0be10 --- /dev/null +++ b/sources/providersConfig/openai_chat.toml @@ -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)" diff --git a/sources/providersConfig/openai_compat.toml b/sources/providersConfig/openai_compat.toml new file mode 100644 index 0000000..b81665b --- /dev/null +++ b/sources/providersConfig/openai_compat.toml @@ -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" diff --git a/sources/providersConfig/openai_responses.toml b/sources/providersConfig/openai_responses.toml new file mode 100644 index 0000000..2fa3a3c --- /dev/null +++ b/sources/providersConfig/openai_responses.toml @@ -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)" diff --git a/sources/providersConfig/openrouter.toml b/sources/providersConfig/openrouter.toml new file mode 100644 index 0000000..d7753f1 --- /dev/null +++ b/sources/providersConfig/openrouter.toml @@ -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" diff --git a/sources/providersConfig/provider_instances.qrc b/sources/providersConfig/provider_instances.qrc new file mode 100644 index 0000000..aa8a855 --- /dev/null +++ b/sources/providersConfig/provider_instances.qrc @@ -0,0 +1,17 @@ + + + ollama_native.toml + ollama_compat.toml + claude.toml + openai_chat.toml + openai_responses.toml + openai_compat.toml + lmstudio_chat.toml + lmstudio_responses.toml + openrouter.toml + mistral.toml + codestral.toml + googleai.toml + llamacpp.toml + + diff --git a/sources/tomlSerializer/CMakeLists.txt b/sources/tomlSerializer/CMakeLists.txt new file mode 100644 index 0000000..a4a7a16 --- /dev/null +++ b/sources/tomlSerializer/CMakeLists.txt @@ -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} +) diff --git a/sources/tomlSerializer/TomlWriter.cpp b/sources/tomlSerializer/TomlWriter.cpp new file mode 100644 index 0000000..d610700 --- /dev/null +++ b/sources/tomlSerializer/TomlWriter.cpp @@ -0,0 +1,135 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "TomlWriter.hpp" + +#include + +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(d); + if (static_cast(i) == d) + writeInt(it.key(), i); + else + writeDouble(it.key(), d); + break; + } + default: + break; + } + } +} + +void TomlWriter::writeStringDict(const QHash &dict) +{ + for (auto it = dict.constBegin(); it != dict.constEnd(); ++it) + writeString(it.key(), it.value()); +} + +} // namespace QodeAssist::TomlSerializer diff --git a/sources/tomlSerializer/TomlWriter.hpp b/sources/tomlSerializer/TomlWriter.hpp new file mode 100644 index 0000000..891e79e --- /dev/null +++ b/sources/tomlSerializer/TomlWriter.hpp @@ -0,0 +1,47 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +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 &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