Live mode: an AI assistant acts on the open project while the user watches

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 : <name>". 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 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-02 10:31:08 +13:00
parent 4f47093cb2
commit 2fb14c5bd0
13 changed files with 619 additions and 15 deletions
+2
View File
@@ -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)
+57
View File
@@ -20,6 +20,7 @@
#include "scripting/qetscripting.h"
#include "scripting/scriptlibrary.h"
#include "scripting/scriptmanagerdialog.h"
#include "scripting/liveserver.h"
#endif
#include <QCoreApplication>
#include <QToolButton>
@@ -85,6 +86,11 @@
#include <QDir>
#include <QDesktopServices>
#include <QTimer>
#include <QJsonObject>
#include <QTime>
#include <QStatusBar>
#include <QHBoxLayout>
#include <QLabel>
#include <QUrl>
#include <algorithm>
#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
+2
View File
@@ -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 *);
+341
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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 <QApplication>
#include <QCoreApplication>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QLocalServer>
#include <QLocalSocket>
#include <QMessageBox>
#include <QPushButton>
#include <QRandomGenerator>
#include <QSaveFile>
#include <QTimer>
#include <QUndoStack>
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<QWidget> 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<Element *>(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));
}
+93
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef LIVESERVER_H
#define LIVESERVER_H
#include <QJsonObject>
#include <QObject>
#include <QPointer>
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<QLocalSocket> m_client;
QByteArray m_buffer;
QString m_token;
State m_state = Off;
bool m_asked = false;
bool m_busy = false;
};
#endif // LIVESERVER_H
+16
View File
@@ -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);
}
+2
View File
@@ -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;
};
+39 -15
View File
@@ -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<std::mutex> lock(mtx);
finished = true;
@@ -196,12 +207,14 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v
if (macro && macro->childCount() == 0) {
const_cast<QUndoCommand *>(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 "
+22
View File
@@ -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
@@ -16,6 +16,7 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#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
@@ -275,6 +275,16 @@
</widget>
</item>
<item row="5" column="0">
<widget class="QCheckBox" name="m_live_assistant">
<property name="text">
<string>Autoriser un assistant IA à agir sur le projet ouvert (mode direct)</string>
</property>
<property name="toolTip">
<string>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.</string>
</property>
</widget>
</item>
<item row="6" column="0">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
+20
View File
@@ -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
+2
View File
@@ -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.