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
+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 "