mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-05-30 02:49:12 -04:00
feat: Add agents pipelines
This commit is contained in:
273
sources/settings/AgentPipelinesPage.cpp
Normal file
273
sources/settings/AgentPipelinesPage.cpp
Normal file
@@ -0,0 +1,273 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "AgentPipelinesPage.hpp"
|
||||
|
||||
#include <coreplugin/dialogs/ioptionspage.h>
|
||||
#include <coreplugin/icore.h>
|
||||
|
||||
#include <QColor>
|
||||
#include <QFont>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "../../Version.hpp"
|
||||
#include "AgentRosterWidget.hpp"
|
||||
#include "AgentsSettingsPage.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include "PipelinesConfig.hpp"
|
||||
#include "SettingsConstants.hpp"
|
||||
#include "SettingsTr.hpp"
|
||||
|
||||
#include <AgentFactory.hpp>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
AgentPipelinesPageNavigator::AgentPipelinesPageNavigator(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kSaveDebounceMs = 300;
|
||||
|
||||
struct SlotMeta
|
||||
{
|
||||
const char *title;
|
||||
const char *hint;
|
||||
};
|
||||
|
||||
const SlotMeta kSlotMeta[] = {
|
||||
{TrConstants::CODE_COMPLETION, TrConstants::SLOT_HINT_CODE_COMPLETION},
|
||||
{TrConstants::CHAT_ASSISTANT, TrConstants::SLOT_HINT_CHAT_ASSISTANT},
|
||||
{TrConstants::CHAT_COMPRESSION, TrConstants::SLOT_HINT_CHAT_COMPRESSION},
|
||||
{TrConstants::QUICK_REFACTOR, TrConstants::SLOT_HINT_QUICK_REFACTOR},
|
||||
};
|
||||
|
||||
class AgentPipelinesPageWidget : public Core::IOptionsPageWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY_MOVE(AgentPipelinesPageWidget)
|
||||
public:
|
||||
AgentPipelinesPageWidget(
|
||||
const QPointer<AgentFactory> &agentFactory,
|
||||
const QPointer<AgentPipelinesPageNavigator> &navigator,
|
||||
const QPointer<AgentsPageNavigator> &agentsNavigator)
|
||||
: m_agentFactory(agentFactory)
|
||||
, m_navigator(navigator)
|
||||
, m_agentsNavigator(agentsNavigator)
|
||||
{
|
||||
m_titleLabel = new QLabel(Tr::tr(TrConstants::AGENT_PIPELINES), this);
|
||||
QFont tf = m_titleLabel->font();
|
||||
tf.setBold(true);
|
||||
tf.setPixelSize(13);
|
||||
m_titleLabel->setFont(tf);
|
||||
|
||||
m_resetBtn = new QPushButton(Tr::tr(TrConstants::RESET_TO_DEFAULTS), this);
|
||||
|
||||
auto *headerRow = new QHBoxLayout;
|
||||
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||
headerRow->setSpacing(8);
|
||||
headerRow->addWidget(m_titleLabel);
|
||||
headerRow->addStretch(1);
|
||||
headerRow->addWidget(m_resetBtn);
|
||||
|
||||
auto *headerSep = new QFrame(this);
|
||||
headerSep->setFrameShape(QFrame::HLine);
|
||||
headerSep->setFrameShadow(QFrame::Sunken);
|
||||
|
||||
m_rosters[0] = new AgentRosterWidget(this);
|
||||
m_rosters[1] = new AgentRosterWidget(this);
|
||||
m_rosters[2] = new AgentRosterWidget(this);
|
||||
m_rosters[3] = new AgentRosterWidget(this);
|
||||
|
||||
for (int i = 0; i < kRosterCount; ++i)
|
||||
m_rosters[i]->setSlot(Tr::tr(kSlotMeta[i].title), Tr::tr(kSlotMeta[i].hint), {});
|
||||
|
||||
auto *content = new QWidget(this);
|
||||
auto *contentLay = new QVBoxLayout(content);
|
||||
contentLay->setContentsMargins(0, 0, 0, 0);
|
||||
contentLay->setSpacing(12);
|
||||
for (int i = 0; i < kRosterCount; ++i)
|
||||
contentLay->addWidget(m_rosters[i]);
|
||||
contentLay->addStretch(1);
|
||||
|
||||
auto *scroll = new QScrollArea(this);
|
||||
scroll->setWidgetResizable(true);
|
||||
scroll->setFrameShape(QFrame::NoFrame);
|
||||
scroll->setWidget(content);
|
||||
|
||||
auto *root = new QVBoxLayout(this);
|
||||
root->setContentsMargins(8, 8, 8, 8);
|
||||
root->setSpacing(6);
|
||||
root->addLayout(headerRow);
|
||||
root->addWidget(headerSep);
|
||||
root->addWidget(scroll, 1);
|
||||
|
||||
m_saveDebounce = new QTimer(this);
|
||||
m_saveDebounce->setSingleShot(true);
|
||||
m_saveDebounce->setInterval(kSaveDebounceMs);
|
||||
connect(m_saveDebounce, &QTimer::timeout, this, [this]() { persistRosters(); });
|
||||
|
||||
loadFromSettings();
|
||||
|
||||
connect(m_resetBtn, &QPushButton::clicked, this, &AgentPipelinesPageWidget::onReset);
|
||||
|
||||
for (int i = 0; i < kRosterCount; ++i) {
|
||||
connect(m_rosters[i], &AgentRosterWidget::editAgentRequested, this,
|
||||
&AgentPipelinesPageWidget::onEditAgent);
|
||||
connect(m_rosters[i], &AgentRosterWidget::rosterChanged, this,
|
||||
[this](const QStringList &) { m_saveDebounce->start(); });
|
||||
}
|
||||
}
|
||||
|
||||
~AgentPipelinesPageWidget() override
|
||||
{
|
||||
if (m_saveDebounce && m_saveDebounce->isActive()) {
|
||||
m_saveDebounce->stop();
|
||||
persistRosters();
|
||||
}
|
||||
}
|
||||
|
||||
void apply() final
|
||||
{
|
||||
if (m_saveDebounce && m_saveDebounce->isActive())
|
||||
m_saveDebounce->stop();
|
||||
persistRosters();
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr int kRosterCount = 4;
|
||||
|
||||
void persistRosters()
|
||||
{
|
||||
PipelineRosters rosters;
|
||||
rosters.codeCompletion = m_rosters[0]->roster();
|
||||
rosters.chatAssistant = m_rosters[1]->roster();
|
||||
rosters.chatCompression = m_rosters[2]->roster();
|
||||
rosters.quickRefactor = m_rosters[3]->roster();
|
||||
QString err;
|
||||
if (!PipelinesConfig::save(rosters, &err)) {
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] save failed (%1): %2")
|
||||
.arg(PipelinesConfig::filePath(), err));
|
||||
if (!m_saveErrorShown) {
|
||||
m_saveErrorShown = true;
|
||||
QMessageBox::warning(
|
||||
Core::ICore::dialogParent(),
|
||||
Tr::tr(TrConstants::AGENT_PIPELINES),
|
||||
tr("Failed to save pipelines.toml:\n%1\n\n"
|
||||
"Further save failures will only be logged.")
|
||||
.arg(err));
|
||||
}
|
||||
} else {
|
||||
m_saveErrorShown = false;
|
||||
}
|
||||
}
|
||||
|
||||
void onReset()
|
||||
{
|
||||
const auto reply = QMessageBox::question(
|
||||
Core::ICore::dialogParent(),
|
||||
Tr::tr(TrConstants::RESET_SETTINGS),
|
||||
Tr::tr(TrConstants::CONFIRMATION),
|
||||
QMessageBox::Yes | QMessageBox::No);
|
||||
|
||||
if (reply != QMessageBox::Yes)
|
||||
return;
|
||||
|
||||
QString err;
|
||||
if (!PipelinesConfig::save(PipelineRosters::defaults(), &err))
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] failed to reset rosters: %1").arg(err));
|
||||
|
||||
m_saveErrorShown = false;
|
||||
loadFromSettings();
|
||||
}
|
||||
|
||||
void onEditAgent(const QString &name)
|
||||
{
|
||||
if (m_agentsNavigator)
|
||||
m_agentsNavigator->requestSelectAgent(name);
|
||||
if (m_navigator)
|
||||
emit m_navigator->editAgentRequested(name);
|
||||
|
||||
#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 83)
|
||||
Core::ICore::showSettings(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID);
|
||||
#else
|
||||
Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID);
|
||||
#endif
|
||||
}
|
||||
|
||||
void loadFromSettings()
|
||||
{
|
||||
const PipelinesLoadResult lr = PipelinesConfig::load();
|
||||
if (lr.status == PipelinesLoadStatus::ParseError
|
||||
|| lr.status == PipelinesLoadStatus::SchemaError) {
|
||||
QMessageBox::warning(
|
||||
Core::ICore::dialogParent(),
|
||||
Tr::tr(TrConstants::AGENT_PIPELINES),
|
||||
tr("pipelines.toml has issues — using defaults for affected entries:\n%1\n\n"
|
||||
"Click OK to continue. Changes you make here will overwrite the file.")
|
||||
.arg(lr.message));
|
||||
}
|
||||
|
||||
AgentFactory *factory = m_agentFactory.data();
|
||||
m_rosters[0]->setRoster(lr.rosters.codeCompletion, factory);
|
||||
m_rosters[1]->setRoster(lr.rosters.chatAssistant, factory);
|
||||
m_rosters[2]->setRoster(lr.rosters.chatCompression, factory);
|
||||
m_rosters[3]->setRoster(lr.rosters.quickRefactor, factory);
|
||||
}
|
||||
|
||||
QPointer<AgentFactory> m_agentFactory;
|
||||
QPointer<AgentPipelinesPageNavigator> m_navigator;
|
||||
QPointer<AgentsPageNavigator> m_agentsNavigator;
|
||||
|
||||
QLabel *m_titleLabel = nullptr;
|
||||
QPushButton *m_resetBtn = nullptr;
|
||||
|
||||
AgentRosterWidget *m_rosters[kRosterCount] = {};
|
||||
|
||||
QTimer *m_saveDebounce = nullptr;
|
||||
bool m_saveErrorShown = false;
|
||||
};
|
||||
|
||||
class AgentPipelinesOptionsPage final : public Core::IOptionsPage
|
||||
{
|
||||
public:
|
||||
AgentPipelinesOptionsPage(
|
||||
AgentFactory *agentFactory,
|
||||
AgentPipelinesPageNavigator *navigator,
|
||||
AgentsPageNavigator *agentsNavigator)
|
||||
{
|
||||
setId(Constants::QODE_ASSIST_AGENT_PIPELINES_PAGE_ID);
|
||||
setDisplayName(Tr::tr(TrConstants::AGENT_PIPELINES));
|
||||
setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY);
|
||||
const QPointer<AgentFactory> factoryPtr(agentFactory);
|
||||
const QPointer<AgentPipelinesPageNavigator> navPtr(navigator);
|
||||
const QPointer<AgentsPageNavigator> agentsNavPtr(agentsNavigator);
|
||||
setWidgetCreator([factoryPtr, navPtr, agentsNavPtr] {
|
||||
return new AgentPipelinesPageWidget(factoryPtr, navPtr, agentsNavPtr);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<Core::IOptionsPage> createAgentPipelinesSettingsPage(
|
||||
AgentFactory *agentFactory,
|
||||
AgentPipelinesPageNavigator *navigator,
|
||||
AgentsPageNavigator *agentsNavigator)
|
||||
{
|
||||
return std::make_unique<AgentPipelinesOptionsPage>(
|
||||
agentFactory, navigator, agentsNavigator);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
|
||||
#include "AgentPipelinesPage.moc"
|
||||
39
sources/settings/AgentPipelinesPage.hpp
Normal file
39
sources/settings/AgentPipelinesPage.hpp
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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 {
|
||||
class AgentFactory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
class AgentsPageNavigator;
|
||||
|
||||
class AgentPipelinesPageNavigator : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY_MOVE(AgentPipelinesPageNavigator)
|
||||
public:
|
||||
explicit AgentPipelinesPageNavigator(QObject *parent = nullptr);
|
||||
|
||||
signals:
|
||||
void editAgentRequested(const QString &agentName);
|
||||
};
|
||||
|
||||
std::unique_ptr<Core::IOptionsPage> createAgentPipelinesSettingsPage(
|
||||
AgentFactory *agentFactory,
|
||||
AgentPipelinesPageNavigator *navigator,
|
||||
AgentsPageNavigator *agentsNavigator);
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
636
sources/settings/AgentRosterWidget.cpp
Normal file
636
sources/settings/AgentRosterWidget.cpp
Normal file
@@ -0,0 +1,636 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "AgentRosterWidget.hpp"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QColor>
|
||||
#include <QFontDatabase>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QStyle>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <coreplugin/icore.h>
|
||||
|
||||
#include <Agent.hpp>
|
||||
#include <AgentConfig.hpp>
|
||||
#include <AgentFactory.hpp>
|
||||
#include <AgentRouter.hpp>
|
||||
|
||||
#include "AgentSelectionDialog.hpp"
|
||||
#include "SettingsTr.hpp"
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
namespace {
|
||||
|
||||
enum class PillKind {
|
||||
Template,
|
||||
On, // capability on (thinking/tools)
|
||||
Off, // capability off ("plain")
|
||||
User, // user-defined agent
|
||||
Active, // ✓ active
|
||||
Match, // matched-this-row chip background
|
||||
Tag, // free-form discoverability tag from AgentConfig::tags
|
||||
Neutral,
|
||||
};
|
||||
|
||||
struct Theme
|
||||
{
|
||||
bool dark = false;
|
||||
|
||||
QColor pageBg;
|
||||
QColor cardBg;
|
||||
QColor cardBorder;
|
||||
QColor groupBorder;
|
||||
QColor rowSeparator;
|
||||
QColor rowMatchBg;
|
||||
QColor listHeader;
|
||||
|
||||
QColor text;
|
||||
QColor textSoft;
|
||||
QColor textMute;
|
||||
QColor textFaint;
|
||||
|
||||
QColor matchChipBg;
|
||||
QColor matchChipBorder;
|
||||
QColor matchChipText;
|
||||
|
||||
QColor codeBg;
|
||||
QColor codeBorder;
|
||||
|
||||
struct Pill
|
||||
{
|
||||
QColor bg, fg, border;
|
||||
};
|
||||
Pill pill(PillKind k) const;
|
||||
};
|
||||
|
||||
Theme::Pill Theme::pill(PillKind k) const
|
||||
{
|
||||
if (dark) {
|
||||
switch (k) {
|
||||
case PillKind::Template: return {{0x2c, 0x3f, 0x5a}, {0xcf, 0xe1, 0xf7}, {0x4a, 0x62, 0x86}};
|
||||
case PillKind::On: return {{0x2f, 0x45, 0x30}, {0xbc, 0xe0, 0xbd}, {0x4a, 0x6c, 0x4b}};
|
||||
case PillKind::Off: return {{0x3a, 0x3a, 0x3a}, {0x8a, 0x8a, 0x8a}, {0x4a, 0x4a, 0x4a}};
|
||||
case PillKind::User: return {{0x4a, 0x3f, 0x24}, {0xe6, 0xcd, 0x92}, {0x6a, 0x5a, 0x30}};
|
||||
case PillKind::Active: return {{0x3a, 0x6a, 0x28}, {0xff, 0xff, 0xff}, {0x4a, 0x80, 0x38}};
|
||||
case PillKind::Match: return {{0x27, 0x38, 0x4a}, {0xbd, 0xd7, 0xee}, {0x3f, 0x5a, 0x78}};
|
||||
case PillKind::Tag: return {{0x2e, 0x2e, 0x3a}, {0xb9, 0xb9, 0xcf}, {0x46, 0x46, 0x5a}};
|
||||
case PillKind::Neutral: return {{0x3a, 0x3a, 0x3a}, {0xcf, 0xcf, 0xcf}, {0x4a, 0x4a, 0x4a}};
|
||||
}
|
||||
} else {
|
||||
switch (k) {
|
||||
case PillKind::Template: return {{0xdb, 0xe7, 0xf6}, {0x1f, 0x3f, 0x73}, {0xa8, 0xc1, 0xe0}};
|
||||
case PillKind::On: return {{0xdb, 0xe9, 0xd3}, {0x2c, 0x5a, 0x1c}, {0xa3, 0xbc, 0x97}};
|
||||
case PillKind::Off: return {{0xec, 0xec, 0xec}, {0x7a, 0x7a, 0x7a}, {0xc8, 0xc8, 0xc8}};
|
||||
case PillKind::User: return {{0xf0, 0xe4, 0xcf}, {0x75, 0x54, 0x1a}, {0xcd, 0xb9, 0x8a}};
|
||||
case PillKind::Active: return {{0x2c, 0x5a, 0x1c}, {0xff, 0xff, 0xff}, {0x2c, 0x5a, 0x1c}};
|
||||
case PillKind::Match: return {{0xd6, 0xe8, 0xf7}, {0x1a, 0x4a, 0x7a}, {0x8a, 0xb1, 0xd5}};
|
||||
case PillKind::Tag: return {{0xe7, 0xe7, 0xf2}, {0x46, 0x46, 0x6e}, {0xc1, 0xc1, 0xd5}};
|
||||
case PillKind::Neutral: return {{0xe3, 0xe3, 0xe3}, {0x3a, 0x3a, 0x3a}, {0xbc, 0xbc, 0xbc}};
|
||||
}
|
||||
}
|
||||
return {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}};
|
||||
}
|
||||
|
||||
Theme themeFor(const QWidget *w)
|
||||
{
|
||||
const QPalette pal = w ? w->palette() : QApplication::palette();
|
||||
const bool dark = pal.color(QPalette::Window).lightness() < 128;
|
||||
Theme t;
|
||||
t.dark = dark;
|
||||
if (dark) {
|
||||
t.pageBg = {0x2b, 0x2b, 0x2b};
|
||||
t.cardBg = {0x2f, 0x2f, 0x2f};
|
||||
t.cardBorder = {0x4a, 0x4a, 0x4a};
|
||||
t.groupBorder = {0x4a, 0x4a, 0x4a};
|
||||
t.rowSeparator = {0x3a, 0x3a, 0x3a};
|
||||
t.rowMatchBg = {0x2d, 0x3d, 0x24};
|
||||
t.listHeader = {0x25, 0x25, 0x25};
|
||||
t.text = {0xe6, 0xe6, 0xe6};
|
||||
t.textSoft = {0xc2, 0xc2, 0xc2};
|
||||
t.textMute = {0x9a, 0x9a, 0x9a};
|
||||
t.textFaint = {0x7a, 0x7a, 0x7a};
|
||||
t.matchChipBg = {0x26, 0x26, 0x26};
|
||||
t.matchChipBorder = {0x3a, 0x3a, 0x3a};
|
||||
t.matchChipText = {0xc2, 0xc2, 0xc2};
|
||||
t.codeBg = {0x26, 0x26, 0x26};
|
||||
t.codeBorder = {0x3a, 0x3a, 0x3a};
|
||||
} else {
|
||||
t.pageBg = {0xed, 0xed, 0xed};
|
||||
t.cardBg = {0xf8, 0xf8, 0xf8};
|
||||
t.cardBorder = {0xbd, 0xbd, 0xbd};
|
||||
t.groupBorder = {0xb8, 0xb8, 0xb8};
|
||||
t.rowSeparator = {0xe0, 0xe0, 0xe0};
|
||||
t.rowMatchBg = {0xe8, 0xf3, 0xdf};
|
||||
t.listHeader = {0xe8, 0xe8, 0xe8};
|
||||
t.text = {0x1c, 0x1c, 0x1c};
|
||||
t.textSoft = {0x3a, 0x3a, 0x3a};
|
||||
t.textMute = {0x5a, 0x5a, 0x5a};
|
||||
t.textFaint = {0x8a, 0x8a, 0x8a};
|
||||
t.matchChipBg = {0xea, 0xea, 0xea};
|
||||
t.matchChipBorder = {0xcf, 0xcf, 0xcf};
|
||||
t.matchChipText = {0x3a, 0x3a, 0x3a};
|
||||
t.codeBg = {0xea, 0xea, 0xea};
|
||||
t.codeBorder = {0xcf, 0xcf, 0xcf};
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
QString hex(const QColor &c)
|
||||
{
|
||||
return c.name(QColor::HexRgb);
|
||||
}
|
||||
|
||||
QString pillStyle(const Theme &t, PillKind k)
|
||||
{
|
||||
const auto p = t.pill(k);
|
||||
return QStringLiteral(
|
||||
"background:%1; color:%2; border:1px solid %3;"
|
||||
"padding:0px 5px; border-radius:2px;"
|
||||
"font-size:10px;")
|
||||
.arg(hex(p.bg), hex(p.fg), hex(p.border));
|
||||
}
|
||||
|
||||
QLabel *makePill(const QString &text, PillKind kind, const Theme &t, QWidget *parent = nullptr)
|
||||
{
|
||||
auto *l = new QLabel(text, parent);
|
||||
l->setStyleSheet(pillStyle(t, kind));
|
||||
l->setAlignment(Qt::AlignCenter);
|
||||
return l;
|
||||
}
|
||||
|
||||
struct MatchSummary
|
||||
{
|
||||
QString icon;
|
||||
QString kind;
|
||||
QString value;
|
||||
};
|
||||
|
||||
MatchSummary summarise(const AgentConfig::Match &m)
|
||||
{
|
||||
if (m.isEmpty())
|
||||
return {QStringLiteral("·"), AgentRosterWidget::tr("fallback"),
|
||||
AgentRosterWidget::tr("any file")};
|
||||
if (!m.filePatterns.isEmpty())
|
||||
return {QStringLiteral("*"), AgentRosterWidget::tr("path"),
|
||||
m.filePatterns.join(QStringLiteral(", "))};
|
||||
if (!m.pathPatterns.isEmpty())
|
||||
return {QStringLiteral("*"), AgentRosterWidget::tr("path"),
|
||||
m.pathPatterns.join(QStringLiteral(", "))};
|
||||
if (!m.projectNames.isEmpty())
|
||||
return {QStringLiteral("▣"), AgentRosterWidget::tr("project"),
|
||||
m.projectNames.join(QStringLiteral(", "))};
|
||||
return {QStringLiteral("·"), AgentRosterWidget::tr("any"), {}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class AgentRosterRow : public QFrame
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
AgentRosterRow(int index,
|
||||
const QString &name,
|
||||
const AgentConfig *cfg,
|
||||
bool active,
|
||||
bool first,
|
||||
bool last,
|
||||
const Theme &theme,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
signals:
|
||||
void moveUpRequested(int index);
|
||||
void moveDownRequested(int index);
|
||||
void editRequested(int index);
|
||||
void removeRequested(int index);
|
||||
|
||||
private:
|
||||
QWidget *buildIdentityLine(const QString &displayName,
|
||||
const QString &model,
|
||||
bool active,
|
||||
bool isUser,
|
||||
const Theme &t);
|
||||
QWidget *buildMetaLine(const AgentConfig *cfg, bool active, const Theme &t);
|
||||
QWidget *buildActions(const Theme &t, bool first, bool last);
|
||||
|
||||
int m_index;
|
||||
};
|
||||
|
||||
AgentRosterRow::AgentRosterRow(int index,
|
||||
const QString &name,
|
||||
const AgentConfig *cfg,
|
||||
bool active,
|
||||
bool first,
|
||||
bool last,
|
||||
const Theme &theme,
|
||||
QWidget *parent)
|
||||
: QFrame(parent), m_index(index)
|
||||
{
|
||||
setAutoFillBackground(true);
|
||||
QPalette pal = palette();
|
||||
pal.setColor(QPalette::Window, active ? theme.rowMatchBg : theme.cardBg);
|
||||
setPalette(pal);
|
||||
|
||||
setStyleSheet(QStringLiteral("AgentRosterRow { border:0; border-top:%1; }")
|
||||
.arg(index == 0 ? QStringLiteral("0px")
|
||||
: QStringLiteral("1px solid %1").arg(hex(theme.rowSeparator))));
|
||||
|
||||
auto *outer = new QHBoxLayout(this);
|
||||
outer->setContentsMargins(8, 6, 8, 6);
|
||||
outer->setSpacing(8);
|
||||
|
||||
auto *moveCol = new QVBoxLayout;
|
||||
moveCol->setContentsMargins(0, 0, 0, 0);
|
||||
moveCol->setSpacing(1);
|
||||
auto makeArrow = [&](const QString &glyph, const QString &tip, bool enabled) {
|
||||
auto *b = new QToolButton(this);
|
||||
b->setText(glyph);
|
||||
b->setToolTip(tip);
|
||||
b->setEnabled(enabled);
|
||||
b->setAutoRaise(true);
|
||||
b->setFixedSize(18, 14);
|
||||
QFont f = b->font();
|
||||
f.setPointSizeF(f.pointSizeF() * 0.75);
|
||||
b->setFont(f);
|
||||
return b;
|
||||
};
|
||||
auto *upBtn = makeArrow(QStringLiteral("▲"), tr("Move up"), !first);
|
||||
auto *dnBtn = makeArrow(QStringLiteral("▼"), tr("Move down"), !last);
|
||||
moveCol->addWidget(upBtn);
|
||||
moveCol->addWidget(dnBtn);
|
||||
outer->addLayout(moveCol);
|
||||
|
||||
auto *idxLbl = new QLabel(QStringLiteral("%1.").arg(index + 1), this);
|
||||
QFont monoFont = QFontDatabase::systemFont(QFontDatabase::FixedFont);
|
||||
idxLbl->setFont(monoFont);
|
||||
QPalette idxPal = idxLbl->palette();
|
||||
idxPal.setColor(QPalette::WindowText, active ? theme.text : theme.textFaint);
|
||||
idxLbl->setPalette(idxPal);
|
||||
idxLbl->setFixedWidth(22);
|
||||
idxLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
outer->addWidget(idxLbl);
|
||||
|
||||
auto *body = new QVBoxLayout;
|
||||
body->setContentsMargins(0, 0, 0, 0);
|
||||
body->setSpacing(2);
|
||||
|
||||
const QString displayName = cfg ? cfg->name : tr("%1 (missing)").arg(name);
|
||||
const QString model = cfg ? cfg->model : QString();
|
||||
const bool isUser = cfg && cfg->isUserSource();
|
||||
|
||||
body->addWidget(buildIdentityLine(displayName, model, active, isUser, theme));
|
||||
body->addWidget(buildMetaLine(cfg, active, theme));
|
||||
outer->addLayout(body, /*stretch*/ 1);
|
||||
|
||||
outer->addWidget(buildActions(theme, first, last));
|
||||
|
||||
if (cfg && !cfg->description.isEmpty())
|
||||
setToolTip(cfg->description);
|
||||
|
||||
connect(upBtn, &QToolButton::clicked, this, [this]() { emit moveUpRequested(m_index); });
|
||||
connect(dnBtn, &QToolButton::clicked, this, [this]() { emit moveDownRequested(m_index); });
|
||||
}
|
||||
|
||||
QWidget *AgentRosterRow::buildIdentityLine(const QString &displayName,
|
||||
const QString &model,
|
||||
bool active,
|
||||
bool isUser,
|
||||
const Theme &t)
|
||||
{
|
||||
auto *w = new QWidget(this);
|
||||
auto *line = new QHBoxLayout(w);
|
||||
line->setContentsMargins(0, 0, 0, 0);
|
||||
line->setSpacing(6);
|
||||
|
||||
auto *nameLbl = new QLabel(displayName, w);
|
||||
QFont nameFont = nameLbl->font();
|
||||
nameFont.setBold(true);
|
||||
nameLbl->setFont(nameFont);
|
||||
line->addWidget(nameLbl);
|
||||
|
||||
if (!model.isEmpty()) {
|
||||
auto *modelLbl = new QLabel(model, w);
|
||||
modelLbl->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
|
||||
QPalette mp = modelLbl->palette();
|
||||
mp.setColor(QPalette::WindowText, t.textSoft);
|
||||
modelLbl->setPalette(mp);
|
||||
line->addWidget(modelLbl);
|
||||
}
|
||||
|
||||
if (active)
|
||||
line->addWidget(makePill(QStringLiteral("✓ ") + tr("active"), PillKind::Active, t, w));
|
||||
if (isUser)
|
||||
line->addWidget(makePill(tr("user"), PillKind::User, t, w));
|
||||
|
||||
line->addStretch(1);
|
||||
return w;
|
||||
}
|
||||
|
||||
QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, const Theme &t)
|
||||
{
|
||||
auto *w = new QWidget(this);
|
||||
auto *line = new QHBoxLayout(w);
|
||||
line->setContentsMargins(0, 0, 0, 0);
|
||||
line->setSpacing(4);
|
||||
|
||||
if (cfg) {
|
||||
const auto sm = summarise(cfg->match);
|
||||
auto *chip = new QLabel(w);
|
||||
const QColor bg = active ? t.pill(PillKind::Match).bg : t.matchChipBg;
|
||||
const QColor bd = active ? t.pill(PillKind::Match).border : t.matchChipBorder;
|
||||
const QColor fg = active ? t.pill(PillKind::Match).fg : t.matchChipText;
|
||||
QString chipText = QStringLiteral(
|
||||
"<span style='opacity:0.7'>%1</span> "
|
||||
"<span style='color:%2'>%3:</span> %4")
|
||||
.arg(sm.icon,
|
||||
hex(active ? t.pill(PillKind::Match).fg : t.textFaint),
|
||||
sm.kind,
|
||||
sm.value.toHtmlEscaped());
|
||||
chip->setTextFormat(Qt::RichText);
|
||||
chip->setText(chipText);
|
||||
chip->setStyleSheet(QStringLiteral("background:%1; color:%2; border:1px solid %3;"
|
||||
"padding:0px 6px; border-radius:2px; font-size:11px;")
|
||||
.arg(hex(bg), hex(fg), hex(bd)));
|
||||
chip->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
|
||||
line->addWidget(chip);
|
||||
|
||||
auto *arrow = new QLabel(QStringLiteral("→"), w);
|
||||
QPalette ap = arrow->palette();
|
||||
ap.setColor(QPalette::WindowText, t.textFaint);
|
||||
arrow->setPalette(ap);
|
||||
line->addWidget(arrow);
|
||||
|
||||
if (!cfg->providerInstance.isEmpty())
|
||||
line->addWidget(makePill(cfg->providerInstance, PillKind::Template, t, w));
|
||||
|
||||
constexpr int kMaxTagPills = 3;
|
||||
const int tagCount = cfg->tags.size();
|
||||
for (int i = 0; i < std::min(tagCount, kMaxTagPills); ++i)
|
||||
line->addWidget(makePill(cfg->tags.at(i), PillKind::Tag, t, w));
|
||||
if (tagCount > kMaxTagPills) {
|
||||
auto *more = makePill(QStringLiteral("+%1").arg(tagCount - kMaxTagPills),
|
||||
PillKind::Tag, t, w);
|
||||
more->setToolTip(cfg->tags.mid(kMaxTagPills).join(QStringLiteral(", ")));
|
||||
line->addWidget(more);
|
||||
}
|
||||
} else {
|
||||
auto *missing = new QLabel(tr("agent configuration is no longer available"), w);
|
||||
QPalette mp = missing->palette();
|
||||
mp.setColor(QPalette::WindowText, t.textMute);
|
||||
missing->setPalette(mp);
|
||||
line->addWidget(missing);
|
||||
}
|
||||
line->addStretch(1);
|
||||
return w;
|
||||
}
|
||||
|
||||
QWidget *AgentRosterRow::buildActions(const Theme &, bool /*first*/, bool /*last*/)
|
||||
{
|
||||
auto *w = new QWidget(this);
|
||||
auto *l = new QHBoxLayout(w);
|
||||
l->setContentsMargins(0, 0, 0, 0);
|
||||
l->setSpacing(4);
|
||||
|
||||
auto *edit = new QToolButton(w);
|
||||
edit->setText(tr("Edit…"));
|
||||
edit->setToolButtonStyle(Qt::ToolButtonTextOnly);
|
||||
edit->setAutoRaise(false);
|
||||
edit->setFocusPolicy(Qt::TabFocus);
|
||||
edit->setMinimumHeight(22);
|
||||
|
||||
auto *remove = new QToolButton(w);
|
||||
remove->setText(QStringLiteral("✕"));
|
||||
remove->setToolTip(tr("Remove from list"));
|
||||
remove->setAutoRaise(false);
|
||||
remove->setFixedSize(22, 22);
|
||||
|
||||
l->addWidget(edit);
|
||||
l->addWidget(remove);
|
||||
|
||||
connect(edit, &QToolButton::clicked, this, [this]() { emit editRequested(m_index); });
|
||||
connect(remove, &QToolButton::clicked, this, [this]() { emit removeRequested(m_index); });
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
AgentRosterWidget::AgentRosterWidget(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
const Theme t = themeFor(this);
|
||||
|
||||
auto *outer = new QVBoxLayout(this);
|
||||
outer->setContentsMargins(0, 0, 0, 0);
|
||||
outer->setSpacing(6);
|
||||
|
||||
auto *titleRow = new QHBoxLayout;
|
||||
titleRow->setContentsMargins(0, 0, 0, 0);
|
||||
titleRow->setSpacing(6);
|
||||
m_accentDot = new QLabel(this);
|
||||
m_accentDot->setFixedSize(10, 10);
|
||||
m_titleLabel = new QLabel(this);
|
||||
QFont tf = m_titleLabel->font();
|
||||
tf.setBold(true);
|
||||
m_titleLabel->setFont(tf);
|
||||
titleRow->addWidget(m_accentDot);
|
||||
titleRow->addWidget(m_titleLabel);
|
||||
titleRow->addStretch(1);
|
||||
outer->addLayout(titleRow);
|
||||
|
||||
m_hintLabel = new QLabel(this);
|
||||
m_hintLabel->setWordWrap(true);
|
||||
QPalette hp = m_hintLabel->palette();
|
||||
hp.setColor(QPalette::WindowText, t.textMute);
|
||||
m_hintLabel->setPalette(hp);
|
||||
QFont hf = m_hintLabel->font();
|
||||
hf.setPointSizeF(hf.pointSizeF() * 0.9);
|
||||
m_hintLabel->setFont(hf);
|
||||
outer->addWidget(m_hintLabel);
|
||||
|
||||
m_rowsFrame = new QFrame(this);
|
||||
m_rowsFrame->setObjectName(QStringLiteral("rosterCard"));
|
||||
m_rowsFrame->setStyleSheet(
|
||||
QStringLiteral("QFrame#rosterCard { background:%1; border:1px solid %2; border-radius:2px; }")
|
||||
.arg(hex(t.cardBg), hex(t.cardBorder)));
|
||||
m_rowsLayout = new QVBoxLayout(m_rowsFrame);
|
||||
m_rowsLayout->setContentsMargins(0, 0, 0, 0);
|
||||
m_rowsLayout->setSpacing(0);
|
||||
|
||||
m_emptyHint = new QLabel(tr("No agents in roster. Click \"Add agent…\" to populate."),
|
||||
m_rowsFrame);
|
||||
m_emptyHint->setAlignment(Qt::AlignCenter);
|
||||
m_emptyHint->setContentsMargins(10, 12, 10, 12);
|
||||
QFont eh = m_emptyHint->font();
|
||||
eh.setItalic(true);
|
||||
m_emptyHint->setFont(eh);
|
||||
QPalette ep = m_emptyHint->palette();
|
||||
ep.setColor(QPalette::WindowText, t.textFaint);
|
||||
m_emptyHint->setPalette(ep);
|
||||
m_rowsLayout->addWidget(m_emptyHint);
|
||||
|
||||
outer->addWidget(m_rowsFrame);
|
||||
|
||||
auto *footer = new QHBoxLayout;
|
||||
footer->setContentsMargins(0, 0, 0, 0);
|
||||
footer->setSpacing(6);
|
||||
m_addBtn = new QPushButton(tr("+ Add agent…"), this);
|
||||
footer->addWidget(m_addBtn);
|
||||
footer->addStretch(1);
|
||||
m_footerHint = new QLabel(tr("first matching agent is used"), this);
|
||||
QPalette fp = m_footerHint->palette();
|
||||
fp.setColor(QPalette::WindowText, t.textMute);
|
||||
m_footerHint->setPalette(fp);
|
||||
QFont ff = m_footerHint->font();
|
||||
ff.setPointSizeF(ff.pointSizeF() * 0.9);
|
||||
m_footerHint->setFont(ff);
|
||||
footer->addWidget(m_footerHint);
|
||||
outer->addLayout(footer);
|
||||
|
||||
connect(m_addBtn, &QPushButton::clicked, this, &AgentRosterWidget::onAddClicked);
|
||||
}
|
||||
|
||||
void AgentRosterWidget::setSlot(const QString &title, const QString &hint, const QColor &accent)
|
||||
{
|
||||
m_titleLabel->setText(title);
|
||||
m_hintLabel->setText(hint);
|
||||
m_hintLabel->setVisible(!hint.isEmpty());
|
||||
if (accent.isValid()) {
|
||||
m_accentDot->setStyleSheet(
|
||||
QStringLiteral("background:%1; border:1px solid %2;")
|
||||
.arg(accent.name(), accent.darker(140).name()));
|
||||
m_accentDot->setVisible(true);
|
||||
} else {
|
||||
m_accentDot->setStyleSheet({});
|
||||
m_accentDot->setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
void AgentRosterWidget::setRoster(const QStringList &names, AgentFactory *factory)
|
||||
{
|
||||
m_factory = factory;
|
||||
m_names = names;
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
void AgentRosterWidget::setRoutingContext(const AgentRouter::Context &ctx)
|
||||
{
|
||||
m_routingCtx = ctx;
|
||||
recomputeActive();
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
void AgentRosterWidget::recomputeActive()
|
||||
{
|
||||
if (!m_factory || m_names.isEmpty()
|
||||
|| (m_routingCtx.filePath.isEmpty() && m_routingCtx.projectName.isEmpty())) {
|
||||
m_activeIndex = -1;
|
||||
return;
|
||||
}
|
||||
const QString picked = AgentRouter::pickAgent(m_names, m_routingCtx, *m_factory);
|
||||
m_activeIndex = picked.isEmpty() ? -1 : m_names.indexOf(picked);
|
||||
}
|
||||
|
||||
void AgentRosterWidget::rebuildRows()
|
||||
{
|
||||
// Tear down existing row widgets (keep the empty hint).
|
||||
while (m_rowsLayout->count() > 0) {
|
||||
QLayoutItem *it = m_rowsLayout->takeAt(0);
|
||||
if (auto *w = it->widget()) {
|
||||
if (w == m_emptyHint)
|
||||
w->setVisible(false);
|
||||
else
|
||||
w->deleteLater();
|
||||
}
|
||||
delete it;
|
||||
}
|
||||
|
||||
if (m_names.isEmpty()) {
|
||||
m_emptyHint->setVisible(true);
|
||||
m_rowsLayout->addWidget(m_emptyHint);
|
||||
return;
|
||||
}
|
||||
|
||||
recomputeActive();
|
||||
const Theme t = themeFor(this);
|
||||
|
||||
for (int i = 0; i < m_names.size(); ++i) {
|
||||
const QString &name = m_names.at(i);
|
||||
const AgentConfig *cfg = m_factory ? m_factory->configByName(name) : nullptr;
|
||||
auto *row = new AgentRosterRow(i,
|
||||
name,
|
||||
cfg,
|
||||
i == m_activeIndex,
|
||||
/*first*/ i == 0,
|
||||
/*last*/ i == m_names.size() - 1,
|
||||
t,
|
||||
m_rowsFrame);
|
||||
connect(row, &AgentRosterRow::moveUpRequested, this, &AgentRosterWidget::onRowMoveUp);
|
||||
connect(row, &AgentRosterRow::moveDownRequested, this, &AgentRosterWidget::onRowMoveDown);
|
||||
connect(row, &AgentRosterRow::editRequested, this, &AgentRosterWidget::onRowEdit);
|
||||
connect(row, &AgentRosterRow::removeRequested, this, &AgentRosterWidget::onRowRemove);
|
||||
m_rowsLayout->addWidget(row);
|
||||
}
|
||||
}
|
||||
|
||||
void AgentRosterWidget::onAddClicked()
|
||||
{
|
||||
if (!m_factory)
|
||||
return;
|
||||
|
||||
AgentSelectionDialog dialog(m_factory->configs(),
|
||||
/*currentName*/ QString{},
|
||||
m_factory.data(),
|
||||
Core::ICore::dialogParent());
|
||||
if (dialog.exec() != QDialog::Accepted)
|
||||
return;
|
||||
const QString picked = dialog.selectedName();
|
||||
if (picked.isEmpty() || m_names.contains(picked))
|
||||
return;
|
||||
|
||||
m_names.append(picked);
|
||||
rebuildRows();
|
||||
emit rosterChanged(m_names);
|
||||
}
|
||||
|
||||
void AgentRosterWidget::onRowMoveUp(int index)
|
||||
{
|
||||
if (index <= 0 || index >= m_names.size())
|
||||
return;
|
||||
m_names.swapItemsAt(index, index - 1);
|
||||
rebuildRows();
|
||||
emit rosterChanged(m_names);
|
||||
}
|
||||
|
||||
void AgentRosterWidget::onRowMoveDown(int index)
|
||||
{
|
||||
if (index < 0 || index >= m_names.size() - 1)
|
||||
return;
|
||||
m_names.swapItemsAt(index, index + 1);
|
||||
rebuildRows();
|
||||
emit rosterChanged(m_names);
|
||||
}
|
||||
|
||||
void AgentRosterWidget::onRowRemove(int index)
|
||||
{
|
||||
if (index < 0 || index >= m_names.size())
|
||||
return;
|
||||
m_names.removeAt(index);
|
||||
rebuildRows();
|
||||
emit rosterChanged(m_names);
|
||||
}
|
||||
|
||||
void AgentRosterWidget::onRowEdit(int index)
|
||||
{
|
||||
if (index < 0 || index >= m_names.size())
|
||||
return;
|
||||
emit editAgentRequested(m_names.at(index));
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
|
||||
#include "AgentRosterWidget.moc"
|
||||
70
sources/settings/AgentRosterWidget.hpp
Normal file
70
sources/settings/AgentRosterWidget.hpp
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QWidget>
|
||||
|
||||
#include <AgentRouter.hpp>
|
||||
|
||||
class QLabel;
|
||||
class QToolButton;
|
||||
class QPushButton;
|
||||
class QVBoxLayout;
|
||||
class QFrame;
|
||||
|
||||
namespace QodeAssist {
|
||||
class AgentFactory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
class AgentRosterRow;
|
||||
|
||||
class AgentRosterWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AgentRosterWidget(QWidget *parent = nullptr);
|
||||
|
||||
void setSlot(const QString &title, const QString &hint, const QColor &accent);
|
||||
void setRoster(const QStringList &names, AgentFactory *factory);
|
||||
|
||||
[[nodiscard]] QStringList roster() const { return m_names; }
|
||||
|
||||
void setRoutingContext(const AgentRouter::Context &ctx);
|
||||
|
||||
signals:
|
||||
void rosterChanged(const QStringList &names);
|
||||
void editAgentRequested(const QString &agentName);
|
||||
|
||||
private:
|
||||
void rebuildRows();
|
||||
void recomputeActive();
|
||||
|
||||
void onAddClicked();
|
||||
void onRowMoveUp(int index);
|
||||
void onRowMoveDown(int index);
|
||||
void onRowRemove(int index);
|
||||
void onRowEdit(int index);
|
||||
|
||||
QStringList m_names;
|
||||
QPointer<AgentFactory> m_factory;
|
||||
AgentRouter::Context m_routingCtx;
|
||||
int m_activeIndex = -1;
|
||||
|
||||
QLabel *m_accentDot = nullptr;
|
||||
QLabel *m_titleLabel = nullptr;
|
||||
QLabel *m_hintLabel = nullptr;
|
||||
QFrame *m_rowsFrame = nullptr;
|
||||
QVBoxLayout *m_rowsLayout = nullptr;
|
||||
QLabel *m_emptyHint = nullptr;
|
||||
QPushButton *m_addBtn = nullptr;
|
||||
QLabel *m_footerHint = nullptr;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
471
sources/settings/AgentSelectionDialog.cpp
Normal file
471
sources/settings/AgentSelectionDialog.cpp
Normal file
@@ -0,0 +1,471 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "AgentSelectionDialog.hpp"
|
||||
|
||||
#include "AgentSlotWidget.hpp"
|
||||
#include "PipelinesConfig.hpp"
|
||||
#include "SettingsTr.hpp"
|
||||
|
||||
#include <coreplugin/icore.h>
|
||||
|
||||
#include <AgentFactory.hpp>
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QEnterEvent>
|
||||
#include <QEvent>
|
||||
#include <QFont>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QMap>
|
||||
#include <QMouseEvent>
|
||||
#include <QPushButton>
|
||||
#include <QScopedValueRollback>
|
||||
#include <QScrollArea>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <algorithm>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
// -- ListRowCard -------------------------------------------------------
|
||||
|
||||
ListRowCard::ListRowCard(QWidget *parent)
|
||||
: QFrame(parent)
|
||||
{
|
||||
setObjectName(QStringLiteral("ListRowCard"));
|
||||
setAttribute(Qt::WA_StyledBackground, true);
|
||||
setCursor(Qt::PointingHandCursor);
|
||||
setFrameShape(QFrame::NoFrame);
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
bool ListRowCard::matches(const QString &needle) const
|
||||
{
|
||||
if (needle.isEmpty())
|
||||
return true;
|
||||
return m_searchHaystack.contains(needle.toLower());
|
||||
}
|
||||
|
||||
void ListRowCard::setSelected(bool selected)
|
||||
{
|
||||
if (m_selected == selected)
|
||||
return;
|
||||
m_selected = selected;
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void ListRowCard::buildSearchHaystack(const QStringList &parts)
|
||||
{
|
||||
m_searchHaystack = parts.join(QLatin1Char(' ')).toLower();
|
||||
}
|
||||
|
||||
void ListRowCard::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton)
|
||||
emit clicked();
|
||||
QFrame::mousePressEvent(event);
|
||||
}
|
||||
|
||||
void ListRowCard::mouseDoubleClickEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton)
|
||||
emit activated();
|
||||
QFrame::mouseDoubleClickEvent(event);
|
||||
}
|
||||
|
||||
void ListRowCard::enterEvent(QEnterEvent *event)
|
||||
{
|
||||
QFrame::enterEvent(event);
|
||||
m_hover = true;
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void ListRowCard::leaveEvent(QEvent *event)
|
||||
{
|
||||
QFrame::leaveEvent(event);
|
||||
m_hover = false;
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void ListRowCard::changeEvent(QEvent *event)
|
||||
{
|
||||
QFrame::changeEvent(event);
|
||||
if (m_inApplyTheme)
|
||||
return;
|
||||
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void ListRowCard::applyTheme()
|
||||
{
|
||||
if (m_inApplyTheme)
|
||||
return;
|
||||
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||
|
||||
const auto t = CardStyle::toneFor(CardStyle::isDark(palette()));
|
||||
QString bg = t.bg;
|
||||
QString bd = t.cardBd;
|
||||
if (m_selected) {
|
||||
bg = t.selectedBg;
|
||||
bd = t.selectedBd;
|
||||
} else if (m_hover) {
|
||||
bg = t.hoverBg;
|
||||
}
|
||||
setStyleSheet(QStringLiteral(
|
||||
"#ListRowCard { background-color: %1; border: 1px solid %2; }")
|
||||
.arg(bg, bd));
|
||||
}
|
||||
|
||||
// -- AgentRowCard ------------------------------------------------------
|
||||
|
||||
AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
|
||||
: ListRowCard(parent)
|
||||
{
|
||||
setItemName(cfg.name);
|
||||
QStringList haystack{cfg.name, cfg.providerInstance, cfg.model,
|
||||
cfg.description, cfg.role,
|
||||
cfg.endpoint};
|
||||
haystack += cfg.tags;
|
||||
buildSearchHaystack(haystack);
|
||||
|
||||
auto *name = new QLabel(cfg.name, this);
|
||||
QFont nameFont = name->font();
|
||||
nameFont.setBold(true);
|
||||
nameFont.setPixelSize(13);
|
||||
name->setFont(nameFont);
|
||||
name->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred);
|
||||
|
||||
QLabel *model = nullptr;
|
||||
if (!cfg.model.isEmpty()) {
|
||||
model = new QLabel(QStringLiteral("· %1").arg(cfg.model), this);
|
||||
model->setFont(CardStyle::monoFont(11));
|
||||
model->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
|
||||
model->setMinimumWidth(0);
|
||||
}
|
||||
|
||||
Pill *sourcePill = nullptr;
|
||||
if (cfg.isUserSource()) {
|
||||
sourcePill = new Pill(
|
||||
Pill::User,
|
||||
cfg.overridesBundled ? Tr::tr("Override") : Tr::tr("User"),
|
||||
this);
|
||||
}
|
||||
|
||||
auto *description = new QLabel(this);
|
||||
description->setWordWrap(false);
|
||||
QFont descFont = description->font();
|
||||
descFont.setItalic(true);
|
||||
description->setFont(descFont);
|
||||
description->setText(cfg.description.isEmpty()
|
||||
? Tr::tr("No description provided.")
|
||||
: cfg.description);
|
||||
description->setMinimumWidth(0);
|
||||
description->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
|
||||
description->setTextInteractionFlags(Qt::NoTextInteraction);
|
||||
|
||||
QStringList endpointParts;
|
||||
if (!cfg.endpoint.isEmpty())
|
||||
endpointParts << cfg.endpoint;
|
||||
endpointParts << (cfg.enableThinking ? Tr::tr("thinking") : Tr::tr("no-thinking"));
|
||||
endpointParts << (cfg.enableTools ? Tr::tr("tools") : Tr::tr("no-tools"));
|
||||
|
||||
auto *endpoint = new QLabel(endpointParts.join(QStringLiteral(" · ")), this);
|
||||
endpoint->setFont(CardStyle::monoFont(11));
|
||||
endpoint->setMinimumWidth(0);
|
||||
endpoint->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
|
||||
endpoint->setTextInteractionFlags(Qt::NoTextInteraction);
|
||||
|
||||
auto *headerRow = new QHBoxLayout;
|
||||
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||
headerRow->setSpacing(6);
|
||||
headerRow->addWidget(name);
|
||||
if (model)
|
||||
headerRow->addWidget(model, 1);
|
||||
else
|
||||
headerRow->addStretch(1);
|
||||
if (sourcePill)
|
||||
headerRow->addWidget(sourcePill);
|
||||
|
||||
auto *outer = new QVBoxLayout(this);
|
||||
outer->setContentsMargins(10, 8, 10, 8);
|
||||
outer->setSpacing(3);
|
||||
outer->addLayout(headerRow);
|
||||
outer->addWidget(description);
|
||||
outer->addWidget(endpoint);
|
||||
|
||||
if (!cfg.tags.isEmpty()) {
|
||||
constexpr int kMaxTagPills = 4;
|
||||
auto *tagsRow = new QHBoxLayout;
|
||||
tagsRow->setContentsMargins(0, 0, 0, 0);
|
||||
tagsRow->setSpacing(4);
|
||||
const int tagCount = cfg.tags.size();
|
||||
for (int i = 0; i < std::min(tagCount, kMaxTagPills); ++i)
|
||||
tagsRow->addWidget(new Pill(Pill::Tag, cfg.tags.at(i), this));
|
||||
if (tagCount > kMaxTagPills) {
|
||||
auto *more = new Pill(
|
||||
Pill::Tag,
|
||||
QStringLiteral("+%1").arg(tagCount - kMaxTagPills),
|
||||
this);
|
||||
more->setToolTip(cfg.tags.mid(kMaxTagPills).join(QStringLiteral(", ")));
|
||||
tagsRow->addWidget(more);
|
||||
}
|
||||
tagsRow->addStretch(1);
|
||||
outer->addLayout(tagsRow);
|
||||
}
|
||||
|
||||
const auto t = CardStyle::toneFor(CardStyle::isDark(palette()));
|
||||
QPalette descPal = description->palette();
|
||||
descPal.setColor(QPalette::WindowText,
|
||||
QColor(cfg.description.isEmpty() ? t.textFaint : t.textSoft));
|
||||
description->setPalette(descPal);
|
||||
QPalette endPal = endpoint->palette();
|
||||
endPal.setColor(QPalette::WindowText, QColor(t.textFaint));
|
||||
endpoint->setPalette(endPal);
|
||||
if (model) {
|
||||
QPalette mp = model->palette();
|
||||
mp.setColor(QPalette::WindowText, QColor(t.textMute));
|
||||
model->setPalette(mp);
|
||||
}
|
||||
|
||||
QString tooltip;
|
||||
if (!cfg.description.isEmpty())
|
||||
tooltip += cfg.description + QStringLiteral("\n\n");
|
||||
if (!cfg.providerInstance.isEmpty())
|
||||
tooltip += Tr::tr("Provider instance: %1\n").arg(cfg.providerInstance);
|
||||
if (!cfg.role.isEmpty())
|
||||
tooltip += Tr::tr("Role: %1\n").arg(cfg.role);
|
||||
if (!cfg.endpoint.isEmpty())
|
||||
tooltip += Tr::tr("Endpoint: %1\n").arg(cfg.endpoint);
|
||||
setToolTip(tooltip.trimmed());
|
||||
}
|
||||
|
||||
// -- ProviderSection ---------------------------------------------------
|
||||
|
||||
ProviderSection::ProviderSection(const QString &name, QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
m_arrow = new QLabel(QStringLiteral("▾"));
|
||||
m_label = new QLabel(name);
|
||||
CardStyle::applySectionFont(m_label);
|
||||
|
||||
QFont arrowFont = m_label->font();
|
||||
arrowFont.setCapitalization(QFont::MixedCase);
|
||||
m_arrow->setFont(arrowFont);
|
||||
QPalette ap = m_arrow->palette();
|
||||
ap.setColor(QPalette::WindowText, ap.color(QPalette::Mid));
|
||||
m_arrow->setPalette(ap);
|
||||
|
||||
m_header = new QFrame;
|
||||
m_header->setObjectName(QStringLiteral("ProviderHeader"));
|
||||
m_header->setCursor(Qt::PointingHandCursor);
|
||||
m_header->setFrameShape(QFrame::NoFrame);
|
||||
auto *headerLayout = new QHBoxLayout(m_header);
|
||||
headerLayout->setContentsMargins(2, 4, 2, 2);
|
||||
headerLayout->setSpacing(6);
|
||||
headerLayout->addWidget(m_arrow);
|
||||
headerLayout->addWidget(m_label);
|
||||
headerLayout->addStretch(1);
|
||||
m_header->installEventFilter(this);
|
||||
|
||||
m_content = new QWidget;
|
||||
m_contentLayout = new QVBoxLayout(m_content);
|
||||
m_contentLayout->setContentsMargins(0, 0, 0, 0);
|
||||
m_contentLayout->setSpacing(4);
|
||||
m_content->setVisible(false);
|
||||
m_arrow->setText(QStringLiteral("▸"));
|
||||
m_expanded = false;
|
||||
|
||||
auto *outer = new QVBoxLayout(this);
|
||||
outer->setContentsMargins(0, 0, 0, 0);
|
||||
outer->setSpacing(0);
|
||||
outer->addWidget(m_header);
|
||||
outer->addWidget(m_content);
|
||||
}
|
||||
|
||||
void ProviderSection::addCard(ListRowCard *card)
|
||||
{
|
||||
m_contentLayout->addWidget(card);
|
||||
m_cards.append(card);
|
||||
}
|
||||
|
||||
int ProviderSection::applyFilter(const QString &needle)
|
||||
{
|
||||
int visible = 0;
|
||||
for (auto *card : m_cards) {
|
||||
const bool show = card->matches(needle);
|
||||
card->setVisible(show);
|
||||
if (show)
|
||||
++visible;
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
void ProviderSection::setExpanded(bool expanded)
|
||||
{
|
||||
if (m_expanded == expanded)
|
||||
return;
|
||||
m_expanded = expanded;
|
||||
m_content->setVisible(expanded);
|
||||
m_arrow->setText(expanded ? QStringLiteral("▾") : QStringLiteral("▸"));
|
||||
}
|
||||
|
||||
bool ProviderSection::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (watched == m_header && event->type() == QEvent::MouseButtonRelease) {
|
||||
auto *me = static_cast<QMouseEvent *>(event);
|
||||
if (me->button() == Qt::LeftButton) {
|
||||
setExpanded(!m_expanded);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
// -- AgentSelectionDialog ----------------------------------------------
|
||||
|
||||
AgentSelectionDialog::AgentSelectionDialog(
|
||||
const std::vector<AgentConfig> &configs,
|
||||
const QString ¤tName,
|
||||
AgentFactory *agentFactory,
|
||||
QWidget *parent)
|
||||
: QDialog(parent)
|
||||
, m_agentFactory(agentFactory)
|
||||
{
|
||||
setWindowTitle(Tr::tr("Change Agent"));
|
||||
resize(720, 600);
|
||||
setSizeGripEnabled(true);
|
||||
|
||||
if (!m_agentFactory)
|
||||
m_localConfigs = configs;
|
||||
|
||||
m_filter = new QLineEdit(this);
|
||||
m_filter->setPlaceholderText(
|
||||
Tr::tr("Filter by name, provider, model, template, description…"));
|
||||
m_filter->setClearButtonEnabled(true);
|
||||
|
||||
auto *topRow = new QHBoxLayout;
|
||||
topRow->setContentsMargins(0, 0, 0, 0);
|
||||
topRow->setSpacing(6);
|
||||
topRow->addWidget(m_filter, 1);
|
||||
|
||||
m_scroll = new QScrollArea(this);
|
||||
m_scroll->setWidgetResizable(true);
|
||||
m_scroll->setFrameShape(QFrame::StyledPanel);
|
||||
m_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
|
||||
auto *buttons
|
||||
= new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
m_okButton = buttons->button(QDialogButtonBox::Ok);
|
||||
m_okButton->setText(Tr::tr("Change"));
|
||||
m_okButton->setEnabled(false);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addLayout(topRow);
|
||||
layout->addWidget(m_scroll);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
rebuild(currentName);
|
||||
|
||||
connect(m_filter, &QLineEdit::textChanged, this,
|
||||
[this](const QString &text) { applyFilter(text); });
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
}
|
||||
|
||||
void AgentSelectionDialog::selectCard(ListRowCard *card)
|
||||
{
|
||||
if (m_currentCard == card)
|
||||
return;
|
||||
if (m_currentCard)
|
||||
m_currentCard->setSelected(false);
|
||||
m_currentCard = card;
|
||||
if (m_currentCard) {
|
||||
m_currentCard->setSelected(true);
|
||||
m_selectedName = m_currentCard->itemName();
|
||||
} else {
|
||||
m_selectedName.clear();
|
||||
}
|
||||
if (m_okButton)
|
||||
m_okButton->setEnabled(!m_selectedName.isEmpty());
|
||||
}
|
||||
|
||||
void AgentSelectionDialog::rebuild(const QString ¤tName)
|
||||
{
|
||||
m_sections.clear();
|
||||
m_currentCard = nullptr;
|
||||
m_selectedName.clear();
|
||||
if (m_okButton)
|
||||
m_okButton->setEnabled(false);
|
||||
|
||||
const auto &configs
|
||||
= m_agentFactory ? m_agentFactory->configs() : m_localConfigs;
|
||||
|
||||
auto *content = new QWidget;
|
||||
auto *contentLayout = new QVBoxLayout(content);
|
||||
contentLayout->setContentsMargins(12, 12, 12, 12);
|
||||
contentLayout->setSpacing(6);
|
||||
|
||||
QMap<QString, std::vector<const AgentConfig *>> byProvider;
|
||||
for (const auto &cfg : configs) {
|
||||
if (cfg.hidden) continue; // hidden profiles stay loaded but don't surface in the picker
|
||||
const QString key = cfg.providerInstance.isEmpty()
|
||||
? Tr::tr("(Unknown provider instance)")
|
||||
: cfg.providerInstance;
|
||||
byProvider[key].push_back(&cfg);
|
||||
}
|
||||
|
||||
AgentRowCard *toSelect = nullptr;
|
||||
ProviderSection *sectionToExpand = nullptr;
|
||||
|
||||
for (auto it = byProvider.cbegin(); it != byProvider.cend(); ++it) {
|
||||
auto *section = new ProviderSection(it.key());
|
||||
auto sortedConfigs = it.value();
|
||||
std::sort(sortedConfigs.begin(), sortedConfigs.end(),
|
||||
[](const AgentConfig *a, const AgentConfig *b) { return a->name < b->name; });
|
||||
for (const AgentConfig *cfg : sortedConfigs) {
|
||||
auto *card = new AgentRowCard(*cfg);
|
||||
connect(card, &ListRowCard::clicked, this,
|
||||
[this, card]() { selectCard(card); });
|
||||
connect(card, &ListRowCard::activated, this, [this, card]() {
|
||||
selectCard(card);
|
||||
accept();
|
||||
});
|
||||
section->addCard(card);
|
||||
if (cfg->name == currentName) {
|
||||
toSelect = card;
|
||||
sectionToExpand = section;
|
||||
}
|
||||
}
|
||||
contentLayout->addWidget(section);
|
||||
m_sections.append(section);
|
||||
}
|
||||
contentLayout->addStretch(1);
|
||||
|
||||
m_scroll->setWidget(content);
|
||||
|
||||
if (sectionToExpand)
|
||||
sectionToExpand->setExpanded(true);
|
||||
|
||||
if (toSelect) {
|
||||
selectCard(toSelect);
|
||||
QTimer::singleShot(0, this, [this, toSelect]() {
|
||||
m_scroll->ensureWidgetVisible(toSelect, 0, 60);
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter(m_filter ? m_filter->text() : QString());
|
||||
}
|
||||
|
||||
void AgentSelectionDialog::applyFilter(const QString &needle)
|
||||
{
|
||||
const QString trimmed = needle.trimmed();
|
||||
for (auto *section : m_sections) {
|
||||
const int visible = section->applyFilter(trimmed);
|
||||
section->setVisible(visible > 0);
|
||||
if (!trimmed.isEmpty())
|
||||
section->setExpanded(visible > 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
182
sources/settings/AgentSelectionDialog.hpp
Normal file
182
sources/settings/AgentSelectionDialog.hpp
Normal file
@@ -0,0 +1,182 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QFont>
|
||||
#include <QFrame>
|
||||
#include <QLabel>
|
||||
#include <QPalette>
|
||||
#include <QStringList>
|
||||
#include <vector>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
|
||||
class QLineEdit;
|
||||
class QPushButton;
|
||||
class QScrollArea;
|
||||
class QVBoxLayout;
|
||||
|
||||
namespace QodeAssist {
|
||||
class AgentFactory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
namespace CardStyle {
|
||||
|
||||
struct Tone
|
||||
{
|
||||
QString bg;
|
||||
QString hoverBg;
|
||||
QString selectedBg;
|
||||
QString selectedBd;
|
||||
QString cardBd;
|
||||
QString textSoft;
|
||||
QString textMute;
|
||||
QString textFaint;
|
||||
};
|
||||
|
||||
inline bool isDark(const QPalette &p)
|
||||
{
|
||||
return p.color(QPalette::Window).lightness() < 128;
|
||||
}
|
||||
|
||||
inline Tone toneFor(bool dark)
|
||||
{
|
||||
return dark
|
||||
? Tone{"#333333", "#3a3a3a", "#2a4566", "#3a6fb7", "#4a4a4a",
|
||||
"#c2c2c2", "#9a9a9a", "#7a7a7a"}
|
||||
: Tone{"#f6f6f6", "#ececec", "#cfe1f7", "#3a6fb7", "#bdbdbd",
|
||||
"#3a3a3a", "#5a5a5a", "#8a8a8a"};
|
||||
}
|
||||
|
||||
inline QFont monoFont(int pixelSize)
|
||||
{
|
||||
QFont f;
|
||||
f.setFamilies({QStringLiteral("SF Mono"),
|
||||
QStringLiteral("Cascadia Code"),
|
||||
QStringLiteral("Consolas"),
|
||||
QStringLiteral("Liberation Mono"),
|
||||
QStringLiteral("Menlo"),
|
||||
QStringLiteral("Courier New")});
|
||||
f.setStyleHint(QFont::Monospace);
|
||||
f.setPixelSize(pixelSize);
|
||||
return f;
|
||||
}
|
||||
|
||||
inline void applySectionFont(QLabel *l)
|
||||
{
|
||||
QFont f = l->font();
|
||||
f.setPointSizeF(f.pointSizeF() * 0.85);
|
||||
f.setBold(true);
|
||||
f.setCapitalization(QFont::AllUppercase);
|
||||
f.setLetterSpacing(QFont::AbsoluteSpacing, 0.5);
|
||||
l->setFont(f);
|
||||
QPalette p = l->palette();
|
||||
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
|
||||
l->setPalette(p);
|
||||
}
|
||||
|
||||
} // namespace CardStyle
|
||||
|
||||
class ListRowCard : public QFrame
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
QString itemName() const { return m_itemName; }
|
||||
bool matches(const QString &needle) const;
|
||||
void setSelected(bool selected);
|
||||
bool isSelected() const { return m_selected; }
|
||||
|
||||
signals:
|
||||
void clicked();
|
||||
void activated();
|
||||
|
||||
protected:
|
||||
explicit ListRowCard(QWidget *parent = nullptr);
|
||||
|
||||
void setItemName(const QString &name) { m_itemName = name; }
|
||||
void buildSearchHaystack(const QStringList &parts);
|
||||
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseDoubleClickEvent(QMouseEvent *event) override;
|
||||
void enterEvent(QEnterEvent *event) override;
|
||||
void leaveEvent(QEvent *event) override;
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
void applyTheme();
|
||||
|
||||
QString m_itemName;
|
||||
QString m_searchHaystack;
|
||||
bool m_selected = false;
|
||||
bool m_hover = false;
|
||||
bool m_inApplyTheme = false;
|
||||
};
|
||||
|
||||
class AgentRowCard : public ListRowCard
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AgentRowCard(const AgentConfig &cfg, QWidget *parent = nullptr);
|
||||
|
||||
QString agentName() const { return itemName(); }
|
||||
};
|
||||
|
||||
class ProviderSection : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ProviderSection(const QString &name, QWidget *parent = nullptr);
|
||||
|
||||
void addCard(ListRowCard *card);
|
||||
void setExpanded(bool expanded);
|
||||
bool isExpanded() const { return m_expanded; }
|
||||
const QList<ListRowCard *> &cards() const { return m_cards; }
|
||||
|
||||
int applyFilter(const QString &needle); // returns number of visible cards
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private:
|
||||
QFrame *m_header = nullptr;
|
||||
QLabel *m_arrow = nullptr;
|
||||
QLabel *m_label = nullptr;
|
||||
QWidget *m_content = nullptr;
|
||||
QVBoxLayout *m_contentLayout = nullptr;
|
||||
QList<ListRowCard *> m_cards;
|
||||
bool m_expanded = true;
|
||||
};
|
||||
|
||||
class AgentSelectionDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
AgentSelectionDialog(
|
||||
const std::vector<AgentConfig> &configs,
|
||||
const QString ¤tName,
|
||||
AgentFactory *agentFactory = nullptr,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
QString selectedName() const { return m_selectedName; }
|
||||
|
||||
private:
|
||||
void rebuild(const QString ¤tName);
|
||||
void selectCard(ListRowCard *card);
|
||||
void applyFilter(const QString &needle);
|
||||
|
||||
QLineEdit *m_filter = nullptr;
|
||||
QScrollArea *m_scroll = nullptr;
|
||||
QPushButton *m_okButton = nullptr;
|
||||
ListRowCard *m_currentCard = nullptr;
|
||||
QList<ProviderSection *> m_sections;
|
||||
QString m_selectedName;
|
||||
|
||||
AgentFactory *m_agentFactory = nullptr;
|
||||
std::vector<AgentConfig> m_localConfigs; // fallback when no factory
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
361
sources/settings/AgentSlotWidget.cpp
Normal file
361
sources/settings/AgentSlotWidget.cpp
Normal file
@@ -0,0 +1,361 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "AgentSlotWidget.hpp"
|
||||
|
||||
#include "SettingsTr.hpp"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QFont>
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QPalette>
|
||||
#include <QPushButton>
|
||||
#include <QScopedValueRollback>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
namespace {
|
||||
|
||||
bool isDarkPalette(const QPalette &p)
|
||||
{
|
||||
return p.color(QPalette::Window).lightness() < 128;
|
||||
}
|
||||
|
||||
QFont monoFont(int pixelSize)
|
||||
{
|
||||
QFont f;
|
||||
f.setFamilies({QStringLiteral("SF Mono"),
|
||||
QStringLiteral("Cascadia Code"),
|
||||
QStringLiteral("Consolas"),
|
||||
QStringLiteral("Liberation Mono"),
|
||||
QStringLiteral("Menlo"),
|
||||
QStringLiteral("Courier New")});
|
||||
f.setStyleHint(QFont::Monospace);
|
||||
f.setPixelSize(pixelSize);
|
||||
return f;
|
||||
}
|
||||
|
||||
struct Tone
|
||||
{
|
||||
QString cardBg;
|
||||
QString cardBd;
|
||||
QString textSoft;
|
||||
QString textMute;
|
||||
QString textFaint;
|
||||
};
|
||||
|
||||
Tone toneFor(bool dark)
|
||||
{
|
||||
return dark ? Tone{"#333333", "#4a4a4a", "#c2c2c2", "#9a9a9a", "#7a7a7a"}
|
||||
: Tone{"#f6f6f6", "#bdbdbd", "#3a3a3a", "#5a5a5a", "#8a8a8a"};
|
||||
}
|
||||
|
||||
struct PillTone
|
||||
{
|
||||
QString bg;
|
||||
QString fg;
|
||||
QString bd;
|
||||
};
|
||||
|
||||
PillTone pillTone(Pill::Kind kind, bool dark)
|
||||
{
|
||||
switch (kind) {
|
||||
case Pill::Template:
|
||||
return dark ? PillTone{"#2c3f5a", "#cfe1f7", "#4a6286"}
|
||||
: PillTone{"#dbe7f6", "#1f3f73", "#a8c1e0"};
|
||||
case Pill::On:
|
||||
return dark ? PillTone{"#2f4530", "#bce0bd", "#4a6c4b"}
|
||||
: PillTone{"#dbe9d3", "#2c5a1c", "#a3bc97"};
|
||||
case Pill::Off:
|
||||
return dark ? PillTone{"#3a3a3a", "#8a8a8a", "#4a4a4a"}
|
||||
: PillTone{"#ececec", "#7a7a7a", "#c8c8c8"};
|
||||
case Pill::User:
|
||||
return dark ? PillTone{"#4a3f24", "#e6cd92", "#6a5a30"}
|
||||
: PillTone{"#f0e4cf", "#75541a", "#cdb98a"};
|
||||
case Pill::Tag:
|
||||
return dark ? PillTone{"#2e2e3a", "#b9b9cf", "#46465a"}
|
||||
: PillTone{"#e7e7f2", "#46466e", "#c1c1d5"};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// -- Pill --------------------------------------------------------------
|
||||
|
||||
Pill::Pill(Kind kind, const QString &text, QWidget *parent)
|
||||
: QLabel(text, parent)
|
||||
, m_kind(kind)
|
||||
{
|
||||
setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
|
||||
setAlignment(Qt::AlignCenter);
|
||||
QFont f = font();
|
||||
f.setPixelSize(11);
|
||||
setFont(f);
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void Pill::setKind(Kind kind)
|
||||
{
|
||||
if (m_kind == kind)
|
||||
return;
|
||||
m_kind = kind;
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void Pill::changeEvent(QEvent *event)
|
||||
{
|
||||
QLabel::changeEvent(event);
|
||||
if (m_inApplyTheme)
|
||||
return;
|
||||
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void Pill::applyTheme()
|
||||
{
|
||||
if (m_inApplyTheme)
|
||||
return;
|
||||
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||
|
||||
const auto t = pillTone(m_kind, isDarkPalette(palette()));
|
||||
setStyleSheet(QStringLiteral(
|
||||
"QLabel { background-color: %1; color: %2;"
|
||||
" border: 1px solid %3; padding: 1px 7px; }")
|
||||
.arg(t.bg, t.fg, t.bd));
|
||||
}
|
||||
|
||||
// -- AgentSlotWidget ---------------------------------------------------
|
||||
|
||||
AgentSlotWidget::AgentSlotWidget(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
setAttribute(Qt::WA_StyledBackground, true);
|
||||
setObjectName(QStringLiteral("AgentSlot"));
|
||||
|
||||
m_name = new QLabel(this);
|
||||
QFont nameFont = m_name->font();
|
||||
nameFont.setBold(true);
|
||||
m_name->setFont(nameFont);
|
||||
m_name->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
|
||||
m_sourcePill = new Pill(Pill::User, {}, this);
|
||||
m_sourcePill->hide();
|
||||
|
||||
m_changeBtn = new QPushButton(Tr::tr("Change…"), this);
|
||||
m_changeBtn->setCursor(Qt::PointingHandCursor);
|
||||
connect(m_changeBtn, &QPushButton::clicked, this, &AgentSlotWidget::changeRequested);
|
||||
|
||||
m_editBtn = new QPushButton(Tr::tr("Edit"), this);
|
||||
m_editBtn->setCursor(Qt::PointingHandCursor);
|
||||
m_editBtn->setToolTip(Tr::tr("Open this agent in the Agents settings page."));
|
||||
connect(m_editBtn, &QPushButton::clicked, this, &AgentSlotWidget::editRequested);
|
||||
|
||||
auto makeFieldLabel = [this]() {
|
||||
auto *l = new QLabel(this);
|
||||
l->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
return l;
|
||||
};
|
||||
auto makeMonoValue = [this]() {
|
||||
auto *l = new QLabel(this);
|
||||
l->setFont(monoFont(11));
|
||||
l->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
l->setMinimumWidth(0);
|
||||
l->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
|
||||
return l;
|
||||
};
|
||||
auto makePlainValue = [this]() {
|
||||
auto *l = new QLabel(this);
|
||||
l->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
l->setMinimumWidth(0);
|
||||
l->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
|
||||
return l;
|
||||
};
|
||||
|
||||
m_modelLabel = makeFieldLabel();
|
||||
m_modelLabel->setText(Tr::tr("Model"));
|
||||
m_modelValue = makeMonoValue();
|
||||
|
||||
m_urlLabel = makeFieldLabel();
|
||||
m_urlLabel->setText(Tr::tr("Provider"));
|
||||
m_urlValue = makeMonoValue();
|
||||
|
||||
m_endpointLabel = makeFieldLabel();
|
||||
m_endpointLabel->setText(Tr::tr("Endpoint"));
|
||||
m_endpointValue = makeMonoValue();
|
||||
|
||||
m_templateLabel = makeFieldLabel();
|
||||
m_templateLabel->setText(Tr::tr("Template"));
|
||||
m_templateValue = makePlainValue();
|
||||
|
||||
m_description = new QLabel(this);
|
||||
m_description->setWordWrap(true);
|
||||
m_description->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
QFont descFont = m_description->font();
|
||||
descFont.setItalic(true);
|
||||
m_description->setFont(descFont);
|
||||
|
||||
m_thinkingPill = new Pill(Pill::Off, {}, this);
|
||||
m_toolsPill = new Pill(Pill::Off, {}, this);
|
||||
|
||||
// Header row
|
||||
auto *nameRow = new QHBoxLayout;
|
||||
nameRow->setContentsMargins(0, 0, 0, 0);
|
||||
nameRow->setSpacing(6);
|
||||
nameRow->addWidget(m_name);
|
||||
nameRow->addWidget(m_sourcePill);
|
||||
nameRow->addStretch(1);
|
||||
|
||||
auto *buttonsBox = new QHBoxLayout;
|
||||
buttonsBox->setContentsMargins(0, 0, 0, 0);
|
||||
buttonsBox->setSpacing(4);
|
||||
buttonsBox->addWidget(m_editBtn);
|
||||
buttonsBox->addWidget(m_changeBtn);
|
||||
|
||||
auto *headerRow = new QHBoxLayout;
|
||||
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||
headerRow->setSpacing(8);
|
||||
headerRow->addLayout(nameRow, 1);
|
||||
headerRow->addLayout(buttonsBox, 0);
|
||||
|
||||
// Two-column field grid
|
||||
auto *grid = new QGridLayout;
|
||||
grid->setContentsMargins(0, 0, 0, 0);
|
||||
grid->setHorizontalSpacing(8);
|
||||
grid->setVerticalSpacing(2);
|
||||
grid->setColumnMinimumWidth(0, 62);
|
||||
grid->setColumnStretch(0, 0);
|
||||
grid->setColumnStretch(1, 1);
|
||||
|
||||
grid->addWidget(m_modelLabel, 0, 0);
|
||||
grid->addWidget(m_modelValue, 0, 1);
|
||||
grid->addWidget(m_urlLabel, 1, 0);
|
||||
grid->addWidget(m_urlValue, 1, 1);
|
||||
grid->addWidget(m_endpointLabel, 2, 0);
|
||||
grid->addWidget(m_endpointValue, 2, 1);
|
||||
grid->addWidget(m_templateLabel, 3, 0);
|
||||
grid->addWidget(m_templateValue, 3, 1);
|
||||
|
||||
auto *pillsRow = new QHBoxLayout;
|
||||
pillsRow->setContentsMargins(0, 0, 0, 0);
|
||||
pillsRow->setSpacing(4);
|
||||
pillsRow->addWidget(m_thinkingPill);
|
||||
pillsRow->addWidget(m_toolsPill);
|
||||
pillsRow->addStretch(1);
|
||||
|
||||
auto *outer = new QVBoxLayout(this);
|
||||
outer->setContentsMargins(10, 10, 10, 10);
|
||||
outer->setSpacing(6);
|
||||
outer->addLayout(headerRow);
|
||||
outer->addLayout(grid);
|
||||
outer->addWidget(m_description);
|
||||
outer->addLayout(pillsRow);
|
||||
|
||||
applyTheme();
|
||||
clear();
|
||||
}
|
||||
|
||||
void AgentSlotWidget::changeEvent(QEvent *event)
|
||||
{
|
||||
QWidget::changeEvent(event);
|
||||
if (m_inApplyTheme)
|
||||
return;
|
||||
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void AgentSlotWidget::applyTheme()
|
||||
{
|
||||
if (m_inApplyTheme)
|
||||
return;
|
||||
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||
|
||||
const Tone t = toneFor(isDarkPalette(palette()));
|
||||
|
||||
setStyleSheet(QStringLiteral(
|
||||
"#AgentSlot { background-color: %1; border: 1px solid %2; }")
|
||||
.arg(t.cardBg, t.cardBd));
|
||||
|
||||
auto applyColor = [](QLabel *label, const QString &color) {
|
||||
QPalette p = label->palette();
|
||||
p.setColor(QPalette::WindowText, QColor(color));
|
||||
label->setPalette(p);
|
||||
};
|
||||
|
||||
for (QLabel *l : {m_modelLabel, m_urlLabel, m_endpointLabel, m_templateLabel})
|
||||
applyColor(l, t.textMute);
|
||||
|
||||
applyColor(m_description, t.textSoft);
|
||||
}
|
||||
|
||||
void AgentSlotWidget::setAgentConfig(const AgentConfig &cfg)
|
||||
{
|
||||
m_name->setText(cfg.name);
|
||||
|
||||
if (cfg.isUserSource()) {
|
||||
m_sourcePill->setText(cfg.overridesBundled
|
||||
? Tr::tr("User overrides bundled")
|
||||
: Tr::tr("User"));
|
||||
m_sourcePill->show();
|
||||
} else {
|
||||
m_sourcePill->hide();
|
||||
}
|
||||
|
||||
m_modelValue->setText(cfg.model);
|
||||
m_modelValue->setToolTip(cfg.model);
|
||||
// The agent profile no longer carries a URL — the URL belongs to
|
||||
// the referenced provider instance and is editable on the Providers
|
||||
// settings page. Surface the instance name in this row instead.
|
||||
m_urlValue->setText(cfg.providerInstance);
|
||||
m_urlValue->setToolTip(cfg.providerInstance);
|
||||
m_endpointValue->setText(cfg.endpoint);
|
||||
m_endpointValue->setToolTip(cfg.endpoint);
|
||||
// Templates are now inline in the agent profile — no separate name to
|
||||
// surface. Keep the row but show '—' so the layout stays stable.
|
||||
m_templateLabel->hide();
|
||||
m_templateValue->hide();
|
||||
|
||||
m_description->setText(cfg.description.isEmpty()
|
||||
? Tr::tr("No description provided.")
|
||||
: cfg.description);
|
||||
|
||||
const Tone t = toneFor(isDarkPalette(palette()));
|
||||
QPalette descPal = m_description->palette();
|
||||
descPal.setColor(QPalette::WindowText,
|
||||
QColor(cfg.description.isEmpty() ? t.textFaint : t.textSoft));
|
||||
m_description->setPalette(descPal);
|
||||
|
||||
m_thinkingPill->setKind(cfg.enableThinking ? Pill::On : Pill::Off);
|
||||
m_thinkingPill->setText(cfg.enableThinking ? Tr::tr("thinking on")
|
||||
: Tr::tr("thinking off"));
|
||||
m_toolsPill->setKind(cfg.enableTools ? Pill::On : Pill::Off);
|
||||
m_toolsPill->setText(cfg.enableTools ? Tr::tr("tools on")
|
||||
: Tr::tr("tools off"));
|
||||
m_thinkingPill->show();
|
||||
m_toolsPill->show();
|
||||
|
||||
m_editBtn->setEnabled(!cfg.name.isEmpty());
|
||||
}
|
||||
|
||||
void AgentSlotWidget::clear()
|
||||
{
|
||||
const Tone t = toneFor(isDarkPalette(palette()));
|
||||
|
||||
m_name->setText(QStringLiteral("<i style=\"color:%1\">%2</i>")
|
||||
.arg(t.textFaint, Tr::tr("(no agent selected)")));
|
||||
m_sourcePill->hide();
|
||||
|
||||
for (QLabel *l : {m_modelValue, m_urlValue, m_endpointValue, m_templateValue}) {
|
||||
l->clear();
|
||||
l->setToolTip({});
|
||||
}
|
||||
|
||||
m_description->setText({});
|
||||
m_thinkingPill->hide();
|
||||
m_toolsPill->hide();
|
||||
m_editBtn->setEnabled(false);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
77
sources/settings/AgentSlotWidget.hpp
Normal file
77
sources/settings/AgentSlotWidget.hpp
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QLabel>
|
||||
#include <QWidget>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
|
||||
class QPushButton;
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
class Pill : public QLabel
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
enum Kind { Template, On, Off, User, Tag };
|
||||
|
||||
Pill(Kind kind, const QString &text = {}, QWidget *parent = nullptr);
|
||||
void setKind(Kind kind);
|
||||
|
||||
protected:
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
void applyTheme();
|
||||
|
||||
Kind m_kind;
|
||||
bool m_inApplyTheme = false;
|
||||
};
|
||||
|
||||
class AgentSlotWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AgentSlotWidget(QWidget *parent = nullptr);
|
||||
|
||||
void setAgentConfig(const AgentConfig &cfg);
|
||||
void clear();
|
||||
|
||||
QPushButton *changeButton() const { return m_changeBtn; }
|
||||
|
||||
signals:
|
||||
void changeRequested();
|
||||
void editRequested();
|
||||
|
||||
protected:
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
void applyTheme();
|
||||
|
||||
QLabel *m_name = nullptr;
|
||||
Pill *m_sourcePill = nullptr;
|
||||
QPushButton *m_changeBtn = nullptr;
|
||||
QPushButton *m_editBtn = nullptr;
|
||||
|
||||
QLabel *m_modelLabel = nullptr;
|
||||
QLabel *m_modelValue = nullptr;
|
||||
QLabel *m_urlLabel = nullptr;
|
||||
QLabel *m_urlValue = nullptr;
|
||||
QLabel *m_endpointLabel = nullptr;
|
||||
QLabel *m_endpointValue = nullptr;
|
||||
QLabel *m_templateLabel = nullptr;
|
||||
QLabel *m_templateValue = nullptr;
|
||||
|
||||
QLabel *m_description = nullptr;
|
||||
|
||||
Pill *m_thinkingPill = nullptr;
|
||||
Pill *m_toolsPill = nullptr;
|
||||
|
||||
bool m_inApplyTheme = false;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
38
sources/settings/CMakeLists.txt
Normal file
38
sources/settings/CMakeLists.txt
Normal file
@@ -0,0 +1,38 @@
|
||||
add_library(QodeAssistAgentPipelines OBJECT
|
||||
AgentPipelinesPage.hpp AgentPipelinesPage.cpp
|
||||
PipelinesConfig.hpp PipelinesConfig.cpp
|
||||
AgentRosterWidget.hpp AgentRosterWidget.cpp
|
||||
AgentSlotWidget.hpp AgentSlotWidget.cpp
|
||||
AgentSelectionDialog.hpp AgentSelectionDialog.cpp
|
||||
)
|
||||
|
||||
target_include_directories(QodeAssistAgentPipelines PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${CMAKE_SOURCE_DIR}/sources/providers
|
||||
${CMAKE_SOURCE_DIR}/sources/common
|
||||
${CMAKE_SOURCE_DIR}/sources/agents
|
||||
${CMAKE_SOURCE_DIR}/settings
|
||||
${CMAKE_SOURCE_DIR}/logger
|
||||
)
|
||||
|
||||
target_link_libraries(QodeAssistAgentPipelines PRIVATE
|
||||
Qt::Core
|
||||
Qt::Gui
|
||||
Qt::Widgets
|
||||
Qt::Network
|
||||
QtCreator::Core
|
||||
QtCreator::Utils
|
||||
Agents
|
||||
Providers
|
||||
Common
|
||||
LLMQore
|
||||
QodeAssistLogger
|
||||
TomlSerializer
|
||||
tomlplusplus::tomlplusplus
|
||||
)
|
||||
|
||||
target_compile_definitions(QodeAssistAgentPipelines PRIVATE
|
||||
QODEASSIST_QT_CREATOR_VERSION_MAJOR=${QODEASSIST_QT_CREATOR_VERSION_MAJOR}
|
||||
QODEASSIST_QT_CREATOR_VERSION_MINOR=${QODEASSIST_QT_CREATOR_VERSION_MINOR}
|
||||
QODEASSIST_QT_CREATOR_VERSION_PATCH=${QODEASSIST_QT_CREATOR_VERSION_PATCH}
|
||||
)
|
||||
280
sources/settings/PipelinesConfig.cpp
Normal file
280
sources/settings/PipelinesConfig.cpp
Normal file
@@ -0,0 +1,280 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "PipelinesConfig.hpp"
|
||||
|
||||
#include <coreplugin/icore.h>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QSaveFile>
|
||||
|
||||
#include <toml++/toml.hpp>
|
||||
|
||||
#include <exception>
|
||||
|
||||
#include "Logger.hpp"
|
||||
#include "TomlWriter.hpp"
|
||||
#include <AgentFactory.hpp>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kSection = "pipelines";
|
||||
constexpr const char *kCodeCompletion = "code_completion";
|
||||
constexpr const char *kChatAssistant = "chat_assistant";
|
||||
constexpr const char *kChatCompression = "chat_compression";
|
||||
constexpr const char *kQuickRefactor = "quick_refactor";
|
||||
constexpr int kMaxAgentNameLen = 256;
|
||||
|
||||
QString trimAndCap(const QString &raw)
|
||||
{
|
||||
QString s = raw.trimmed();
|
||||
if (s.size() > kMaxAgentNameLen)
|
||||
s.truncate(kMaxAgentNameLen);
|
||||
return s;
|
||||
}
|
||||
|
||||
QStringList toStringList(const toml::node *node, const QString &slotKey, bool *schemaOk)
|
||||
{
|
||||
QStringList out;
|
||||
if (!node)
|
||||
return out;
|
||||
const auto *arr = node->as_array();
|
||||
if (!arr) {
|
||||
LOG_MESSAGE(QStringLiteral(
|
||||
"[Pipelines] schema error: '%1' must be an array of strings, got non-array")
|
||||
.arg(slotKey));
|
||||
if (schemaOk)
|
||||
*schemaOk = false;
|
||||
return out;
|
||||
}
|
||||
out.reserve(static_cast<qsizetype>(arr->size()));
|
||||
for (size_t i = 0; i < arr->size(); ++i) {
|
||||
const auto &el = (*arr)[i];
|
||||
if (const auto *s = el.as_string()) {
|
||||
const QString name = trimAndCap(QString::fromStdString(s->get()));
|
||||
if (name.isEmpty())
|
||||
continue;
|
||||
if (out.contains(name)) {
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] '%1' element #%2 is a duplicate, dropping")
|
||||
.arg(slotKey)
|
||||
.arg(i));
|
||||
continue;
|
||||
}
|
||||
out.append(name);
|
||||
} else {
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] '%1' element #%2 is not a string, dropping")
|
||||
.arg(slotKey)
|
||||
.arg(i));
|
||||
if (schemaOk)
|
||||
*schemaOk = false;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void fillMissingFromDefaults(PipelineRosters &r, const toml::table §ion)
|
||||
{
|
||||
const PipelineRosters defs = PipelineRosters::defaults();
|
||||
if (!section.contains(kCodeCompletion))
|
||||
r.codeCompletion = defs.codeCompletion;
|
||||
if (!section.contains(kChatAssistant))
|
||||
r.chatAssistant = defs.chatAssistant;
|
||||
if (!section.contains(kChatCompression))
|
||||
r.chatCompression = defs.chatCompression;
|
||||
if (!section.contains(kQuickRefactor))
|
||||
r.quickRefactor = defs.quickRefactor;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PipelineRosters PipelineRosters::defaults()
|
||||
{
|
||||
PipelineRosters r;
|
||||
r.codeCompletion = {QStringLiteral("Ollama Qwen2.5-Coder Completion")};
|
||||
r.chatAssistant = {QStringLiteral("Ollama Chat")};
|
||||
r.chatCompression = {QStringLiteral("Ollama Compression")};
|
||||
r.quickRefactor = {QStringLiteral("Ollama Quick Refactor")};
|
||||
return r;
|
||||
}
|
||||
|
||||
QString PipelinesConfig::filePath()
|
||||
{
|
||||
return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/pipelines.toml"))
|
||||
.toFSPathString();
|
||||
}
|
||||
|
||||
PipelinesLoadResult PipelinesConfig::load()
|
||||
{
|
||||
PipelinesLoadResult result;
|
||||
const QString path = filePath();
|
||||
QFile f(path);
|
||||
if (!f.exists()) {
|
||||
result.rosters = PipelineRosters::defaults();
|
||||
result.status = PipelinesLoadStatus::FileMissing;
|
||||
return result;
|
||||
}
|
||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
result.rosters = PipelineRosters::defaults();
|
||||
result.status = PipelinesLoadStatus::ParseError;
|
||||
result.message = QStringLiteral("cannot open %1: %2").arg(path, f.errorString());
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message));
|
||||
return result;
|
||||
}
|
||||
const QByteArray contents = f.readAll();
|
||||
f.close();
|
||||
|
||||
toml::table tbl;
|
||||
try {
|
||||
tbl = toml::parse(std::string_view(contents.constData(),
|
||||
static_cast<size_t>(contents.size())),
|
||||
path.toStdString());
|
||||
} catch (const toml::parse_error &e) {
|
||||
result.rosters = PipelineRosters::defaults();
|
||||
result.status = PipelinesLoadStatus::ParseError;
|
||||
result.message = QStringLiteral("parse error in %1: %2")
|
||||
.arg(path, QString::fromStdString(std::string(e.description())));
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message));
|
||||
return result;
|
||||
} catch (const std::exception &e) {
|
||||
result.rosters = PipelineRosters::defaults();
|
||||
result.status = PipelinesLoadStatus::ParseError;
|
||||
result.message = QStringLiteral("error reading %1: %2")
|
||||
.arg(path, QString::fromUtf8(e.what()));
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message));
|
||||
return result;
|
||||
} catch (...) {
|
||||
result.rosters = PipelineRosters::defaults();
|
||||
result.status = PipelinesLoadStatus::ParseError;
|
||||
result.message = QStringLiteral("unknown error reading %1").arg(path);
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(result.message));
|
||||
return result;
|
||||
}
|
||||
|
||||
const auto *section = tbl[kSection].as_table();
|
||||
if (!section) {
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] no [pipelines] section in %1; using defaults")
|
||||
.arg(path));
|
||||
result.rosters = PipelineRosters::defaults();
|
||||
result.status = PipelinesLoadStatus::SchemaError;
|
||||
result.message = QStringLiteral("missing [pipelines] section");
|
||||
return result;
|
||||
}
|
||||
|
||||
bool schemaOk = true;
|
||||
result.rosters.codeCompletion
|
||||
= toStringList((*section)[kCodeCompletion].node(), kCodeCompletion, &schemaOk);
|
||||
result.rosters.chatAssistant
|
||||
= toStringList((*section)[kChatAssistant].node(), kChatAssistant, &schemaOk);
|
||||
result.rosters.chatCompression
|
||||
= toStringList((*section)[kChatCompression].node(), kChatCompression, &schemaOk);
|
||||
result.rosters.quickRefactor
|
||||
= toStringList((*section)[kQuickRefactor].node(), kQuickRefactor, &schemaOk);
|
||||
|
||||
fillMissingFromDefaults(result.rosters, *section);
|
||||
|
||||
if (!schemaOk) {
|
||||
result.status = PipelinesLoadStatus::SchemaError;
|
||||
result.message = QStringLiteral("some entries had wrong type; see log");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut)
|
||||
{
|
||||
const QString path = filePath();
|
||||
const QFileInfo info(path);
|
||||
if (!QDir().mkpath(info.absolutePath())) {
|
||||
const QString err = QStringLiteral("cannot create configuration directory: %1")
|
||||
.arg(info.absolutePath());
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(err));
|
||||
if (errorOut)
|
||||
*errorOut = err;
|
||||
return false;
|
||||
}
|
||||
|
||||
TomlSerializer::TomlWriter w;
|
||||
w.writeComment(QStringLiteral(
|
||||
"QodeAssist pipelines — slot → ordered list of agent names."));
|
||||
w.writeComment(QStringLiteral(
|
||||
"The router walks each list top-down at request time and uses"));
|
||||
w.writeComment(QStringLiteral("the first matching agent."));
|
||||
w.writeBlankLine();
|
||||
w.writeTableHeader(QString::fromUtf8(kSection));
|
||||
w.writeStringArray(QString::fromUtf8(kCodeCompletion), rosters.codeCompletion);
|
||||
w.writeStringArray(QString::fromUtf8(kChatAssistant), rosters.chatAssistant);
|
||||
w.writeStringArray(QString::fromUtf8(kChatCompression), rosters.chatCompression);
|
||||
w.writeStringArray(QString::fromUtf8(kQuickRefactor), rosters.quickRefactor);
|
||||
|
||||
QSaveFile out(path);
|
||||
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
if (errorOut)
|
||||
*errorOut = out.errorString();
|
||||
return false;
|
||||
}
|
||||
const QByteArray payload = w.toUtf8();
|
||||
const qint64 written = out.write(payload);
|
||||
if (written != payload.size()) {
|
||||
const QString err = QStringLiteral("short write (%1/%2): %3")
|
||||
.arg(written)
|
||||
.arg(payload.size())
|
||||
.arg(out.errorString());
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(err));
|
||||
out.cancelWriting();
|
||||
if (errorOut)
|
||||
*errorOut = err;
|
||||
return false;
|
||||
}
|
||||
if (!out.commit()) {
|
||||
if (errorOut)
|
||||
*errorOut = out.errorString();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PipelinesConfig::validate(const QodeAssist::AgentFactory &factory, QString *errorOut)
|
||||
{
|
||||
PipelinesLoadResult lr = load();
|
||||
PipelineRosters &r = lr.rosters;
|
||||
bool changed = false;
|
||||
|
||||
auto fix = [&](QStringList ¤t) {
|
||||
QStringList kept;
|
||||
kept.reserve(current.size());
|
||||
for (const QString &raw : current) {
|
||||
const QString name = trimAndCap(raw);
|
||||
if (name.isEmpty() || kept.contains(name))
|
||||
continue;
|
||||
if (factory.configByName(name))
|
||||
kept.append(name);
|
||||
}
|
||||
if (kept != current) {
|
||||
current = std::move(kept);
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
|
||||
fix(r.codeCompletion);
|
||||
fix(r.chatAssistant);
|
||||
fix(r.chatCompression);
|
||||
fix(r.quickRefactor);
|
||||
|
||||
if (!changed && lr.status == PipelinesLoadStatus::Ok)
|
||||
return true;
|
||||
|
||||
QString saveErr;
|
||||
if (!save(r, &saveErr)) {
|
||||
const QString msg = QStringLiteral("failed to persist after validation: %1").arg(saveErr);
|
||||
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(msg));
|
||||
if (errorOut)
|
||||
*errorOut = msg;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
47
sources/settings/PipelinesConfig.hpp
Normal file
47
sources/settings/PipelinesConfig.hpp
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
namespace QodeAssist {
|
||||
class AgentFactory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
struct PipelineRosters
|
||||
{
|
||||
QStringList codeCompletion;
|
||||
QStringList chatAssistant;
|
||||
QStringList chatCompression;
|
||||
QStringList quickRefactor;
|
||||
|
||||
[[nodiscard]] static PipelineRosters defaults();
|
||||
};
|
||||
|
||||
enum class PipelinesLoadStatus { Ok, FileMissing, ParseError, SchemaError };
|
||||
|
||||
struct PipelinesLoadResult
|
||||
{
|
||||
PipelineRosters rosters;
|
||||
PipelinesLoadStatus status = PipelinesLoadStatus::Ok;
|
||||
QString message;
|
||||
};
|
||||
|
||||
class PipelinesConfig
|
||||
{
|
||||
public:
|
||||
[[nodiscard]] static QString filePath();
|
||||
|
||||
[[nodiscard]] static PipelinesLoadResult load();
|
||||
|
||||
[[nodiscard]] static bool save(const PipelineRosters &rosters, QString *errorOut = nullptr);
|
||||
|
||||
[[nodiscard]] static bool validate(
|
||||
const QodeAssist::AgentFactory &factory, QString *errorOut = nullptr);
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
Reference in New Issue
Block a user