diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index d620bd3d9..956f44962 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -930,6 +930,8 @@ list(APPEND QET_SRC_FILES ${QET_DIR}/sources/scripting/scriptmanagerdialog.h ${QET_DIR}/sources/scripting/liveserver.cpp ${QET_DIR}/sources/scripting/liveserver.h + ${QET_DIR}/sources/scripting/macrorecorder.cpp + ${QET_DIR}/sources/scripting/macrorecorder.h ${QET_DIR}/sources/scripting/assistantinfo.cpp ${QET_DIR}/sources/scripting/assistantinfo.h ) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 0d07b2e8f..9a5761e06 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -21,6 +21,7 @@ #include "scripting/scriptlibrary.h" #include "scripting/scriptmanagerdialog.h" #include "scripting/liveserver.h" +#include "scripting/macrorecorder.h" #include "scripting/assistantinfo.h" #endif #include @@ -87,6 +88,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -180,6 +186,7 @@ QETDiagramEditor::QETDiagramEditor(const QStringList &files, QWidget *parent) : readSettingsState(); // restoreState() must be called after show() in Qt6 #ifdef QET_HAS_SCRIPTING setUpLiveIndicator(); + setUpMacroRecorder(); //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); }); @@ -727,6 +734,31 @@ void QETDiagramEditor::setUpActions() " dans le script pour l'API disponible)")); connect(m_run_script, &QAction::triggered, this, &QETDiagramEditor::slot_runScript); + //Record what the user does, for an assistant to turn into a script + { + QPixmap dot(32, 32); + dot.fill(Qt::transparent); + QPainter painter(&dot); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0xd0, 0x30, 0x30)); + painter.drawEllipse(QRectF(6, 6, 20, 20)); + painter.end(); + m_record_macro = new QAction(QIcon(dot), tr("Enregistrer une macro"), this); + } + m_record_macro->setCheckable(true); + m_record_macro->setStatusTip(tr("Enregistre ce que vous faites sur le projet, pour qu'un " + "assistant IA en fasse un script ; recliquez pour arrêter")); + connect(m_record_macro, &QAction::triggered, this, [this](bool checked) { + if (checked) { + if (!MacroRecorder::instance().start(this)) m_record_macro->setChecked(false); + } else { + MacroRecorder::instance().stop(); + } + }); + ShortcutManager::instance().registerAction(m_record_macro, "diagrameditor.record_macro", + tr("Éditeur de schémas"), QKeySequence()); + //Write, try, give an icon to and delete stored scripts m_manage_scripts = new QAction(tr("Gérer les scripts…"), this); m_manage_scripts->setStatusTip(tr("Écrire un script et en faire un bouton avec une icône")); @@ -3902,6 +3934,7 @@ void QETDiagramEditor::rebuildScriptActions() } m_scripts_menu->addSeparator(); + m_scripts_menu->addAction(m_record_macro); m_scripts_menu->addAction(m_manage_scripts); m_scripts_menu->addAction(m_run_script); m_scripts_menu->addAction(m_open_scripts_folder); @@ -3915,6 +3948,68 @@ void QETDiagramEditor::rebuildScriptActions() updateScriptActions(); } +/** + @brief QETDiagramEditor::setUpMacroRecorder + While recording: the action checked, and "● Enregistrement : N étapes" + with Arrêter on the status bar. At the end, where it was saved and the + request to paste into the assistant. +*/ +void QETDiagramEditor::setUpMacroRecorder() +{ + auto *box = new QWidget(this); + auto *layout = new QHBoxLayout(box); + layout->setContentsMargins(0, 0, 0, 0); + auto *label = new QLabel(box); + label->setStyleSheet(QStringLiteral("color: #d03030; font-weight: bold")); + auto *stop = new QToolButton(box); + stop->setText(tr("Arrêter")); + stop->setToolTip(tr("Arrêter l'enregistrement de la macro")); + layout->addWidget(label); + layout->addWidget(stop); + statusBar()->addPermanentWidget(box); + box->hide(); + connect(stop, &QToolButton::clicked, this, []() { MacroRecorder::instance().stop(); }); + + connect(&MacroRecorder::instance(), &MacroRecorder::stateChanged, box, + [this, box, label](bool recording, int steps) { + box->setVisible(recording); + label->setText(tr("● Enregistrement : %n étape(s)", nullptr, steps)); + const QSignalBlocker blocker(m_record_macro); + m_record_macro->setChecked(recording); + updateScriptActions(); + }); + connect(&MacroRecorder::instance(), &MacroRecorder::finished, this, + [this](const QJsonObject &recording, QETDiagramEditor *editor) { + if (editor == this) macroRecorded(recording); + }); +} + +void QETDiagramEditor::macroRecorded(const QJsonObject &recording) +{ + const int steps = recording.value(QStringLiteral("steps")).toArray().size(); + const QString dir = recording.value(QStringLiteral("folder")).toString(); + QMessageBox box(QMessageBox::Information, tr("Macro enregistrée"), + tr("« %1 » : %n étape(s).\n\nPour en faire un script, demandez-le à " + "votre assistant IA : le bouton ci-dessous copie la demande, il " + "suffit de la coller dans sa fenêtre.", nullptr, steps) + .arg(recording.value(QStringLiteral("name")).toString()), + QMessageBox::NoButton, this); + box.setInformativeText(QDir::toNativeSeparators(dir) + + (recording.value(QStringLiteral("complete")).toBool() + ? QString() : QStringLiteral("\n") + recording.value(QStringLiteral("note")).toString())); + QPushButton *copy = box.addButton(tr("&Copier la demande pour l'assistant"), QMessageBox::AcceptRole); + QPushButton *open = box.addButton(tr("&Ouvrir le dossier"), QMessageBox::ActionRole); + box.addButton(tr("&Fermer"), QMessageBox::RejectRole); + box.setDefaultButton(copy); + box.exec(); + if (box.clickedButton() == copy) { + QGuiApplication::clipboard()->setText(MacroRecorder::assistantRequest(recording)); + statusBar()->showMessage(tr("Demande copiée : collez-la dans la fenêtre de l'assistant"), 8000); + } else if (box.clickedButton() == open) { + QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + } +} + /** @brief QETDiagramEditor::setUpLiveIndicator "Assistant connecté" and a Stop button on the status bar, shown @@ -4027,6 +4122,7 @@ void QETDiagramEditor::updateScriptActions() Diagram *diagram = dv ? dv->diagram() : nullptr; const bool selection = diagram && !diagram->selectedItems().isEmpty(); const bool conductor = diagram && !diagram->selectedConductors().isEmpty(); + m_record_macro->setEnabled(currentProject() || MacroRecorder::instance().isRecording()); for (QAction *action : std::as_const(m_script_actions)) { const QString context = action->property("qet_script_context").toString(); bool enabled = currentProject() != nullptr; diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 921829113..f585e3d8f 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -62,6 +62,7 @@ class QETDiagramEditor : public QETMainWindow friend class TerminalStripEditorWindow; friend class LiveServer; + friend class MacroRecorder; public: QETDiagramEditor( @@ -160,6 +161,8 @@ class QETDiagramEditor : public QETMainWindow bool ensureScriptingEnabled(const QString &title); void runStoredScript(const QString &path, const QString &name); void setUpLiveIndicator(); + void setUpMacroRecorder(); + void macroRecorded(const QJsonObject &recording); #endif void editDiagramProperties(DiagramView *); void editDiagramProperties(Diagram *); @@ -253,6 +256,7 @@ class QETDiagramEditor : public QETMainWindow *m_run_script, ///< Action to run a JavaScript macro against the current project *m_open_scripts_folder, ///< Action to open the folder stored scripts are read from *m_manage_scripts, ///< Action to open the script manager + *m_record_macro, ///< Action to start / stop recording a macro #endif *m_export_project_db, ///Export to file the internal database of the current project *m_tile_window, ///< Show MDI subwindows as tile diff --git a/sources/scripting/assistantinfo.cpp b/sources/scripting/assistantinfo.cpp index 96d032d97..88193b5a0 100644 --- a/sources/scripting/assistantinfo.cpp +++ b/sources/scripting/assistantinfo.cpp @@ -19,6 +19,7 @@ #include "qetscriptapi.h" #include "scriptlibrary.h" +#include "macrorecorder.h" #include "../qetapp.h" #include "../qetversion.h" #include "../utils/qetsettings.h" @@ -89,6 +90,7 @@ void write() {QStringLiteral("data"), QETApp::dataDir()}, {QStringLiteral("config"), QETApp::configDir()}, {QStringLiteral("scripts"), ScriptLibrary::folder()}, + {QStringLiteral("recordings"), MacroRecorder::folder()}, {QStringLiteral("elements_common"), QETApp::commonElementsDir()}, {QStringLiteral("elements_company"), QETApp::companyElementsDir()}, {QStringLiteral("elements_custom"), QETApp::customElementsDir()}, @@ -103,10 +105,12 @@ void write() #endif {QStringLiteral("scripting_enabled"), QetSettings::scriptingEnabled()}, {QStringLiteral("live_mode_setting"), QetSettings::liveAssistantEnabled()}, - {QStringLiteral("live_mode_open"), !s_live.isEmpty()}}}, + {QStringLiteral("live_mode_open"), !s_live.isEmpty()}, + {QStringLiteral("macro_recording"), MacroRecorder::instance().isRecording()}}}, {QStringLiteral("script_api"), QJsonArray::fromStringList(QetScriptApi::signatures())}, {QStringLiteral("stored_scripts"), scripts}, {QStringLiteral("refused_scripts"), refused}, + {QStringLiteral("recordings"), MacroRecorder::recordings()}, {QStringLiteral("live"), s_live.isEmpty() ? QJsonValue() : QJsonValue(s_live)}}; const QString file_path = path(); diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index 5e165d58d..ce25e8ba1 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -18,6 +18,7 @@ #include "liveserver.h" #include "assistantinfo.h" +#include "macrorecorder.h" #include "qetscripting.h" #include "scriptlibrary.h" #include "../diagram.h" @@ -310,6 +311,11 @@ QJsonObject LiveServer::status() scripts.append(QJsonObject{{QStringLiteral("id"), s.header.id}, {QStringLiteral("name"), s.header.name}}); answer.insert(QStringLiteral("stored_scripts"), scripts); + //So an assistant can notice a recording the user just made + answer.insert(QStringLiteral("macro_recorder"), QJsonObject{ + {QStringLiteral("recording"), MacroRecorder::instance().isRecording()}, + {QStringLiteral("steps"), MacroRecorder::instance().stepCount()}, + {QStringLiteral("last_recording"), MacroRecorder::instance().lastId()}}); QETDiagramEditor *e = editor(); QETProject *project = e ? e->currentProject() : nullptr; diff --git a/sources/scripting/macrorecorder.cpp b/sources/scripting/macrorecorder.cpp new file mode 100644 index 000000000..f9ee0e9e0 --- /dev/null +++ b/sources/scripting/macrorecorder.cpp @@ -0,0 +1,242 @@ +/* + 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 "macrorecorder.h" + +#include "assistantinfo.h" +#include "../diagram.h" +#include "../diagramview.h" +#include "../qetapp.h" +#include "../qetdiagrameditor.h" +#include "../qetgraphicsitem/element.h" +#include "../qetproject.h" + +#include +#include +#include +#include +#include +#include + +namespace { + bool writeFile(const QString &path, const QByteArray &data) + { + QSaveFile file(path); + return file.open(QIODevice::WriteOnly) && file.write(data) >= 0 && file.commit(); + } +} + +MacroRecorder &MacroRecorder::instance() +{ + static MacroRecorder recorder; + return recorder; +} + +QString MacroRecorder::folder() +{ + return QETApp::dataDir() + QStringLiteral("/recordings"); +} + +/** + @brief MacroRecorder::recordings + What qet-assistant.json lists: every saved recording, newest first. +*/ +QJsonArray MacroRecorder::recordings() +{ + QJsonArray list; + QDir dir(folder()); + const QStringList ids = dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot, + QDir::Name | QDir::Reversed); + for (const QString &id : ids) { + QFile file(dir.filePath(id + QStringLiteral("/recording.json"))); + if (!file.open(QIODevice::ReadOnly)) continue; + const QJsonObject r = QJsonDocument::fromJson(file.readAll()).object(); + list.append(QJsonObject{ + {QStringLiteral("id"), id}, + {QStringLiteral("name"), r.value(QStringLiteral("name"))}, + {QStringLiteral("steps"), r.value(QStringLiteral("steps")).toArray().size()}, + {QStringLiteral("started"), r.value(QStringLiteral("started"))}, + {QStringLiteral("complete"), r.value(QStringLiteral("complete"))}, + {QStringLiteral("folder"), dir.filePath(id)}}); + } + return list; +} + +bool MacroRecorder::start(QETDiagramEditor *editor) +{ + QETProject *project = editor ? editor->currentProject() : nullptr; + if (!project || isRecording()) return false; + + m_id = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss")); + m_dir = folder() + QLatin1Char('/') + m_id; + if (!QDir().mkpath(m_dir + QStringLiteral("/steps"))) return false; + if (!writeFile(m_dir + QStringLiteral("/before.qet"), project->toXml().toByteArray(4))) + return false; + + m_project = project; + m_editor = editor; + m_started = QDateTime::currentDateTime().toString(Qt::ISODate); + m_project_title = project->title(); + m_project_path = project->filePath(); + m_steps = QJsonArray(); + m_start = where(); + m_last_index = project->undoStack()->index(); + connect(project->undoStack(), &QUndoStack::indexChanged, + this, &MacroRecorder::undoIndexChanged); + //Closed while recording: keep what was recorded + connect(project, &QObject::destroyed, this, [this]() { finish(true); }); + + emit stateChanged(true, 0); + AssistantInfo::write(); + return true; +} + +void MacroRecorder::undoIndexChanged(int index) +{ + if (!m_project) return; + QUndoStack *stack = m_project->undoStack(); + if (index > m_last_index) { + for (int i = m_last_index; i < index; ++i) { + const QUndoCommand *cmd = stack->command(i); + if (!cmd) continue; + QStringList parts; + for (int c = 0; c < cmd->childCount(); ++c) parts << cmd->child(c)->text(); + addStep(QStringLiteral("do"), cmd->text(), parts); + } + } else if (index < m_last_index) { + for (int i = m_last_index - 1; i >= index; --i) { + const QUndoCommand *cmd = stack->command(i); + addStep(QStringLiteral("undo"), cmd ? cmd->text() : QString(), {}); + } + } + m_last_index = index; +} + +void MacroRecorder::addStep(const QString &kind, const QString &text, const QStringList &parts) +{ + const int n = int(m_steps.size()) + 1; + QJsonObject step{ + {QStringLiteral("n"), n}, + {QStringLiteral("kind"), kind}, + {QStringLiteral("undo_text"), text}, + {QStringLiteral("parts"), QJsonArray::fromStringList(parts)}}; + + const QJsonObject here = where(); + for (auto it = here.begin(); it != here.end(); ++it) step.insert(it.key(), it.value()); + DiagramView *view = m_editor ? m_editor->currentDiagramView() : nullptr; + Diagram *diagram = view ? view->diagram() : nullptr; + if (diagram && diagram->project() == m_project) { + const QString file = QStringLiteral("steps/%1.xml").arg(n, 3, 10, QLatin1Char('0')); + if (writeFile(m_dir + QLatin1Char('/') + file, diagram->toXml(true).toByteArray(4))) + step.insert(QStringLiteral("folio_file"), file); + } + m_steps.append(step); + emit stateChanged(true, n); +} + +/** + @brief MacroRecorder::where + The folio on screen and the elements selected on it: where the person + was working, which a script that acts "on the selection" needs to be + checked against. +*/ +QJsonObject MacroRecorder::where() const +{ + DiagramView *view = m_editor ? m_editor->currentDiagramView() : nullptr; + Diagram *diagram = view ? view->diagram() : nullptr; + if (!diagram || diagram->project() != m_project) return {}; + QJsonArray selected; + for (QGraphicsItem *item : diagram->selectedItems()) { + if (item->type() != Element::Type) continue; + auto *element = static_cast(item); + selected.append(QJsonObject{ + {QStringLiteral("uuid"), element->uuid().toString()}, + {QStringLiteral("label"), element->actualLabel()}, + {QStringLiteral("x"), element->pos().x()}, + {QStringLiteral("y"), element->pos().y()}}); + } + return {{QStringLiteral("folio"), int(m_project->diagrams().indexOf(diagram))}, + {QStringLiteral("folio_title"), diagram->title()}, + {QStringLiteral("selected_elements"), selected}}; +} + +void MacroRecorder::stop() +{ + if (isRecording()) finish(false); +} + +void MacroRecorder::finish(bool project_closed) +{ + if (m_dir.isEmpty()) return; + QETDiagramEditor *editor = m_editor; + if (m_project) { + disconnect(m_project->undoStack(), nullptr, this, nullptr); + disconnect(m_project, nullptr, this, nullptr); + } + bool complete = false; + if (!project_closed && m_project) + complete = writeFile(m_dir + QStringLiteral("/after.qet"), + m_project->toXml().toByteArray(4)); + + const QJsonObject recording{ + {QStringLiteral("format"), 1}, + {QStringLiteral("id"), m_id}, + {QStringLiteral("name"), tr("Macro du %1").arg( + QDateTime::fromString(m_started, Qt::ISODate).toString(QStringLiteral("dd/MM/yyyy HH:mm")))}, + {QStringLiteral("project_title"), m_project_title}, + {QStringLiteral("project_path"), m_project_path}, + {QStringLiteral("started"), m_started}, + {QStringLiteral("stopped"), QDateTime::currentDateTime().toString(Qt::ISODate)}, + {QStringLiteral("complete"), complete}, + {QStringLiteral("note"), complete ? QString() + : tr("projet fermé pendant l'enregistrement : pas de after.qet")}, + {QStringLiteral("before"), QStringLiteral("before.qet")}, + {QStringLiteral("after"), complete ? QJsonValue(QStringLiteral("after.qet")) : QJsonValue()}, + {QStringLiteral("folder"), m_dir}, + {QStringLiteral("start"), m_start}, + {QStringLiteral("steps"), m_steps}}; + writeFile(m_dir + QStringLiteral("/recording.json"), + QJsonDocument(recording).toJson(QJsonDocument::Indented)); + + m_last_id = m_id; + m_project = nullptr; + m_editor = nullptr; + m_dir.clear(); + emit stateChanged(false, int(m_steps.size())); + AssistantInfo::write(); + emit finished(recording, editor); +} + +/** + @brief MacroRecorder::assistantRequest + What "Copier la demande pour l'assistant" puts on the clipboard: the + recording named, and the round the assistant should follow. +*/ +QString MacroRecorder::assistantRequest(const QJsonObject &recording) +{ + const int steps = int(recording.value(QStringLiteral("steps")).toArray().size()); + return tr("J'ai enregistré une macro dans QElectroTech : « %1 », %n étape(s) " + "(identifiant %2, dossier %3).\n" + "Avec le serveur MCP qet : lis-la avec qet_recording_read, écris un " + "script qui fait la même chose de façon générale (par exemple sur les " + "éléments sélectionnés plutôt que sur ceux-là précisément), vérifie-le " + "avec qet_recording_check jusqu'à ce qu'il corresponde, puis propose-le " + "comme bouton avec qet_script_install.", nullptr, steps) + .arg(recording.value(QStringLiteral("name")).toString()) + .arg(recording.value(QStringLiteral("id")).toString(), + QDir::toNativeSeparators(recording.value(QStringLiteral("folder")).toString())); +} diff --git a/sources/scripting/macrorecorder.h b/sources/scripting/macrorecorder.h new file mode 100644 index 000000000..ee6ee5391 --- /dev/null +++ b/sources/scripting/macrorecorder.h @@ -0,0 +1,92 @@ +/* + 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 MACRORECORDER_H +#define MACRORECORDER_H + +#include +#include +#include +#include + +class QETDiagramEditor; +class QETProject; + +/** + @brief The MacroRecorder class + Records what a person does by hand on one project, so an AI assistant + can turn it into a script (see MACRO-RECORDER-SCOPE.md): + + @code + /recordings// + recording.json name, project, times, the steps + before.qet the whole project when recording started + after.qet the whole project when it stopped + steps/003.xml the folio a step happened on, after the step + @endcode + + Steps come from the project's undo history -- each new entry is one + step, with its name and the names of the commands inside it -- so no + editing command has to be taught to the recorder. Before and after are + exact whatever was done; the steps give the order. Nothing new walks + the scene: the files are QElectroTech's own serialisation. + + QElectroTech cannot send anything to an assistant; the assistant fetches + recordings through the qet MCP server, and qet-assistant.json lists them. +*/ +class MacroRecorder : public QObject +{ + Q_OBJECT + + public: + static MacroRecorder &instance(); + static QString folder(); + static QJsonArray recordings(); + + bool start(QETDiagramEditor *editor); + void stop(); + bool isRecording() const { return !m_project.isNull(); } + int stepCount() const { return int(m_steps.size()); } + QString lastId() const { return m_last_id; } + static QString assistantRequest(const QJsonObject &recording); + + signals: + void stateChanged(bool recording, int steps); + /// A recording was saved; @a recording is its recording.json + void finished(const QJsonObject &recording, QETDiagramEditor *editor); + + private: + MacroRecorder() = default; + void undoIndexChanged(int index); + void addStep(const QString &kind, const QString &text, const QStringList &parts); + void finish(bool project_closed); + QJsonObject where() const; + + QPointer m_project; + QPointer m_editor; + QString m_id; + QString m_dir; + QString m_started; + QString m_project_title; + QString m_project_path; + QJsonArray m_steps; + QJsonObject m_start; ///< folio and selection when recording started + QString m_last_id; + int m_last_index = 0; +}; + +#endif // MACRORECORDER_H