/* 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 "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 #include #include #include #include #include #include #include #include #include #include #include #include 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 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(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)); }