From 2fb14c5bd0e20f40941541f58d6a5469bfb26ddf Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 10:31:08 +1300 Subject: [PATCH] Live mode: an AI assistant acts on the open project while the user watches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new setting, Configurer QElectroTech > Général > "Autoriser un assistant IA à agir sur le projet ouvert", off by default. Off, nothing changes: no channel is opened. On, every start shows a warning first -- Continuer, Pas pour cette session, or Désactiver -- and nothing can connect until the user answers Continuer. It waits for any other start-up question to be answered, so the two never stack. Accepted, LiveServer opens a local socket only the user's account can use, with a random name and token written to live-session.json in the data folder for the qet MCP server, and removed when the channel closes. One JSON request per line: status (project, folio on screen, selection, last undo step, stored scripts), run_script and run_stored. Requests are queued out of the socket handler before they run (the lesson of PR #861). Each run is one undo step named "Assistant : ". The status bar shows the mode and the assistant's last action with a ✓ or ✗ and the time, and an Arrêter button that closes the channel for the session. Unticking the setting closes it at once. QetScripting::runSource() runs script text and, for a live run, returns what qet.log() wrote, the error with its line and the undo step instead of showing boxes; qet.showMessage() is logged rather than opening a box nobody asked for. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 2 + sources/qetdiagrameditor.cpp | 57 +++ sources/qetdiagrameditor.h | 2 + sources/scripting/liveserver.cpp | 341 ++++++++++++++++++ sources/scripting/liveserver.h | 93 +++++ sources/scripting/qetscriptapi.cpp | 16 + sources/scripting/qetscriptapi.h | 2 + sources/scripting/qetscripting.cpp | 54 ++- sources/scripting/qetscripting.h | 22 ++ .../configpage/generalconfigurationpage.cpp | 13 + .../ui/configpage/generalconfigurationpage.ui | 10 + sources/utils/qetsettings.cpp | 20 + sources/utils/qetsettings.h | 2 + 13 files changed, 619 insertions(+), 15 deletions(-) create mode 100644 sources/scripting/liveserver.cpp create mode 100644 sources/scripting/liveserver.h diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index e744edd88..f5bff1bb1 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -928,6 +928,8 @@ list(APPEND QET_SRC_FILES ${QET_DIR}/sources/scripting/scriptlibrary.h ${QET_DIR}/sources/scripting/scriptmanagerdialog.cpp ${QET_DIR}/sources/scripting/scriptmanagerdialog.h + ${QET_DIR}/sources/scripting/liveserver.cpp + ${QET_DIR}/sources/scripting/liveserver.h ) if(QET_SPACEMOUSE_ENABLED) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 33075da0e..6d792124b 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -20,6 +20,7 @@ #include "scripting/qetscripting.h" #include "scripting/scriptlibrary.h" #include "scripting/scriptmanagerdialog.h" +#include "scripting/liveserver.h" #endif #include #include @@ -85,6 +86,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #ifdef BUILD_WITHOUT_KF @@ -167,6 +173,10 @@ QETDiagramEditor::QETDiagramEditor(const QStringList &files, QWidget *parent) : show(); readSettingsState(); // restoreState() must be called after show() in Qt6 #ifdef QET_HAS_SCRIPTING + setUpLiveIndicator(); + //Live mode asks once per run, from the first window to open, and + //only once that window is on screen to anchor its warning. + QTimer::singleShot(0, this, [this]() { LiveServer::instance().askAndStart(this); }); //A toolbar saved as shown while there were scripts stays out of //the way while there are none. if (m_script_actions.isEmpty()) m_scripts_tool_bar->hide(); @@ -3898,6 +3908,53 @@ void QETDiagramEditor::rebuildScriptActions() updateScriptActions(); } +/** + @brief QETDiagramEditor::setUpLiveIndicator + "Assistant connecté" and a Stop button on the status bar, shown + whenever live mode is open, so it is never on without being seen. +*/ +void QETDiagramEditor::setUpLiveIndicator() +{ + auto *box = new QWidget(this); + auto *layout = new QHBoxLayout(box); + layout->setContentsMargins(0, 0, 0, 0); + auto *label = new QLabel(box); + auto *stop = new QToolButton(box); + stop->setText(tr("Arrêter")); + stop->setToolTip(tr("Couper la connexion de l'assistant pour le reste de la session")); + layout->addWidget(label); + layout->addWidget(stop); + statusBar()->addPermanentWidget(box); + connect(stop, &QToolButton::clicked, this, []() { LiveServer::instance().stop(); }); + + auto update = [box, label](LiveServer::State state) { + box->setVisible(state != LiveServer::Off); + label->setText(state == LiveServer::Connected + ? tr("Mode direct : assistant connecté") + : tr("Mode direct : en attente d'un assistant")); + label->setStyleSheet(state == LiveServer::Connected + ? QStringLiteral("font-weight: bold") : QString()); + }; + connect(&LiveServer::instance(), &LiveServer::stateChanged, box, update); + update(LiveServer::instance().state()); + //What the assistant just did, where the user is already looking + connect(&LiveServer::instance(), &LiveServer::handled, box, + [label](const QJsonObject &request, const QJsonObject &answer) { + const QString cmd = request.value(QStringLiteral("cmd")).toString(); + if (cmd == QLatin1String("status")) return; + QString what = answer.value(QStringLiteral("undo")).toString(); + if (what.isEmpty()) + what = request.value(QStringLiteral("name")).toString(); + if (what.isEmpty()) + what = request.value(QStringLiteral("script")).toString(); + label->setText(tr("Mode direct : %1 %2 à %3") + .arg(answer.value(QStringLiteral("ok")).toBool() ? QStringLiteral("✓") + : QStringLiteral("✗"), + what, QTime::currentTime().toString(QStringLiteral("HH:mm:ss")))); + label->setToolTip(answer.value(QStringLiteral("error")).toString()); + }); +} + /** @brief QETDiagramEditor::updateScriptActions Enable each script for what its header's @context asks for: always diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index d8a48e18e..921829113 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -61,6 +61,7 @@ class QETDiagramEditor : public QETMainWindow Q_OBJECT friend class TerminalStripEditorWindow; + friend class LiveServer; public: QETDiagramEditor( @@ -158,6 +159,7 @@ class QETDiagramEditor : public QETMainWindow void updateScriptActions(); bool ensureScriptingEnabled(const QString &title); void runStoredScript(const QString &path, const QString &name); + void setUpLiveIndicator(); #endif void editDiagramProperties(DiagramView *); void editDiagramProperties(Diagram *); diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp new file mode 100644 index 000000000..42419c50c --- /dev/null +++ b/sources/scripting/liveserver.cpp @@ -0,0 +1,341 @@ +/* + 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 . +*/ +#include "liveserver.h" + +#include "qetscripting.h" +#include "scriptlibrary.h" +#include "../diagram.h" +#include "../diagramview.h" +#include "../qetapp.h" +#include "../qetdiagrameditor.h" +#include "../qetproject.h" +#include "../utils/qetsettings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + QString randomHex(int bytes) + { + QByteArray raw(bytes, Qt::Uninitialized); + for (char &c : raw) c = char(QRandomGenerator::system()->bounded(256)); + return QString::fromLatin1(raw.toHex()); + } + + QJsonObject failure(const QString &error) + { + return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), error}}; + } +} + +LiveServer &LiveServer::instance() +{ + static LiveServer server; + return server; +} + +LiveServer::LiveServer() +{ + connect(qApp, &QCoreApplication::aboutToQuit, this, &LiveServer::stop); +} + +LiveServer::~LiveServer() +{ + QFile::remove(sessionFile()); +} + +/** + @brief LiveServer::sessionFile + Where the channel's name and token are written for the MCP server: + next to the stored scripts, in the user's data folder. +*/ +QString LiveServer::sessionFile() +{ + return QETApp::dataDir() + QStringLiteral("/live-session.json"); +} + +/** + @brief LiveServer::askAndStart + Called by every editor window as it opens; acts once per run. With the + setting on, warn and let the user decide for this session before the + channel exists. +*/ +void LiveServer::askAndStart(QWidget *parent) +{ + if (m_asked) return; + if (!QetSettings::liveAssistantEnabled()) { + m_asked = true; + return; + } + //Not on top of another start-up question (the backup copy, the + //recovery files): two stacked boxes, and an answer meant for one + //lands on the other. Ask once nothing else is waiting. + if (QApplication::activeModalWidget()) { + QPointer anchor(parent); + QTimer::singleShot(300, this, [this, anchor]() { askAndStart(anchor); }); + return; + } + m_asked = true; + + QMessageBox box(QMessageBox::Warning, tr("Mode direct"), + tr("Le mode direct est activé : un assistant IA connecté " + "pourra exécuter des scripts sur le projet ouvert.\n\n" + "Chaque action s'annule d'un Ctrl+Z, et le bouton " + "« Arrêter » de la barre d'état coupe la connexion.\n\n" + "Ce réglage se trouve dans Configurer QElectroTech > Général."), + QMessageBox::NoButton, parent); + QPushButton *go = box.addButton(tr("&Continuer"), QMessageBox::AcceptRole); + box.addButton(tr("&Pas pour cette session"), QMessageBox::RejectRole); + QPushButton *off = box.addButton(tr("&Désactiver"), QMessageBox::DestructiveRole); + box.setDefaultButton(go); + box.exec(); + + if (box.clickedButton() == off) { + QetSettings::setLiveAssistantEnabled(false); + } else if (box.clickedButton() == go) { + start(); + } +} + +bool LiveServer::start() +{ + if (m_server) return true; + m_server = new QLocalServer(this); + m_server->setSocketOptions(QLocalServer::UserAccessOption); + if (!m_server->listen(QStringLiteral("qet-live-") + randomHex(8))) { + delete m_server; + m_server = nullptr; + return false; + } + m_token = randomHex(16); + + const QJsonObject session{ + {QStringLiteral("version"), 1}, + {QStringLiteral("socket"), m_server->fullServerName()}, + {QStringLiteral("token"), m_token}, + {QStringLiteral("pid"), QCoreApplication::applicationPid()}}; + QSaveFile file(sessionFile()); + if (!file.open(QIODevice::WriteOnly) + || file.write(QJsonDocument(session).toJson(QJsonDocument::Compact)) < 0 + || !file.commit()) { + stop(); + return false; + } + QFile::setPermissions(sessionFile(), QFile::ReadOwner | QFile::WriteOwner); + + connect(m_server, &QLocalServer::newConnection, this, &LiveServer::newConnection); + setState(Waiting); + return true; +} + +/** + @brief LiveServer::stop + Drop the assistant and close the channel for the rest of the session. +*/ +void LiveServer::stop() +{ + if (m_client) { + m_client->disconnectFromServer(); + m_client->deleteLater(); + } + if (m_server) { + m_server->close(); + m_server->deleteLater(); + m_server = nullptr; + } + QFile::remove(sessionFile()); + m_token.clear(); + m_buffer.clear(); + setState(Off); +} + +void LiveServer::setState(State state) +{ + if (state == m_state) return; + m_state = state; + emit stateChanged(state); +} + +void LiveServer::newConnection() +{ + while (QLocalSocket *socket = m_server ? m_server->nextPendingConnection() : nullptr) { + if (m_client) { + socket->write(QJsonDocument(failure(QStringLiteral( + "another assistant is already connected"))).toJson(QJsonDocument::Compact) + '\n'); + socket->disconnectFromServer(); + socket->deleteLater(); + continue; + } + m_client = socket; + m_buffer.clear(); + connect(socket, &QLocalSocket::readyRead, this, &LiveServer::readClient); + //Connected stays on once an assistant has used the channel: + //the MCP server connects per request, so following the socket + //would flicker the indicator off between two actions. + connect(socket, &QLocalSocket::disconnected, socket, &QObject::deleteLater); + } +} + +void LiveServer::readClient() +{ + if (!m_client) return; + m_buffer += m_client->readAll(); + int newline; + while ((newline = m_buffer.indexOf('\n')) >= 0) { + const QByteArray line = m_buffer.left(newline).trimmed(); + m_buffer.remove(0, newline + 1); + if (line.isEmpty()) continue; + const QJsonObject request = QJsonDocument::fromJson(line).object(); + if (request.value(QStringLiteral("token")).toString() != m_token || m_token.isEmpty()) { + send(failure(QStringLiteral("bad token"))); + m_client->disconnectFromServer(); + return; + } + setState(Connected); + //Out of the socket handler before anything runs (see class doc) + QTimer::singleShot(0, this, [this, request]() { handle(request); }); + } +} + +void LiveServer::send(const QJsonObject &answer) +{ + if (m_client) { + m_client->write(QJsonDocument(answer).toJson(QJsonDocument::Compact) + '\n'); + m_client->flush(); + } +} + +void LiveServer::handle(const QJsonObject &request) +{ + const QString cmd = request.value(QStringLiteral("cmd")).toString(); + QJsonObject answer; + if (m_busy) { + answer = failure(QStringLiteral("busy: the previous request is still running")); + } else { + m_busy = true; + if (cmd == QLatin1String("status")) { + answer = status(); + } else if (cmd == QLatin1String("run_script")) { + answer = runScript(request.value(QStringLiteral("name")).toString(), + request.value(QStringLiteral("source")).toString()); + } else if (cmd == QLatin1String("run_stored")) { + answer = runStored(request.value(QStringLiteral("script")).toString()); + } else { + answer = failure(QStringLiteral("unknown command: %1").arg(cmd)); + } + m_busy = false; + } + if (request.contains(QStringLiteral("id"))) + answer.insert(QStringLiteral("id"), request.value(QStringLiteral("id"))); + send(answer); + emit handled(request, answer); +} + +/** + @brief LiveServer::editor + The editor window the user is working in: the active one, or the first + shown. +*/ +QETDiagramEditor *LiveServer::editor() const +{ + QETDiagramEditor *shown = nullptr; + for (QETDiagramEditor *e : QETApp::diagramEditors()) { + if (e->isActiveWindow()) return e; + if (!shown && e->isVisible()) shown = e; + } + return shown; +} + +QJsonObject LiveServer::status() +{ + QJsonObject answer{{QStringLiteral("ok"), true}, + {QStringLiteral("scripting"), QetSettings::scriptingEnabled()}}; + QJsonArray scripts; + for (const ScriptLibrary::Script &s : ScriptLibrary::instance().scripts()) + scripts.append(QJsonObject{{QStringLiteral("id"), s.header.id}, + {QStringLiteral("name"), s.header.name}}); + answer.insert(QStringLiteral("stored_scripts"), scripts); + + QETDiagramEditor *e = editor(); + QETProject *project = e ? e->currentProject() : nullptr; + if (!project) { + answer.insert(QStringLiteral("project"), QJsonValue()); + return answer; + } + DiagramView *view = e->currentDiagramView(); + Diagram *diagram = view ? view->diagram() : nullptr; + QJsonArray selected; + if (diagram) { + for (QGraphicsItem *item : diagram->selectedItems()) + if (item->type() == Element::Type) + selected.append(static_cast(item)->uuid().toString()); + } + answer.insert(QStringLiteral("project"), QJsonObject{ + {QStringLiteral("title"), project->title()}, + {QStringLiteral("path"), project->filePath()}, + {QStringLiteral("folios"), int(project->diagrams().count())}, + {QStringLiteral("folio"), diagram ? int(project->diagrams().indexOf(diagram)) : -1}, + {QStringLiteral("folio_title"), diagram ? diagram->title() : QString()}, + {QStringLiteral("selected_elements"), selected}, + {QStringLiteral("read_only"), project->isReadOnly()}, + {QStringLiteral("last_undo"), project->undoStack()->text(project->undoStack()->index() - 1)}}); + return answer; +} + +QJsonObject LiveServer::runScript(const QString &name, const QString &source) +{ + if (source.trimmed().isEmpty()) return failure(QStringLiteral("no script given")); + QETDiagramEditor *e = editor(); + QETProject *project = e ? e->currentProject() : nullptr; + if (!project) return failure(QStringLiteral("no project is open in QElectroTech")); + + const QString title = name.trimmed().isEmpty() ? tr("script") : name.trimmed(); + QetScripting::LiveRun run; + const bool ok = QetScripting::runSource(source, QStringLiteral("assistant:") + title, title, + project, e->currentDiagramView(), &run); + QJsonObject answer{{QStringLiteral("ok"), ok}, + {QStringLiteral("log"), QJsonArray::fromStringList(run.log)}, + {QStringLiteral("undo"), run.undoText}}; + if (!run.error.isEmpty()) answer.insert(QStringLiteral("error"), run.error); + return answer; +} + +QJsonObject LiveServer::runStored(const QString &id) +{ + for (const ScriptLibrary::Script &s : ScriptLibrary::instance().scripts()) { + if (s.header.id != id) continue; + QFile file(s.path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return failure(QStringLiteral("cannot read %1").arg(s.path)); + QJsonObject answer = runScript(s.header.name, QString::fromUtf8(file.readAll())); + answer.insert(QStringLiteral("name"), s.header.name); + return answer; + } + return failure(QStringLiteral("no stored script with id %1").arg(id)); +} diff --git a/sources/scripting/liveserver.h b/sources/scripting/liveserver.h new file mode 100644 index 000000000..7bf4db57d --- /dev/null +++ b/sources/scripting/liveserver.h @@ -0,0 +1,93 @@ +/* + 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 . +*/ +#ifndef LIVESERVER_H +#define LIVESERVER_H + +#include +#include +#include + +class QLocalServer; +class QLocalSocket; +class QETDiagramEditor; +class QWidget; + +/** + @brief The LiveServer class + Live mode: an AI assistant, through the qet MCP server, runs scripts on + the project open in this QElectroTech while the user watches. + + Three things must all be true before anything can connect: scripting is + allowed, the "mode direct" setting is on (off by default), and the user + accepted the warning shown at this start. With the setting off this + class opens nothing and QElectroTech behaves as if it did not exist. + + The channel is a QLocalServer only the user's own account can open, + with a random name and token written to live-session.json in the data + folder, which the MCP server reads; the file goes when the channel + closes. One request per line, one answer per line, both JSON; every + request carries the token. + + Requests are never handled inside the socket's readyRead: each is + queued to the event loop first. QETApp::receiveMessage() learnt why -- + a modal dialog opened from inside a socket handler crashed QElectroTech + once the socket went away (PR #861). +*/ +class LiveServer : public QObject +{ + Q_OBJECT + + public: + enum State { Off, Waiting, Connected }; + + static LiveServer &instance(); + static QString sessionFile(); + + void askAndStart(QWidget *parent); + void stop(); + State state() const { return m_state; } + + signals: + void stateChanged(LiveServer::State state); + /// A request was handled: what was asked, and the answer sent + void handled(const QJsonObject &request, const QJsonObject &answer); + + private: + LiveServer(); + ~LiveServer() override; + bool start(); + void setState(State state); + void newConnection(); + void readClient(); + void handle(const QJsonObject &request); + void send(const QJsonObject &answer); + QJsonObject status(); + QJsonObject runScript(const QString &name, const QString &source); + QJsonObject runStored(const QString &id); + QETDiagramEditor *editor() const; + + QLocalServer *m_server = nullptr; + QPointer m_client; + QByteArray m_buffer; + QString m_token; + State m_state = Off; + bool m_asked = false; + bool m_busy = false; +}; + +#endif // LIVESERVER_H diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index a5fdec93f..203849800 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -393,6 +393,18 @@ bool QetScriptApi::save(const QString &output) void QetScriptApi::log(const QString &message) { QTextStream(stderr) << message << "\n"; + if (m_live_log) m_live_log->append(message); +} + +/** + @brief QetScriptApi::setLive + A run asked for by an assistant (LiveServer): what the script logs is + collected in @p log for the answer, and a message box -- which would + wait for someone who did not ask for it -- is logged instead. +*/ +void QetScriptApi::setLive(QStringList *log) +{ + m_live_log = log; } /** @@ -4386,5 +4398,9 @@ bool QetScriptApi::zoomReset() */ void QetScriptApi::showMessage(const QString &text) { + if (m_live_log) { + log(QStringLiteral("qet.showMessage: ") + text); + return; + } QET::QetMessageBox::information(nullptr, QObject::tr("Script"), text); } diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 44abbfe2a..27479313e 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -583,6 +583,7 @@ class QetScriptApi : public QObject Q_INVOKABLE QStringList apiSignatures() const; void setUndoGrouped(bool grouped); + void setLive(QStringList *log); private: bool runFlag(const QString &flag, const QStringList &args); @@ -604,6 +605,7 @@ class QetScriptApi : public QObject QETProject *m_project; DiagramView *m_view; bool m_undo_grouped = false; + QStringList *m_live_log = nullptr; ///< set for a live run: log() and showMessage() go here QString m_query_error; }; diff --git a/sources/scripting/qetscripting.cpp b/sources/scripting/qetscripting.cpp index ab9943a51..fbf3873cf 100644 --- a/sources/scripting/qetscripting.cpp +++ b/sources/scripting/qetscripting.cpp @@ -115,6 +115,22 @@ namespace { bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view, const QString &title) +{ + QFile file(scriptPath); + if (QetSettings::scriptingEnabled() + && !file.open(QIODevice::ReadOnly | QIODevice::Text)) { + err << "Cannot open script: " << scriptPath << "\n"; + return false; + } + const QString source = QString::fromUtf8(file.readAll()); + file.close(); + return runSource(source, scriptPath, + title.isEmpty() ? QFileInfo(scriptPath).completeBaseName() : title, + project, view, nullptr); +} + +bool runSource(const QString &source, const QString &fileName, const QString &title, + QETProject *project, DiagramView *view, LiveRun *live) { // Checked here as well as at each caller, deliberately: this is the // one function that actually evaluates JavaScript, so it is the one @@ -123,23 +139,18 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v // on the command line, an offer to switch the setting on in the editor. if (!QetSettings::scriptingEnabled()) { err << refusalMessage() << "\n"; - if (view) { + if (live) { + live->error = refusalMessage(); + } else if (view) { QET::QetMessageBox::warning(nullptr, QObject::tr("Script"), refusalMessage()); } return false; } - QFile file(scriptPath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - err << "Cannot open script: " << scriptPath << "\n"; - return false; - } - const QString source = QString::fromUtf8(file.readAll()); - file.close(); - QJSEngine engine; auto *api = new QetScriptApi(project, view, &engine); + if (live) api->setLive(&live->log); QJSValue qet_value = engine.newQObject(api); // newQObject() takes ownership by default (QJSEngine::JavaScriptOwnership), // which would delete api as soon as the engine's GC decides to -- api's @@ -172,14 +183,14 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v // has its macro taken off again: an obsolete command is deleted by // QUndoStack::undo() instead of being undone onto the redo side. QUndoStack *stack = (view && project) ? project->undoStack() : nullptr; + const QString undo_text = live ? QObject::tr("Assistant : %1").arg(title) + : QObject::tr("Script : %1").arg(title); if (stack) { - stack->beginMacro(QObject::tr("Script : %1") - .arg(title.isEmpty() ? QFileInfo(scriptPath).completeBaseName() - : title)); + stack->beginMacro(undo_text); api->setUndoGrouped(true); } - QJSValue result = engine.evaluate(source, scriptPath); + QJSValue result = engine.evaluate(source, fileName); { std::lock_guard lock(mtx); finished = true; @@ -196,12 +207,14 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v if (macro && macro->childCount() == 0) { const_cast(macro)->setObsolete(true); stack->undo(); + } else if (live) { + live->undoText = undo_text; } } if (result.isError()) { const QString message = QStringLiteral("Script error: %1:%2: %3") - .arg(scriptPath) + .arg(fileName) .arg(result.property(QStringLiteral("lineNumber")).toInt()) .arg(result.toString()); err << message << "\n"; @@ -209,7 +222,11 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v // launched the GUI normally (nowhere on Windows, easy to miss // everywhere else). Headless --run has no GUI to show this in, and // no session for it to block. - if (view) { + if (live) { + // The assistant gets the error and the user sees it in the + // log: a box here would wait on someone who did not ask. + live->error = message; + } else if (view) { QET::QetMessageBox::critical(nullptr, QObject::tr("Script"), message); } return false; @@ -231,6 +248,13 @@ int run(const QStringList &) return 1; } +bool runSource(const QString &, const QString &, const QString &, QETProject *, + DiagramView *, LiveRun *live) +{ + if (live) live->error = QStringLiteral("built without Qt Qml: no scripting"); + return false; +} + bool runOnProject(const QString &, QETProject *, DiagramView *, const QString &) { err << "This build of QElectroTech was compiled without the Qt Qml " diff --git a/sources/scripting/qetscripting.h b/sources/scripting/qetscripting.h index a1d8deafd..bcafff043 100644 --- a/sources/scripting/qetscripting.h +++ b/sources/scripting/qetscripting.h @@ -64,6 +64,28 @@ namespace QetScripting { bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view = nullptr, const QString &title = QString()); + /** + @brief What a live run (an assistant acting on the open project, + see LiveServer) reports instead of showing: what the script + logged, the error if it threw, and the undo step it left, empty + if it changed nothing. + */ + struct LiveRun + { + QStringList log; + QString error; + QString undoText; + }; + + /** + @brief Run @p source as runOnProject() runs a file. @p fileName is + what errors name; @p title names the undo step. With @p live, no + box is shown: qet.log() and errors are collected there, and + qet.showMessage() is logged rather than shown. + */ + bool runSource(const QString &source, const QString &fileName, const QString &title, + QETProject *project, DiagramView *view, LiveRun *live); + } #endif // QET_SCRIPTING_H diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp index 206735c64..cbd9a615d 100644 --- a/sources/ui/configpage/generalconfigurationpage.cpp +++ b/sources/ui/configpage/generalconfigurationpage.cpp @@ -16,6 +16,7 @@ along with QElectroTech. If not, see . */ #include "generalconfigurationpage.h" +#include "../../scripting/liveserver.h" #include "../../qetapp.h" #include "../../qeticons.h" @@ -131,6 +132,11 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) : //quietly clear a preference set on a build that does have Qml. ui->m_enable_scripting->setVisible(false); ui->m_enable_scripting->setEnabled(false); +#endif + ui->m_live_assistant->setChecked(QetSettings::liveAssistantEnabled()); +#ifndef QET_HAS_SCRIPTING + ui->m_live_assistant->setVisible(false); + ui->m_live_assistant->setEnabled(false); #endif ui->m_use_folio_label->setChecked(settings.value("genericpanel/folio", true).toBool()); ui->m_border_0->setChecked(settings.value("border-columns_0", false).toBool()); @@ -306,6 +312,13 @@ void GeneralConfigurationPage::applyConf() //real preference the first time this dialog is accepted. if (ui->m_enable_scripting->isEnabled()) { QetSettings::setScriptingEnabled(ui->m_enable_scripting->isChecked()); + } + if (ui->m_live_assistant->isEnabled()) { + QetSettings::setLiveAssistantEnabled(ui->m_live_assistant->isChecked()); +#ifdef QET_HAS_SCRIPTING + //Switching it off closes the door now, not at the next start + if (!ui->m_live_assistant->isChecked()) LiveServer::instance().stop(); +#endif } //GENERIC PANEL diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui index 2298006ee..bc60c7c27 100644 --- a/sources/ui/configpage/generalconfigurationpage.ui +++ b/sources/ui/configpage/generalconfigurationpage.ui @@ -275,6 +275,16 @@ + + + Autoriser un assistant IA à agir sur le projet ouvert (mode direct) + + + Un assistant connecté par le serveur MCP peut alors exécuter des scripts sur le projet ouvert, sous vos yeux. Un avertissement est affiché à chaque démarrage tant que ce réglage est activé. Prend effet au prochain démarrage ; le décocher coupe la connexion tout de suite. + + + + Qt::Vertical diff --git a/sources/utils/qetsettings.cpp b/sources/utils/qetsettings.cpp index d15cdcb3e..43adb2ce4 100644 --- a/sources/utils/qetsettings.cpp +++ b/sources/utils/qetsettings.cpp @@ -153,6 +153,26 @@ namespace QetSettings settings.setValue("scripting/enabled", enabled); } + /** + * @brief liveAssistantEnabled + * @return whether an AI assistant may connect to this QElectroTech and + * act on the open project while the user watches (live mode, see + * LiveServer). Off unless the user turned it on, and even then every + * start asks before anything can connect. No environment override: + * this is a choice made by the person in front of the window. + */ + bool liveAssistantEnabled() + { + QSettings settings; + return settings.value("scripting/live_assistant", false).toBool(); + } + + void setLiveAssistantEnabled(bool enabled) + { + QSettings settings; + settings.setValue("scripting/live_assistant", enabled); + } + /** * @brief setSheetBackground * Store the sheet background last picked in the diagram editor, so the diff --git a/sources/utils/qetsettings.h b/sources/utils/qetsettings.h index 5712aaf37..64bf4b615 100644 --- a/sources/utils/qetsettings.h +++ b/sources/utils/qetsettings.h @@ -38,6 +38,8 @@ namespace QetSettings bool scriptingEnabled(); void setScriptingEnabled(bool enabled); bool scriptingForcedByEnvironment(); + bool liveAssistantEnabled(); + void setLiveAssistantEnabled(bool enabled); /** @brief The sheet (folio) background the diagram editors draw.