mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-03 01:04:13 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 *);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user