Merge pull request #1138 from ispyisail/feature/connect-ai-assistant

Add Help → Connect an AI assistant
This commit is contained in:
ispyisail
2026-09-29 18:53:29 +13:00
committed by GitHub
9 changed files with 745 additions and 0 deletions
+4
View File
@@ -714,6 +714,10 @@ set(QET_SRC_FILES
${QET_DIR}/sources/ui/aboutqetdialog.cpp
${QET_DIR}/sources/ui/aboutqetdialog.h
${QET_DIR}/sources/ui/aiassistantdialog.cpp
${QET_DIR}/sources/ui/aiassistantdialog.h
${QET_DIR}/sources/ui/aiassistantsetup.cpp
${QET_DIR}/sources/ui/aiassistantsetup.h
${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.cpp
${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.h
${QET_DIR}/sources/ui/alignmenttextdialog.cpp
+9
View File
@@ -26,6 +26,7 @@
#include "qetmainwindow.h"
#include "qeticons.h"
#include "ui/aiassistantdialog.h"
#include "shortcutmanager.h"
#include "qetapp.h"
#include "qetdiagrameditor.h"
@@ -111,6 +112,13 @@ void QETMainWindow::initCommonActions()
ShortcutManager::instance().registerAction(manual_online_, "mainwindow.manual_online", tr("Général"), Qt::Key_F1);
connect_ai_ = new QAction(tr("Connecter un assistant IA..."), this);
connect_ai_ -> setStatusTip(tr("Affiche la configuration qui permet à un assistant IA d'utiliser QElectroTech", "status bar tip"));
connect(connect_ai_, &QAction::triggered, this, [this]() {
AiAssistantDialog dialog(this);
dialog.exec();
});
youtube_ = new QAction(QET::Icons::QETVideo, tr("Chaine Youtube"), this);
youtube_ -> setStatusTip(tr("Lance le navigateur par défaut vers la chaine Youtube de QElectroTech", "status bar tip"));
@@ -170,6 +178,7 @@ void QETMainWindow::initCommonMenus()
help_menu_ -> addAction(whatsthis_action_);
help_menu_ -> addSeparator();
help_menu_ -> addAction(manual_online_);
help_menu_ -> addAction(connect_ai_);
help_menu_ -> addAction(youtube_);
help_menu_ -> addAction(upgrade_);
help_menu_ -> addAction(upgrade_M);
+1
View File
@@ -66,6 +66,7 @@ class QETMainWindow : public QMainWindow {
QAction *donate_; ///< Launch browser to donate link
QAction *about_qt_; ///< launch the "About Qt" dialog
QAction *diagnostics_action_; ///< Open the diagnostics report dialog (discussion #644, step 5)
QAction *connect_ai_; ///< Show how to connect an AI assistant (MCP server)
QMenu *settings_menu_; ///< Settings menu
QMenu *help_menu_; ///< Help menu
QMenu *display_toolbars_; ///< Show/hide toolbars/docks
+220
View File
@@ -0,0 +1,220 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "aiassistantdialog.h"
#include <QApplication>
#include <QCheckBox>
#include <QClipboard>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDir>
#include <QFileDialog>
#include <QFontDatabase>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QVBoxLayout>
using AiAssistantSetup::Client;
namespace {
const char *const GUIDE_URL =
"https://github.com/qelectrotech/qelectrotech-source-mirror/wiki/ai_assistants";
}
AiAssistantDialog::AiAssistantDialog(QWidget *parent) :
QDialog(parent)
{
setWindowTitle(tr("Connecter un assistant IA"));
resize(720, 560);
#ifdef Q_OS_WIN
const bool windows = true;
#else
const bool windows = false;
#endif
m_paths = AiAssistantSetup::detect(QCoreApplication::applicationDirPath(),
QCoreApplication::applicationFilePath(),
windows);
auto *layout = new QVBoxLayout(this);
auto *intro = new QLabel(
tr("Un assistant IA (Claude, GitHub Copilot, Gemini…) peut ouvrir, "
"vérifier et modifier vos schémas grâce au serveur MCP de "
"QElectroTech, qui fonctionne sur cet ordinateur. Copiez le texte "
"ci-dessous dans la configuration de votre assistant. "
"<a href=\"%1\">Guide détaillé</a>").arg(QLatin1String(GUIDE_URL)),
this);
intro->setWordWrap(true);
intro->setOpenExternalLinks(true);
layout->addWidget(intro);
if (m_paths.server.isEmpty()) {
auto *missing = new QLabel(
tr("<b>Le serveur MCP n'est pas installé avec cette version de "
"QElectroTech.</b> Le guide explique comment l'obtenir."),
this);
missing->setWordWrap(true);
layout->addWidget(missing);
}
if (!m_paths.server.isEmpty()
&& m_paths.python_status != AiAssistantSetup::PythonStatus::Found) {
QString text;
if (m_paths.python_status == AiAssistantSetup::PythonStatus::StoreShortcut)
text = tr("<b>Python n'est peut-être pas installé.</b> Seul le raccourci "
"« python » du Microsoft Store a été trouvé : sans Python, il "
"ouvre le Store au lieu de lancer le serveur.");
else
text = tr("<b>Python est introuvable sur cet ordinateur</b> (commande "
"« %1 »). Le serveur en a besoin.").arg(m_paths.python);
if (windows)
text += QLatin1Char(' ') + tr("Relancez l'installateur de QElectroTech et "
"cochez « Python pour l'assistant IA », ou "
"installez Python depuis python.org.");
else
text += QLatin1Char(' ') + tr("Installez Python 3 avec le gestionnaire "
"de paquets de votre système.");
auto *python = new QLabel(text, this);
python->setWordWrap(true);
layout->addWidget(python);
}
auto *form = new QFormLayout();
m_client = new QComboBox(this);
m_client->addItem(QStringLiteral("Claude Desktop"), int(Client::ClaudeDesktop));
m_client->addItem(QStringLiteral("Claude Code"), int(Client::ClaudeCode));
m_client->addItem(QStringLiteral("GitHub Copilot (VS Code)"), int(Client::VsCode));
m_client->addItem(QStringLiteral("Cursor"), int(Client::Cursor));
m_client->addItem(QStringLiteral("Gemini CLI"), int(Client::GeminiCli));
m_client->addItem(QStringLiteral("Codex CLI"), int(Client::CodexCli));
m_client->addItem(QStringLiteral("LM Studio"), int(Client::LmStudio));
form->addRow(tr("Assistant :"), m_client);
auto *workspace_row = new QHBoxLayout();
m_workspace = new QLineEdit(this);
m_workspace->setPlaceholderText(tr("Le dossier de vos schémas"));
auto *browse = new QPushButton(tr("Parcourir…"), this);
workspace_row->addWidget(m_workspace);
workspace_row->addWidget(browse);
form->addRow(tr("Dossier accessible :"), workspace_row);
m_allow_edit = new QCheckBox(tr("Autoriser l'assistant à modifier les schémas"), this);
form->addRow(QString(), m_allow_edit);
layout->addLayout(form);
auto *scope = new QLabel(
tr("L'assistant ne peut lire et écrire que dans ce dossier. Sans "
"modification autorisée, il peut seulement lire, comparer et "
"exporter. Un assistant lit le texte des projets (repères, "
"notes…) : un texte écrit comme une instruction peut "
"l'influencer. Laissez les modifications désactivées pour les "
"schémas reçus d'autres personnes."),
this);
scope->setWordWrap(true);
layout->addWidget(scope);
m_where = new QLabel(this);
m_where->setWordWrap(true);
m_where->setTextInteractionFlags(Qt::TextSelectableByMouse);
layout->addWidget(m_where);
m_text = new QPlainTextEdit(this);
m_text->setReadOnly(true);
m_text->setLineWrapMode(QPlainTextEdit::NoWrap);
m_text->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
layout->addWidget(m_text);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
m_copy = buttons->addButton(tr("Copier"), QDialogButtonBox::ActionRole);
layout->addWidget(buttons);
connect(m_client, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &AiAssistantDialog::refresh);
connect(m_workspace, &QLineEdit::textChanged, this, &AiAssistantDialog::refresh);
connect(m_allow_edit, &QCheckBox::toggled, this, &AiAssistantDialog::refresh);
connect(browse, &QPushButton::clicked, this, &AiAssistantDialog::chooseWorkspace);
connect(m_copy, &QPushButton::clicked, this, &AiAssistantDialog::copy);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
refresh();
}
void AiAssistantDialog::refresh()
{
const auto client = Client(m_client->currentData().toInt());
m_where->setText(whereItGoes(client) + QLatin1Char(' ')
+ tr("Si le fichier contient déjà d'autres serveurs, "
"ajoutez seulement l'entrée « qet »."));
const QString workspace = m_workspace->text().trimmed();
const bool ready = !m_paths.server.isEmpty() && !workspace.isEmpty();
m_copy->setEnabled(ready);
if (m_paths.server.isEmpty())
m_text->setPlainText(QString());
else if (workspace.isEmpty())
m_text->setPlainText(tr("Choisissez d'abord le dossier de vos schémas."));
else
m_text->setPlainText(AiAssistantSetup::configuration(
client, m_paths, workspace, m_allow_edit->isChecked()));
}
void AiAssistantDialog::chooseWorkspace()
{
const QString dir = QFileDialog::getExistingDirectory(
this, tr("Dossier accessible à l'assistant"), m_workspace->text());
if (!dir.isEmpty())
m_workspace->setText(QDir::toNativeSeparators(dir));
}
void AiAssistantDialog::copy()
{
QApplication::clipboard()->setText(m_text->toPlainText());
m_copy->setText(tr("Copié"));
}
QString AiAssistantDialog::whereItGoes(Client client) const
{
switch (client) {
case Client::ClaudeDesktop:
return tr("Dans Claude Desktop : Paramètres → Développeur → "
"Modifier la configuration. Puis quittez et relancez Claude.");
case Client::ClaudeCode:
return tr("Enregistrez-le sous le nom .mcp.json dans le dossier de vos schémas.");
case Client::VsCode:
return tr("Enregistrez-le sous le nom .vscode/mcp.json dans le dossier "
"ouvert dans VS Code. Copilot utilise les outils en mode agent.");
case Client::Cursor:
return tr("Ajoutez-le au fichier .cursor/mcp.json de votre dossier "
"personnel.");
case Client::GeminiCli:
return tr("Ajoutez-le au fichier .gemini/settings.json de votre dossier "
"personnel. Gemini CLI demande de faire confiance au dossier "
"la première fois.");
case Client::CodexCli:
return tr("Ajoutez-le au fichier .codex/config.toml de votre dossier "
"personnel.");
case Client::LmStudio:
return tr("Dans LM Studio : onglet Program → Install → Edit mcp.json.");
}
return QString();
}
+61
View File
@@ -0,0 +1,61 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef AIASSISTANTDIALOG_H
#define AIASSISTANTDIALOG_H
#include "aiassistantsetup.h"
#include <QDialog>
class QCheckBox;
class QComboBox;
class QLabel;
class QLineEdit;
class QPlainTextEdit;
class QPushButton;
/**
@brief The AiAssistantDialog class
Help > Connect an AI assistant: shows the configuration an assistant
needs to start QElectroTech's MCP server, with this installation's
paths filled in, for the user to copy into that assistant. It writes
nothing and starts nothing.
*/
class AiAssistantDialog : public QDialog
{
Q_OBJECT
public:
explicit AiAssistantDialog(QWidget *parent = nullptr);
private:
void refresh();
void chooseWorkspace();
void copy();
QString whereItGoes(AiAssistantSetup::Client client) const;
AiAssistantSetup::Paths m_paths;
QComboBox *m_client = nullptr;
QLineEdit *m_workspace = nullptr;
QCheckBox *m_allow_edit = nullptr;
QLabel *m_where = nullptr;
QPlainTextEdit *m_text = nullptr;
QPushButton *m_copy = nullptr;
};
#endif // AIASSISTANTDIALOG_H
+164
View File
@@ -0,0 +1,164 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "aiassistantsetup.h"
#include <QDir>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
namespace {
/**
The first of \a candidates that is a file, as a clean absolute path,
or an empty string.
*/
QString firstFile(const QStringList &candidates)
{
for (const QString &c : candidates) {
const QFileInfo info(c);
if (info.isFile())
return QDir::cleanPath(info.absoluteFilePath());
}
return QString();
}
QJsonObject environment(const AiAssistantSetup::Paths &paths,
const QString &workspace,
bool allow_edit)
{
QJsonObject env;
env.insert(QStringLiteral("QET_MCP_WORKSPACE"), QDir::toNativeSeparators(workspace));
env.insert(QStringLiteral("QET_BINARY"), QDir::toNativeSeparators(paths.qet_binary));
if (allow_edit)
env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1"));
return env;
}
/// A TOML basic string: backslashes and quotes escaped.
QString toml(const QString &s)
{
QString out = s;
out.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
out.replace(QLatin1Char('"'), QStringLiteral("\\\""));
return QLatin1Char('"') + out + QLatin1Char('"');
}
} // namespace
QList<AiAssistantSetup::Client> AiAssistantSetup::clients()
{
return {Client::ClaudeDesktop, Client::ClaudeCode, Client::VsCode,
Client::Cursor, Client::GeminiCli, Client::CodexCli,
Client::LmStudio};
}
/**
@brief AiAssistantSetup::detect
Finds the server beside this QElectroTech, in the two places its
packages put it: <root>/mcp/ beside <root>/bin/ (the Windows installers
and portable folder) and <prefix>/share/qelectrotech/mcp/ beside
<prefix>/bin/ (make install). On Windows the Python the installer can
add is preferred to one on PATH.
@param application_dir : QCoreApplication::applicationDirPath()
@param application_file : QCoreApplication::applicationFilePath()
@param windows : true on Windows, where Python is "python", not "python3"
@param find_executable : looks a program up on PATH; empty means
QStandardPaths::findExecutable(). Given in tests.
*/
AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir,
const QString &application_file,
bool windows,
const ExecutableFinder &find_executable)
{
const QDir bin(application_dir);
Paths paths;
paths.qet_binary = QDir::cleanPath(application_file);
paths.server = firstFile({
bin.filePath(QStringLiteral("../mcp/qet_mcp.py")),
bin.filePath(QStringLiteral("../share/qelectrotech/mcp/qet_mcp.py"))});
paths.python = windows ? QStringLiteral("python") : QStringLiteral("python3");
if (windows) {
const QString bundled = firstFile({
bin.filePath(QStringLiteral("../mcp/python/python.exe"))});
if (!bundled.isEmpty()) {
paths.python = bundled;
paths.python_status = PythonStatus::Found;
return paths;
}
}
const ExecutableFinder find = find_executable
? find_executable
: ExecutableFinder([](const QString &name) {
return QStandardPaths::findExecutable(name); });
const QString found = find(paths.python);
if (found.isEmpty())
paths.python_status = PythonStatus::Missing;
// Windows 10 and 11 put a python.exe in WindowsApps that only opens the
// Microsoft Store. A Python installed from the Store answers from there
// too, so this cannot tell the two apart: say which it may be.
else if (windows && QString(found).replace(QLatin1Char('\\'), QLatin1Char('/'))
.contains(QLatin1String("/WindowsApps/"), Qt::CaseInsensitive))
paths.python_status = PythonStatus::StoreShortcut;
else
paths.python_status = PythonStatus::Found;
return paths;
}
/**
@brief AiAssistantSetup::configuration
@return the text to paste into \a client's configuration: JSON for all
but Codex CLI, which reads TOML. Windows paths come out with native
separators, escaped as each format requires.
*/
QString AiAssistantSetup::configuration(Client client,
const Paths &paths,
const QString &workspace,
bool allow_edit)
{
const QString python = QDir::toNativeSeparators(paths.python);
const QString server = QDir::toNativeSeparators(paths.server);
const QJsonObject env = environment(paths, workspace, allow_edit);
if (client == Client::CodexCli) {
QString text = QStringLiteral("[mcp_servers.qet]\n");
text += QStringLiteral("command = ") + toml(python) + QLatin1Char('\n');
text += QStringLiteral("args = [") + toml(server) + QStringLiteral("]\n\n");
text += QStringLiteral("[mcp_servers.qet.env]\n");
for (auto it = env.constBegin(); it != env.constEnd(); ++it)
text += it.key() + QStringLiteral(" = ") + toml(it.value().toString()) + QLatin1Char('\n');
return text;
}
QJsonObject server_entry;
if (client == Client::VsCode || client == Client::Cursor)
server_entry.insert(QStringLiteral("type"), QStringLiteral("stdio"));
server_entry.insert(QStringLiteral("command"), python);
server_entry.insert(QStringLiteral("args"), QJsonArray{server});
server_entry.insert(QStringLiteral("env"), env);
// VS Code names the list "servers"; every other client, "mcpServers".
const QString key = client == Client::VsCode ? QStringLiteral("servers")
: QStringLiteral("mcpServers");
const QJsonObject root{{key, QJsonObject{{QStringLiteral("qet"), server_entry}}}};
return QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented));
}
+80
View File
@@ -0,0 +1,80 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef AIASSISTANTSETUP_H
#define AIASSISTANTSETUP_H
#include <QString>
#include <QStringList>
#include <functional>
/**
@brief What an AI assistant needs to start QElectroTech's MCP server
(misc/qet-mcp), and the configuration text each assistant expects.
No widgets and no application object, so it can be tested alone. It
reads the file system and nothing else: it writes nothing and starts
nothing. AiAssistantDialog shows its result.
*/
namespace AiAssistantSetup
{
/// The assistants a configuration can be written for.
enum class Client {
ClaudeDesktop,
ClaudeCode,
VsCode,
Cursor,
GeminiCli,
CodexCli,
LmStudio
};
QList<Client> clients();
/// Whether the Python the configuration names can actually run.
enum class PythonStatus {
Found,
Missing,
/// Only Windows' "python.exe" shortcut in WindowsApps, which opens
/// the Microsoft Store when Python is not installed.
StoreShortcut
};
/// Where the pieces are. An empty server means none was found.
struct Paths {
QString server; ///< qet_mcp.py
QString python; ///< the Python to run it with
QString qet_binary; ///< this QElectroTech
PythonStatus python_status = PythonStatus::Missing;
};
/// Finds a program on PATH; QStandardPaths::findExecutable() by default.
using ExecutableFinder = std::function<QString(const QString &)>;
Paths detect(const QString &application_dir,
const QString &application_file,
bool windows,
const ExecutableFinder &find_executable = ExecutableFinder());
QString configuration(Client client,
const Paths &paths,
const QString &workspace,
bool allow_edit);
}
#endif // AIASSISTANTSETUP_H
+10
View File
@@ -212,6 +212,16 @@ add_test(NAME tst_scriptingsetting COMMAND tst_scriptingsetting)
target_include_directories(tst_scriptingsetting PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_scriptingsetting PRIVATE Qt::Test Qt::Gui)
# AiAssistantSetup: finding the MCP server beside an installation, and the
# configuration text for each AI assistant. Compiles the one file alone.
add_executable(
tst_aiassistantsetup
tst_aiassistantsetup.cpp
${QET_DIR}/sources/ui/aiassistantsetup.cpp)
add_test(NAME tst_aiassistantsetup COMMAND tst_aiassistantsetup)
target_include_directories(tst_aiassistantsetup PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_aiassistantsetup PRIVATE Qt::Test Qt::Core)
# QetSettings::sheetBackground() -- the sheet (folio) background the diagram
# editor remembers between runs. Compiles qetsettings.cpp alone for the same
# reason as the test above; Qt::Gui because the pair carries a QColor.
+196
View File
@@ -0,0 +1,196 @@
/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ui/aiassistantsetup.h"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryDir>
#include <QtTest>
using AiAssistantSetup::Client;
class TestAiAssistantSetup : public QObject
{
Q_OBJECT
static void touch(const QString &path)
{
QDir().mkpath(QFileInfo(path).absolutePath());
QFile f(path);
QVERIFY(f.open(QIODevice::WriteOnly));
}
static AiAssistantSetup::Paths windowsPaths()
{
AiAssistantSetup::Paths p;
p.server = QStringLiteral("C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py");
p.python = QStringLiteral("C:\\Program Files\\QElectroTech\\mcp\\python\\python.exe");
p.qet_binary = QStringLiteral("C:\\Program Files\\QElectroTech\\bin\\QElectroTech.exe");
return p;
}
static QJsonObject entry(Client client, const AiAssistantSetup::Paths &p, bool edit)
{
const QByteArray text = AiAssistantSetup::configuration(
client, p, QStringLiteral("D:\\Drawings"), edit).toUtf8();
QJsonParseError error;
const QJsonDocument doc = QJsonDocument::fromJson(text, &error);
if (error.error != QJsonParseError::NoError)
qWarning() << error.errorString() << text;
const QString key = client == Client::VsCode ? QStringLiteral("servers")
: QStringLiteral("mcpServers");
return doc.object().value(key).toObject().value(QStringLiteral("qet")).toObject();
}
private slots:
void windowsInstallIsFound()
{
QTemporaryDir root;
touch(root.filePath(QStringLiteral("bin/QElectroTech.exe")));
touch(root.filePath(QStringLiteral("mcp/qet_mcp.py")));
touch(root.filePath(QStringLiteral("mcp/python/python.exe")));
const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")),
root.filePath(QStringLiteral("bin/QElectroTech.exe")), true);
QCOMPARE(p.server, QDir::cleanPath(root.filePath(QStringLiteral("mcp/qet_mcp.py"))));
QCOMPARE(p.python, QDir::cleanPath(root.filePath(QStringLiteral("mcp/python/python.exe"))));
QCOMPARE(p.qet_binary, QDir::cleanPath(root.filePath(QStringLiteral("bin/QElectroTech.exe"))));
}
void windowsWithoutBundledPythonUsesPath()
{
QTemporaryDir root;
touch(root.filePath(QStringLiteral("mcp/qet_mcp.py")));
QDir().mkpath(root.filePath(QStringLiteral("bin")));
const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")),
root.filePath(QStringLiteral("bin/QElectroTech.exe")), true);
QCOMPARE(p.python, QStringLiteral("python"));
}
void unixInstallIsFound()
{
QTemporaryDir prefix;
touch(prefix.filePath(QStringLiteral("share/qelectrotech/mcp/qet_mcp.py")));
// A bundled-Python layout is only a Windows thing.
touch(prefix.filePath(QStringLiteral("mcp/python/python.exe")));
QDir().mkpath(prefix.filePath(QStringLiteral("bin")));
const auto p = AiAssistantSetup::detect(prefix.filePath(QStringLiteral("bin")),
prefix.filePath(QStringLiteral("bin/qelectrotech")), false);
QCOMPARE(p.server, QDir::cleanPath(prefix.filePath(QStringLiteral("share/qelectrotech/mcp/qet_mcp.py"))));
QCOMPARE(p.python, QStringLiteral("python3"));
}
void bundledPythonIsFoundWithoutLookingOnPath()
{
QTemporaryDir root;
touch(root.filePath(QStringLiteral("mcp/qet_mcp.py")));
touch(root.filePath(QStringLiteral("mcp/python/python.exe")));
QDir().mkpath(root.filePath(QStringLiteral("bin")));
bool looked = false;
const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")),
root.filePath(QStringLiteral("bin/QElectroTech.exe")), true,
[&looked](const QString &) { looked = true; return QString(); });
QCOMPARE(p.python_status, AiAssistantSetup::PythonStatus::Found);
QVERIFY(!looked);
}
void pythonStatusFromPath_data()
{
QTest::addColumn<bool>("windows");
QTest::addColumn<QString>("found");
QTest::addColumn<int>("status");
using S = AiAssistantSetup::PythonStatus;
QTest::newRow("windows, none") << true << QString() << int(S::Missing);
QTest::newRow("windows, python.org") << true
<< QStringLiteral("C:/Users/me/AppData/Local/Programs/Python/Python314/python.exe") << int(S::Found);
QTest::newRow("windows, Store shortcut") << true
<< QStringLiteral("C:/Users/me/AppData/Local/Microsoft/WindowsApps/python.exe") << int(S::StoreShortcut);
QTest::newRow("windows, Store shortcut, backslashes") << true
<< QStringLiteral("C:\\Users\\me\\AppData\\Local\\Microsoft\\WINDOWSAPPS\\python.exe") << int(S::StoreShortcut);
QTest::newRow("linux, none") << false << QString() << int(S::Missing);
QTest::newRow("linux, python3") << false << QStringLiteral("/usr/bin/python3") << int(S::Found);
QTest::newRow("linux, a folder named WindowsApps") << false
<< QStringLiteral("/opt/WindowsApps/python3") << int(S::Found);
}
void pythonStatusFromPath()
{
QFETCH(bool, windows);
QFETCH(QString, found);
QFETCH(int, status);
QTemporaryDir build;
QString asked;
const auto p = AiAssistantSetup::detect(build.path(),
build.filePath(QStringLiteral("qelectrotech")), windows,
[&](const QString &name) { asked = name; return found; });
QCOMPARE(int(p.python_status), status);
QCOMPARE(asked, windows ? QStringLiteral("python") : QStringLiteral("python3"));
}
void noServerIsReportedAsEmpty()
{
QTemporaryDir build;
const auto p = AiAssistantSetup::detect(build.path(),
build.filePath(QStringLiteral("qelectrotech")), false);
QVERIFY(p.server.isEmpty());
}
void everyJsonClientGetsValidJson()
{
for (Client c : AiAssistantSetup::clients()) {
if (c == Client::CodexCli)
continue;
const QJsonObject e = entry(c, windowsPaths(), false);
QVERIFY2(!e.isEmpty(), qPrintable(QString::number(int(c))));
// Backslashes survive: parsing the text gives the path back.
QCOMPARE(e.value(QStringLiteral("args")).toArray().at(0).toString(), windowsPaths().server);
QCOMPARE(e.value(QStringLiteral("command")).toString(), windowsPaths().python);
const QJsonObject env = e.value(QStringLiteral("env")).toObject();
QCOMPARE(env.value(QStringLiteral("QET_MCP_WORKSPACE")).toString(), QStringLiteral("D:\\Drawings"));
QCOMPARE(env.value(QStringLiteral("QET_BINARY")).toString(), windowsPaths().qet_binary);
const bool typed = c == Client::VsCode || c == Client::Cursor;
QCOMPARE(e.contains(QStringLiteral("type")), typed);
}
}
void editingIsOffUnlessAllowed()
{
for (Client c : AiAssistantSetup::clients()) {
const QString off = AiAssistantSetup::configuration(c, windowsPaths(), QStringLiteral("D:\\x"), false);
const QString on = AiAssistantSetup::configuration(c, windowsPaths(), QStringLiteral("D:\\x"), true);
QVERIFY(!off.contains(QStringLiteral("QET_ENABLE_SCRIPTING")));
QVERIFY(on.contains(QStringLiteral("QET_ENABLE_SCRIPTING")));
}
}
void codexGetsEscapedToml()
{
const QString t = AiAssistantSetup::configuration(
Client::CodexCli, windowsPaths(), QStringLiteral("D:\\Drawings"), true);
QVERIFY(t.startsWith(QStringLiteral("[mcp_servers.qet]\n")));
QVERIFY(t.contains(QStringLiteral("args = [\"C:\\\\Program Files\\\\QElectroTech\\\\mcp\\\\qet_mcp.py\"]")));
QVERIFY(t.contains(QStringLiteral("[mcp_servers.qet.env]\n")));
QVERIFY(t.contains(QStringLiteral("QET_MCP_WORKSPACE = \"D:\\\\Drawings\"")));
QVERIFY(t.contains(QStringLiteral("QET_ENABLE_SCRIPTING = \"1\"")));
}
};
QTEST_GUILESS_MAIN(TestAiAssistantSetup)
#include "tst_aiassistantsetup.moc"