Merge pull request #1223 from ispyisail/feature/script-live-ask-first

Add ask-first, an Assistant panel and safe commands to live mode
This commit is contained in:
ispyisail
2026-10-02 15:12:45 +13:00
committed by GitHub
3 changed files with 233 additions and 2 deletions
+58
View File
@@ -87,6 +87,11 @@
#include <QDir>
#include <QDesktopServices>
#include <QTimer>
#include <QJsonArray>
#include <QVBoxLayout>
#include <QListWidget>
#include <QCheckBox>
#include <QDockWidget>
#include <QJsonObject>
#include <QTime>
#include <QStatusBar>
@@ -3939,6 +3944,55 @@ void QETDiagramEditor::setUpLiveIndicator()
};
connect(&LiveServer::instance(), &LiveServer::stateChanged, box, update);
update(LiveServer::instance().state());
//The Assistant dock: every action, and whether to be asked first
auto *dock = new QDockWidget(tr("Assistant"), this);
dock->setObjectName(QStringLiteral("assistant_dock"));
auto *content = new QWidget(dock);
auto *dock_layout = new QVBoxLayout(content);
auto *ask = new QCheckBox(tr("Demander avant d'exécuter un script écrit par l'assistant"), content);
ask->setChecked(LiveServer::instance().askFirst());
ask->setToolTip(tr("Pour cette session seulement : chaque démarrage redemande"));
auto *log = new QListWidget(content);
log->setWordWrap(true);
dock_layout->addWidget(ask);
dock_layout->addWidget(log);
dock->setWidget(content);
addDockWidget(Qt::RightDockWidgetArea, dock);
dock->hide();
connect(ask, &QCheckBox::toggled, &LiveServer::instance(), &LiveServer::setAskFirst);
connect(&LiveServer::instance(), &LiveServer::askFirstChanged, ask, &QCheckBox::setChecked);
connect(&LiveServer::instance(), &LiveServer::stateChanged, dock,
[dock](LiveServer::State state) {
if (state == LiveServer::Waiting) dock->show();
else if (state == LiveServer::Off) dock->hide();
});
connect(&LiveServer::instance(), &LiveServer::handled, log,
[log](const QJsonObject &request, const QJsonObject &answer) {
const QString cmd = request.value(QStringLiteral("cmd")).toString();
if (cmd == QLatin1String("status")) return;
const bool ok = answer.value(QStringLiteral("ok")).toBool();
QString what = answer.value(QStringLiteral("undo")).toString();
for (const char *key : {"name", "script", "action"})
if (what.isEmpty()) what = request.value(QLatin1String(key)).toString();
if (what.isEmpty()) what = cmd;
auto *item = new QListWidgetItem(QStringLiteral("%1 %2 %3")
.arg(QTime::currentTime().toString(QStringLiteral("HH:mm:ss")),
ok ? QStringLiteral("✓") : QStringLiteral("✗"), what));
QStringList details;
if (!ok) details << answer.value(QStringLiteral("error")).toString();
const QJsonArray lines = answer.value(QStringLiteral("log")).toArray();
for (const QJsonValue &l : lines) details << l.toString();
const QString source = request.value(QStringLiteral("source")).toString();
if (!source.isEmpty()) details << QString() << source;
item->setToolTip(details.join(QLatin1Char('\n')));
item->setData(Qt::UserRole, details.join(QLatin1Char('\n')));
log->insertItem(0, item);
});
connect(log, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) {
QET::QetMessageBox::information(this, tr("Assistant"),
item->data(Qt::UserRole).toString());
});
//What the assistant just did, where the user is already looking
connect(&LiveServer::instance(), &LiveServer::handled, box,
[label](const QJsonObject &request, const QJsonObject &answer) {
@@ -3949,6 +4003,10 @@ void QETDiagramEditor::setUpLiveIndicator()
what = request.value(QStringLiteral("name")).toString();
if (what.isEmpty())
what = request.value(QStringLiteral("script")).toString();
if (what.isEmpty())
what = request.value(QStringLiteral("action")).toString();
if (what.isEmpty())
what = cmd;
label->setText(tr("Mode direct : %1 %2 à %3")
.arg(answer.value(QStringLiteral("ok")).toBool() ? QStringLiteral("✓")
: QStringLiteral("✗"),
+165 -2
View File
@@ -28,6 +28,7 @@
#include "../utils/qetsettings.h"
#include <QApplication>
#include <QFontDatabase>
#include <QCoreApplication>
#include <QFile>
#include <QJsonArray>
@@ -40,6 +41,16 @@
#include <QSaveFile>
#include <QTimer>
#include <QUndoStack>
#include <QAction>
#include <QBuffer>
#include <QPixmap>
#include <QPlainTextEdit>
#include <QVBoxLayout>
#include <QDialog>
#include <QDialogButtonBox>
#include <QLabel>
#include "../projectview.h"
#include "../shortcutmanager.h"
namespace {
QString randomHex(int bytes)
@@ -225,10 +236,25 @@ void LiveServer::handle(const QJsonObject &request)
if (cmd == QLatin1String("status")) {
answer = status();
} else if (cmd == QLatin1String("run_script")) {
answer = runScript(request.value(QStringLiteral("name")).toString(),
request.value(QStringLiteral("source")).toString());
const QString name = request.value(QStringLiteral("name")).toString();
const QString source = request.value(QStringLiteral("source")).toString();
//A script the assistant just wrote: the user sees it first,
//unless they said "always" this session. A stored script
//is one the user already has, so it runs as a click would.
if (m_ask_first && !confirm(name, source))
answer = failure(QStringLiteral("refused by the user"));
else
answer = runScript(name, source);
} else if (cmd == QLatin1String("run_stored")) {
answer = runStored(request.value(QStringLiteral("script")).toString());
} else if (cmd == QLatin1String("command")) {
answer = command(request.value(QStringLiteral("action")).toString());
} else if (cmd == QLatin1String("show_folio")) {
answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1));
} else if (cmd == QLatin1String("undo_last")) {
answer = undoLast();
} else if (cmd == QLatin1String("screenshot")) {
answer = screenshot();
} else {
answer = failure(QStringLiteral("unknown command: %1").arg(cmd));
}
@@ -342,3 +368,140 @@ QJsonObject LiveServer::runStored(const QString &id)
}
return failure(QStringLiteral("no stored script with id %1").arg(id));
}
void LiveServer::setAskFirst(bool ask)
{
if (ask == m_ask_first) return;
m_ask_first = ask;
emit askFirstChanged(ask);
}
/**
@brief LiveServer::allowedCommands
The editor commands an assistant may trigger: ones that open no dialog
(a dialog would wait for an answer nobody is there to give the
assistant) and change nothing that undo cannot take back. Saving,
printing, exporting, deleting, opening and closing are not in it: a
script can delete, as one undo step, and the rest stay the user's.
*/
QStringList LiveServer::allowedCommands()
{
static const QStringList ids{
QStringLiteral("diagrameditor.select_all"),
QStringLiteral("diagrameditor.select_nothing"),
QStringLiteral("diagrameditor.select_invert"),
QStringLiteral("diagrameditor.select_all_conductors"),
QStringLiteral("diagrameditor.select_all_text_fields"),
QStringLiteral("diagrameditor.zoom_in"),
QStringLiteral("diagrameditor.zoom_out"),
QStringLiteral("diagrameditor.zoom_content"),
QStringLiteral("diagrameditor.zoom_fit"),
QStringLiteral("diagrameditor.zoom_reset"),
QStringLiteral("diagrameditor.rotate_selection"),
QStringLiteral("diagrameditor.rotate_texts"),
QStringLiteral("diagrameditor.snap_selection_to_grid"),
QStringLiteral("diagrameditor.group_selection"),
QStringLiteral("diagrameditor.ungroup_selection"),
QStringLiteral("diagrameditor.conductor_reset")};
return ids;
}
QJsonObject LiveServer::command(const QString &id)
{
if (!allowedCommands().contains(id))
return failure(QStringLiteral("%1 is not a command an assistant may use; allowed: %2")
.arg(id, allowedCommands().join(QStringLiteral(", "))));
QETDiagramEditor *e = editor();
QAction *action = e ? ShortcutManager::instance().action(id, e) : nullptr;
if (!action) return failure(QStringLiteral("no editor window has %1").arg(id));
if (!action->isEnabled())
return failure(QStringLiteral("%1 is not available right now (nothing "
"selected, or no project open)").arg(id));
action->trigger();
return {{QStringLiteral("ok"), true}, {QStringLiteral("action"), id}};
}
QJsonObject LiveServer::showFolio(int folio)
{
QETDiagramEditor *e = editor();
ProjectView *pv = e ? e->currentProjectView() : nullptr;
if (!pv) return failure(QStringLiteral("no project is open in QElectroTech"));
const QList<Diagram *> diagrams = pv->project()->diagrams();
if (folio < 0 || folio >= diagrams.count())
return failure(QStringLiteral("no folio %1: the project has %2, counted from 0")
.arg(folio).arg(diagrams.count()));
pv->showDiagram(diagrams.at(folio));
return {{QStringLiteral("ok"), true}, {QStringLiteral("folio"), folio}};
}
/**
@brief LiveServer::undoLast
Undo the newest step, only if the assistant made it: what the user did
by hand stays theirs to undo.
*/
QJsonObject LiveServer::undoLast()
{
QETDiagramEditor *e = editor();
QETProject *project = e ? e->currentProject() : nullptr;
if (!project) return failure(QStringLiteral("no project is open in QElectroTech"));
QUndoStack *stack = project->undoStack();
const QString text = stack->text(stack->index() - 1);
const QString prefix = tr("Assistant : %1").arg(QString());
if (!stack->canUndo() || !text.startsWith(prefix))
return failure(QStringLiteral("the last step is not the assistant's (\"%1\"); "
"only the user undoes their own").arg(text));
stack->undo();
return {{QStringLiteral("ok"), true}, {QStringLiteral("undone"), text}};
}
QJsonObject LiveServer::screenshot()
{
QETDiagramEditor *e = editor();
DiagramView *view = e ? e->currentDiagramView() : nullptr;
if (!view) return failure(QStringLiteral("no folio is shown in QElectroTech"));
const QPixmap pixmap = view->grab();
QByteArray png;
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
pixmap.save(&buffer, "PNG");
return {{QStringLiteral("ok"), true},
{QStringLiteral("width"), pixmap.width()},
{QStringLiteral("height"), pixmap.height()},
{QStringLiteral("png_base64"), QString::fromLatin1(png.toBase64())}};
}
/**
@brief LiveServer::confirm
Show the user the script the assistant wants to run, and ask.
@return true to run it
*/
bool LiveServer::confirm(const QString &name, const QString &source)
{
QDialog dialog(editor());
dialog.setWindowTitle(tr("L'assistant veut exécuter un script"));
auto *layout = new QVBoxLayout(&dialog);
layout->addWidget(new QLabel(
tr("« %1 » sur le projet ouvert. Une fois exécuté, Ctrl+Z l'annule.")
.arg(name.isEmpty() ? tr("script") : name), &dialog));
auto *text = new QPlainTextEdit(source, &dialog);
text->setReadOnly(true);
text->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
text->setMinimumSize(520, 220);
layout->addWidget(text);
auto *buttons = new QDialogButtonBox(&dialog);
QPushButton *run = buttons->addButton(tr("&Exécuter"), QDialogButtonBox::AcceptRole);
buttons->addButton(tr("&Refuser"), QDialogButtonBox::RejectRole);
QPushButton *always = buttons->addButton(tr("&Toujours pour cette session"),
QDialogButtonBox::AcceptRole);
layout->addWidget(buttons);
QPushButton *clicked = nullptr;
connect(buttons, &QDialogButtonBox::clicked, &dialog, [&](QAbstractButton *b) {
clicked = qobject_cast<QPushButton *>(b);
});
connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
run->setDefault(true);
dialog.exec();
if (clicked == always) setAskFirst(false);
return clicked == run || clicked == always;
}
+10
View File
@@ -60,11 +60,15 @@ class LiveServer : public QObject
void askAndStart(QWidget *parent);
void stop();
State state() const { return m_state; }
bool askFirst() const { return m_ask_first; }
void setAskFirst(bool ask);
static QStringList allowedCommands();
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);
void askFirstChanged(bool ask);
private:
LiveServer();
@@ -78,6 +82,11 @@ class LiveServer : public QObject
QJsonObject status();
QJsonObject runScript(const QString &name, const QString &source);
QJsonObject runStored(const QString &id);
QJsonObject command(const QString &id);
QJsonObject showFolio(int folio);
QJsonObject undoLast();
QJsonObject screenshot();
bool confirm(const QString &name, const QString &source);
QETDiagramEditor *editor() const;
QLocalServer *m_server = nullptr;
@@ -87,6 +96,7 @@ class LiveServer : public QObject
State m_state = Off;
bool m_asked = false;
bool m_busy = false;
bool m_ask_first = true; ///< per session, never saved: every start asks again
};
#endif // LIVESERVER_H