mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-19 12:34: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.cpp
|
||||||
${QET_DIR}/sources/ui/aboutqetdialog.h
|
${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.cpp
|
||||||
${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.h
|
${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.h
|
||||||
${QET_DIR}/sources/ui/alignmenttextdialog.cpp
|
${QET_DIR}/sources/ui/alignmenttextdialog.cpp
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
#include "qetmainwindow.h"
|
#include "qetmainwindow.h"
|
||||||
#include "qeticons.h"
|
#include "qeticons.h"
|
||||||
|
#include "ui/aiassistantdialog.h"
|
||||||
#include "shortcutmanager.h"
|
#include "shortcutmanager.h"
|
||||||
#include "qetapp.h"
|
#include "qetapp.h"
|
||||||
#include "qetdiagrameditor.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);
|
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_ = 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"));
|
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_ -> addAction(whatsthis_action_);
|
||||||
help_menu_ -> addSeparator();
|
help_menu_ -> addSeparator();
|
||||||
help_menu_ -> addAction(manual_online_);
|
help_menu_ -> addAction(manual_online_);
|
||||||
|
help_menu_ -> addAction(connect_ai_);
|
||||||
help_menu_ -> addAction(youtube_);
|
help_menu_ -> addAction(youtube_);
|
||||||
help_menu_ -> addAction(upgrade_);
|
help_menu_ -> addAction(upgrade_);
|
||||||
help_menu_ -> addAction(upgrade_M);
|
help_menu_ -> addAction(upgrade_M);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class QETMainWindow : public QMainWindow {
|
|||||||
QAction *donate_; ///< Launch browser to donate link
|
QAction *donate_; ///< Launch browser to donate link
|
||||||
QAction *about_qt_; ///< launch the "About Qt" dialog
|
QAction *about_qt_; ///< launch the "About Qt" dialog
|
||||||
QAction *diagnostics_action_; ///< Open the diagnostics report dialog (discussion #644, step 5)
|
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 *settings_menu_; ///< Settings menu
|
||||||
QMenu *help_menu_; ///< Help menu
|
QMenu *help_menu_; ///< Help menu
|
||||||
QMenu *display_toolbars_; ///< Show/hide toolbars/docks
|
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_include_directories(tst_scriptingsetting PRIVATE ${QET_DIR}/sources)
|
||||||
target_link_libraries(tst_scriptingsetting PRIVATE Qt::Test Qt::Gui)
|
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
|
# QetSettings::sheetBackground() -- the sheet (folio) background the diagram
|
||||||
# editor remembers between runs. Compiles qetsettings.cpp alone for the same
|
# editor remembers between runs. Compiles qetsettings.cpp alone for the same
|
||||||
# reason as the test above; Qt::Gui because the pair carries a QColor.
|
# 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