mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-29 14:14:15 +02:00
Merge pull request #1138 from ispyisail/feature/connect-ai-assistant
Add Help → Connect an AI assistant
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user