mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-05-30 10:59:30 -04:00
feat: Add settings page for providers (#353)
This commit is contained in:
@@ -2,6 +2,10 @@ cmake_minimum_required(VERSION 3.16)
|
|||||||
|
|
||||||
project(QodeAssist)
|
project(QodeAssist)
|
||||||
|
|
||||||
|
option(QODEASSIST_EXPERIMENTAL
|
||||||
|
"Enable experimental features" OFF)
|
||||||
|
message(STATUS "QodeAssist experimental features: ${QODEASSIST_EXPERIMENTAL}")
|
||||||
|
|
||||||
set(CMAKE_AUTOMOC ON)
|
set(CMAKE_AUTOMOC ON)
|
||||||
set(CMAKE_AUTORCC ON)
|
set(CMAKE_AUTORCC ON)
|
||||||
set(CMAKE_AUTOUIC ON)
|
set(CMAKE_AUTOUIC ON)
|
||||||
@@ -34,11 +38,13 @@ add_definitions(
|
|||||||
-DQODEASSIST_QT_CREATOR_VERSION_PATCH=${QODEASSIST_QT_CREATOR_VERSION_PATCH}
|
-DQODEASSIST_QT_CREATOR_VERSION_PATCH=${QODEASSIST_QT_CREATOR_VERSION_PATCH}
|
||||||
)
|
)
|
||||||
|
|
||||||
add_subdirectory(sources/external/llmqore)
|
add_subdirectory(sources/external)
|
||||||
|
add_subdirectory(sources/tomlSerializer)
|
||||||
add_subdirectory(sources/skills)
|
add_subdirectory(sources/skills)
|
||||||
add_subdirectory(pluginllmcore)
|
add_subdirectory(pluginllmcore)
|
||||||
add_subdirectory(settings)
|
add_subdirectory(settings)
|
||||||
add_subdirectory(logger)
|
add_subdirectory(logger)
|
||||||
|
add_subdirectory(sources/providersConfig)
|
||||||
add_subdirectory(UIControls)
|
add_subdirectory(UIControls)
|
||||||
add_subdirectory(ChatView)
|
add_subdirectory(ChatView)
|
||||||
add_subdirectory(context)
|
add_subdirectory(context)
|
||||||
@@ -65,6 +71,7 @@ add_qtc_plugin(QodeAssist
|
|||||||
QtCreator::CPlusPlus
|
QtCreator::CPlusPlus
|
||||||
LLMQore
|
LLMQore
|
||||||
PluginLLMCore
|
PluginLLMCore
|
||||||
|
ProvidersConfig
|
||||||
Skills
|
Skills
|
||||||
QodeAssistChatViewplugin
|
QodeAssistChatViewplugin
|
||||||
SOURCES
|
SOURCES
|
||||||
@@ -160,6 +167,10 @@ add_qtc_plugin(QodeAssist
|
|||||||
settings/McpClientsListAspect.hpp settings/McpClientsListAspect.cpp
|
settings/McpClientsListAspect.hpp settings/McpClientsListAspect.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(QODEASSIST_EXPERIMENTAL)
|
||||||
|
target_compile_definitions(QodeAssist PRIVATE QODEASSIST_EXPERIMENTAL)
|
||||||
|
endif()
|
||||||
|
|
||||||
get_target_property(QtCreatorCorePath QtCreator::Core LOCATION)
|
get_target_property(QtCreatorCorePath QtCreator::Core LOCATION)
|
||||||
find_program(QtCreatorExecutable
|
find_program(QtCreatorExecutable
|
||||||
NAMES
|
NAMES
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright (C) 2024-2026 Petr Mironychev
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
#include "QodeAssistConstants.hpp"
|
#include "QodeAssistConstants.hpp"
|
||||||
#include "QodeAssisttr.h"
|
#include "QodeAssisttr.h"
|
||||||
#include "settings/PluginUpdater.hpp"
|
#include "settings/PluginUpdater.hpp"
|
||||||
@@ -10,6 +12,7 @@
|
|||||||
#include <coreplugin/actionmanager/actionmanager.h>
|
#include <coreplugin/actionmanager/actionmanager.h>
|
||||||
#include <coreplugin/actionmanager/command.h>
|
#include <coreplugin/actionmanager/command.h>
|
||||||
#include <coreplugin/coreconstants.h>
|
#include <coreplugin/coreconstants.h>
|
||||||
|
#include <coreplugin/dialogs/ioptionspage.h>
|
||||||
#include <coreplugin/editormanager/documentmodel.h>
|
#include <coreplugin/editormanager/documentmodel.h>
|
||||||
#include <coreplugin/editormanager/editormanager.h>
|
#include <coreplugin/editormanager/editormanager.h>
|
||||||
#include <coreplugin/icontext.h>
|
#include <coreplugin/icontext.h>
|
||||||
@@ -50,8 +53,17 @@
|
|||||||
#include "settings/ChatAssistantSettings.hpp"
|
#include "settings/ChatAssistantSettings.hpp"
|
||||||
#include "settings/GeneralSettings.hpp"
|
#include "settings/GeneralSettings.hpp"
|
||||||
#include "settings/ProjectSettingsPanel.hpp"
|
#include "settings/ProjectSettingsPanel.hpp"
|
||||||
|
#ifdef QODEASSIST_EXPERIMENTAL
|
||||||
|
#include "settings/ProvidersSettingsPage.hpp"
|
||||||
|
#endif
|
||||||
#include "settings/QuickRefactorSettings.hpp"
|
#include "settings/QuickRefactorSettings.hpp"
|
||||||
#include "settings/SettingsConstants.hpp"
|
#include "settings/SettingsConstants.hpp"
|
||||||
|
|
||||||
|
#ifdef QODEASSIST_EXPERIMENTAL
|
||||||
|
#include "ProviderInstanceFactory.hpp"
|
||||||
|
#include "ProviderLauncher.hpp"
|
||||||
|
#include "ProviderSecretsStore.hpp"
|
||||||
|
#endif
|
||||||
#include "templates/Templates.hpp"
|
#include "templates/Templates.hpp"
|
||||||
#include "widgets/CustomInstructionsManager.hpp"
|
#include "widgets/CustomInstructionsManager.hpp"
|
||||||
#include "widgets/QuickRefactorDialog.hpp"
|
#include "widgets/QuickRefactorDialog.hpp"
|
||||||
@@ -192,6 +204,18 @@ public:
|
|||||||
Settings::setupProjectPanel();
|
Settings::setupProjectPanel();
|
||||||
ConfigurationManager::instance().init();
|
ConfigurationManager::instance().init();
|
||||||
|
|
||||||
|
#ifdef QODEASSIST_EXPERIMENTAL
|
||||||
|
m_providerInstanceFactory = new Providers::ProviderInstanceFactory(this);
|
||||||
|
m_providerSecretsStore = new Providers::ProviderSecretsStore(this);
|
||||||
|
m_providerLauncher = new Providers::ProviderLauncher(this);
|
||||||
|
m_providersPageNavigator = new Settings::ProvidersPageNavigator(this);
|
||||||
|
m_providersOptionsPage = Settings::createProvidersSettingsPage(
|
||||||
|
m_providerInstanceFactory,
|
||||||
|
m_providerSecretsStore,
|
||||||
|
m_providerLauncher,
|
||||||
|
m_providersPageNavigator);
|
||||||
|
#endif
|
||||||
|
|
||||||
m_mcpServerManager = new Mcp::McpServerManager(this);
|
m_mcpServerManager = new Mcp::McpServerManager(this);
|
||||||
m_mcpServerManager->init();
|
m_mcpServerManager->init();
|
||||||
|
|
||||||
@@ -482,10 +506,17 @@ private:
|
|||||||
QPointer<PluginUpdater> m_updater;
|
QPointer<PluginUpdater> m_updater;
|
||||||
UpdateStatusWidget *m_statusWidget{nullptr};
|
UpdateStatusWidget *m_statusWidget{nullptr};
|
||||||
QString m_lastRefactorInstructions;
|
QString m_lastRefactorInstructions;
|
||||||
QScopedPointer<Chat::ChatView> m_chatView;
|
std::unique_ptr<Chat::ChatView> m_chatView;
|
||||||
QPointer<Mcp::McpServerManager> m_mcpServerManager;
|
QPointer<Mcp::McpServerManager> m_mcpServerManager;
|
||||||
QPointer<QQmlEngine> m_engine;
|
QPointer<QQmlEngine> m_engine;
|
||||||
QPointer<Skills::SkillsManager> m_skillsManager;
|
QPointer<Skills::SkillsManager> m_skillsManager;
|
||||||
|
#ifdef QODEASSIST_EXPERIMENTAL
|
||||||
|
QPointer<Providers::ProviderInstanceFactory> m_providerInstanceFactory;
|
||||||
|
QPointer<Providers::ProviderSecretsStore> m_providerSecretsStore;
|
||||||
|
QPointer<Providers::ProviderLauncher> m_providerLauncher;
|
||||||
|
QPointer<Settings::ProvidersPageNavigator> m_providersPageNavigator;
|
||||||
|
std::unique_ptr<Core::IOptionsPage> m_providersOptionsPage;
|
||||||
|
#endif
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Internal
|
} // namespace QodeAssist::Internal
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ add_library(QodeAssistSettings STATIC
|
|||||||
ProjectSettingsPanel.hpp ProjectSettingsPanel.cpp
|
ProjectSettingsPanel.hpp ProjectSettingsPanel.cpp
|
||||||
ProviderSettings.hpp ProviderSettings.cpp
|
ProviderSettings.hpp ProviderSettings.cpp
|
||||||
ProviderNameMigration.hpp
|
ProviderNameMigration.hpp
|
||||||
|
ProvidersSettingsPage.hpp ProvidersSettingsPage.cpp
|
||||||
|
ProvidersSettingsHelpers.hpp
|
||||||
|
SectionBox.hpp SectionBox.cpp
|
||||||
|
ProviderListItem.hpp ProviderListItem.cpp
|
||||||
|
ProviderDetailPane.hpp ProviderDetailPane.cpp
|
||||||
|
NewProviderDialog.hpp NewProviderDialog.cpp
|
||||||
PluginUpdater.hpp PluginUpdater.cpp
|
PluginUpdater.hpp PluginUpdater.cpp
|
||||||
UpdateDialog.hpp UpdateDialog.cpp
|
UpdateDialog.hpp UpdateDialog.cpp
|
||||||
AgentRole.hpp AgentRole.cpp
|
AgentRole.hpp AgentRole.cpp
|
||||||
@@ -31,6 +37,7 @@ target_link_libraries(QodeAssistSettings
|
|||||||
QtCreator::Core
|
QtCreator::Core
|
||||||
QtCreator::Utils
|
QtCreator::Utils
|
||||||
QodeAssistLogger
|
QodeAssistLogger
|
||||||
|
ProvidersConfig
|
||||||
Skills
|
Skills
|
||||||
)
|
)
|
||||||
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|||||||
81
settings/NewProviderDialog.cpp
Normal file
81
settings/NewProviderDialog.cpp
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "NewProviderDialog.hpp"
|
||||||
|
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
NewProviderDialog::NewProviderDialog(const QStringList &types, QWidget *parent)
|
||||||
|
: QDialog(parent)
|
||||||
|
{
|
||||||
|
setWindowTitle(tr("New provider"));
|
||||||
|
setMinimumWidth(520);
|
||||||
|
|
||||||
|
auto *intro = new QLabel(
|
||||||
|
tr("A provider binds a client API to a URL and API key. "
|
||||||
|
"Agents reference providers by name."),
|
||||||
|
this);
|
||||||
|
intro->setWordWrap(true);
|
||||||
|
QPalette ip = intro->palette();
|
||||||
|
ip.setColor(QPalette::WindowText, ip.color(QPalette::Mid));
|
||||||
|
intro->setPalette(ip);
|
||||||
|
|
||||||
|
m_typeCombo = new QComboBox(this);
|
||||||
|
m_typeCombo->addItems(types);
|
||||||
|
m_typeCombo->setEditable(false);
|
||||||
|
|
||||||
|
m_nameEdit = new QLineEdit(this);
|
||||||
|
m_nameEdit->setPlaceholderText(tr("Shown in the providers list and referenced by agents."));
|
||||||
|
|
||||||
|
m_urlEdit = new QLineEdit(this);
|
||||||
|
m_urlEdit->setPlaceholderText(QStringLiteral("https://api.example.com"));
|
||||||
|
|
||||||
|
m_descriptionEdit = new QLineEdit(this);
|
||||||
|
m_descriptionEdit->setPlaceholderText(tr("Optional — what this provider is for."));
|
||||||
|
|
||||||
|
m_apiKeyEdit = new QLineEdit(this);
|
||||||
|
m_apiKeyEdit->setEchoMode(QLineEdit::Password);
|
||||||
|
m_apiKeyEdit->setPlaceholderText(tr("(stored — leave blank to set later)"));
|
||||||
|
|
||||||
|
auto *form = new QFormLayout;
|
||||||
|
form->addRow(tr("Client API:"), m_typeCombo);
|
||||||
|
form->addRow(tr("Name:"), m_nameEdit);
|
||||||
|
form->addRow(tr("URL:"), m_urlEdit);
|
||||||
|
form->addRow(tr("Description:"), m_descriptionEdit);
|
||||||
|
form->addRow(tr("API key:"), m_apiKeyEdit);
|
||||||
|
|
||||||
|
auto *buttons = new QDialogButtonBox(
|
||||||
|
QDialogButtonBox::Cancel | QDialogButtonBox::Ok, this);
|
||||||
|
buttons->button(QDialogButtonBox::Ok)->setText(tr("Create"));
|
||||||
|
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||||
|
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
|
||||||
|
auto *root = new QVBoxLayout(this);
|
||||||
|
root->addWidget(intro);
|
||||||
|
root->addLayout(form);
|
||||||
|
root->addWidget(buttons);
|
||||||
|
|
||||||
|
connect(m_typeCombo, &QComboBox::currentTextChanged, this, [this](const QString &type) {
|
||||||
|
if (m_nameEdit->text().isEmpty())
|
||||||
|
m_nameEdit->setText(type);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!types.isEmpty() && m_nameEdit->text().isEmpty())
|
||||||
|
m_nameEdit->setText(types.front());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString NewProviderDialog::providerType() const { return m_typeCombo->currentText(); }
|
||||||
|
QString NewProviderDialog::providerName() const { return m_nameEdit->text().trimmed(); }
|
||||||
|
QString NewProviderDialog::url() const { return m_urlEdit->text().trimmed(); }
|
||||||
|
QString NewProviderDialog::description() const { return m_descriptionEdit->text().trimmed(); }
|
||||||
|
QString NewProviderDialog::apiKey() const { return m_apiKeyEdit->text(); }
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
34
settings/NewProviderDialog.hpp
Normal file
34
settings/NewProviderDialog.hpp
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
class QComboBox;
|
||||||
|
class QLineEdit;
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class NewProviderDialog : public QDialog
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit NewProviderDialog(const QStringList &types, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
QString providerType() const;
|
||||||
|
QString providerName() const;
|
||||||
|
QString url() const;
|
||||||
|
QString description() const;
|
||||||
|
QString apiKey() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QComboBox *m_typeCombo = nullptr;
|
||||||
|
QLineEdit *m_nameEdit = nullptr;
|
||||||
|
QLineEdit *m_urlEdit = nullptr;
|
||||||
|
QLineEdit *m_descriptionEdit = nullptr;
|
||||||
|
QLineEdit *m_apiKeyEdit = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
527
settings/ProviderDetailPane.cpp
Normal file
527
settings/ProviderDetailPane.cpp
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ProviderDetailPane.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QPlainTextEdit>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include <solutions/terminal/terminalview.h>
|
||||||
|
|
||||||
|
#include "ProviderInstanceWriter.hpp"
|
||||||
|
#include "ProvidersSettingsHelpers.hpp"
|
||||||
|
#include "SectionBox.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
ProviderDetailPane::ProviderDetailPane(QWidget *parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
{
|
||||||
|
m_nameLabel = new QLabel(this);
|
||||||
|
QFont nf = m_nameLabel->font();
|
||||||
|
nf.setBold(true);
|
||||||
|
nf.setPixelSize(15);
|
||||||
|
m_nameLabel->setFont(nf);
|
||||||
|
|
||||||
|
m_sourcePathLabel = new QLabel(this);
|
||||||
|
m_sourcePathLabel->setFont(monospaceFont(11));
|
||||||
|
QPalette spp = m_sourcePathLabel->palette();
|
||||||
|
spp.setColor(QPalette::WindowText, spp.color(QPalette::Mid));
|
||||||
|
m_sourcePathLabel->setPalette(spp);
|
||||||
|
|
||||||
|
m_editBtn = new QPushButton(tr("Edit…"), this);
|
||||||
|
m_editBtn->setDefault(true);
|
||||||
|
m_openInEditorBtn = new QPushButton(tr("Open in editor"), this);
|
||||||
|
m_openInEditorBtn->setToolTip(
|
||||||
|
tr("Open this provider's TOML file in Qt Creator. "
|
||||||
|
"Bundled providers are read-only — duplicate first."));
|
||||||
|
m_dupBtn = new QPushButton(tr("Duplicate…"), this);
|
||||||
|
m_deleteBtn = new QPushButton(tr("Delete"), this);
|
||||||
|
m_cancelBtn = new QPushButton(tr("Cancel"), this);
|
||||||
|
m_saveBtn = new QPushButton(tr("Save"), this);
|
||||||
|
m_saveBtn->setDefault(true);
|
||||||
|
m_cancelBtn->hide();
|
||||||
|
m_saveBtn->hide();
|
||||||
|
|
||||||
|
connect(m_editBtn, &QPushButton::clicked, this, [this] { setEditing(true); });
|
||||||
|
connect(m_cancelBtn, &QPushButton::clicked, this, [this] {
|
||||||
|
setEditing(false);
|
||||||
|
populate(m_current, m_currentHasStoredKey);
|
||||||
|
});
|
||||||
|
connect(m_saveBtn, &QPushButton::clicked, this, [this] {
|
||||||
|
emit saveRequested(collectEdits());
|
||||||
|
});
|
||||||
|
connect(m_openInEditorBtn, &QPushButton::clicked, this,
|
||||||
|
[this] { emit openInEditorRequested(m_current.sourcePath); });
|
||||||
|
connect(m_dupBtn, &QPushButton::clicked, this, [this] { emit duplicateRequested(); });
|
||||||
|
connect(m_deleteBtn, &QPushButton::clicked, this, [this] { emit deleteRequested(); });
|
||||||
|
|
||||||
|
auto *btnBar = new QHBoxLayout;
|
||||||
|
btnBar->setContentsMargins(0, 0, 0, 0);
|
||||||
|
btnBar->setSpacing(4);
|
||||||
|
btnBar->addWidget(m_editBtn);
|
||||||
|
btnBar->addWidget(m_openInEditorBtn);
|
||||||
|
btnBar->addWidget(m_dupBtn);
|
||||||
|
btnBar->addWidget(m_deleteBtn);
|
||||||
|
btnBar->addWidget(m_cancelBtn);
|
||||||
|
btnBar->addWidget(m_saveBtn);
|
||||||
|
|
||||||
|
auto *titleRow = new QHBoxLayout;
|
||||||
|
titleRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
titleRow->setSpacing(8);
|
||||||
|
titleRow->addWidget(m_nameLabel);
|
||||||
|
titleRow->addStretch(1);
|
||||||
|
|
||||||
|
auto *headerLeft = new QVBoxLayout;
|
||||||
|
headerLeft->setContentsMargins(0, 0, 0, 0);
|
||||||
|
headerLeft->setSpacing(2);
|
||||||
|
headerLeft->addLayout(titleRow);
|
||||||
|
headerLeft->addWidget(m_sourcePathLabel);
|
||||||
|
|
||||||
|
auto *headerRow = new QHBoxLayout;
|
||||||
|
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
headerRow->setSpacing(8);
|
||||||
|
headerRow->addLayout(headerLeft, 1);
|
||||||
|
headerRow->addLayout(btnBar);
|
||||||
|
|
||||||
|
auto *headerSep = new QFrame(this);
|
||||||
|
headerSep->setFrameShape(QFrame::HLine);
|
||||||
|
headerSep->setFrameShadow(QFrame::Sunken);
|
||||||
|
|
||||||
|
m_descriptionLabel = new QLabel(this);
|
||||||
|
m_descriptionLabel->setWordWrap(true);
|
||||||
|
m_descriptionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||||
|
|
||||||
|
auto *identitySection = new SectionBox(tr("Identity"), this);
|
||||||
|
m_nameEdit = new QLineEdit(this);
|
||||||
|
m_typeEdit = new QLineEdit(this);
|
||||||
|
m_typeEdit->setReadOnly(true);
|
||||||
|
m_descriptionEdit = new QPlainTextEdit(this);
|
||||||
|
m_descriptionEdit->setMaximumHeight(60);
|
||||||
|
m_descriptionEdit->setReadOnly(true);
|
||||||
|
auto *identityGrid = new QGridLayout;
|
||||||
|
identityGrid->setContentsMargins(0, 0, 0, 0);
|
||||||
|
identityGrid->setHorizontalSpacing(8);
|
||||||
|
identityGrid->setVerticalSpacing(4);
|
||||||
|
int identityRow = 0;
|
||||||
|
identityRow = addFormRow(identityGrid, identityRow, tr("Name:"),
|
||||||
|
singleField(m_nameEdit));
|
||||||
|
identityRow = addFormRow(identityGrid, identityRow, tr("Client API:"),
|
||||||
|
singleField(m_typeEdit),
|
||||||
|
tr("The client API this provider speaks. "
|
||||||
|
"Cannot be changed after creation."));
|
||||||
|
identityRow = addFormRow(identityGrid, identityRow, tr("Description:"),
|
||||||
|
singleField(m_descriptionEdit));
|
||||||
|
identitySection->bodyLayout()->addLayout(identityGrid);
|
||||||
|
|
||||||
|
auto *endpointSection = new SectionBox(tr("Endpoint"), this);
|
||||||
|
m_urlEdit = new QLineEdit(this);
|
||||||
|
m_urlEdit->setFont(monospaceFont(11));
|
||||||
|
auto *endpointGrid = new QGridLayout;
|
||||||
|
endpointGrid->setContentsMargins(0, 0, 0, 0);
|
||||||
|
endpointGrid->setHorizontalSpacing(8);
|
||||||
|
endpointGrid->setVerticalSpacing(4);
|
||||||
|
int endpointRow = 0;
|
||||||
|
endpointRow = addFormRow(endpointGrid, endpointRow, tr("URL:"),
|
||||||
|
singleField(m_urlEdit),
|
||||||
|
tr("Base URL. Agents append their endpoint path "
|
||||||
|
"(e.g. /chat/completions) to this."));
|
||||||
|
endpointSection->bodyLayout()->addLayout(endpointGrid);
|
||||||
|
|
||||||
|
m_samplePreview = new QLabel(this);
|
||||||
|
m_samplePreview->setFont(monospaceFont(11));
|
||||||
|
m_samplePreview->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||||
|
m_samplePreview->setWordWrap(true);
|
||||||
|
m_samplePreview->setContentsMargins(6, 4, 6, 4);
|
||||||
|
m_samplePreview->setAutoFillBackground(true);
|
||||||
|
endpointSection->bodyLayout()->addWidget(m_samplePreview);
|
||||||
|
|
||||||
|
auto *credSection = new SectionBox(tr("Credentials"), this);
|
||||||
|
m_apiKeyEdit = new QLineEdit(this);
|
||||||
|
m_apiKeyEdit->setEchoMode(QLineEdit::Password);
|
||||||
|
m_apiKeyEdit->setPlaceholderText(tr("Enter API key…"));
|
||||||
|
m_revealKeyBtn = new QToolButton(this);
|
||||||
|
m_revealKeyBtn->setText(QStringLiteral("👁"));
|
||||||
|
m_revealKeyBtn->setCheckable(true);
|
||||||
|
m_revealKeyBtn->setToolTip(tr("Show / hide API key"));
|
||||||
|
connect(m_revealKeyBtn, &QToolButton::toggled, this, [this](bool on) {
|
||||||
|
m_apiKeyEdit->setEchoMode(on ? QLineEdit::Normal : QLineEdit::Password);
|
||||||
|
});
|
||||||
|
m_apiKeySaveBtn = new QPushButton(tr("Save key"), this);
|
||||||
|
m_apiKeySaveBtn->setEnabled(false);
|
||||||
|
m_apiKeyClearBtn = new QPushButton(tr("Clear"), this);
|
||||||
|
m_apiKeyClearBtn->setToolTip(tr("Erase the stored API key for this provider"));
|
||||||
|
connect(m_apiKeyEdit, &QLineEdit::textChanged, this, [this](const QString &t) {
|
||||||
|
m_apiKeySaveBtn->setEnabled(!t.isEmpty());
|
||||||
|
});
|
||||||
|
connect(m_apiKeyEdit, &QLineEdit::returnPressed, this, [this] {
|
||||||
|
if (!m_apiKeyEdit->text().isEmpty())
|
||||||
|
m_apiKeySaveBtn->click();
|
||||||
|
});
|
||||||
|
connect(m_apiKeySaveBtn, &QPushButton::clicked, this, [this] {
|
||||||
|
const QString key = m_apiKeyEdit->text();
|
||||||
|
if (key.isEmpty())
|
||||||
|
return;
|
||||||
|
emit apiKeySaveRequested(key);
|
||||||
|
m_apiKeyEdit->clear();
|
||||||
|
});
|
||||||
|
connect(m_apiKeyClearBtn, &QPushButton::clicked, this,
|
||||||
|
[this] { emit apiKeyClearRequested(); });
|
||||||
|
m_keyHint = new QLabel(this);
|
||||||
|
QFont khf = m_keyHint->font();
|
||||||
|
khf.setPixelSize(11);
|
||||||
|
m_keyHint->setFont(khf);
|
||||||
|
m_keyHint->setWordWrap(true);
|
||||||
|
QPalette khp = m_keyHint->palette();
|
||||||
|
khp.setColor(QPalette::WindowText, khp.color(QPalette::Mid));
|
||||||
|
m_keyHint->setPalette(khp);
|
||||||
|
|
||||||
|
auto *keyRow = new QHBoxLayout;
|
||||||
|
keyRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
keyRow->setSpacing(4);
|
||||||
|
keyRow->addWidget(m_apiKeyEdit, 1);
|
||||||
|
keyRow->addWidget(m_revealKeyBtn);
|
||||||
|
keyRow->addWidget(m_apiKeySaveBtn);
|
||||||
|
keyRow->addWidget(m_apiKeyClearBtn);
|
||||||
|
|
||||||
|
auto *credGrid = new QGridLayout;
|
||||||
|
credGrid->setContentsMargins(0, 0, 0, 0);
|
||||||
|
credGrid->setHorizontalSpacing(8);
|
||||||
|
credGrid->setVerticalSpacing(4);
|
||||||
|
int credRow = 0;
|
||||||
|
credRow = addFormRow(credGrid, credRow, tr("API key:"), keyRow);
|
||||||
|
credGrid->addWidget(m_keyHint, credRow, 1);
|
||||||
|
credSection->bodyLayout()->addLayout(credGrid);
|
||||||
|
|
||||||
|
m_launchSection = new SectionBox(tr("Launch"), this);
|
||||||
|
m_launchEmptyHint = new QLabel(this);
|
||||||
|
m_launchEmptyHint->setWordWrap(true);
|
||||||
|
QPalette lehp = m_launchEmptyHint->palette();
|
||||||
|
lehp.setColor(QPalette::WindowText, lehp.color(QPalette::Mid));
|
||||||
|
m_launchEmptyHint->setPalette(lehp);
|
||||||
|
m_launchCmdLabel = new QLabel(this);
|
||||||
|
m_launchCmdLabel->setFont(monospaceFont(11));
|
||||||
|
m_launchCmdLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||||
|
m_launchCmdLabel->setWordWrap(true);
|
||||||
|
m_launchStatusPill = new QLabel(tr("idle"), this);
|
||||||
|
m_startBtn = new QPushButton(tr("Start"), this);
|
||||||
|
m_stopBtn = new QPushButton(tr("Stop"), this);
|
||||||
|
m_restartBtn = new QPushButton(tr("Restart"), this);
|
||||||
|
connect(m_startBtn, &QPushButton::clicked, this,
|
||||||
|
[this] { emit launchStartRequested(m_current.name); });
|
||||||
|
connect(m_stopBtn, &QPushButton::clicked, this,
|
||||||
|
[this] { emit launchStopRequested(m_current.name); });
|
||||||
|
connect(m_restartBtn, &QPushButton::clicked, this,
|
||||||
|
[this] { emit launchRestartRequested(m_current.name); });
|
||||||
|
auto *launchBtnRow = new QHBoxLayout;
|
||||||
|
launchBtnRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
launchBtnRow->setSpacing(6);
|
||||||
|
launchBtnRow->addWidget(m_launchStatusPill);
|
||||||
|
launchBtnRow->addStretch(1);
|
||||||
|
launchBtnRow->addWidget(m_startBtn);
|
||||||
|
launchBtnRow->addWidget(m_stopBtn);
|
||||||
|
launchBtnRow->addWidget(m_restartBtn);
|
||||||
|
|
||||||
|
m_launchTerminalToggle = new QToolButton(this);
|
||||||
|
m_launchTerminalToggle->setText(tr("▸ Show launch terminal"));
|
||||||
|
m_launchTerminalToggle->setCursor(Qt::PointingHandCursor);
|
||||||
|
m_launchTerminalToggle->setAutoRaise(true);
|
||||||
|
m_launchTerminalToggle->setCheckable(true);
|
||||||
|
m_launchTerminal = new TerminalSolution::TerminalView(this);
|
||||||
|
{
|
||||||
|
QFont termFont(TerminalSolution::defaultFontFamily());
|
||||||
|
const int sz = TerminalSolution::defaultFontSize();
|
||||||
|
if (sz > 0)
|
||||||
|
termFont.setPixelSize(sz);
|
||||||
|
termFont.setStyleHint(QFont::Monospace);
|
||||||
|
m_launchTerminal->setFont(termFont);
|
||||||
|
applyTerminalPalette();
|
||||||
|
}
|
||||||
|
m_launchTerminal->setMinimumHeight(180);
|
||||||
|
m_launchTerminal->setVisible(false);
|
||||||
|
connect(m_launchTerminalToggle, &QToolButton::toggled, this, [this](bool on) {
|
||||||
|
m_launchTerminal->setVisible(on);
|
||||||
|
m_launchTerminalToggle->setText(
|
||||||
|
on ? tr("▾ Hide launch terminal") : tr("▸ Show launch terminal"));
|
||||||
|
});
|
||||||
|
|
||||||
|
m_launchSection->bodyLayout()->addWidget(m_launchEmptyHint);
|
||||||
|
m_launchSection->bodyLayout()->addWidget(m_launchCmdLabel);
|
||||||
|
m_launchSection->bodyLayout()->addLayout(launchBtnRow);
|
||||||
|
m_launchSection->bodyLayout()->addWidget(m_launchTerminalToggle, 0, Qt::AlignLeft);
|
||||||
|
m_launchSection->bodyLayout()->addWidget(m_launchTerminal);
|
||||||
|
|
||||||
|
m_rawToggle = new QToolButton(this);
|
||||||
|
m_rawToggle->setText(tr("▸ Show raw TOML"));
|
||||||
|
m_rawToggle->setCursor(Qt::PointingHandCursor);
|
||||||
|
m_rawToggle->setAutoRaise(true);
|
||||||
|
m_rawToggle->setCheckable(true);
|
||||||
|
m_rawToml = new QPlainTextEdit(this);
|
||||||
|
m_rawToml->setReadOnly(true);
|
||||||
|
m_rawToml->setFont(monospaceFont(11));
|
||||||
|
m_rawToml->setMinimumHeight(120);
|
||||||
|
m_rawToml->setVisible(false);
|
||||||
|
connect(m_rawToggle, &QToolButton::toggled, this, [this](bool on) {
|
||||||
|
m_rawToml->setVisible(on);
|
||||||
|
m_rawToggle->setText(on ? tr("▾ Hide raw TOML") : tr("▸ Show raw TOML"));
|
||||||
|
});
|
||||||
|
|
||||||
|
auto *root = new QVBoxLayout(this);
|
||||||
|
root->setContentsMargins(12, 12, 12, 12);
|
||||||
|
root->setSpacing(10);
|
||||||
|
root->addLayout(headerRow);
|
||||||
|
root->addWidget(headerSep);
|
||||||
|
root->addWidget(m_descriptionLabel);
|
||||||
|
root->addWidget(identitySection);
|
||||||
|
root->addWidget(endpointSection);
|
||||||
|
root->addWidget(credSection);
|
||||||
|
root->addWidget(m_launchSection);
|
||||||
|
root->addWidget(m_rawToggle, 0, Qt::AlignLeft);
|
||||||
|
root->addWidget(m_rawToml);
|
||||||
|
root->addStretch(1);
|
||||||
|
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderDetailPane::populate(const Providers::ProviderInstance &inst, bool hasStoredKey)
|
||||||
|
{
|
||||||
|
m_current = inst;
|
||||||
|
m_currentHasStoredKey = hasStoredKey;
|
||||||
|
const bool isUser = inst.isUserSource();
|
||||||
|
const bool needsKey = !inst.apiKeyRef.isEmpty();
|
||||||
|
|
||||||
|
m_nameLabel->setText(inst.name);
|
||||||
|
m_sourcePathLabel->setText(inst.sourcePath);
|
||||||
|
|
||||||
|
m_descriptionLabel->setText(
|
||||||
|
inst.description.isEmpty() ? tr("No description provided.") : inst.description);
|
||||||
|
|
||||||
|
m_nameEdit->setText(inst.name);
|
||||||
|
m_typeEdit->setText(inst.clientApi);
|
||||||
|
m_descriptionEdit->setPlainText(inst.description);
|
||||||
|
m_urlEdit->setText(inst.url);
|
||||||
|
|
||||||
|
m_apiKeyEdit->clear();
|
||||||
|
m_apiKeyEdit->setEnabled(needsKey);
|
||||||
|
m_apiKeySaveBtn->setEnabled(false);
|
||||||
|
m_apiKeyClearBtn->setEnabled(needsKey && hasStoredKey);
|
||||||
|
m_revealKeyBtn->setEnabled(needsKey);
|
||||||
|
m_revealKeyBtn->setChecked(false);
|
||||||
|
if (!needsKey) {
|
||||||
|
m_apiKeyEdit->setPlaceholderText(tr("— not required (local provider)"));
|
||||||
|
m_keyHint->setText(tr("This provider type does not use a key."));
|
||||||
|
} else if (hasStoredKey) {
|
||||||
|
m_apiKeyEdit->setPlaceholderText(tr("Stored — enter a new key to replace it."));
|
||||||
|
m_keyHint->setText(tr("A key is stored. Type a new key and press Save key to "
|
||||||
|
"replace it, or Clear to erase it."));
|
||||||
|
} else {
|
||||||
|
m_apiKeyEdit->setPlaceholderText(tr("Enter API key…"));
|
||||||
|
m_keyHint->setText(tr("No key stored yet. Type a key and press Save key."));
|
||||||
|
}
|
||||||
|
|
||||||
|
m_samplePreview->setText(
|
||||||
|
QStringLiteral("# sample request line\nPOST %1/<agent endpoint>").arg(inst.url));
|
||||||
|
applyPreviewPalette();
|
||||||
|
|
||||||
|
m_deleteBtn->setEnabled(isUser);
|
||||||
|
m_dupBtn->setEnabled(true);
|
||||||
|
m_editBtn->setVisible(isUser);
|
||||||
|
m_openInEditorBtn->setEnabled(isUser);
|
||||||
|
setEditing(false);
|
||||||
|
|
||||||
|
QString toml = QStringLiteral("# %1\n").arg(inst.sourcePath);
|
||||||
|
toml += Providers::ProviderInstanceWriter::toToml(inst);
|
||||||
|
m_rawToml->setPlainText(toml);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderDetailPane::clear()
|
||||||
|
{
|
||||||
|
m_current = {};
|
||||||
|
m_nameLabel->setText(tr("Select a provider"));
|
||||||
|
m_sourcePathLabel->clear();
|
||||||
|
m_descriptionLabel->clear();
|
||||||
|
m_nameEdit->clear();
|
||||||
|
m_typeEdit->clear();
|
||||||
|
m_descriptionEdit->clear();
|
||||||
|
m_urlEdit->clear();
|
||||||
|
m_apiKeyEdit->clear();
|
||||||
|
m_apiKeyEdit->setEnabled(false);
|
||||||
|
m_apiKeySaveBtn->setEnabled(false);
|
||||||
|
m_apiKeyClearBtn->setEnabled(false);
|
||||||
|
m_revealKeyBtn->setEnabled(false);
|
||||||
|
m_samplePreview->clear();
|
||||||
|
m_rawToml->clear();
|
||||||
|
m_editBtn->setVisible(false);
|
||||||
|
m_dupBtn->setEnabled(false);
|
||||||
|
m_deleteBtn->setEnabled(false);
|
||||||
|
m_openInEditorBtn->setEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderDetailPane::refreshKeyStatus(bool hasStoredKey)
|
||||||
|
{
|
||||||
|
m_currentHasStoredKey = hasStoredKey;
|
||||||
|
const bool needsKey = !m_current.apiKeyRef.isEmpty();
|
||||||
|
m_apiKeyClearBtn->setEnabled(needsKey && hasStoredKey);
|
||||||
|
if (!needsKey)
|
||||||
|
return;
|
||||||
|
if (hasStoredKey) {
|
||||||
|
m_apiKeyEdit->setPlaceholderText(tr("Stored — enter a new key to replace it."));
|
||||||
|
m_keyHint->setText(tr("A key is stored. Type a new key and press Save key to "
|
||||||
|
"replace it, or Clear to erase it."));
|
||||||
|
} else {
|
||||||
|
m_apiKeyEdit->setPlaceholderText(tr("Enter API key…"));
|
||||||
|
m_keyHint->setText(tr("No key stored yet. Type a key and press Save key."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderDetailPane::setLaunchState(
|
||||||
|
Providers::ProviderLauncher::State st, const QString &lastError)
|
||||||
|
{
|
||||||
|
const bool hasLaunch = !m_current.launch.isEmpty();
|
||||||
|
m_launchSection->setVisible(true);
|
||||||
|
m_launchEmptyHint->setVisible(!hasLaunch);
|
||||||
|
m_launchCmdLabel->setVisible(hasLaunch);
|
||||||
|
m_startBtn->setVisible(hasLaunch);
|
||||||
|
m_stopBtn->setVisible(hasLaunch);
|
||||||
|
m_restartBtn->setVisible(hasLaunch);
|
||||||
|
m_launchStatusPill->setVisible(hasLaunch);
|
||||||
|
m_launchTerminalToggle->setVisible(hasLaunch);
|
||||||
|
|
||||||
|
if (!hasLaunch) {
|
||||||
|
m_launchEmptyHint->setText(tr(
|
||||||
|
"No [launch] block. This provider is treated as external — "
|
||||||
|
"the plugin will not spawn or supervise any process. "
|
||||||
|
"Add a [launch] block to the TOML to have the plugin manage "
|
||||||
|
"a local server here."));
|
||||||
|
m_launchCmdLabel->clear();
|
||||||
|
m_launchTerminal->clearContents();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString detachedNote = m_current.launch.detach
|
||||||
|
? tr(" <span style='color:gray'>(detached — survives Qt Creator restart)</span>")
|
||||||
|
: QString();
|
||||||
|
m_launchCmdLabel->setText(
|
||||||
|
QStringLiteral("<b>%1</b> %2%3")
|
||||||
|
.arg(m_current.launch.command.toHtmlEscaped(),
|
||||||
|
m_current.launch.args.join(QLatin1Char(' ')).toHtmlEscaped(),
|
||||||
|
detachedNote));
|
||||||
|
|
||||||
|
QString statusText;
|
||||||
|
switch (st) {
|
||||||
|
case Providers::ProviderLauncher::Idle: statusText = tr("idle"); break;
|
||||||
|
case Providers::ProviderLauncher::Starting: statusText = tr("starting…"); break;
|
||||||
|
case Providers::ProviderLauncher::Probing: statusText = tr("probing…"); break;
|
||||||
|
case Providers::ProviderLauncher::Ready: statusText = tr("ready"); break;
|
||||||
|
case Providers::ProviderLauncher::Stopping: statusText = tr("stopping…"); break;
|
||||||
|
case Providers::ProviderLauncher::Failed:
|
||||||
|
statusText = lastError.isEmpty() ? tr("failed")
|
||||||
|
: tr("failed — %1").arg(lastError);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
m_launchStatusPill->setText(statusText);
|
||||||
|
|
||||||
|
const bool running = st == Providers::ProviderLauncher::Starting
|
||||||
|
|| st == Providers::ProviderLauncher::Probing
|
||||||
|
|| st == Providers::ProviderLauncher::Ready;
|
||||||
|
m_startBtn->setEnabled(!running && st != Providers::ProviderLauncher::Stopping);
|
||||||
|
m_stopBtn->setEnabled(running);
|
||||||
|
m_restartBtn->setEnabled(running || st == Providers::ProviderLauncher::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderDetailPane::resetLaunchTerminal(const QByteArray &scrollback)
|
||||||
|
{
|
||||||
|
m_launchTerminal->clearContents();
|
||||||
|
if (!scrollback.isEmpty())
|
||||||
|
m_launchTerminal->writeToTerminal(scrollback, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderDetailPane::appendLaunchBytes(const QByteArray &chunk)
|
||||||
|
{
|
||||||
|
m_launchTerminal->writeToTerminal(chunk, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderDetailPane::changeEvent(QEvent *event)
|
||||||
|
{
|
||||||
|
QWidget::changeEvent(event);
|
||||||
|
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange) {
|
||||||
|
applyPreviewPalette();
|
||||||
|
applyTerminalPalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderDetailPane::setEditing(bool on)
|
||||||
|
{
|
||||||
|
m_editing = on;
|
||||||
|
m_nameEdit->setReadOnly(!on);
|
||||||
|
m_descriptionEdit->setReadOnly(!on);
|
||||||
|
m_urlEdit->setReadOnly(!on);
|
||||||
|
m_editBtn->setVisible(!on && m_current.isUserSource());
|
||||||
|
m_dupBtn->setVisible(!on);
|
||||||
|
m_deleteBtn->setVisible(!on);
|
||||||
|
m_cancelBtn->setVisible(on);
|
||||||
|
m_saveBtn->setVisible(on);
|
||||||
|
}
|
||||||
|
|
||||||
|
Providers::ProviderInstance ProviderDetailPane::collectEdits() const
|
||||||
|
{
|
||||||
|
Providers::ProviderInstance out = m_current;
|
||||||
|
out.name = m_nameEdit->text().trimmed();
|
||||||
|
out.description = m_descriptionEdit->toPlainText().trimmed();
|
||||||
|
out.url = m_urlEdit->text().trimmed();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderDetailPane::applyPreviewPalette()
|
||||||
|
{
|
||||||
|
const bool dark = isDarkPalette(palette());
|
||||||
|
const QString bg = dark ? QStringLiteral("#1f1f1f") : QStringLiteral("#f4f4f4");
|
||||||
|
const QString bd = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
|
||||||
|
m_samplePreview->setStyleSheet(QStringLiteral(
|
||||||
|
"QLabel { background:%1; border:1px solid %2; }")
|
||||||
|
.arg(bg, bd));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderDetailPane::applyTerminalPalette()
|
||||||
|
{
|
||||||
|
if (!m_launchTerminal)
|
||||||
|
return;
|
||||||
|
const QPalette pal = palette();
|
||||||
|
const bool dark = isDarkPalette(pal);
|
||||||
|
const std::array<QColor, 16> ansi = dark
|
||||||
|
? std::array<QColor, 16>{
|
||||||
|
QColor("#000000"), QColor("#cd3131"), QColor("#0dbc79"),
|
||||||
|
QColor("#e5e510"), QColor("#2472c8"), QColor("#bc3fbc"),
|
||||||
|
QColor("#11a8cd"), QColor("#e5e5e5"),
|
||||||
|
QColor("#666666"), QColor("#f14c4c"), QColor("#23d18b"),
|
||||||
|
QColor("#f5f543"), QColor("#3b8eea"), QColor("#d670d6"),
|
||||||
|
QColor("#29b8db"), QColor("#ffffff"),
|
||||||
|
}
|
||||||
|
: std::array<QColor, 16>{
|
||||||
|
QColor("#000000"), QColor("#c91b00"), QColor("#00c200"),
|
||||||
|
QColor("#c7c400"), QColor("#0037da"), QColor("#c930c7"),
|
||||||
|
QColor("#00c5c7"), QColor("#c7c7c7"),
|
||||||
|
QColor("#676767"), QColor("#ff6d67"), QColor("#5ff967"),
|
||||||
|
QColor("#fefb67"), QColor("#6871ff"), QColor("#ff76ff"),
|
||||||
|
QColor("#5ffdff"), QColor("#ffffff"),
|
||||||
|
};
|
||||||
|
std::array<QColor, 20> colors{};
|
||||||
|
for (int i = 0; i < 16; ++i)
|
||||||
|
colors[i] = ansi[i];
|
||||||
|
colors[16] = pal.color(QPalette::Text);
|
||||||
|
colors[17] = pal.color(QPalette::Base);
|
||||||
|
colors[18] = pal.color(QPalette::Highlight);
|
||||||
|
colors[19] = dark ? QColor("#5a5a40") : QColor("#fff59d");
|
||||||
|
m_launchTerminal->setColors(colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
101
settings/ProviderDetailPane.hpp
Normal file
101
settings/ProviderDetailPane.hpp
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "ProviderInstance.hpp"
|
||||||
|
#include "ProviderLauncher.hpp"
|
||||||
|
|
||||||
|
class QLabel;
|
||||||
|
class QLineEdit;
|
||||||
|
class QPlainTextEdit;
|
||||||
|
class QPushButton;
|
||||||
|
class QToolButton;
|
||||||
|
|
||||||
|
namespace TerminalSolution {
|
||||||
|
class TerminalView;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class SectionBox;
|
||||||
|
|
||||||
|
class ProviderDetailPane : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit ProviderDetailPane(QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
void populate(const Providers::ProviderInstance &inst, bool hasStoredKey);
|
||||||
|
void clear();
|
||||||
|
void refreshKeyStatus(bool hasStoredKey);
|
||||||
|
void setLaunchState(Providers::ProviderLauncher::State st, const QString &lastError);
|
||||||
|
void resetLaunchTerminal(const QByteArray &scrollback);
|
||||||
|
void appendLaunchBytes(const QByteArray &chunk);
|
||||||
|
|
||||||
|
QString currentName() const { return m_current.name; }
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void saveRequested(const Providers::ProviderInstance &edited);
|
||||||
|
void duplicateRequested();
|
||||||
|
void deleteRequested();
|
||||||
|
void apiKeySaveRequested(const QString &newKey);
|
||||||
|
void apiKeyClearRequested();
|
||||||
|
void launchStartRequested(const QString &providerName);
|
||||||
|
void launchStopRequested(const QString &providerName);
|
||||||
|
void launchRestartRequested(const QString &providerName);
|
||||||
|
void openInEditorRequested(const QString &sourcePath);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void changeEvent(QEvent *event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void setEditing(bool on);
|
||||||
|
Providers::ProviderInstance collectEdits() const;
|
||||||
|
void applyPreviewPalette();
|
||||||
|
void applyTerminalPalette();
|
||||||
|
|
||||||
|
bool m_editing = false;
|
||||||
|
bool m_currentHasStoredKey = false;
|
||||||
|
Providers::ProviderInstance m_current;
|
||||||
|
|
||||||
|
QLabel *m_nameLabel = nullptr;
|
||||||
|
QLabel *m_sourcePathLabel = nullptr;
|
||||||
|
QPushButton *m_editBtn = nullptr;
|
||||||
|
QPushButton *m_openInEditorBtn = nullptr;
|
||||||
|
QPushButton *m_dupBtn = nullptr;
|
||||||
|
QPushButton *m_deleteBtn = nullptr;
|
||||||
|
QPushButton *m_cancelBtn = nullptr;
|
||||||
|
QPushButton *m_saveBtn = nullptr;
|
||||||
|
|
||||||
|
QLabel *m_descriptionLabel = nullptr;
|
||||||
|
|
||||||
|
QLineEdit *m_nameEdit = nullptr;
|
||||||
|
QLineEdit *m_typeEdit = nullptr;
|
||||||
|
QPlainTextEdit *m_descriptionEdit = nullptr;
|
||||||
|
QLineEdit *m_urlEdit = nullptr;
|
||||||
|
QLabel *m_samplePreview = nullptr;
|
||||||
|
|
||||||
|
QLineEdit *m_apiKeyEdit = nullptr;
|
||||||
|
QToolButton *m_revealKeyBtn = nullptr;
|
||||||
|
QLabel *m_keyHint = nullptr;
|
||||||
|
QPushButton *m_apiKeySaveBtn = nullptr;
|
||||||
|
QPushButton *m_apiKeyClearBtn = nullptr;
|
||||||
|
|
||||||
|
SectionBox *m_launchSection = nullptr;
|
||||||
|
QLabel *m_launchEmptyHint = nullptr;
|
||||||
|
QLabel *m_launchCmdLabel = nullptr;
|
||||||
|
QLabel *m_launchStatusPill = nullptr;
|
||||||
|
QPushButton *m_startBtn = nullptr;
|
||||||
|
QPushButton *m_stopBtn = nullptr;
|
||||||
|
QPushButton *m_restartBtn = nullptr;
|
||||||
|
QToolButton *m_launchTerminalToggle = nullptr;
|
||||||
|
TerminalSolution::TerminalView *m_launchTerminal = nullptr;
|
||||||
|
|
||||||
|
QToolButton *m_rawToggle = nullptr;
|
||||||
|
QPlainTextEdit *m_rawToml = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
110
settings/ProviderListItem.cpp
Normal file
110
settings/ProviderListItem.cpp
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ProviderListItem.hpp"
|
||||||
|
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QScopedValueRollback>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "ProviderInstance.hpp"
|
||||||
|
#include "ProvidersSettingsHelpers.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
ProviderListItem::ProviderListItem(
|
||||||
|
const Providers::ProviderInstance &inst, QWidget *parent)
|
||||||
|
: QFrame(parent)
|
||||||
|
, m_name(inst.name)
|
||||||
|
{
|
||||||
|
setObjectName(QStringLiteral("ProvListItem"));
|
||||||
|
setFrameShape(QFrame::NoFrame);
|
||||||
|
setAutoFillBackground(true);
|
||||||
|
setCursor(Qt::PointingHandCursor);
|
||||||
|
|
||||||
|
auto *headerRow = new QHBoxLayout;
|
||||||
|
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
headerRow->setSpacing(6);
|
||||||
|
m_statusDot = new QLabel(QStringLiteral("●"), this);
|
||||||
|
QFont df = m_statusDot->font();
|
||||||
|
df.setPixelSize(11);
|
||||||
|
m_statusDot->setFont(df);
|
||||||
|
m_statusDot->setStyleSheet(QStringLiteral("color: %1;").arg(statusColor(Status::Unknown)));
|
||||||
|
m_nameLabel = new QLabel(inst.name, this);
|
||||||
|
QFont nf = m_nameLabel->font();
|
||||||
|
nf.setBold(true);
|
||||||
|
nf.setPixelSize(12);
|
||||||
|
m_nameLabel->setFont(nf);
|
||||||
|
headerRow->addWidget(m_statusDot, 0, Qt::AlignVCenter);
|
||||||
|
headerRow->addWidget(m_nameLabel, 1);
|
||||||
|
|
||||||
|
m_urlLabel = new QLabel(inst.url, this);
|
||||||
|
m_urlLabel->setFont(monospaceFont(10));
|
||||||
|
QPalette up = m_urlLabel->palette();
|
||||||
|
up.setColor(QPalette::WindowText, up.color(QPalette::Mid));
|
||||||
|
m_urlLabel->setPalette(up);
|
||||||
|
m_urlLabel->setContentsMargins(17, 0, 0, 0);
|
||||||
|
|
||||||
|
auto *outer = new QVBoxLayout(this);
|
||||||
|
outer->setContentsMargins(8, 6, 8, 6);
|
||||||
|
outer->setSpacing(2);
|
||||||
|
outer->addLayout(headerRow);
|
||||||
|
outer->addWidget(m_urlLabel);
|
||||||
|
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderListItem::setStatus(Status s)
|
||||||
|
{
|
||||||
|
m_status = s;
|
||||||
|
m_statusDot->setStyleSheet(QStringLiteral("color: %1;").arg(statusColor(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderListItem::setSelected(bool s)
|
||||||
|
{
|
||||||
|
if (m_selected == s)
|
||||||
|
return;
|
||||||
|
m_selected = s;
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderListItem::mouseReleaseEvent(QMouseEvent *event)
|
||||||
|
{
|
||||||
|
if (event->button() == Qt::LeftButton)
|
||||||
|
emit clicked(m_name);
|
||||||
|
QFrame::mouseReleaseEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderListItem::changeEvent(QEvent *event)
|
||||||
|
{
|
||||||
|
QFrame::changeEvent(event);
|
||||||
|
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ProviderListItem::statusColor(Status s)
|
||||||
|
{
|
||||||
|
switch (s) {
|
||||||
|
case Status::Ok: return QStringLiteral("#3a8a4f");
|
||||||
|
case Status::Fail: return QStringLiteral("#c94a4a");
|
||||||
|
case Status::Unknown: return QStringLiteral("#888888");
|
||||||
|
}
|
||||||
|
return QStringLiteral("#888888");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderListItem::applyTheme()
|
||||||
|
{
|
||||||
|
if (m_inApplyTheme)
|
||||||
|
return;
|
||||||
|
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||||
|
const bool dark = isDarkPalette(palette());
|
||||||
|
const QString sep = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
|
||||||
|
const QString sel = dark ? QStringLiteral("#2c4060") : QStringLiteral("#cfe2ff");
|
||||||
|
setStyleSheet(QStringLiteral(
|
||||||
|
"#ProvListItem { background:%1; border-top: 1px solid %2; }")
|
||||||
|
.arg(m_selected ? sel : QStringLiteral("transparent"), sep));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
50
settings/ProviderListItem.hpp
Normal file
50
settings/ProviderListItem.hpp
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QFrame>
|
||||||
|
|
||||||
|
class QLabel;
|
||||||
|
class QMouseEvent;
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
struct ProviderInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class ProviderListItem : public QFrame
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
enum class Status : int { Unknown, Ok, Fail };
|
||||||
|
|
||||||
|
explicit ProviderListItem(
|
||||||
|
const Providers::ProviderInstance &inst, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
void setStatus(Status s);
|
||||||
|
void setSelected(bool s);
|
||||||
|
QString providerName() const { return m_name; }
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void clicked(const QString &name);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||||
|
void changeEvent(QEvent *event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
static QString statusColor(Status s);
|
||||||
|
void applyTheme();
|
||||||
|
|
||||||
|
QString m_name;
|
||||||
|
Status m_status = Status::Unknown;
|
||||||
|
bool m_selected = false;
|
||||||
|
bool m_inApplyTheme = false;
|
||||||
|
QLabel *m_statusDot = nullptr;
|
||||||
|
QLabel *m_nameLabel = nullptr;
|
||||||
|
QLabel *m_urlLabel = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
64
settings/ProvidersSettingsHelpers.hpp
Normal file
64
settings/ProvidersSettingsHelpers.hpp
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QFont>
|
||||||
|
#include <QFontDatabase>
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QString>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
inline QFont monospaceFont(int pixelSize = 11)
|
||||||
|
{
|
||||||
|
QFont f = QFontDatabase::systemFont(QFontDatabase::FixedFont);
|
||||||
|
f.setStyleHint(QFont::Monospace);
|
||||||
|
if (pixelSize > 0)
|
||||||
|
f.setPixelSize(pixelSize);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool isDarkPalette(const QPalette &p)
|
||||||
|
{
|
||||||
|
return p.color(QPalette::Window).lightness() < 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int addFormRow(
|
||||||
|
QGridLayout *grid, int row, const QString &label, QLayout *value, const QString &hint = {})
|
||||||
|
{
|
||||||
|
auto *l = new QLabel(label);
|
||||||
|
l->setMinimumWidth(96);
|
||||||
|
l->setAlignment(Qt::AlignLeft | Qt::AlignTop);
|
||||||
|
grid->addWidget(l, row, 0, Qt::AlignTop);
|
||||||
|
auto *holder = new QWidget;
|
||||||
|
holder->setLayout(value);
|
||||||
|
grid->addWidget(holder, row, 1);
|
||||||
|
if (hint.isEmpty())
|
||||||
|
return row + 1;
|
||||||
|
auto *h = new QLabel(hint);
|
||||||
|
QFont hf = h->font();
|
||||||
|
hf.setPixelSize(11);
|
||||||
|
h->setFont(hf);
|
||||||
|
h->setWordWrap(true);
|
||||||
|
QPalette p = h->palette();
|
||||||
|
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
|
||||||
|
h->setPalette(p);
|
||||||
|
grid->addWidget(h, row + 1, 1);
|
||||||
|
return row + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline QHBoxLayout *singleField(QWidget *w)
|
||||||
|
{
|
||||||
|
auto *lay = new QHBoxLayout;
|
||||||
|
lay->setContentsMargins(0, 0, 0, 0);
|
||||||
|
lay->setSpacing(4);
|
||||||
|
lay->addWidget(w, 1);
|
||||||
|
return lay;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
674
settings/ProvidersSettingsPage.cpp
Normal file
674
settings/ProvidersSettingsPage.cpp
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ProvidersSettingsPage.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <coreplugin/dialogs/ioptionspage.h>
|
||||||
|
#include <coreplugin/editormanager/editormanager.h>
|
||||||
|
#include <coreplugin/icore.h>
|
||||||
|
|
||||||
|
#include <utils/filepath.h>
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QInputDialog>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QSplitter>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "NewProviderDialog.hpp"
|
||||||
|
#include "ProviderDetailPane.hpp"
|
||||||
|
#include "ProviderInstance.hpp"
|
||||||
|
#include "ProviderInstanceFactory.hpp"
|
||||||
|
#include "ProviderInstanceWriter.hpp"
|
||||||
|
#include "ProviderLauncher.hpp"
|
||||||
|
#include "ProviderListItem.hpp"
|
||||||
|
#include "ProviderSecretsStore.hpp"
|
||||||
|
#include "ProvidersSettingsHelpers.hpp"
|
||||||
|
#include "SettingsConstants.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
ProvidersPageNavigator::ProvidersPageNavigator(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{}
|
||||||
|
|
||||||
|
void ProvidersPageNavigator::requestSelectInstance(const QString &name)
|
||||||
|
{
|
||||||
|
m_pending = name;
|
||||||
|
emit selectInstanceRequested(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ProvidersPageNavigator::takePendingSelection()
|
||||||
|
{
|
||||||
|
QString p = m_pending;
|
||||||
|
m_pending.clear();
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
class ProvidersPageWidget : public Core::IOptionsPageWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
ProvidersPageWidget(
|
||||||
|
Providers::ProviderInstanceFactory *factory,
|
||||||
|
Providers::ProviderSecretsStore *secrets,
|
||||||
|
Providers::ProviderLauncher *launcher,
|
||||||
|
ProvidersPageNavigator *navigator)
|
||||||
|
: m_factory(factory)
|
||||||
|
, m_secrets(secrets)
|
||||||
|
, m_launcher(launcher)
|
||||||
|
, m_navigator(navigator)
|
||||||
|
{
|
||||||
|
m_titleLabel = new QLabel(tr("Providers"), this);
|
||||||
|
QFont tf = m_titleLabel->font();
|
||||||
|
tf.setBold(true);
|
||||||
|
tf.setPixelSize(13);
|
||||||
|
m_titleLabel->setFont(tf);
|
||||||
|
|
||||||
|
m_newBtn = new QPushButton(tr("+ New provider…"), this);
|
||||||
|
|
||||||
|
auto *headerRow = new QHBoxLayout;
|
||||||
|
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
headerRow->setSpacing(8);
|
||||||
|
headerRow->addWidget(m_titleLabel, 1);
|
||||||
|
headerRow->addWidget(m_newBtn);
|
||||||
|
|
||||||
|
auto *headerSep = new QFrame(this);
|
||||||
|
headerSep->setFrameShape(QFrame::HLine);
|
||||||
|
headerSep->setFrameShadow(QFrame::Sunken);
|
||||||
|
|
||||||
|
m_filterEdit = new QLineEdit(this);
|
||||||
|
m_filterEdit->setPlaceholderText(tr("Filter providers…"));
|
||||||
|
|
||||||
|
m_listScroll = new QScrollArea(this);
|
||||||
|
m_listScroll->setWidgetResizable(true);
|
||||||
|
m_listScroll->setFrameShape(QFrame::NoFrame);
|
||||||
|
m_listContent = new QWidget(this);
|
||||||
|
m_listLayout = new QVBoxLayout(m_listContent);
|
||||||
|
m_listLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
m_listLayout->setSpacing(0);
|
||||||
|
m_listLayout->addStretch(1);
|
||||||
|
m_listScroll->setWidget(m_listContent);
|
||||||
|
|
||||||
|
auto *leftBox = new QFrame(this);
|
||||||
|
leftBox->setFrameShape(QFrame::StyledPanel);
|
||||||
|
auto *leftLay = new QVBoxLayout(leftBox);
|
||||||
|
leftLay->setContentsMargins(0, 0, 0, 0);
|
||||||
|
leftLay->setSpacing(0);
|
||||||
|
auto *filterRow = new QHBoxLayout;
|
||||||
|
filterRow->setContentsMargins(6, 6, 6, 6);
|
||||||
|
filterRow->addWidget(m_filterEdit, 1);
|
||||||
|
leftLay->addLayout(filterRow);
|
||||||
|
leftLay->addWidget(m_listScroll, 1);
|
||||||
|
|
||||||
|
m_detailPane = new ProviderDetailPane(this);
|
||||||
|
connect(m_detailPane, &ProviderDetailPane::saveRequested,
|
||||||
|
this, &ProvidersPageWidget::onSaveEdited);
|
||||||
|
connect(m_detailPane, &ProviderDetailPane::duplicateRequested,
|
||||||
|
this, &ProvidersPageWidget::onDuplicateClicked);
|
||||||
|
connect(m_detailPane, &ProviderDetailPane::deleteRequested,
|
||||||
|
this, &ProvidersPageWidget::onRemoveClicked);
|
||||||
|
connect(m_detailPane, &ProviderDetailPane::apiKeySaveRequested,
|
||||||
|
this, &ProvidersPageWidget::onApiKeySave);
|
||||||
|
connect(m_detailPane, &ProviderDetailPane::apiKeyClearRequested,
|
||||||
|
this, &ProvidersPageWidget::onApiKeyClear);
|
||||||
|
connect(m_detailPane, &ProviderDetailPane::launchStartRequested,
|
||||||
|
this, &ProvidersPageWidget::onLaunchStart);
|
||||||
|
connect(m_detailPane, &ProviderDetailPane::launchStopRequested,
|
||||||
|
this, &ProvidersPageWidget::onLaunchStop);
|
||||||
|
connect(m_detailPane, &ProviderDetailPane::launchRestartRequested,
|
||||||
|
this, &ProvidersPageWidget::onLaunchRestart);
|
||||||
|
connect(m_detailPane, &ProviderDetailPane::openInEditorRequested,
|
||||||
|
this, [this](const QString &path) {
|
||||||
|
if (path.isEmpty() || path.startsWith(QLatin1String(":/"))) {
|
||||||
|
QMessageBox::information(
|
||||||
|
this, tr("Open in editor"),
|
||||||
|
tr("Bundled providers are read-only. "
|
||||||
|
"Use Duplicate to create an editable user copy first."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Core::EditorManager::openEditor(Utils::FilePath::fromString(path));
|
||||||
|
});
|
||||||
|
if (m_launcher) {
|
||||||
|
connect(m_launcher.data(), &Providers::ProviderLauncher::stateChanged,
|
||||||
|
this, [this](const QString &name,
|
||||||
|
Providers::ProviderLauncher::State newState) {
|
||||||
|
if (name == m_currentName)
|
||||||
|
refreshDetailLaunch();
|
||||||
|
const ProviderListItem::Status status = rowStatusFromState(newState);
|
||||||
|
for (auto *row : m_rows) {
|
||||||
|
if (row->providerName() == name)
|
||||||
|
row->setStatus(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
connect(m_launcher.data(), &Providers::ProviderLauncher::bytesReceived,
|
||||||
|
this, [this](const QString &name, const QByteArray &chunk) {
|
||||||
|
if (name == m_currentName)
|
||||||
|
m_detailPane->appendLaunchBytes(chunk);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
m_detailScroll = new QScrollArea(this);
|
||||||
|
m_detailScroll->setWidgetResizable(true);
|
||||||
|
m_detailScroll->setFrameShape(QFrame::StyledPanel);
|
||||||
|
m_detailScroll->setWidget(m_detailPane);
|
||||||
|
|
||||||
|
auto *splitter = new QSplitter(Qt::Horizontal, this);
|
||||||
|
splitter->addWidget(leftBox);
|
||||||
|
splitter->addWidget(m_detailScroll);
|
||||||
|
splitter->setStretchFactor(0, 0);
|
||||||
|
splitter->setStretchFactor(1, 1);
|
||||||
|
splitter->setSizes({320, 700});
|
||||||
|
|
||||||
|
auto *root = new QVBoxLayout(this);
|
||||||
|
root->setContentsMargins(8, 8, 8, 8);
|
||||||
|
root->setSpacing(6);
|
||||||
|
root->addLayout(headerRow);
|
||||||
|
root->addWidget(headerSep);
|
||||||
|
root->addWidget(splitter, 1);
|
||||||
|
|
||||||
|
connect(m_newBtn, &QPushButton::clicked, this, &ProvidersPageWidget::onNewClicked);
|
||||||
|
m_filterDebounce = new QTimer(this);
|
||||||
|
m_filterDebounce->setSingleShot(true);
|
||||||
|
m_filterDebounce->setInterval(100);
|
||||||
|
connect(m_filterDebounce, &QTimer::timeout, this,
|
||||||
|
&ProvidersPageWidget::rebuildList);
|
||||||
|
connect(m_filterEdit, &QLineEdit::textChanged, this,
|
||||||
|
[this](const QString &) { m_filterDebounce->start(); });
|
||||||
|
|
||||||
|
if (m_factory) {
|
||||||
|
connect(m_factory.data(),
|
||||||
|
&Providers::ProviderInstanceFactory::instancesReloaded,
|
||||||
|
this, &ProvidersPageWidget::rebuildList);
|
||||||
|
}
|
||||||
|
if (m_navigator) {
|
||||||
|
connect(m_navigator.data(),
|
||||||
|
&ProvidersPageNavigator::selectInstanceRequested,
|
||||||
|
this, &ProvidersPageWidget::selectInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuildList();
|
||||||
|
|
||||||
|
const QString pending
|
||||||
|
= m_navigator ? m_navigator->takePendingSelection() : QString{};
|
||||||
|
if (!pending.isEmpty())
|
||||||
|
selectInstance(pending);
|
||||||
|
else if (m_factory && !m_factory->instances().empty())
|
||||||
|
selectInstance(m_factory->instances().front().name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void apply() final {}
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void rebuildList()
|
||||||
|
{
|
||||||
|
if (!m_factory)
|
||||||
|
return;
|
||||||
|
while (m_listLayout->count() > 0) {
|
||||||
|
QLayoutItem *item = m_listLayout->takeAt(0);
|
||||||
|
if (auto *w = item->widget())
|
||||||
|
w->deleteLater();
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
m_rows.clear();
|
||||||
|
m_listLayout->addStretch(1); // re-add trailing stretch
|
||||||
|
|
||||||
|
const QString filter = m_filterEdit->text().trimmed().toLower();
|
||||||
|
auto matches = [&](const Providers::ProviderInstance &inst) {
|
||||||
|
if (filter.isEmpty())
|
||||||
|
return true;
|
||||||
|
return inst.name.toLower().contains(filter)
|
||||||
|
|| inst.clientApi.toLower().contains(filter)
|
||||||
|
|| inst.url.toLower().contains(filter);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
auto addSection = [&](const QString &title, bool userSection) {
|
||||||
|
auto *header = new QLabel(title.toUpper(), m_listContent);
|
||||||
|
QFont hf = header->font();
|
||||||
|
hf.setPixelSize(10);
|
||||||
|
hf.setLetterSpacing(QFont::AbsoluteSpacing, 0.5);
|
||||||
|
header->setFont(hf);
|
||||||
|
QPalette hp = header->palette();
|
||||||
|
hp.setColor(QPalette::WindowText, hp.color(QPalette::Mid));
|
||||||
|
header->setPalette(hp);
|
||||||
|
header->setContentsMargins(8, 4, 8, 4);
|
||||||
|
header->setAutoFillBackground(true);
|
||||||
|
const bool dark = isDarkPalette(palette());
|
||||||
|
const QString bg = dark ? QStringLiteral("#262626") : QStringLiteral("#f0f0f0");
|
||||||
|
header->setStyleSheet(QStringLiteral("QLabel { background:%1; }").arg(bg));
|
||||||
|
m_listLayout->insertWidget(m_listLayout->count() - 1, header);
|
||||||
|
|
||||||
|
std::vector<const Providers::ProviderInstance *> sorted;
|
||||||
|
for (const auto &inst : m_factory->instances()) {
|
||||||
|
if (inst.isUserSource() != userSection)
|
||||||
|
continue;
|
||||||
|
if (!matches(inst))
|
||||||
|
continue;
|
||||||
|
sorted.push_back(&inst);
|
||||||
|
}
|
||||||
|
std::sort(sorted.begin(), sorted.end(),
|
||||||
|
[](const Providers::ProviderInstance *a,
|
||||||
|
const Providers::ProviderInstance *b) {
|
||||||
|
return a->name.compare(b->name, Qt::CaseInsensitive) < 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
int shown = 0;
|
||||||
|
for (const auto *inst : sorted) {
|
||||||
|
auto *row = new ProviderListItem(*inst, m_listContent);
|
||||||
|
connect(row, &ProviderListItem::clicked,
|
||||||
|
this, &ProvidersPageWidget::selectInstance);
|
||||||
|
if (m_launcher)
|
||||||
|
row->setStatus(rowStatusFromLauncher(inst->name));
|
||||||
|
m_rows.append(row);
|
||||||
|
m_listLayout->insertWidget(m_listLayout->count() - 1, row);
|
||||||
|
++shown;
|
||||||
|
}
|
||||||
|
if (shown == 0) {
|
||||||
|
auto *empty = new QLabel(
|
||||||
|
userSection ? tr("No user instances yet.")
|
||||||
|
: tr("No bundled instances loaded."),
|
||||||
|
m_listContent);
|
||||||
|
empty->setContentsMargins(10, 6, 10, 6);
|
||||||
|
QPalette ep = empty->palette();
|
||||||
|
ep.setColor(QPalette::WindowText, ep.color(QPalette::Mid));
|
||||||
|
empty->setPalette(ep);
|
||||||
|
m_listLayout->insertWidget(m_listLayout->count() - 1, empty);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addSection(tr("User"), true);
|
||||||
|
addSection(tr("Bundled"), false);
|
||||||
|
|
||||||
|
for (auto *row : m_rows)
|
||||||
|
row->setSelected(row->providerName() == m_currentName);
|
||||||
|
|
||||||
|
if (!m_currentName.isEmpty())
|
||||||
|
populateDetail(m_currentName);
|
||||||
|
else
|
||||||
|
m_detailPane->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void selectInstance(const QString &name)
|
||||||
|
{
|
||||||
|
if (name.isEmpty())
|
||||||
|
return;
|
||||||
|
const auto *inst = m_factory ? m_factory->instanceByName(name) : nullptr;
|
||||||
|
if (!inst)
|
||||||
|
return;
|
||||||
|
m_currentName = inst->name;
|
||||||
|
for (auto *row : m_rows)
|
||||||
|
row->setSelected(row->providerName() == inst->name);
|
||||||
|
populateDetail(inst->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNewClicked()
|
||||||
|
{
|
||||||
|
if (!m_factory)
|
||||||
|
return;
|
||||||
|
NewProviderDialog dlg(m_factory->knownClientApis(), this);
|
||||||
|
if (dlg.exec() != QDialog::Accepted)
|
||||||
|
return;
|
||||||
|
Providers::ProviderInstance inst;
|
||||||
|
inst.name = dlg.providerName();
|
||||||
|
inst.clientApi = dlg.providerType();
|
||||||
|
inst.description = dlg.description();
|
||||||
|
inst.url = dlg.url();
|
||||||
|
inst.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(inst.name);
|
||||||
|
|
||||||
|
if (inst.name.isEmpty()) {
|
||||||
|
QMessageBox::warning(this, tr("New provider"), tr("Name cannot be empty."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m_factory->instanceByName(inst.name)) {
|
||||||
|
QMessageBox::warning(this, tr("New provider"),
|
||||||
|
tr("An instance named '%1' already exists.").arg(inst.name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString validation = Providers::ProviderInstance::validate(
|
||||||
|
inst, m_factory->knownClientApis());
|
||||||
|
if (!validation.isEmpty()) {
|
||||||
|
QMessageBox::warning(this, tr("New provider"), validation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString softWarning = Providers::ProviderInstance::warnings(inst);
|
||||||
|
if (!softWarning.isEmpty()) {
|
||||||
|
if (QMessageBox::warning(this, tr("New provider"),
|
||||||
|
softWarning + QStringLiteral("\n\n")
|
||||||
|
+ tr("Save anyway?"),
|
||||||
|
QMessageBox::Yes | QMessageBox::No,
|
||||||
|
QMessageBox::No)
|
||||||
|
!= QMessageBox::Yes)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QString writeErr;
|
||||||
|
if (Providers::ProviderInstanceWriter::writeToUserDir(
|
||||||
|
inst, /*previousPath=*/QString{}, &writeErr).isEmpty()) {
|
||||||
|
QMessageBox::warning(this, tr("New provider"), writeErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m_secrets && !dlg.apiKey().isEmpty())
|
||||||
|
m_secrets->writeKey(inst.apiKeyRef, dlg.apiKey());
|
||||||
|
m_factory->reload();
|
||||||
|
selectInstance(inst.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onDuplicateClicked()
|
||||||
|
{
|
||||||
|
if (!m_factory || m_currentName.isEmpty())
|
||||||
|
return;
|
||||||
|
const Providers::ProviderInstance *srcPtr
|
||||||
|
= m_factory->instanceByName(m_currentName);
|
||||||
|
if (!srcPtr)
|
||||||
|
return;
|
||||||
|
const Providers::ProviderInstance srcCopy = *srcPtr;
|
||||||
|
bool ok = false;
|
||||||
|
const QString name = QInputDialog::getText(
|
||||||
|
this, tr("Duplicate provider"),
|
||||||
|
tr("Name for the new provider:"), QLineEdit::Normal,
|
||||||
|
QStringLiteral("%1 (copy)").arg(srcCopy.name), &ok);
|
||||||
|
if (!ok || name.trimmed().isEmpty())
|
||||||
|
return;
|
||||||
|
if (m_factory->instanceByName(name.trimmed())) {
|
||||||
|
QMessageBox::warning(this, tr("Duplicate provider"),
|
||||||
|
tr("An instance named '%1' already exists.").arg(name.trimmed()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Providers::ProviderInstance copy = srcCopy;
|
||||||
|
copy.name = name.trimmed();
|
||||||
|
copy.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(copy.name);
|
||||||
|
copy.sourcePath.clear();
|
||||||
|
copy.overridesBundled = false;
|
||||||
|
QString writeErr;
|
||||||
|
if (Providers::ProviderInstanceWriter::writeToUserDir(
|
||||||
|
copy, /*previousPath=*/QString{}, &writeErr).isEmpty()) {
|
||||||
|
QMessageBox::warning(this, tr("Duplicate provider"), writeErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_factory->reload();
|
||||||
|
selectInstance(copy.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onRemoveClicked()
|
||||||
|
{
|
||||||
|
if (!m_factory || m_currentName.isEmpty())
|
||||||
|
return;
|
||||||
|
const Providers::ProviderInstance *instPtr
|
||||||
|
= m_factory->instanceByName(m_currentName);
|
||||||
|
if (!instPtr || !instPtr->isUserSource())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QString instName = instPtr->name;
|
||||||
|
const QString sourcePath = instPtr->sourcePath;
|
||||||
|
if (QMessageBox::question(
|
||||||
|
this, tr("Delete provider"),
|
||||||
|
tr("Delete user provider '%1'?\n\nFile: %2").arg(instName, sourcePath))
|
||||||
|
!= QMessageBox::Yes)
|
||||||
|
return;
|
||||||
|
if (!QFile::remove(sourcePath)) {
|
||||||
|
QMessageBox::warning(this, tr("Delete provider"),
|
||||||
|
tr("Failed to delete file:\n%1").arg(sourcePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_currentName.clear();
|
||||||
|
m_factory->reload();
|
||||||
|
m_detailPane->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSaveEdited(const Providers::ProviderInstance &edited)
|
||||||
|
{
|
||||||
|
if (!m_factory)
|
||||||
|
return;
|
||||||
|
Providers::ProviderInstance e = edited;
|
||||||
|
if (e.name.isEmpty()) {
|
||||||
|
QMessageBox::warning(this, tr("Save"), tr("Name cannot be empty."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto *prior = m_factory->instanceByName(m_currentName);
|
||||||
|
const QString priorRef = prior ? prior->apiKeyRef : QString{};
|
||||||
|
const QString priorName = prior ? prior->name : QString{};
|
||||||
|
const bool nameChanged = !priorName.isEmpty() && priorName != e.name;
|
||||||
|
if (e.apiKeyRef.isEmpty() || (nameChanged && e.apiKeyRef == priorRef))
|
||||||
|
e.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(e.name);
|
||||||
|
|
||||||
|
const QString validation = Providers::ProviderInstance::validate(
|
||||||
|
e, m_factory->knownClientApis());
|
||||||
|
if (!validation.isEmpty()) {
|
||||||
|
QMessageBox::warning(this, tr("Save"), validation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nameChanged) {
|
||||||
|
const auto *clash = m_factory->instanceByName(e.name);
|
||||||
|
if (clash) {
|
||||||
|
QMessageBox::warning(this, tr("Save"),
|
||||||
|
tr("An instance named '%1' already exists.").arg(e.name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const QString softWarning = Providers::ProviderInstance::warnings(e);
|
||||||
|
if (!softWarning.isEmpty()) {
|
||||||
|
if (QMessageBox::warning(this, tr("Save"),
|
||||||
|
softWarning + QStringLiteral("\n\n")
|
||||||
|
+ tr("Save anyway?"),
|
||||||
|
QMessageBox::Yes | QMessageBox::No,
|
||||||
|
QMessageBox::No)
|
||||||
|
!= QMessageBox::Yes)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString previousPath
|
||||||
|
= (prior && prior->isUserSource()) ? prior->sourcePath : QString{};
|
||||||
|
QString writeErr;
|
||||||
|
const QString writtenPath = Providers::ProviderInstanceWriter::writeToUserDir(
|
||||||
|
e, previousPath, &writeErr);
|
||||||
|
if (writtenPath.isEmpty()) {
|
||||||
|
QMessageBox::warning(this, tr("Save"), writeErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!previousPath.isEmpty()
|
||||||
|
&& QFileInfo(writtenPath).absoluteFilePath()
|
||||||
|
!= QFileInfo(previousPath).absoluteFilePath()) {
|
||||||
|
if (!QFile::remove(previousPath)) {
|
||||||
|
QMessageBox::warning(
|
||||||
|
this, tr("Save"),
|
||||||
|
tr("Saved to:\n%1\n\nbut could not remove the old file:\n%2\n\n"
|
||||||
|
"Two provider files now describe this instance — delete the "
|
||||||
|
"old file manually to avoid a duplicate-name error.")
|
||||||
|
.arg(writtenPath, previousPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_secrets && !priorRef.isEmpty() && priorRef != e.apiKeyRef) {
|
||||||
|
const QString carried = m_secrets->readKeySync(priorRef);
|
||||||
|
if (!carried.isEmpty())
|
||||||
|
m_secrets->writeKey(e.apiKeyRef, carried);
|
||||||
|
m_secrets->eraseKey(priorRef);
|
||||||
|
}
|
||||||
|
m_factory->reload();
|
||||||
|
selectInstance(e.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onApiKeySave(const QString &newKey)
|
||||||
|
{
|
||||||
|
if (!m_factory || !m_secrets || m_currentName.isEmpty() || newKey.isEmpty())
|
||||||
|
return;
|
||||||
|
const auto *inst = m_factory->instanceByName(m_currentName);
|
||||||
|
if (!inst || inst->apiKeyRef.isEmpty())
|
||||||
|
return;
|
||||||
|
m_secrets->writeKey(inst->apiKeyRef, newKey);
|
||||||
|
m_detailPane->refreshKeyStatus(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onApiKeyClear()
|
||||||
|
{
|
||||||
|
if (!m_factory || !m_secrets || m_currentName.isEmpty())
|
||||||
|
return;
|
||||||
|
const Providers::ProviderInstance *instPtr
|
||||||
|
= m_factory->instanceByName(m_currentName);
|
||||||
|
if (!instPtr || instPtr->apiKeyRef.isEmpty())
|
||||||
|
return;
|
||||||
|
const QString instName = instPtr->name;
|
||||||
|
const QString apiKeyRef = instPtr->apiKeyRef;
|
||||||
|
if (QMessageBox::question(
|
||||||
|
this, tr("Clear API key"),
|
||||||
|
tr("Erase the stored API key for '%1'?").arg(instName))
|
||||||
|
!= QMessageBox::Yes)
|
||||||
|
return;
|
||||||
|
m_secrets->eraseKey(apiKeyRef);
|
||||||
|
m_detailPane->refreshKeyStatus(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onLaunchStart(const QString &name)
|
||||||
|
{
|
||||||
|
if (!m_factory || !m_launcher)
|
||||||
|
return;
|
||||||
|
const auto *inst = m_factory->instanceByName(name);
|
||||||
|
if (!inst || inst->launch.isEmpty())
|
||||||
|
return;
|
||||||
|
m_launcher->start(name, inst->launch);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onLaunchStop(const QString &name)
|
||||||
|
{
|
||||||
|
if (!m_launcher)
|
||||||
|
return;
|
||||||
|
m_launcher->stop(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onLaunchRestart(const QString &name)
|
||||||
|
{
|
||||||
|
if (!m_factory || !m_launcher)
|
||||||
|
return;
|
||||||
|
const auto *inst = m_factory->instanceByName(name);
|
||||||
|
if (!inst || inst->launch.isEmpty())
|
||||||
|
return;
|
||||||
|
m_launcher->restart(name, inst->launch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void populateDetail(const QString &name)
|
||||||
|
{
|
||||||
|
if (!m_factory)
|
||||||
|
return;
|
||||||
|
const auto *inst = m_factory->instanceByName(name);
|
||||||
|
if (!inst) {
|
||||||
|
m_detailPane->clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bool hasStoredKey
|
||||||
|
= m_secrets && !inst->apiKeyRef.isEmpty() && m_secrets->hasKey(inst->apiKeyRef);
|
||||||
|
m_detailPane->populate(*inst, hasStoredKey);
|
||||||
|
|
||||||
|
if (m_launcher) {
|
||||||
|
m_detailPane->setLaunchState(
|
||||||
|
m_launcher->state(inst->name),
|
||||||
|
m_launcher->lastError(inst->name));
|
||||||
|
m_detailPane->resetLaunchTerminal(m_launcher->scrollback(inst->name));
|
||||||
|
} else {
|
||||||
|
m_detailPane->setLaunchState(Providers::ProviderLauncher::Idle, {});
|
||||||
|
m_detailPane->resetLaunchTerminal({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QPointer<Providers::ProviderInstanceFactory> m_factory;
|
||||||
|
QPointer<Providers::ProviderSecretsStore> m_secrets;
|
||||||
|
QPointer<ProvidersPageNavigator> m_navigator;
|
||||||
|
|
||||||
|
QLabel *m_titleLabel = nullptr;
|
||||||
|
QPushButton *m_newBtn = nullptr;
|
||||||
|
QLineEdit *m_filterEdit = nullptr;
|
||||||
|
|
||||||
|
QScrollArea *m_listScroll = nullptr;
|
||||||
|
QWidget *m_listContent = nullptr;
|
||||||
|
QVBoxLayout *m_listLayout = nullptr;
|
||||||
|
QList<ProviderListItem *> m_rows;
|
||||||
|
|
||||||
|
QScrollArea *m_detailScroll = nullptr;
|
||||||
|
ProviderDetailPane *m_detailPane = nullptr;
|
||||||
|
|
||||||
|
QString m_currentName;
|
||||||
|
|
||||||
|
QPointer<Providers::ProviderLauncher> m_launcher;
|
||||||
|
QTimer *m_filterDebounce = nullptr;
|
||||||
|
|
||||||
|
void refreshDetailLaunch()
|
||||||
|
{
|
||||||
|
if (!m_launcher || m_currentName.isEmpty())
|
||||||
|
return;
|
||||||
|
m_detailPane->setLaunchState(
|
||||||
|
m_launcher->state(m_currentName),
|
||||||
|
m_launcher->lastError(m_currentName));
|
||||||
|
}
|
||||||
|
|
||||||
|
static ProviderListItem::Status rowStatusFromState(
|
||||||
|
Providers::ProviderLauncher::State state)
|
||||||
|
{
|
||||||
|
switch (state) {
|
||||||
|
case Providers::ProviderLauncher::Ready:
|
||||||
|
return ProviderListItem::Status::Ok;
|
||||||
|
case Providers::ProviderLauncher::Failed:
|
||||||
|
return ProviderListItem::Status::Fail;
|
||||||
|
case Providers::ProviderLauncher::Idle:
|
||||||
|
case Providers::ProviderLauncher::Starting:
|
||||||
|
case Providers::ProviderLauncher::Probing:
|
||||||
|
case Providers::ProviderLauncher::Stopping:
|
||||||
|
return ProviderListItem::Status::Unknown;
|
||||||
|
}
|
||||||
|
return ProviderListItem::Status::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderListItem::Status rowStatusFromLauncher(const QString &name) const
|
||||||
|
{
|
||||||
|
if (!m_launcher)
|
||||||
|
return ProviderListItem::Status::Unknown;
|
||||||
|
return rowStatusFromState(m_launcher->state(name));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class ProvidersOptionsPage : public Core::IOptionsPage
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ProvidersOptionsPage(
|
||||||
|
Providers::ProviderInstanceFactory *factory,
|
||||||
|
Providers::ProviderSecretsStore *secrets,
|
||||||
|
Providers::ProviderLauncher *launcher,
|
||||||
|
ProvidersPageNavigator *navigator)
|
||||||
|
{
|
||||||
|
setId(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID);
|
||||||
|
setDisplayName(QObject::tr("Providers"));
|
||||||
|
setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY);
|
||||||
|
setWidgetCreator([factory, secrets, launcher, navigator] {
|
||||||
|
return new ProvidersPageWidget(factory, secrets, launcher, navigator);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::unique_ptr<Core::IOptionsPage> createProvidersSettingsPage(
|
||||||
|
Providers::ProviderInstanceFactory *instanceFactory,
|
||||||
|
Providers::ProviderSecretsStore *secrets,
|
||||||
|
Providers::ProviderLauncher *launcher,
|
||||||
|
ProvidersPageNavigator *navigator)
|
||||||
|
{
|
||||||
|
return std::make_unique<ProvidersOptionsPage>(
|
||||||
|
instanceFactory, secrets, launcher, navigator);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
|
|
||||||
|
#include "ProvidersSettingsPage.moc"
|
||||||
43
settings/ProvidersSettingsPage.hpp
Normal file
43
settings/ProvidersSettingsPage.hpp
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace Core { class IOptionsPage; }
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
class ProviderInstanceFactory;
|
||||||
|
class ProviderSecretsStore;
|
||||||
|
class ProviderLauncher;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class ProvidersPageNavigator : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit ProvidersPageNavigator(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
void requestSelectInstance(const QString &name);
|
||||||
|
QString takePendingSelection();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void selectInstanceRequested(const QString &name);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::unique_ptr<Core::IOptionsPage> createProvidersSettingsPage(
|
||||||
|
Providers::ProviderInstanceFactory *instanceFactory,
|
||||||
|
Providers::ProviderSecretsStore *secrets,
|
||||||
|
Providers::ProviderLauncher *launcher,
|
||||||
|
ProvidersPageNavigator *navigator);
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
31
settings/SectionBox.cpp
Normal file
31
settings/SectionBox.cpp
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "SectionBox.hpp"
|
||||||
|
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
SectionBox::SectionBox(const QString &title, QWidget *parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
{
|
||||||
|
m_title = new QLabel(title, this);
|
||||||
|
QFont tf = m_title->font();
|
||||||
|
tf.setBold(true);
|
||||||
|
m_title->setFont(tf);
|
||||||
|
|
||||||
|
m_body = new QWidget(this);
|
||||||
|
m_bodyLayout = new QVBoxLayout(m_body);
|
||||||
|
m_bodyLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
m_bodyLayout->setSpacing(4);
|
||||||
|
|
||||||
|
auto *outer = new QVBoxLayout(this);
|
||||||
|
outer->setContentsMargins(0, 4, 0, 4);
|
||||||
|
outer->setSpacing(4);
|
||||||
|
outer->addWidget(m_title);
|
||||||
|
outer->addWidget(m_body, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
26
settings/SectionBox.hpp
Normal file
26
settings/SectionBox.hpp
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
class QLabel;
|
||||||
|
class QVBoxLayout;
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class SectionBox : public QWidget
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit SectionBox(const QString &title, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
QVBoxLayout *bodyLayout() const { return m_bodyLayout; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
QLabel *m_title = nullptr;
|
||||||
|
QWidget *m_body = nullptr;
|
||||||
|
QVBoxLayout *m_bodyLayout = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
10
sources/external/CMakeLists.txt
vendored
Normal file
10
sources/external/CMakeLists.txt
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
include(FetchContent)
|
||||||
|
|
||||||
|
FetchContent_Declare(tomlplusplus
|
||||||
|
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
|
||||||
|
GIT_TAG v3.4.0
|
||||||
|
GIT_SHALLOW TRUE
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(tomlplusplus)
|
||||||
|
|
||||||
|
add_subdirectory(llmqore)
|
||||||
27
sources/providersConfig/CMakeLists.txt
Normal file
27
sources/providersConfig/CMakeLists.txt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
add_library(ProvidersConfig STATIC
|
||||||
|
ProviderInstance.hpp ProviderInstance.cpp
|
||||||
|
ProviderInstanceLoader.hpp ProviderInstanceLoader.cpp
|
||||||
|
ProviderInstanceWriter.hpp ProviderInstanceWriter.cpp
|
||||||
|
ProviderInstanceFactory.hpp ProviderInstanceFactory.cpp
|
||||||
|
ProviderSecretsStore.hpp ProviderSecretsStore.cpp
|
||||||
|
ProviderLauncher.hpp ProviderLauncher.cpp
|
||||||
|
|
||||||
|
provider_instances.qrc
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(ProvidersConfig
|
||||||
|
PUBLIC
|
||||||
|
Qt::Core
|
||||||
|
Qt::Network
|
||||||
|
QtCreator::Core
|
||||||
|
QtCreator::Utils
|
||||||
|
QtCreator::TerminalLib
|
||||||
|
PRIVATE
|
||||||
|
QodeAssistLogger
|
||||||
|
TomlSerializer
|
||||||
|
tomlplusplus::tomlplusplus
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(ProvidersConfig
|
||||||
|
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
)
|
||||||
51
sources/providersConfig/ProviderInstance.cpp
Normal file
51
sources/providersConfig/ProviderInstance.cpp
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ProviderInstance.hpp"
|
||||||
|
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
QString ProviderInstance::validate(
|
||||||
|
const ProviderInstance &inst, const QStringList &knownClientApis)
|
||||||
|
{
|
||||||
|
if (inst.name.isEmpty())
|
||||||
|
return QStringLiteral("Provider instance has no name");
|
||||||
|
if (inst.clientApi.isEmpty())
|
||||||
|
return QStringLiteral("Provider instance '%1' has no client_api").arg(inst.name);
|
||||||
|
if (!knownClientApis.isEmpty() && !knownClientApis.contains(inst.clientApi)) {
|
||||||
|
return QStringLiteral("Provider instance '%1' references unknown client_api '%2'")
|
||||||
|
.arg(inst.name, inst.clientApi);
|
||||||
|
}
|
||||||
|
if (inst.url.isEmpty())
|
||||||
|
return QStringLiteral("Provider instance '%1' has no URL").arg(inst.name);
|
||||||
|
const QUrl parsed(inst.url);
|
||||||
|
if (!parsed.isValid()
|
||||||
|
|| (parsed.scheme() != QLatin1StringView{"http"}
|
||||||
|
&& parsed.scheme() != QLatin1StringView{"https"})) {
|
||||||
|
return QStringLiteral("Provider instance '%1' has an invalid or unsafe URL: %2")
|
||||||
|
.arg(inst.name, inst.url);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ProviderInstance::warnings(const ProviderInstance &inst)
|
||||||
|
{
|
||||||
|
const QUrl parsed(inst.url);
|
||||||
|
if (parsed.scheme() == QLatin1StringView{"http"} && !inst.apiKeyRef.isEmpty()) {
|
||||||
|
const QString host = parsed.host();
|
||||||
|
const bool isLoopback = host == QLatin1StringView{"localhost"}
|
||||||
|
|| host == QLatin1StringView{"127.0.0.1"}
|
||||||
|
|| host == QLatin1StringView{"::1"};
|
||||||
|
if (!isLoopback) {
|
||||||
|
return QStringLiteral(
|
||||||
|
"URL uses plaintext http:// to '%1' but the provider has an API key. "
|
||||||
|
"Any request will transmit the key unencrypted — prefer https://.")
|
||||||
|
.arg(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
57
sources/providersConfig/ProviderInstance.hpp
Normal file
57
sources/providersConfig/ProviderInstance.hpp
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
struct LaunchConfig
|
||||||
|
{
|
||||||
|
QString command;
|
||||||
|
QStringList args;
|
||||||
|
QString cwd;
|
||||||
|
QHash<QString, QString> env;
|
||||||
|
|
||||||
|
QString readyUrl;
|
||||||
|
std::chrono::seconds readyTimeout{30};
|
||||||
|
|
||||||
|
bool autoStart = false;
|
||||||
|
|
||||||
|
bool detach = false;
|
||||||
|
|
||||||
|
[[nodiscard]] bool isEmpty() const noexcept { return command.isEmpty(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ProviderInstance
|
||||||
|
{
|
||||||
|
QString name;
|
||||||
|
QString clientApi;
|
||||||
|
QString description;
|
||||||
|
QString url;
|
||||||
|
QString apiKeyRef;
|
||||||
|
QJsonObject extras;
|
||||||
|
LaunchConfig launch;
|
||||||
|
QString extendsName;
|
||||||
|
bool abstract = false;
|
||||||
|
|
||||||
|
QString sourcePath;
|
||||||
|
bool overridesBundled = false;
|
||||||
|
[[nodiscard]] bool isUserSource() const
|
||||||
|
{
|
||||||
|
return !sourcePath.startsWith(QLatin1StringView{":/"});
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] static QString validate(
|
||||||
|
const ProviderInstance &inst, const QStringList &knownClientApis);
|
||||||
|
|
||||||
|
[[nodiscard]] static QString warnings(const ProviderInstance &inst);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
198
sources/providersConfig/ProviderInstanceFactory.cpp
Normal file
198
sources/providersConfig/ProviderInstanceFactory.cpp
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ProviderInstanceFactory.hpp"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QFileSystemWatcher>
|
||||||
|
#include <QLoggingCategory>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
#include <coreplugin/icore.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "ProviderInstanceLoader.hpp"
|
||||||
|
#include "Logger.hpp"
|
||||||
|
|
||||||
|
static inline void initProviderInstancesResource()
|
||||||
|
{
|
||||||
|
Q_INIT_RESOURCE(provider_instances);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
Q_LOGGING_CATEGORY(providerInstanceFactoryLog, "qodeassist.providerinstancefactory")
|
||||||
|
|
||||||
|
QString instanceQrcPrefix() { return QStringLiteral(":/provider-instances"); }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
ProviderInstanceFactory::ProviderInstanceFactory(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
::initProviderInstancesResource();
|
||||||
|
|
||||||
|
m_watcher = new QFileSystemWatcher(this);
|
||||||
|
m_reloadDebounce = new QTimer(this);
|
||||||
|
m_reloadDebounce->setSingleShot(true);
|
||||||
|
m_reloadDebounce->setInterval(150);
|
||||||
|
connect(m_reloadDebounce, &QTimer::timeout, this, [this] { reload(); });
|
||||||
|
auto kick = [this](const QString &) { m_reloadDebounce->start(); };
|
||||||
|
connect(m_watcher, &QFileSystemWatcher::fileChanged, this, kick);
|
||||||
|
connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, kick);
|
||||||
|
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderInstanceFactory::~ProviderInstanceFactory() = default;
|
||||||
|
|
||||||
|
QString ProviderInstanceFactory::userInstancesDir()
|
||||||
|
{
|
||||||
|
return Core::ICore::userResourcePath(
|
||||||
|
QStringLiteral("qodeassist/config/providers")).toFSPathString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderInstanceFactory::reload()
|
||||||
|
{
|
||||||
|
Q_ASSERT_X(QThread::currentThread() == thread(),
|
||||||
|
Q_FUNC_INFO, "ProviderInstanceFactory must be used from its owner thread");
|
||||||
|
clear();
|
||||||
|
|
||||||
|
auto result = ProviderInstanceLoader::load(instanceQrcPrefix(), userInstancesDir());
|
||||||
|
for (const QString &err : result.errors)
|
||||||
|
LOG_MESSAGE(QString("[ProviderInstances] error: %1").arg(err));
|
||||||
|
for (const QString &warn : result.warnings)
|
||||||
|
LOG_MESSAGE(QString("[ProviderInstances] warning: %1").arg(warn));
|
||||||
|
LOG_MESSAGE(QString("[ProviderInstances] Loaded %1 instances (qrc=%2, user=%3)")
|
||||||
|
.arg(result.instances.size())
|
||||||
|
.arg(instanceQrcPrefix(), userInstancesDir()));
|
||||||
|
|
||||||
|
for (auto &inst : result.instances) {
|
||||||
|
LOG_MESSAGE(QString("[ProviderInstances] Loaded: %1 (client_api=%2, url=%3)")
|
||||||
|
.arg(inst.name, inst.clientApi, inst.url));
|
||||||
|
m_instances.push_back(std::move(inst));
|
||||||
|
}
|
||||||
|
m_errors = std::move(result.errors);
|
||||||
|
m_warnings = std::move(result.warnings);
|
||||||
|
|
||||||
|
rebuildIndexes();
|
||||||
|
rewatchUserDir();
|
||||||
|
emit instancesReloaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderInstanceFactory::rebuildIndexes()
|
||||||
|
{
|
||||||
|
m_nameIndex.clear();
|
||||||
|
m_instanceNamesCache.clear();
|
||||||
|
m_knownClientApisCache.clear();
|
||||||
|
m_nameIndex.reserve(static_cast<qsizetype>(m_instances.size()));
|
||||||
|
m_instanceNamesCache.reserve(static_cast<qsizetype>(m_instances.size()));
|
||||||
|
|
||||||
|
std::sort(m_instances.begin(), m_instances.end(),
|
||||||
|
[](const ProviderInstance &a, const ProviderInstance &b) {
|
||||||
|
return a.name.compare(b.name, Qt::CaseInsensitive) < 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
QSet<QString> seenApis;
|
||||||
|
for (qsizetype i = 0; i < static_cast<qsizetype>(m_instances.size()); ++i) {
|
||||||
|
const ProviderInstance &inst = m_instances[i];
|
||||||
|
m_nameIndex.insert(inst.name.toCaseFolded(), i);
|
||||||
|
m_instanceNamesCache.append(inst.name);
|
||||||
|
if (!seenApis.contains(inst.clientApi)) {
|
||||||
|
seenApis.insert(inst.clientApi);
|
||||||
|
m_knownClientApisCache.append(inst.clientApi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::sort(m_knownClientApisCache.begin(), m_knownClientApisCache.end(),
|
||||||
|
[](const QString &a, const QString &b) {
|
||||||
|
return a.compare(b, Qt::CaseInsensitive) < 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderInstanceFactory::rewatchUserDir()
|
||||||
|
{
|
||||||
|
if (!m_watcher)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QStringList stale = m_watcher->files() + m_watcher->directories();
|
||||||
|
if (!stale.isEmpty())
|
||||||
|
m_watcher->removePaths(stale);
|
||||||
|
|
||||||
|
const QString userDir = userInstancesDir();
|
||||||
|
QDir().mkpath(userDir);
|
||||||
|
m_watcher->addPath(userDir);
|
||||||
|
QDir d(userDir);
|
||||||
|
for (const QFileInfo &fi : d.entryInfoList({"*.toml"}, QDir::Files))
|
||||||
|
m_watcher->addPath(fi.absoluteFilePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderInstanceFactory::registerInstance(ProviderInstance instance)
|
||||||
|
{
|
||||||
|
Q_ASSERT_X(QThread::currentThread() == thread(),
|
||||||
|
Q_FUNC_INFO, "ProviderInstanceFactory must be used from its owner thread");
|
||||||
|
const QString validation = ProviderInstance::validate(instance, knownClientApis());
|
||||||
|
if (!validation.isEmpty()) {
|
||||||
|
qCWarning(providerInstanceFactoryLog).noquote()
|
||||||
|
<< "Refusing to register provider instance:" << validation;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString name = instance.name;
|
||||||
|
for (auto &existing : m_instances) {
|
||||||
|
if (existing.name == name) {
|
||||||
|
existing = std::move(instance);
|
||||||
|
emit instanceChanged(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m_instances.push_back(std::move(instance));
|
||||||
|
rebuildIndexes();
|
||||||
|
emit instanceChanged(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProviderInstance *ProviderInstanceFactory::instanceByName(const QString &name) const
|
||||||
|
{
|
||||||
|
const auto it = m_nameIndex.constFind(name.toCaseFolded());
|
||||||
|
if (it == m_nameIndex.constEnd())
|
||||||
|
return nullptr;
|
||||||
|
return &m_instances[it.value()];
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList ProviderInstanceFactory::instanceNames() const
|
||||||
|
{
|
||||||
|
return m_instanceNamesCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList ProviderInstanceFactory::instanceNamesForClientApi(const QString &clientApi) const
|
||||||
|
{
|
||||||
|
QStringList out;
|
||||||
|
for (const auto &inst : m_instances) {
|
||||||
|
if (inst.clientApi == clientApi)
|
||||||
|
out.append(inst.name);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList ProviderInstanceFactory::knownClientApis() const
|
||||||
|
{
|
||||||
|
return m_knownClientApisCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderInstanceFactory::clear()
|
||||||
|
{
|
||||||
|
Q_ASSERT_X(QThread::currentThread() == thread(),
|
||||||
|
Q_FUNC_INFO, "ProviderInstanceFactory must be used from its owner thread");
|
||||||
|
m_instances.clear();
|
||||||
|
m_nameIndex.clear();
|
||||||
|
m_instanceNamesCache.clear();
|
||||||
|
m_knownClientApisCache.clear();
|
||||||
|
m_errors.clear();
|
||||||
|
m_warnings.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
67
sources/providersConfig/ProviderInstanceFactory.hpp
Normal file
67
sources/providersConfig/ProviderInstanceFactory.hpp
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include "ProviderInstance.hpp"
|
||||||
|
|
||||||
|
class QFileSystemWatcher;
|
||||||
|
class QTimer;
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
class ProviderInstanceFactory : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_DISABLE_COPY_MOVE(ProviderInstanceFactory)
|
||||||
|
public:
|
||||||
|
explicit ProviderInstanceFactory(QObject *parent = nullptr);
|
||||||
|
~ProviderInstanceFactory() override;
|
||||||
|
|
||||||
|
void reload();
|
||||||
|
|
||||||
|
[[nodiscard]] static QString userInstancesDir();
|
||||||
|
|
||||||
|
[[nodiscard]] const ProviderInstance *instanceByName(const QString &name) const;
|
||||||
|
[[nodiscard]] QStringList instanceNames() const;
|
||||||
|
[[nodiscard]] QStringList instanceNamesForClientApi(const QString &clientApi) const;
|
||||||
|
[[nodiscard]] QStringList knownClientApis() const;
|
||||||
|
[[nodiscard]] const std::vector<ProviderInstance> &instances() const noexcept
|
||||||
|
{
|
||||||
|
return m_instances;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] QStringList lastLoadErrors() const { return m_errors; }
|
||||||
|
[[nodiscard]] QStringList lastLoadWarnings() const { return m_warnings; }
|
||||||
|
|
||||||
|
void registerInstance(ProviderInstance instance);
|
||||||
|
void clear();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void instanceChanged(const QString &name);
|
||||||
|
void instancesReloaded();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void rewatchUserDir();
|
||||||
|
void rebuildIndexes();
|
||||||
|
|
||||||
|
std::vector<ProviderInstance> m_instances;
|
||||||
|
QHash<QString, qsizetype> m_nameIndex;
|
||||||
|
QStringList m_instanceNamesCache;
|
||||||
|
QStringList m_knownClientApisCache;
|
||||||
|
QStringList m_errors;
|
||||||
|
QStringList m_warnings;
|
||||||
|
|
||||||
|
|
||||||
|
QFileSystemWatcher *m_watcher = nullptr;
|
||||||
|
QTimer *m_reloadDebounce = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
270
sources/providersConfig/ProviderInstanceLoader.cpp
Normal file
270
sources/providersConfig/ProviderInstanceLoader.cpp
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ProviderInstanceLoader.hpp"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QSet>
|
||||||
|
|
||||||
|
#include <toml++/toml.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QJsonValue tomlToJson(const toml::node &node)
|
||||||
|
{
|
||||||
|
if (auto *table = node.as_table()) {
|
||||||
|
QJsonObject obj;
|
||||||
|
for (const auto &[key, value] : *table) {
|
||||||
|
obj.insert(QString::fromStdString(std::string{key.str()}), tomlToJson(value));
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (auto *array = node.as_array()) {
|
||||||
|
QJsonArray arr;
|
||||||
|
for (const auto &item : *array)
|
||||||
|
arr.append(tomlToJson(item));
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
if (auto *str = node.as_string())
|
||||||
|
return QString::fromStdString(str->get());
|
||||||
|
if (auto *integer = node.as_integer())
|
||||||
|
return static_cast<qint64>(integer->get());
|
||||||
|
if (auto *floating = node.as_floating_point())
|
||||||
|
return floating->get();
|
||||||
|
if (auto *boolean = node.as_boolean())
|
||||||
|
return boolean->get();
|
||||||
|
return QJsonValue::Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject deepMerge(const QJsonObject &base, const QJsonObject &overlay)
|
||||||
|
{
|
||||||
|
QJsonObject result = base;
|
||||||
|
for (auto it = overlay.constBegin(); it != overlay.constEnd(); ++it) {
|
||||||
|
const QJsonValue baseVal = result.value(it.key());
|
||||||
|
const QJsonValue overlayVal = it.value();
|
||||||
|
if (baseVal.isObject() && overlayVal.isObject())
|
||||||
|
result[it.key()] = deepMerge(baseVal.toObject(), overlayVal.toObject());
|
||||||
|
else
|
||||||
|
result[it.key()] = overlayVal;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString readUtf8(const QString &path, QString *error)
|
||||||
|
{
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
if (error)
|
||||||
|
*error = QStringLiteral("Cannot open: %1").arg(path);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return QString::fromUtf8(f.readAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<QJsonObject> parseTomlFile(const QString &path, QString *error)
|
||||||
|
{
|
||||||
|
QString readErr;
|
||||||
|
const QString contents = readUtf8(path, &readErr);
|
||||||
|
if (!readErr.isEmpty()) {
|
||||||
|
if (error)
|
||||||
|
*error = readErr;
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
toml::table tbl;
|
||||||
|
try {
|
||||||
|
tbl = toml::parse(contents.toStdString(), path.toStdString());
|
||||||
|
} catch (const toml::parse_error &e) {
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << e;
|
||||||
|
if (error) {
|
||||||
|
*error = QStringLiteral("TOML parse error in %1: %2")
|
||||||
|
.arg(path, QString::fromStdString(oss.str()));
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return tomlToJson(tbl).toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList stringArray(const QJsonValue &v)
|
||||||
|
{
|
||||||
|
QStringList out;
|
||||||
|
if (!v.isArray())
|
||||||
|
return out;
|
||||||
|
for (const auto &elem : v.toArray()) {
|
||||||
|
if (elem.isString())
|
||||||
|
out.append(elem.toString());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchConfig launchConfigFromObject(const QJsonObject &launchObj)
|
||||||
|
{
|
||||||
|
LaunchConfig l;
|
||||||
|
if (launchObj.isEmpty())
|
||||||
|
return l;
|
||||||
|
l.command = launchObj.value("command").toString();
|
||||||
|
l.args = stringArray(launchObj.value("args"));
|
||||||
|
l.cwd = launchObj.value("cwd").toString();
|
||||||
|
const QJsonObject envObj = launchObj.value("env").toObject();
|
||||||
|
for (auto it = envObj.constBegin(); it != envObj.constEnd(); ++it) {
|
||||||
|
if (it.value().isString())
|
||||||
|
l.env.insert(it.key(), it.value().toString());
|
||||||
|
}
|
||||||
|
l.readyUrl = launchObj.value("ready_url").toString();
|
||||||
|
if (launchObj.contains("ready_timeout_s")) {
|
||||||
|
const int raw = launchObj.value("ready_timeout_s")
|
||||||
|
.toInt(static_cast<int>(l.readyTimeout.count()));
|
||||||
|
l.readyTimeout = std::chrono::seconds{std::max(1, raw)};
|
||||||
|
}
|
||||||
|
l.autoStart = launchObj.value("auto_start").toBool(false);
|
||||||
|
l.detach = launchObj.value("detach").toBool(false);
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderInstance instanceFromMerged(const QJsonObject &obj)
|
||||||
|
{
|
||||||
|
ProviderInstance inst;
|
||||||
|
inst.name = obj.value("name").toString();
|
||||||
|
inst.clientApi = obj.value("client_api").toString();
|
||||||
|
inst.description = obj.value("description").toString();
|
||||||
|
inst.url = obj.value("url").toString();
|
||||||
|
inst.apiKeyRef = obj.value("api_key_ref").toString();
|
||||||
|
inst.extras = obj.value("extras").toObject();
|
||||||
|
inst.launch = launchConfigFromObject(obj.value("launch").toObject());
|
||||||
|
inst.extendsName = obj.value("extends").toString();
|
||||||
|
inst.abstract = obj.value("abstract").toBool(false);
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RawEntry
|
||||||
|
{
|
||||||
|
QJsonObject obj;
|
||||||
|
QString filePath;
|
||||||
|
bool overridesBundled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr int kMaxExtendsDepth = 16;
|
||||||
|
|
||||||
|
QJsonObject resolveExtends(
|
||||||
|
const QString &name,
|
||||||
|
const QHash<QString, RawEntry> &raw,
|
||||||
|
QSet<QString> &visiting,
|
||||||
|
QStringList &errors,
|
||||||
|
int depth = 0)
|
||||||
|
{
|
||||||
|
if (depth > kMaxExtendsDepth) {
|
||||||
|
errors.append(QStringLiteral("Provider instance extends chain too deep (>%1) at '%2'")
|
||||||
|
.arg(kMaxExtendsDepth)
|
||||||
|
.arg(name));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (visiting.contains(name)) {
|
||||||
|
errors.append(QStringLiteral("Cyclic 'extends' involving provider instance '%1'").arg(name));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!raw.contains(name)) {
|
||||||
|
errors.append(QStringLiteral("Unknown parent provider instance '%1'").arg(name));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
visiting.insert(name);
|
||||||
|
|
||||||
|
QJsonObject self = raw.value(name).obj;
|
||||||
|
const QString parent = self.value("extends").toString();
|
||||||
|
if (!parent.isEmpty()) {
|
||||||
|
const QJsonObject parentMerged
|
||||||
|
= resolveExtends(parent, raw, visiting, errors, depth + 1);
|
||||||
|
self.remove("extends");
|
||||||
|
QJsonObject merged = deepMerge(parentMerged, self);
|
||||||
|
merged["name"] = name;
|
||||||
|
if (self.contains("abstract"))
|
||||||
|
merged["abstract"] = self.value("abstract");
|
||||||
|
else
|
||||||
|
merged.remove("abstract");
|
||||||
|
self = merged;
|
||||||
|
}
|
||||||
|
visiting.remove(name);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::optional<ProviderInstance> ProviderInstanceLoader::parseFile(
|
||||||
|
const QString &path, QString *error)
|
||||||
|
{
|
||||||
|
auto objOpt = parseTomlFile(path, error);
|
||||||
|
if (!objOpt)
|
||||||
|
return std::nullopt;
|
||||||
|
ProviderInstance inst = instanceFromMerged(*objOpt);
|
||||||
|
inst.sourcePath = path;
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderInstanceLoader::LoadResult ProviderInstanceLoader::load(
|
||||||
|
const QString &qrcPrefix, const QString &userDir)
|
||||||
|
{
|
||||||
|
LoadResult result;
|
||||||
|
QHash<QString, RawEntry> raw;
|
||||||
|
|
||||||
|
auto scan = [&](const QString &dir, bool isUserLayer) {
|
||||||
|
if (dir.isEmpty())
|
||||||
|
return;
|
||||||
|
QDir d(dir);
|
||||||
|
if (!d.exists())
|
||||||
|
return;
|
||||||
|
const QStringList files = d.entryList({"*.toml"}, QDir::Files);
|
||||||
|
for (const QString &fname : files) {
|
||||||
|
const QString fullPath = d.filePath(fname);
|
||||||
|
QString err;
|
||||||
|
auto objOpt = parseTomlFile(fullPath, &err);
|
||||||
|
if (!objOpt) {
|
||||||
|
result.errors.append(err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString name = objOpt->value("name").toString();
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
result.errors.append(
|
||||||
|
QStringLiteral("Provider instance at %1 has no 'name'").arg(fullPath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bool overrides = isUserLayer && raw.contains(name);
|
||||||
|
raw.insert(name, {*objOpt, fullPath, overrides});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
scan(qrcPrefix, /*isUserLayer=*/false);
|
||||||
|
scan(userDir, /*isUserLayer=*/true);
|
||||||
|
|
||||||
|
for (auto it = raw.constBegin(); it != raw.constEnd(); ++it) {
|
||||||
|
const QString &name = it.key();
|
||||||
|
|
||||||
|
QSet<QString> visiting;
|
||||||
|
const QJsonObject merged = resolveExtends(name, raw, visiting, result.errors);
|
||||||
|
if (merged.isEmpty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
ProviderInstance inst = instanceFromMerged(merged);
|
||||||
|
inst.sourcePath = it.value().filePath;
|
||||||
|
inst.overridesBundled = it.value().overridesBundled;
|
||||||
|
|
||||||
|
if (inst.abstract)
|
||||||
|
continue;
|
||||||
|
result.instances.push_back(std::move(inst));
|
||||||
|
}
|
||||||
|
std::sort(result.instances.begin(), result.instances.end(),
|
||||||
|
[](const ProviderInstance &a, const ProviderInstance &b) {
|
||||||
|
return a.name.compare(b.name, Qt::CaseInsensitive) < 0;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
32
sources/providersConfig/ProviderInstanceLoader.hpp
Normal file
32
sources/providersConfig/ProviderInstanceLoader.hpp
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include "ProviderInstance.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
class ProviderInstanceLoader
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
struct LoadResult
|
||||||
|
{
|
||||||
|
std::vector<ProviderInstance> instances;
|
||||||
|
QStringList errors;
|
||||||
|
QStringList warnings;
|
||||||
|
};
|
||||||
|
|
||||||
|
static LoadResult load(const QString &qrcPrefix, const QString &userDir);
|
||||||
|
|
||||||
|
static std::optional<ProviderInstance> parseFile(
|
||||||
|
const QString &path, QString *error);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
165
sources/providersConfig/ProviderInstanceWriter.cpp
Normal file
165
sources/providersConfig/ProviderInstanceWriter.cpp
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ProviderInstanceWriter.hpp"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QSet>
|
||||||
|
|
||||||
|
#include "ProviderInstanceFactory.hpp"
|
||||||
|
#include "TomlWriter.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Tr
|
||||||
|
{
|
||||||
|
Q_DECLARE_TR_FUNCTIONS(QtC::QodeAssist)
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr int kIdentityKeyColumn = 11; // longest key: "description"
|
||||||
|
constexpr int kLaunchKeyColumn = 15; // longest key: "ready_timeout_s"
|
||||||
|
|
||||||
|
void writeIdentityBlock(TomlSerializer::TomlWriter &w, const ProviderInstance &inst)
|
||||||
|
{
|
||||||
|
w.setKeyColumnWidth(0);
|
||||||
|
w.writeInt(QStringLiteral("schema_version"), 1);
|
||||||
|
w.writeBlankLine();
|
||||||
|
|
||||||
|
w.setKeyColumnWidth(kIdentityKeyColumn);
|
||||||
|
w.writeString(QStringLiteral("name"), inst.name);
|
||||||
|
w.writeString(QStringLiteral("client_api"), inst.clientApi);
|
||||||
|
if (!inst.description.isEmpty())
|
||||||
|
w.writeString(QStringLiteral("description"), inst.description);
|
||||||
|
w.writeBlankLine();
|
||||||
|
w.writeString(QStringLiteral("url"), inst.url);
|
||||||
|
if (!inst.apiKeyRef.isEmpty())
|
||||||
|
w.writeString(QStringLiteral("api_key_ref"), inst.apiKeyRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeExtrasBlock(TomlSerializer::TomlWriter &w, const QJsonObject &extras)
|
||||||
|
{
|
||||||
|
w.writeBlankLine();
|
||||||
|
w.writeTableHeader(QStringLiteral("extras"));
|
||||||
|
w.setKeyColumnWidth(0);
|
||||||
|
w.writeJsonPrimitives(extras);
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeLaunchBlock(TomlSerializer::TomlWriter &w, const LaunchConfig &l)
|
||||||
|
{
|
||||||
|
w.writeBlankLine();
|
||||||
|
w.writeTableHeader(QStringLiteral("launch"));
|
||||||
|
w.setKeyColumnWidth(kLaunchKeyColumn);
|
||||||
|
w.writeString(QStringLiteral("command"), l.command);
|
||||||
|
if (!l.args.isEmpty())
|
||||||
|
w.writeStringArray(QStringLiteral("args"), l.args);
|
||||||
|
if (!l.cwd.isEmpty())
|
||||||
|
w.writeString(QStringLiteral("cwd"), l.cwd);
|
||||||
|
if (!l.readyUrl.isEmpty())
|
||||||
|
w.writeString(QStringLiteral("ready_url"), l.readyUrl);
|
||||||
|
w.writeInt(QStringLiteral("ready_timeout_s"), l.readyTimeout.count());
|
||||||
|
w.writeBool(QStringLiteral("auto_start"), l.autoStart);
|
||||||
|
w.writeBool(QStringLiteral("detach"), l.detach);
|
||||||
|
|
||||||
|
if (!l.env.isEmpty()) {
|
||||||
|
w.writeBlankLine();
|
||||||
|
w.writeTableHeader(QStringLiteral("launch.env"));
|
||||||
|
w.setKeyColumnWidth(0);
|
||||||
|
w.writeStringDict(l.env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QString ProviderInstanceWriter::toToml(const ProviderInstance &inst)
|
||||||
|
{
|
||||||
|
TomlSerializer::TomlWriter w;
|
||||||
|
writeIdentityBlock(w, inst);
|
||||||
|
if (!inst.extras.isEmpty())
|
||||||
|
writeExtrasBlock(w, inst.extras);
|
||||||
|
if (!inst.launch.isEmpty())
|
||||||
|
writeLaunchBlock(w, inst.launch);
|
||||||
|
return w.result();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ProviderInstanceWriter::deriveBaseName(const QString &name)
|
||||||
|
{
|
||||||
|
QString baseName;
|
||||||
|
for (QChar c : name) {
|
||||||
|
if (c.isLetterOrNumber())
|
||||||
|
baseName.append(c.toLower());
|
||||||
|
else if (c == QLatin1Char(' ') || c == QLatin1Char('-') || c == QLatin1Char('_'))
|
||||||
|
baseName.append(QLatin1Char('_'));
|
||||||
|
}
|
||||||
|
while (baseName.startsWith(QLatin1Char('_')))
|
||||||
|
baseName.remove(0, 1);
|
||||||
|
while (baseName.endsWith(QLatin1Char('_')))
|
||||||
|
baseName.chop(1);
|
||||||
|
if (baseName.isEmpty())
|
||||||
|
baseName = QStringLiteral("instance");
|
||||||
|
return baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr int kMaxCollisionRetries = 1000;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QString ProviderInstanceWriter::pickUserFilePath(
|
||||||
|
const QString &userDir, const QString &name, const QString &previousPath)
|
||||||
|
{
|
||||||
|
const QDir dir(userDir);
|
||||||
|
const QString base = deriveBaseName(name);
|
||||||
|
const QString preferred = dir.filePath(base + QLatin1String(".toml"));
|
||||||
|
if (!previousPath.isEmpty()
|
||||||
|
&& QFileInfo(previousPath).absolutePath() == dir.absolutePath()
|
||||||
|
&& QFileInfo(previousPath).absoluteFilePath() == QFileInfo(preferred).absoluteFilePath())
|
||||||
|
return preferred;
|
||||||
|
QSet<QString> taken;
|
||||||
|
for (const QString &existing : dir.entryList({"*.toml"}, QDir::Files))
|
||||||
|
taken.insert(existing);
|
||||||
|
if (!taken.contains(base + QLatin1String(".toml")))
|
||||||
|
return preferred;
|
||||||
|
for (int i = 2; i < kMaxCollisionRetries; ++i) {
|
||||||
|
const QString candidate = QStringLiteral("%1_%2.toml").arg(base).arg(i);
|
||||||
|
if (!taken.contains(candidate))
|
||||||
|
return dir.filePath(candidate);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ProviderInstanceWriter::writeToUserDir(
|
||||||
|
const ProviderInstance &inst, const QString &previousPath, QString *errorOut)
|
||||||
|
{
|
||||||
|
const QString userDir = ProviderInstanceFactory::userInstancesDir();
|
||||||
|
if (!QDir().mkpath(userDir)) {
|
||||||
|
if (errorOut)
|
||||||
|
*errorOut = Tr::tr("Cannot create user provider folder:\n%1").arg(userDir);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QString filePath = pickUserFilePath(userDir, inst.name, previousPath);
|
||||||
|
if (filePath.isEmpty()) {
|
||||||
|
if (errorOut)
|
||||||
|
*errorOut = Tr::tr("Cannot pick a free filename in:\n%1").arg(userDir);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
QSaveFile f(filePath);
|
||||||
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
||||||
|
if (errorOut)
|
||||||
|
*errorOut = Tr::tr("Cannot write %1:\n%2").arg(filePath, f.errorString());
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QByteArray bytes = toToml(inst).toUtf8();
|
||||||
|
if (f.write(bytes) != bytes.size() || !f.commit()) {
|
||||||
|
if (errorOut)
|
||||||
|
*errorOut = Tr::tr("Write failed for %1:\n%2").arg(filePath, f.errorString());
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
27
sources/providersConfig/ProviderInstanceWriter.hpp
Normal file
27
sources/providersConfig/ProviderInstanceWriter.hpp
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "ProviderInstance.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
class ProviderInstanceWriter
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
[[nodiscard]] static QString toToml(const ProviderInstance &inst);
|
||||||
|
[[nodiscard]] static QString writeToUserDir(
|
||||||
|
const ProviderInstance &inst,
|
||||||
|
const QString &previousPath,
|
||||||
|
QString *errorOut = nullptr);
|
||||||
|
[[nodiscard]] static QString pickUserFilePath(
|
||||||
|
const QString &userDir,
|
||||||
|
const QString &name,
|
||||||
|
const QString &previousPath);
|
||||||
|
[[nodiscard]] static QString deriveBaseName(const QString &name);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
637
sources/providersConfig/ProviderLauncher.cpp
Normal file
637
sources/providersConfig/ProviderLauncher.cpp
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ProviderLauncher.hpp"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QElapsedTimer>
|
||||||
|
#include <QLoggingCategory>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QSslError>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QProcessEnvironment>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include <utils/commandline.h>
|
||||||
|
#include <utils/environment.h>
|
||||||
|
#include <utils/filepath.h>
|
||||||
|
#include <utils/processinterface.h>
|
||||||
|
#include <utils/qtcprocess.h>
|
||||||
|
|
||||||
|
#include "Logger.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <signal.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
Q_LOGGING_CATEGORY(launcherLog, "qodeassist.providerlauncher")
|
||||||
|
|
||||||
|
constexpr std::chrono::milliseconds kProbeInterval{500};
|
||||||
|
constexpr std::chrono::milliseconds kProbeTransferTimeout{2000};
|
||||||
|
constexpr std::chrono::milliseconds kAdoptionTransferTimeout{1500};
|
||||||
|
constexpr std::chrono::milliseconds kStartTimeout{2000};
|
||||||
|
constexpr int kScrollbackBytesMax = 1 * 1024 * 1024; // 1 MiB cap per slot
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
struct ProviderLauncher::Slot
|
||||||
|
{
|
||||||
|
QString name;
|
||||||
|
LaunchConfig cfg;
|
||||||
|
State state = Idle;
|
||||||
|
Utils::Process *process = nullptr;
|
||||||
|
qint64 detachedPid = 0;
|
||||||
|
bool adoptedExternal = false;
|
||||||
|
bool started = false;
|
||||||
|
QTimer *probeTimer = nullptr;
|
||||||
|
QTimer *startTimer = nullptr; // fail-fast timer for QProcess::started
|
||||||
|
QElapsedTimer probeStart;
|
||||||
|
QPointer<QNetworkReply> probeReply;
|
||||||
|
QList<QPointer<QNetworkReply>> oneShotProbes;
|
||||||
|
int generation = 0;
|
||||||
|
QString lastError;
|
||||||
|
QByteArray scrollback;
|
||||||
|
};
|
||||||
|
|
||||||
|
ProviderLauncher::ProviderLauncher(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_nam(new QNetworkAccessManager(this))
|
||||||
|
{
|
||||||
|
connect(m_nam, &QNetworkAccessManager::sslErrors, this,
|
||||||
|
[this](QNetworkReply *reply, const QList<QSslError> &errors) {
|
||||||
|
QStringList msgs;
|
||||||
|
msgs.reserve(errors.size());
|
||||||
|
for (const QSslError &e : errors)
|
||||||
|
msgs.append(e.errorString());
|
||||||
|
qCWarning(launcherLog).noquote()
|
||||||
|
<< "SSL errors on probe to" << reply->url().toString() << ":"
|
||||||
|
<< msgs.join(QStringLiteral("; "));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderLauncher::~ProviderLauncher()
|
||||||
|
{
|
||||||
|
m_nam->disconnect(this);
|
||||||
|
for (Slot *slot : m_slots) {
|
||||||
|
if (slot->cfg.detach) {
|
||||||
|
if (slot->probeTimer) {
|
||||||
|
slot->probeTimer->stop();
|
||||||
|
slot->probeTimer->deleteLater();
|
||||||
|
slot->probeTimer = nullptr;
|
||||||
|
}
|
||||||
|
if (slot->probeReply) {
|
||||||
|
slot->probeReply->abort();
|
||||||
|
slot->probeReply->deleteLater();
|
||||||
|
slot->probeReply.clear();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
teardownSlot(slot);
|
||||||
|
}
|
||||||
|
delete slot;
|
||||||
|
}
|
||||||
|
m_slots.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::start(const QString &instanceName, const LaunchConfig &cfg)
|
||||||
|
{
|
||||||
|
if (instanceName.isEmpty() || cfg.isEmpty())
|
||||||
|
return;
|
||||||
|
Slot *slot = m_slots.value(instanceName, nullptr);
|
||||||
|
if (slot) {
|
||||||
|
if (slot->state == Starting || slot->state == Probing || slot->state == Ready) {
|
||||||
|
slot->cfg = cfg;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
teardownSlot(slot);
|
||||||
|
} else {
|
||||||
|
slot = new Slot;
|
||||||
|
slot->name = instanceName;
|
||||||
|
m_slots.insert(instanceName, slot);
|
||||||
|
}
|
||||||
|
slot->cfg = cfg;
|
||||||
|
slot->scrollback.clear();
|
||||||
|
slot->lastError.clear();
|
||||||
|
slot->detachedPid = 0;
|
||||||
|
slot->adoptedExternal = false;
|
||||||
|
slot->started = false;
|
||||||
|
++slot->generation;
|
||||||
|
const int gen = slot->generation;
|
||||||
|
|
||||||
|
if (!cfg.readyUrl.isEmpty()) {
|
||||||
|
changeState(slot, Starting);
|
||||||
|
const QString name = instanceName;
|
||||||
|
const QString readyUrl = cfg.readyUrl;
|
||||||
|
probeOnceAsync(slot, gen, readyUrl, [this, name, readyUrl](bool ok) {
|
||||||
|
Slot *s = m_slots.value(name, nullptr);
|
||||||
|
if (!s || s->state != Starting)
|
||||||
|
return;
|
||||||
|
if (ok) {
|
||||||
|
s->adoptedExternal = true;
|
||||||
|
s->detachedPid = 0;
|
||||||
|
appendLog(s, QStringLiteral(
|
||||||
|
"[adopt] %1 is already up — reusing the running process (no pid).")
|
||||||
|
.arg(readyUrl));
|
||||||
|
changeState(s, Ready);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s->cfg.detach)
|
||||||
|
launchDetached(s);
|
||||||
|
else
|
||||||
|
launchProcess(s);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.detach)
|
||||||
|
launchDetached(slot);
|
||||||
|
else
|
||||||
|
launchProcess(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::stop(const QString &instanceName)
|
||||||
|
{
|
||||||
|
Slot *slot = m_slots.value(instanceName, nullptr);
|
||||||
|
if (!slot)
|
||||||
|
return;
|
||||||
|
if (slot->state == Idle || slot->state == Failed) {
|
||||||
|
changeState(slot, Idle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changeState(slot, Stopping);
|
||||||
|
if (slot->cfg.detach) {
|
||||||
|
const qint64 pid = slot->detachedPid;
|
||||||
|
const QString readyUrl = slot->cfg.readyUrl;
|
||||||
|
if (slot->probeTimer) {
|
||||||
|
slot->probeTimer->stop();
|
||||||
|
slot->probeTimer->deleteLater();
|
||||||
|
slot->probeTimer = nullptr;
|
||||||
|
}
|
||||||
|
if (slot->probeReply) {
|
||||||
|
slot->probeReply->abort();
|
||||||
|
slot->probeReply->deleteLater();
|
||||||
|
slot->probeReply.clear();
|
||||||
|
}
|
||||||
|
slot->detachedPid = 0;
|
||||||
|
slot->adoptedExternal = false;
|
||||||
|
|
||||||
|
if (pid <= 0) {
|
||||||
|
appendLog(slot, QStringLiteral(
|
||||||
|
"[stop] no pid recorded (process was adopted via probe) — "
|
||||||
|
"cannot terminate from the plugin; kill manually if needed."));
|
||||||
|
changeState(slot, Idle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readyUrl.isEmpty()) {
|
||||||
|
appendLog(slot, QStringLiteral("[stop] SIGTERM pid=%1").arg(pid));
|
||||||
|
killByPid(pid);
|
||||||
|
changeState(slot, Idle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString name = instanceName;
|
||||||
|
++slot->generation;
|
||||||
|
const int gen = slot->generation;
|
||||||
|
probeOnceAsync(slot, gen, readyUrl, [this, name, pid](bool stillUp) {
|
||||||
|
Slot *s = m_slots.value(name, nullptr);
|
||||||
|
if (!s)
|
||||||
|
return;
|
||||||
|
if (stillUp) {
|
||||||
|
appendLog(s, QStringLiteral("[stop] SIGTERM pid=%1").arg(pid));
|
||||||
|
killByPid(pid);
|
||||||
|
} else {
|
||||||
|
appendLog(s, QStringLiteral(
|
||||||
|
"[stop] pid=%1 no longer responsive on ready_url — "
|
||||||
|
"skipping kill to avoid hitting a reused PID.").arg(pid));
|
||||||
|
}
|
||||||
|
if (s->state == Stopping)
|
||||||
|
changeState(s, Idle);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
teardownSlot(slot);
|
||||||
|
changeState(slot, Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::restart(const QString &instanceName, const LaunchConfig &cfg)
|
||||||
|
{
|
||||||
|
stop(instanceName);
|
||||||
|
start(instanceName, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderLauncher::State ProviderLauncher::state(const QString &instanceName) const
|
||||||
|
{
|
||||||
|
const Slot *slot = m_slots.value(instanceName, nullptr);
|
||||||
|
return slot ? slot->state : Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ProviderLauncher::isReady(const QString &instanceName) const
|
||||||
|
{
|
||||||
|
return state(instanceName) == Ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ProviderLauncher::lastError(const QString &instanceName) const
|
||||||
|
{
|
||||||
|
const Slot *slot = m_slots.value(instanceName, nullptr);
|
||||||
|
return slot ? slot->lastError : QString{};
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray ProviderLauncher::scrollback(const QString &instanceName) const
|
||||||
|
{
|
||||||
|
const Slot *slot = m_slots.value(instanceName, nullptr);
|
||||||
|
return slot ? slot->scrollback : QByteArray{};
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList ProviderLauncher::activeInstances() const
|
||||||
|
{
|
||||||
|
QStringList out;
|
||||||
|
for (auto it = m_slots.constBegin(); it != m_slots.constEnd(); ++it) {
|
||||||
|
if (it.value()->state != Idle)
|
||||||
|
out.append(it.key());
|
||||||
|
}
|
||||||
|
std::sort(out.begin(), out.end(),
|
||||||
|
[](const QString &a, const QString &b) {
|
||||||
|
return a.compare(b, Qt::CaseInsensitive) < 0;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::launchProcess(Slot *slot)
|
||||||
|
{
|
||||||
|
const LaunchConfig &cfg = slot->cfg;
|
||||||
|
const QString command = expandVars(cfg.command, slot);
|
||||||
|
const QStringList args = expandVars(cfg.args, slot);
|
||||||
|
const QString cwd = cfg.cwd.isEmpty() ? QDir::homePath() : expandVars(cfg.cwd, slot);
|
||||||
|
const QString name = slot->name;
|
||||||
|
|
||||||
|
auto *proc = new Utils::Process(this);
|
||||||
|
slot->process = proc;
|
||||||
|
|
||||||
|
Utils::Environment env = Utils::Environment::systemEnvironment();
|
||||||
|
env.set(QStringLiteral("PROVIDER_NAME"), slot->name);
|
||||||
|
for (auto it = cfg.env.constBegin(); it != cfg.env.constEnd(); ++it)
|
||||||
|
env.set(it.key(), it.value());
|
||||||
|
proc->setEnvironment(env);
|
||||||
|
proc->setWorkingDirectory(Utils::FilePath::fromString(cwd));
|
||||||
|
proc->setCommand(Utils::CommandLine{Utils::FilePath::fromString(command), args});
|
||||||
|
|
||||||
|
proc->setPtyData(Utils::Pty::Data{});
|
||||||
|
|
||||||
|
connect(proc, &Utils::Process::readyReadStandardOutput, this, [this, name] {
|
||||||
|
Slot *s = m_slots.value(name, nullptr);
|
||||||
|
if (!s || !s->process) return;
|
||||||
|
const QByteArray chunk = s->process->readAllRawStandardOutput();
|
||||||
|
if (!chunk.isEmpty()) {
|
||||||
|
appendScrollback(s, chunk);
|
||||||
|
emit bytesReceived(s->name, chunk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
connect(proc, &Utils::Process::readyReadStandardError, this, [this, name] {
|
||||||
|
Slot *s = m_slots.value(name, nullptr);
|
||||||
|
if (!s || !s->process) return;
|
||||||
|
const QByteArray chunk = s->process->readAllRawStandardError();
|
||||||
|
if (!chunk.isEmpty()) {
|
||||||
|
appendScrollback(s, chunk);
|
||||||
|
emit bytesReceived(s->name, chunk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
connect(proc, &Utils::Process::started, this, [this, name] {
|
||||||
|
Slot *s = m_slots.value(name, nullptr);
|
||||||
|
if (!s)
|
||||||
|
return;
|
||||||
|
s->started = true;
|
||||||
|
if (s->startTimer) {
|
||||||
|
s->startTimer->stop();
|
||||||
|
s->startTimer->deleteLater();
|
||||||
|
s->startTimer = nullptr;
|
||||||
|
}
|
||||||
|
if (s->state != Starting)
|
||||||
|
return;
|
||||||
|
if (s->cfg.readyUrl.isEmpty()) {
|
||||||
|
changeState(s, Ready);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s->probeStart.start();
|
||||||
|
changeState(s, Probing);
|
||||||
|
scheduleReadyProbe(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(proc, &Utils::Process::done, this, [this, name] {
|
||||||
|
Slot *s = m_slots.value(name, nullptr);
|
||||||
|
if (!s || !s->process) return;
|
||||||
|
const QByteArray tailOut = s->process->readAllRawStandardOutput();
|
||||||
|
const QByteArray tailErr = s->process->readAllRawStandardError();
|
||||||
|
if (!tailOut.isEmpty()) { appendScrollback(s, tailOut); emit bytesReceived(s->name, tailOut); }
|
||||||
|
if (!tailErr.isEmpty()) { appendScrollback(s, tailErr); emit bytesReceived(s->name, tailErr); }
|
||||||
|
|
||||||
|
const int code = s->process->exitCode();
|
||||||
|
const QProcess::ExitStatus status = s->process->exitStatus();
|
||||||
|
appendLog(s, QStringLiteral("[exit] code=%1 status=%2")
|
||||||
|
.arg(code)
|
||||||
|
.arg(status == QProcess::NormalExit ? "normal" : "crashed"));
|
||||||
|
const State prev = s->state;
|
||||||
|
teardownSlot(s);
|
||||||
|
if (prev != Stopping && code != 0) {
|
||||||
|
s->lastError = QStringLiteral("Process exited (code %1)").arg(code);
|
||||||
|
changeState(s, Failed);
|
||||||
|
} else {
|
||||||
|
changeState(s, Idle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
appendLog(slot, QStringLiteral("[spawn] %1 %2")
|
||||||
|
.arg(command, args.join(QLatin1Char(' '))));
|
||||||
|
changeState(slot, Starting);
|
||||||
|
proc->start();
|
||||||
|
|
||||||
|
if (slot->startTimer) {
|
||||||
|
slot->startTimer->stop();
|
||||||
|
slot->startTimer->deleteLater();
|
||||||
|
}
|
||||||
|
slot->startTimer = new QTimer(this);
|
||||||
|
slot->startTimer->setSingleShot(true);
|
||||||
|
const QString slotName = slot->name;
|
||||||
|
connect(slot->startTimer, &QTimer::timeout, this, [this, slotName] {
|
||||||
|
Slot *s = m_slots.value(slotName, nullptr);
|
||||||
|
if (!s || s->started || s->state != Starting)
|
||||||
|
return;
|
||||||
|
s->lastError = s->process && !s->process->errorString().isEmpty()
|
||||||
|
? s->process->errorString()
|
||||||
|
: QStringLiteral("Process failed to start");
|
||||||
|
appendLog(s, QStringLiteral("[error] %1").arg(s->lastError));
|
||||||
|
teardownSlot(s);
|
||||||
|
changeState(s, Failed);
|
||||||
|
});
|
||||||
|
slot->startTimer->start(kStartTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::launchDetached(Slot *slot)
|
||||||
|
{
|
||||||
|
const LaunchConfig &cfg = slot->cfg;
|
||||||
|
const QString command = expandVars(cfg.command, slot);
|
||||||
|
const QStringList args = expandVars(cfg.args, slot);
|
||||||
|
const QString cwd = cfg.cwd.isEmpty() ? QDir::homePath() : expandVars(cfg.cwd, slot);
|
||||||
|
|
||||||
|
appendLog(slot, QStringLiteral("[spawn-detached] %1 %2")
|
||||||
|
.arg(command, args.join(QLatin1Char(' '))));
|
||||||
|
|
||||||
|
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||||||
|
env.insert(QStringLiteral("PROVIDER_NAME"), slot->name);
|
||||||
|
for (auto it = cfg.env.constBegin(); it != cfg.env.constEnd(); ++it)
|
||||||
|
env.insert(it.key(), it.value());
|
||||||
|
|
||||||
|
QProcess tmp;
|
||||||
|
tmp.setProgram(command);
|
||||||
|
tmp.setArguments(args);
|
||||||
|
tmp.setWorkingDirectory(cwd);
|
||||||
|
tmp.setProcessEnvironment(env);
|
||||||
|
tmp.setStandardOutputFile(QProcess::nullDevice());
|
||||||
|
tmp.setStandardErrorFile(QProcess::nullDevice());
|
||||||
|
qint64 pid = 0;
|
||||||
|
const bool ok = tmp.startDetached(&pid);
|
||||||
|
if (!ok || pid <= 0) {
|
||||||
|
slot->lastError = tmp.errorString().isEmpty()
|
||||||
|
? QStringLiteral("Detached spawn failed")
|
||||||
|
: tmp.errorString();
|
||||||
|
appendLog(slot, QStringLiteral("[error] %1").arg(slot->lastError));
|
||||||
|
changeState(slot, Failed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slot->detachedPid = pid;
|
||||||
|
appendLog(slot, QStringLiteral("[detached] pid=%1 (stdout/stderr discarded)").arg(pid));
|
||||||
|
|
||||||
|
if (cfg.readyUrl.isEmpty()) {
|
||||||
|
changeState(slot, Ready);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slot->probeStart.start();
|
||||||
|
changeState(slot, Probing);
|
||||||
|
scheduleReadyProbe(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::probeOnceAsync(
|
||||||
|
Slot *slot, int expectedGeneration, const QString &url,
|
||||||
|
std::function<void(bool)> onResult)
|
||||||
|
{
|
||||||
|
QNetworkRequest req(QUrl{url});
|
||||||
|
req.setTransferTimeout(kAdoptionTransferTimeout);
|
||||||
|
QNetworkReply *reply = m_nam->get(req);
|
||||||
|
if (slot)
|
||||||
|
slot->oneShotProbes.append(QPointer<QNetworkReply>(reply));
|
||||||
|
const QString name = slot ? slot->name : QString{};
|
||||||
|
connect(reply, &QNetworkReply::finished, this,
|
||||||
|
[this, reply, name, expectedGeneration, cb = std::move(onResult)] {
|
||||||
|
reply->deleteLater();
|
||||||
|
Slot *s = m_slots.value(name, nullptr);
|
||||||
|
if (s) {
|
||||||
|
s->oneShotProbes.removeAll(QPointer<QNetworkReply>(reply));
|
||||||
|
if (s->generation != expectedGeneration)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const int http = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
|
const bool ok = reply->error() == QNetworkReply::NoError && http >= 200 && http < 300;
|
||||||
|
cb(ok);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::killByPid(qint64 pid)
|
||||||
|
{
|
||||||
|
if (pid <= 0)
|
||||||
|
return;
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
HANDLE h = ::OpenProcess(PROCESS_TERMINATE, FALSE, static_cast<DWORD>(pid));
|
||||||
|
if (h) {
|
||||||
|
::TerminateProcess(h, 1);
|
||||||
|
::CloseHandle(h);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
::kill(static_cast<pid_t>(pid), SIGTERM);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::scheduleReadyProbe(Slot *slot)
|
||||||
|
{
|
||||||
|
if (!slot->probeTimer) {
|
||||||
|
const QString name = slot->name;
|
||||||
|
slot->probeTimer = new QTimer(this);
|
||||||
|
slot->probeTimer->setSingleShot(true);
|
||||||
|
connect(slot->probeTimer, &QTimer::timeout, this, [this, name] {
|
||||||
|
if (Slot *s = m_slots.value(name, nullptr))
|
||||||
|
runReadyProbe(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
slot->probeTimer->start(kProbeInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::runReadyProbe(Slot *slot)
|
||||||
|
{
|
||||||
|
if (!slot || slot->state != Probing)
|
||||||
|
return;
|
||||||
|
const auto elapsed = std::chrono::milliseconds{slot->probeStart.elapsed()};
|
||||||
|
if (elapsed > slot->cfg.readyTimeout) {
|
||||||
|
slot->lastError = QStringLiteral("Ready probe timed out after %1 s")
|
||||||
|
.arg(slot->cfg.readyTimeout.count());
|
||||||
|
appendLog(slot, QStringLiteral("[probe] timeout — %1").arg(slot->lastError));
|
||||||
|
changeState(slot, Failed);
|
||||||
|
teardownSlot(slot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QNetworkRequest req(QUrl{slot->cfg.readyUrl});
|
||||||
|
req.setTransferTimeout(kProbeTransferTimeout);
|
||||||
|
slot->probeReply = m_nam->get(req);
|
||||||
|
const QString name = slot->name;
|
||||||
|
connect(slot->probeReply, &QNetworkReply::finished, this, [this, name] {
|
||||||
|
Slot *s = m_slots.value(name, nullptr);
|
||||||
|
if (!s || !s->probeReply)
|
||||||
|
return;
|
||||||
|
QNetworkReply *reply = s->probeReply;
|
||||||
|
s->probeReply.clear();
|
||||||
|
reply->deleteLater();
|
||||||
|
if (s->state != Probing)
|
||||||
|
return;
|
||||||
|
const int http = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
|
if (reply->error() == QNetworkReply::NoError && http >= 200 && http < 300) {
|
||||||
|
appendLog(s, QStringLiteral("[probe] %1 → %2 OK").arg(s->cfg.readyUrl).arg(http));
|
||||||
|
changeState(s, Ready);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
appendLog(s, QStringLiteral("[probe] %1 → %2")
|
||||||
|
.arg(s->cfg.readyUrl, reply->errorString()));
|
||||||
|
}
|
||||||
|
scheduleReadyProbe(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::teardownSlot(Slot *slot)
|
||||||
|
{
|
||||||
|
if (!slot)
|
||||||
|
return;
|
||||||
|
|
||||||
|
++slot->generation;
|
||||||
|
if (slot->probeTimer) {
|
||||||
|
slot->probeTimer->stop();
|
||||||
|
slot->probeTimer->deleteLater();
|
||||||
|
slot->probeTimer = nullptr;
|
||||||
|
}
|
||||||
|
if (slot->startTimer) {
|
||||||
|
slot->startTimer->stop();
|
||||||
|
slot->startTimer->deleteLater();
|
||||||
|
slot->startTimer = nullptr;
|
||||||
|
}
|
||||||
|
if (slot->probeReply) {
|
||||||
|
slot->probeReply->abort();
|
||||||
|
slot->probeReply->deleteLater();
|
||||||
|
slot->probeReply.clear();
|
||||||
|
}
|
||||||
|
for (const QPointer<QNetworkReply> &probe : slot->oneShotProbes) {
|
||||||
|
if (probe) {
|
||||||
|
probe->abort();
|
||||||
|
probe->deleteLater();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slot->oneShotProbes.clear();
|
||||||
|
if (slot->process) {
|
||||||
|
Utils::Process *p = slot->process;
|
||||||
|
slot->process = nullptr;
|
||||||
|
p->disconnect(this);
|
||||||
|
if (p->state() == QProcess::NotRunning) {
|
||||||
|
p->deleteLater();
|
||||||
|
} else {
|
||||||
|
QObject::connect(p, &Utils::Process::done, p, &QObject::deleteLater);
|
||||||
|
QTimer::singleShot(std::chrono::seconds{15}, p, &QObject::deleteLater);
|
||||||
|
p->stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::appendLog(Slot *slot, const QString &line)
|
||||||
|
{
|
||||||
|
if (line.isEmpty())
|
||||||
|
return;
|
||||||
|
const QByteArray bytes = (line + QStringLiteral("\r\n")).toUtf8();
|
||||||
|
appendScrollback(slot, bytes);
|
||||||
|
emit bytesReceived(slot->name, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::appendScrollback(Slot *slot, const QByteArray &chunk)
|
||||||
|
{
|
||||||
|
if (chunk.isEmpty())
|
||||||
|
return;
|
||||||
|
slot->scrollback.append(chunk);
|
||||||
|
if (slot->scrollback.size() > kScrollbackBytesMax) {
|
||||||
|
const int over = slot->scrollback.size() - kScrollbackBytesMax;
|
||||||
|
slot->scrollback.remove(0, over);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderLauncher::changeState(Slot *slot, State newState)
|
||||||
|
{
|
||||||
|
if (slot->state == newState)
|
||||||
|
return;
|
||||||
|
slot->state = newState;
|
||||||
|
const QString name = slot->name;
|
||||||
|
qCDebug(launcherLog).noquote() << name << "→ state" << newState;
|
||||||
|
emit stateChanged(name, newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ProviderLauncher::expandOne(
|
||||||
|
const QString &input, const Slot *slot, const QProcessEnvironment &sys)
|
||||||
|
{
|
||||||
|
if (!input.contains(QLatin1String("${")))
|
||||||
|
return input;
|
||||||
|
QString out = input;
|
||||||
|
int searchFrom = 0;
|
||||||
|
while (searchFrom < out.size()) {
|
||||||
|
const int open = out.indexOf(QLatin1String("${"), searchFrom);
|
||||||
|
if (open < 0)
|
||||||
|
break;
|
||||||
|
const int close = out.indexOf(QLatin1Char('}'), open + 2);
|
||||||
|
if (close < 0)
|
||||||
|
break;
|
||||||
|
const QString key = out.mid(open + 2, close - open - 2);
|
||||||
|
QString value;
|
||||||
|
if (slot && slot->cfg.env.contains(key))
|
||||||
|
value = slot->cfg.env.value(key);
|
||||||
|
else if (key == QLatin1String("PROVIDER_NAME") && slot)
|
||||||
|
value = slot->name;
|
||||||
|
else if (sys.contains(key))
|
||||||
|
value = sys.value(key);
|
||||||
|
out.replace(open, close - open + 1, value);
|
||||||
|
searchFrom = open + value.size();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ProviderLauncher::expandVars(const QString &input, const Slot *slot) const
|
||||||
|
{
|
||||||
|
if (!input.contains(QLatin1String("${")))
|
||||||
|
return input;
|
||||||
|
return expandOne(input, slot, QProcessEnvironment::systemEnvironment());
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList ProviderLauncher::expandVars(const QStringList &args, const Slot *slot) const
|
||||||
|
{
|
||||||
|
if (args.isEmpty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const QProcessEnvironment sys = QProcessEnvironment::systemEnvironment();
|
||||||
|
QStringList out;
|
||||||
|
out.reserve(args.size());
|
||||||
|
for (const QString &a : args)
|
||||||
|
out.append(expandOne(a, slot, sys));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
82
sources/providersConfig/ProviderLauncher.hpp
Normal file
82
sources/providersConfig/ProviderLauncher.hpp
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include "ProviderInstance.hpp"
|
||||||
|
|
||||||
|
class QNetworkAccessManager;
|
||||||
|
class QNetworkReply;
|
||||||
|
class QProcessEnvironment;
|
||||||
|
class QTimer;
|
||||||
|
|
||||||
|
namespace Utils { class Process; }
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
class ProviderLauncher : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_DISABLE_COPY_MOVE(ProviderLauncher)
|
||||||
|
public:
|
||||||
|
enum State : quint8 {
|
||||||
|
Idle, // no process running
|
||||||
|
Starting, // QProcess::start invoked, waiting for it to be alive
|
||||||
|
Probing, // process alive, polling ready_url
|
||||||
|
Ready, // ready probe succeeded (or no probe configured)
|
||||||
|
Stopping, // termination requested; waiting for QProcess to exit
|
||||||
|
Failed, // process exited unexpectedly or readiness probe gave up
|
||||||
|
};
|
||||||
|
Q_ENUM(State)
|
||||||
|
|
||||||
|
explicit ProviderLauncher(QObject *parent = nullptr);
|
||||||
|
~ProviderLauncher() override;
|
||||||
|
|
||||||
|
|
||||||
|
void start(const QString &instanceName, const LaunchConfig &cfg);
|
||||||
|
void stop(const QString &instanceName);
|
||||||
|
void restart(const QString &instanceName, const LaunchConfig &cfg);
|
||||||
|
|
||||||
|
[[nodiscard]] State state(const QString &instanceName) const;
|
||||||
|
[[nodiscard]] bool isReady(const QString &instanceName) const;
|
||||||
|
[[nodiscard]] QString lastError(const QString &instanceName) const;
|
||||||
|
[[nodiscard]] QByteArray scrollback(const QString &instanceName) const;
|
||||||
|
|
||||||
|
[[nodiscard]] QStringList activeInstances() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void stateChanged(const QString &instanceName, State newState);
|
||||||
|
void bytesReceived(const QString &instanceName, const QByteArray &chunk);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Slot;
|
||||||
|
void teardownSlot(Slot *slot);
|
||||||
|
void launchProcess(Slot *slot);
|
||||||
|
void launchDetached(Slot *slot);
|
||||||
|
void appendScrollback(Slot *slot, const QByteArray &chunk);
|
||||||
|
void scheduleReadyProbe(Slot *slot);
|
||||||
|
void runReadyProbe(Slot *slot);
|
||||||
|
void probeOnceAsync(Slot *slot, int expectedGeneration, const QString &url,
|
||||||
|
std::function<void(bool)> onResult);
|
||||||
|
void appendLog(Slot *slot, const QString &line);
|
||||||
|
void changeState(Slot *slot, State newState);
|
||||||
|
QString expandVars(const QString &input, const Slot *slot) const;
|
||||||
|
QStringList expandVars(const QStringList &args, const Slot *slot) const;
|
||||||
|
static QString expandOne(const QString &input, const Slot *slot,
|
||||||
|
const QProcessEnvironment &sys);
|
||||||
|
static void killByPid(qint64 pid);
|
||||||
|
|
||||||
|
QHash<QString, Slot *> m_slots;
|
||||||
|
QNetworkAccessManager *m_nam = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
75
sources/providersConfig/ProviderSecretsStore.cpp
Normal file
75
sources/providersConfig/ProviderSecretsStore.cpp
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ProviderSecretsStore.hpp"
|
||||||
|
|
||||||
|
#include <coreplugin/icore.h>
|
||||||
|
#include <utils/qtcsettings.h>
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr auto kGroup = "QodeAssist/Keychain";
|
||||||
|
|
||||||
|
Utils::Key settingsKey(const QString &ref)
|
||||||
|
{
|
||||||
|
return Utils::Key(QStringLiteral("%1/%2")
|
||||||
|
.arg(QLatin1StringView(kGroup))
|
||||||
|
.arg(ref)
|
||||||
|
.toUtf8());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ProviderSecretsStore::ProviderSecretsStore(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{}
|
||||||
|
|
||||||
|
ProviderSecretsStore::~ProviderSecretsStore() = default;
|
||||||
|
|
||||||
|
QString ProviderSecretsStore::readKeySync(const QString &ref) const
|
||||||
|
{
|
||||||
|
if (ref.isEmpty())
|
||||||
|
return {};
|
||||||
|
auto *s = Core::ICore::settings();
|
||||||
|
if (!s)
|
||||||
|
return {};
|
||||||
|
return s->value(settingsKey(ref)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderSecretsStore::writeKey(const QString &ref, const QString &value)
|
||||||
|
{
|
||||||
|
if (ref.isEmpty())
|
||||||
|
return;
|
||||||
|
auto *s = Core::ICore::settings();
|
||||||
|
if (!s)
|
||||||
|
return;
|
||||||
|
s->setValue(settingsKey(ref), value);
|
||||||
|
s->sync();
|
||||||
|
emit keyChanged(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProviderSecretsStore::eraseKey(const QString &ref)
|
||||||
|
{
|
||||||
|
if (ref.isEmpty())
|
||||||
|
return;
|
||||||
|
auto *s = Core::ICore::settings();
|
||||||
|
if (!s)
|
||||||
|
return;
|
||||||
|
s->remove(settingsKey(ref));
|
||||||
|
s->sync();
|
||||||
|
emit keyChanged(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ProviderSecretsStore::hasKey(const QString &ref) const
|
||||||
|
{
|
||||||
|
if (ref.isEmpty())
|
||||||
|
return false;
|
||||||
|
auto *s = Core::ICore::settings();
|
||||||
|
if (!s)
|
||||||
|
return false;
|
||||||
|
return s->contains(settingsKey(ref));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
30
sources/providersConfig/ProviderSecretsStore.hpp
Normal file
30
sources/providersConfig/ProviderSecretsStore.hpp
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace QodeAssist::Providers {
|
||||||
|
|
||||||
|
class ProviderSecretsStore : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_DISABLE_COPY_MOVE(ProviderSecretsStore)
|
||||||
|
public:
|
||||||
|
explicit ProviderSecretsStore(QObject *parent = nullptr);
|
||||||
|
~ProviderSecretsStore() override;
|
||||||
|
|
||||||
|
[[nodiscard]] QString readKeySync(const QString &ref) const;
|
||||||
|
|
||||||
|
void writeKey(const QString &ref, const QString &value);
|
||||||
|
void eraseKey(const QString &ref);
|
||||||
|
|
||||||
|
[[nodiscard]] bool hasKey(const QString &ref) const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void keyChanged(const QString &ref);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Providers
|
||||||
8
sources/providersConfig/claude.toml
Normal file
8
sources/providersConfig/claude.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "Claude"
|
||||||
|
client_api = "Claude"
|
||||||
|
description = "Anthropic's hosted Claude API."
|
||||||
|
|
||||||
|
url = "https://api.anthropic.com"
|
||||||
|
api_key_ref = "qodeassist/providers/Claude"
|
||||||
8
sources/providersConfig/codestral.toml
Normal file
8
sources/providersConfig/codestral.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "Codestral"
|
||||||
|
client_api = "Codestral"
|
||||||
|
description = "Mistral's Codestral FIM-capable code model API."
|
||||||
|
|
||||||
|
url = "https://codestral.mistral.ai"
|
||||||
|
api_key_ref = "qodeassist/providers/Codestral"
|
||||||
8
sources/providersConfig/googleai.toml
Normal file
8
sources/providersConfig/googleai.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "Google AI"
|
||||||
|
client_api = "Google AI"
|
||||||
|
description = "Google AI Studio (Gemini) hosted API."
|
||||||
|
|
||||||
|
url = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
api_key_ref = "qodeassist/providers/Google AI"
|
||||||
8
sources/providersConfig/llamacpp.toml
Normal file
8
sources/providersConfig/llamacpp.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "llama.cpp"
|
||||||
|
client_api = "llama.cpp"
|
||||||
|
description = "Local llama.cpp server (llama-server)."
|
||||||
|
|
||||||
|
url = "http://localhost:8080"
|
||||||
|
api_key_ref = "qodeassist/providers/llama.cpp"
|
||||||
8
sources/providersConfig/lmstudio_chat.toml
Normal file
8
sources/providersConfig/lmstudio_chat.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "LM Studio (Chat Completions)"
|
||||||
|
client_api = "LM Studio (Chat Completions)"
|
||||||
|
description = "Local LM Studio server over the /v1/chat/completions endpoint."
|
||||||
|
|
||||||
|
url = "http://localhost:1234"
|
||||||
|
api_key_ref = "qodeassist/providers/LM Studio (Chat Completions)"
|
||||||
8
sources/providersConfig/lmstudio_responses.toml
Normal file
8
sources/providersConfig/lmstudio_responses.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "LM Studio (Responses API)"
|
||||||
|
client_api = "LM Studio (Responses API)"
|
||||||
|
description = "Local LM Studio server over the /v1/responses endpoint."
|
||||||
|
|
||||||
|
url = "http://localhost:1234"
|
||||||
|
api_key_ref = "qodeassist/providers/LM Studio (Responses API)"
|
||||||
8
sources/providersConfig/mistral.toml
Normal file
8
sources/providersConfig/mistral.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "Mistral AI"
|
||||||
|
client_api = "Mistral AI"
|
||||||
|
description = "Mistral's hosted chat / completions API."
|
||||||
|
|
||||||
|
url = "https://api.mistral.ai"
|
||||||
|
api_key_ref = "qodeassist/providers/Mistral AI"
|
||||||
8
sources/providersConfig/ollama_compat.toml
Normal file
8
sources/providersConfig/ollama_compat.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "Ollama (OpenAI-compatible)"
|
||||||
|
client_api = "Ollama (OpenAI-compatible)"
|
||||||
|
description = "Local Ollama daemon spoken to via the OpenAI-compatible /v1 routes."
|
||||||
|
|
||||||
|
url = "http://localhost:11434"
|
||||||
|
api_key_ref = "qodeassist/providers/Ollama (OpenAI-compatible)"
|
||||||
8
sources/providersConfig/ollama_native.toml
Normal file
8
sources/providersConfig/ollama_native.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "Ollama (Native)"
|
||||||
|
client_api = "Ollama (Native)"
|
||||||
|
description = "Default local Ollama daemon over its native /api/* endpoints."
|
||||||
|
|
||||||
|
url = "http://localhost:11434"
|
||||||
|
api_key_ref = "qodeassist/providers/Ollama (Native)"
|
||||||
8
sources/providersConfig/openai_chat.toml
Normal file
8
sources/providersConfig/openai_chat.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "OpenAI (Chat Completions)"
|
||||||
|
client_api = "OpenAI (Chat Completions)"
|
||||||
|
description = "OpenAI's hosted /v1/chat/completions endpoint."
|
||||||
|
|
||||||
|
url = "https://api.openai.com/v1"
|
||||||
|
api_key_ref = "qodeassist/providers/OpenAI (Chat Completions)"
|
||||||
8
sources/providersConfig/openai_compat.toml
Normal file
8
sources/providersConfig/openai_compat.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "OpenAI Compatible"
|
||||||
|
client_api = "OpenAI Compatible"
|
||||||
|
description = "Self-hosted OpenAI-compatible server (vLLM, TGI, ...). Edit the URL to match your deployment."
|
||||||
|
|
||||||
|
url = "http://localhost:1234/v1"
|
||||||
|
api_key_ref = "qodeassist/providers/OpenAI Compatible"
|
||||||
8
sources/providersConfig/openai_responses.toml
Normal file
8
sources/providersConfig/openai_responses.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "OpenAI (Responses API)"
|
||||||
|
client_api = "OpenAI (Responses API)"
|
||||||
|
description = "OpenAI's hosted /v1/responses endpoint."
|
||||||
|
|
||||||
|
url = "https://api.openai.com/v1"
|
||||||
|
api_key_ref = "qodeassist/providers/OpenAI (Responses API)"
|
||||||
8
sources/providersConfig/openrouter.toml
Normal file
8
sources/providersConfig/openrouter.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
name = "OpenRouter"
|
||||||
|
client_api = "OpenRouter"
|
||||||
|
description = "OpenRouter aggregator (https://openrouter.ai)."
|
||||||
|
|
||||||
|
url = "https://openrouter.ai/api"
|
||||||
|
api_key_ref = "qodeassist/providers/OpenRouter"
|
||||||
17
sources/providersConfig/provider_instances.qrc
Normal file
17
sources/providersConfig/provider_instances.qrc
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/provider-instances">
|
||||||
|
<file>ollama_native.toml</file>
|
||||||
|
<file>ollama_compat.toml</file>
|
||||||
|
<file>claude.toml</file>
|
||||||
|
<file>openai_chat.toml</file>
|
||||||
|
<file>openai_responses.toml</file>
|
||||||
|
<file>openai_compat.toml</file>
|
||||||
|
<file>lmstudio_chat.toml</file>
|
||||||
|
<file>lmstudio_responses.toml</file>
|
||||||
|
<file>openrouter.toml</file>
|
||||||
|
<file>mistral.toml</file>
|
||||||
|
<file>codestral.toml</file>
|
||||||
|
<file>googleai.toml</file>
|
||||||
|
<file>llamacpp.toml</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
12
sources/tomlSerializer/CMakeLists.txt
Normal file
12
sources/tomlSerializer/CMakeLists.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
add_library(TomlSerializer STATIC
|
||||||
|
TomlWriter.hpp TomlWriter.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(TomlSerializer
|
||||||
|
PUBLIC
|
||||||
|
Qt::Core
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(TomlSerializer
|
||||||
|
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
)
|
||||||
135
sources/tomlSerializer/TomlWriter.cpp
Normal file
135
sources/tomlSerializer/TomlWriter.cpp
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "TomlWriter.hpp"
|
||||||
|
|
||||||
|
#include <QJsonValue>
|
||||||
|
|
||||||
|
namespace QodeAssist::TomlSerializer {
|
||||||
|
|
||||||
|
QString escapeBasic(const QString &s)
|
||||||
|
{
|
||||||
|
QString out;
|
||||||
|
out.reserve(s.size());
|
||||||
|
for (QChar c : s) {
|
||||||
|
const ushort u = c.unicode();
|
||||||
|
switch (u) {
|
||||||
|
case '\\': out += QLatin1String("\\\\"); break;
|
||||||
|
case '"': out += QLatin1String("\\\""); break;
|
||||||
|
case '\b': out += QLatin1String("\\b"); break;
|
||||||
|
case '\t': out += QLatin1String("\\t"); break;
|
||||||
|
case '\n': out += QLatin1String("\\n"); break;
|
||||||
|
case '\f': out += QLatin1String("\\f"); break;
|
||||||
|
case '\r': out += QLatin1String("\\r"); break;
|
||||||
|
default:
|
||||||
|
if (u < 0x20 || u == 0x7f)
|
||||||
|
out += QStringLiteral("\\u%1").arg(u, 4, 16, QLatin1Char('0'));
|
||||||
|
else
|
||||||
|
out += c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TomlWriter::writeBlankLine()
|
||||||
|
{
|
||||||
|
m_out += QLatin1Char('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
void TomlWriter::writeComment(const QString &line)
|
||||||
|
{
|
||||||
|
m_out += QLatin1String("# ");
|
||||||
|
m_out += line;
|
||||||
|
m_out += QLatin1Char('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
void TomlWriter::writeTableHeader(const QString &name)
|
||||||
|
{
|
||||||
|
m_out += QLatin1Char('[');
|
||||||
|
m_out += name;
|
||||||
|
m_out += QLatin1String("]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TomlWriter::writeKeyPrefix(const QString &key)
|
||||||
|
{
|
||||||
|
m_out += key;
|
||||||
|
if (m_keyColumnWidth > key.size())
|
||||||
|
m_out += QString(m_keyColumnWidth - key.size(), QLatin1Char(' '));
|
||||||
|
m_out += QLatin1String(" = ");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TomlWriter::writeString(const QString &key, const QString &value)
|
||||||
|
{
|
||||||
|
writeKeyPrefix(key);
|
||||||
|
m_out += QLatin1Char('"');
|
||||||
|
m_out += escapeBasic(value);
|
||||||
|
m_out += QLatin1String("\"\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TomlWriter::writeBool(const QString &key, bool value)
|
||||||
|
{
|
||||||
|
writeKeyPrefix(key);
|
||||||
|
m_out += value ? QLatin1String("true") : QLatin1String("false");
|
||||||
|
m_out += QLatin1Char('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
void TomlWriter::writeInt(const QString &key, qint64 value)
|
||||||
|
{
|
||||||
|
writeKeyPrefix(key);
|
||||||
|
m_out += QString::number(value);
|
||||||
|
m_out += QLatin1Char('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
void TomlWriter::writeDouble(const QString &key, double value)
|
||||||
|
{
|
||||||
|
writeKeyPrefix(key);
|
||||||
|
m_out += QString::number(value);
|
||||||
|
m_out += QLatin1Char('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
void TomlWriter::writeStringArray(const QString &key, const QStringList &values)
|
||||||
|
{
|
||||||
|
writeKeyPrefix(key);
|
||||||
|
m_out += QLatin1Char('[');
|
||||||
|
bool first = true;
|
||||||
|
for (const QString &v : values) {
|
||||||
|
if (!first)
|
||||||
|
m_out += QLatin1String(", ");
|
||||||
|
m_out += QLatin1Char('"');
|
||||||
|
m_out += escapeBasic(v);
|
||||||
|
m_out += QLatin1Char('"');
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
m_out += QLatin1String("]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TomlWriter::writeJsonPrimitives(const QJsonObject &obj)
|
||||||
|
{
|
||||||
|
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) {
|
||||||
|
const QJsonValue &v = it.value();
|
||||||
|
switch (v.type()) {
|
||||||
|
case QJsonValue::String: writeString(it.key(), v.toString()); break;
|
||||||
|
case QJsonValue::Bool: writeBool(it.key(), v.toBool()); break;
|
||||||
|
case QJsonValue::Double: {
|
||||||
|
const double d = v.toDouble();
|
||||||
|
const qint64 i = static_cast<qint64>(d);
|
||||||
|
if (static_cast<double>(i) == d)
|
||||||
|
writeInt(it.key(), i);
|
||||||
|
else
|
||||||
|
writeDouble(it.key(), d);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TomlWriter::writeStringDict(const QHash<QString, QString> &dict)
|
||||||
|
{
|
||||||
|
for (auto it = dict.constBegin(); it != dict.constEnd(); ++it)
|
||||||
|
writeString(it.key(), it.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::TomlSerializer
|
||||||
47
sources/tomlSerializer/TomlWriter.hpp
Normal file
47
sources/tomlSerializer/TomlWriter.hpp
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
namespace QodeAssist::TomlSerializer {
|
||||||
|
|
||||||
|
[[nodiscard]] QString escapeBasic(const QString &s);
|
||||||
|
|
||||||
|
class TomlWriter
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
TomlWriter() = default;
|
||||||
|
explicit TomlWriter(int keyColumnWidth) : m_keyColumnWidth(keyColumnWidth) {}
|
||||||
|
|
||||||
|
void setKeyColumnWidth(int width) { m_keyColumnWidth = width; }
|
||||||
|
|
||||||
|
void writeBlankLine();
|
||||||
|
void writeComment(const QString &line); // "# line\n"
|
||||||
|
void writeTableHeader(const QString &name); // "[name]\n"
|
||||||
|
|
||||||
|
void writeString(const QString &key, const QString &value);
|
||||||
|
void writeBool(const QString &key, bool value);
|
||||||
|
void writeInt(const QString &key, qint64 value);
|
||||||
|
void writeDouble(const QString &key, double value);
|
||||||
|
void writeStringArray(const QString &key, const QStringList &values);
|
||||||
|
|
||||||
|
void writeJsonPrimitives(const QJsonObject &obj);
|
||||||
|
|
||||||
|
void writeStringDict(const QHash<QString, QString> &dict);
|
||||||
|
|
||||||
|
[[nodiscard]] QString result() const { return m_out; }
|
||||||
|
[[nodiscard]] QByteArray toUtf8() const { return m_out.toUtf8(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void writeKeyPrefix(const QString &key);
|
||||||
|
|
||||||
|
QString m_out;
|
||||||
|
int m_keyColumnWidth = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::TomlSerializer
|
||||||
Reference in New Issue
Block a user