From 2fb14c5bd0e20f40941541f58d6a5469bfb26ddf Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 10:31:08 +1300 Subject: [PATCH 1/4] 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. From e7b699b59acb19562c698f11bd212a1e0c6b8f3a Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 11:15:15 +1300 Subject: [PATCH 2/4] Live mode: close each connection once it is answered The qet MCP server connects once per request. On Windows a named pipe's disconnection reaches QLocalSocket late, so the second of two quick calls was turned away as "another assistant is already connected". Found testing the Windows package under Wine with a Windows-side client. Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/scripting/liveserver.cpp | 9 +++++++++ sources/scripting/liveserver.h | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index 42419c50c..c7806fd3d 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -254,6 +254,15 @@ void LiveServer::handle(const QJsonObject &request) if (request.contains(QStringLiteral("id"))) answer.insert(QStringLiteral("id"), request.value(QStringLiteral("id"))); send(answer); + //One request per connection: close it from this side once it is + //answered. Waiting for the client to hang up raced the next + //request on Windows, where a named pipe's disconnection reaches + //QLocalSocket late -- the second call of a quick pair was turned + //away as "another assistant" (found under Wine, 2026-10-02). + if (m_client) { + m_client->disconnectFromServer(); + m_client = nullptr; + } emit handled(request, answer); } diff --git a/sources/scripting/liveserver.h b/sources/scripting/liveserver.h index 7bf4db57d..60f47e8c7 100644 --- a/sources/scripting/liveserver.h +++ b/sources/scripting/liveserver.h @@ -40,8 +40,8 @@ class QWidget; 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. + closes. One JSON request per connection, one JSON answer back, then + the server closes the connection; 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 -- From 7d91e8fdf19ea32c1d0e7dcb1d6a9db6d2019467 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 11:57:53 +1300 Subject: [PATCH 3/4] Live mode: find the open project while QElectroTech is not the active window While the user types to the assistant, QElectroTech is not the active application, and QMdiArea then reports no active sub-window: status said no project was open and every run was refused. Make the sub-window it remembers active again before handling a request; the focus stays where the user put it. Found with the Windows package under Wine; reproduced on Linux with another window focused. Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/scripting/liveserver.cpp | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index c7806fd3d..1b9ab2995 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -273,12 +273,23 @@ void LiveServer::handle(const QJsonObject &request) */ QETDiagramEditor *LiveServer::editor() const { - QETDiagramEditor *shown = nullptr; + QETDiagramEditor *chosen = nullptr; for (QETDiagramEditor *e : QETApp::diagramEditors()) { - if (e->isActiveWindow()) return e; - if (!shown && e->isVisible()) shown = e; + if (e->isActiveWindow()) { + chosen = e; + break; + } + if (!chosen && e->isVisible()) chosen = e; } - return shown; + //While the user is typing to the assistant, QElectroTech is not + //the active application, and QMdiArea then has no active + //sub-window: every "current project" in the editor reads as none. + //The one it remembers is the project the user was looking at. + if (chosen && !chosen->m_workspace.activeSubWindow() + && chosen->m_workspace.currentSubWindow()) { + chosen->m_workspace.setActiveSubWindow(chosen->m_workspace.currentSubWindow()); + } + return chosen; } QJsonObject LiveServer::status() From 124173514d239978c1b7e5c5b2a5faa61d357e6b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 13:03:33 +1300 Subject: [PATCH 4/4] Live mode: advertise the channel in qet-assistant.json The socket name and token now go in the "live" part of the one file the qet MCP server reads about this QElectroTech, rather than in a live-session.json of their own, and are cleared when the channel closes. The file also says whether the live setting is on. Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/scripting/assistantinfo.cpp | 1 + sources/scripting/liveserver.cpp | 29 ++++++----------------------- sources/scripting/liveserver.h | 7 +++---- 3 files changed, 10 insertions(+), 27 deletions(-) diff --git a/sources/scripting/assistantinfo.cpp b/sources/scripting/assistantinfo.cpp index 97d236722..96d032d97 100644 --- a/sources/scripting/assistantinfo.cpp +++ b/sources/scripting/assistantinfo.cpp @@ -102,6 +102,7 @@ void write() {QStringLiteral("scripting_available"), false}, #endif {QStringLiteral("scripting_enabled"), QetSettings::scriptingEnabled()}, + {QStringLiteral("live_mode_setting"), QetSettings::liveAssistantEnabled()}, {QStringLiteral("live_mode_open"), !s_live.isEmpty()}}}, {QStringLiteral("script_api"), QJsonArray::fromStringList(QetScriptApi::signatures())}, {QStringLiteral("stored_scripts"), scripts}, diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index 1b9ab2995..1d14179f6 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -17,6 +17,7 @@ */ #include "liveserver.h" +#include "assistantinfo.h" #include "qetscripting.h" #include "scriptlibrary.h" #include "../diagram.h" @@ -67,17 +68,6 @@ LiveServer::LiveServer() 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"); } /** @@ -135,19 +125,12 @@ bool LiveServer::start() } m_token = randomHex(16); - const QJsonObject session{ - {QStringLiteral("version"), 1}, + //Where the MCP server finds the channel: the "live" part of + //qet-assistant.json, the one file it reads about this QElectroTech + AssistantInfo::setLive(QJsonObject{ {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); + {QStringLiteral("pid"), QCoreApplication::applicationPid()}}); connect(m_server, &QLocalServer::newConnection, this, &LiveServer::newConnection); setState(Waiting); @@ -169,7 +152,7 @@ void LiveServer::stop() m_server->deleteLater(); m_server = nullptr; } - QFile::remove(sessionFile()); + if (!m_token.isEmpty()) AssistantInfo::setLive(QJsonObject()); m_token.clear(); m_buffer.clear(); setState(Off); diff --git a/sources/scripting/liveserver.h b/sources/scripting/liveserver.h index 60f47e8c7..24f098ea4 100644 --- a/sources/scripting/liveserver.h +++ b/sources/scripting/liveserver.h @@ -38,9 +38,9 @@ class QWidget; 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 JSON request per connection, one JSON answer back, then + with a random name and token written to the "live" part of + qet-assistant.json (AssistantInfo), which the MCP server reads, and + cleared when the channel closes. One JSON request per connection, one JSON answer back, then the server closes the connection; every request carries the token. Requests are never handled inside the socket's readyRead: each is @@ -56,7 +56,6 @@ class LiveServer : public QObject enum State { Off, Waiting, Connected }; static LiveServer &instance(); - static QString sessionFile(); void askAndStart(QWidget *parent); void stop();