From ac952d3df6907ac78f2785be5fa04dc1b16c4b27 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 12:55:10 +1300 Subject: [PATCH 1/6] Install the MCP server with QElectroTech The MCP server (misc/qet-mcp) was only reachable from a source checkout. It now ships with every package, next to the program, and finds that QElectroTech and its element collection from where it sits: - make install (Linux distributions, snap, flatpak): /share/qelectrotech/mcp/qet_mcp.py, executable, with its README. - Windows installer: a new "AI assistant (MCP)" component (on by default, ~200 KB), installed to \mcp. The workflow stages files/mcp, so the portable folder and the MSI, which packs all of files/, carry it too. The server learns the Windows layout (/mcp beside /bin, whose program is QElectroTech.exe, and /elements), in addition to /share/qelectrotech/mcp. A copy saved anywhere else, even beside some bin/ folder, is not taken for an installation. The installer strings are given in all 29 installer languages: French translated, the others in English until translated, which is what NSIS would fall back to anyway but without its warning 6040 per language. Checked: make install into a scratch prefix, then from the installed script with nothing configured and no qelectrotech on PATH, an SVG export and a qet_edit placing a common:// element both succeeded. makensis 3.10 compiles the installer with 0 warnings before and after (removing the French description gives warning 6040), and the installer contains mcp/qet_mcp.py. Suite 274/274 none skipped; each layout check was removed in turn and a test failed. Snap/flatpak: installed by the same CMake rule; launching their QElectroTech from outside is untested. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/windows-build.yml | 5 ++- CMakeLists.txt | 4 +++ build-aux/windows/QET64.nsi | 8 +++++ build-aux/windows/lang_extra.nsh | 32 +++++++++++++++++++ build-aux/windows/lang_extra_fr.nsh | 2 ++ build-aux/windows/lang_extra_missing.nsh | 24 ++++++++++++++ misc/qet-mcp/README.md | 22 +++++++++++++ misc/qet-mcp/qet_mcp.py | 40 +++++++++++++++--------- misc/qet-mcp/test_qet_mcp.py | 37 ++++++++++++++++++++++ 9 files changed, 159 insertions(+), 15 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 2412235c5..0338cfffd 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -227,6 +227,9 @@ jobs: cp -r "$GITHUB_WORKSPACE/titleblocks" "$FILES/titleblocks" || true cp -r "$GITHUB_WORKSPACE/examples" "$FILES/examples" || true cp -r "$GITHUB_WORKSPACE/fonts" "$FILES/fonts" || true + # The MCP server finds bin/QElectroTech.exe and elements/ from mcp/. + mkdir -p "$FILES/mcp" + cp "$GITHUB_WORKSPACE/misc/qet-mcp/qet_mcp.py" "$GITHUB_WORKSPACE/misc/qet-mcp/README.md" "$FILES/mcp/" # --- Translations --- # Since PR #751 the .qm files are no longer tracked in git: lrelease @@ -305,7 +308,7 @@ jobs: && echo " OK : $f" \ || echo " MISSING: $f" done - for d in ico elements lang titleblocks fonts examples bin; do + for d in ico elements lang titleblocks fonts examples bin mcp; do [ -d "$FILES/$d" ] \ && echo " OK : $d/" \ || echo " MISSING: $d/" diff --git a/CMakeLists.txt b/CMakeLists.txt index a14f309f1..120f53a40 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -358,6 +358,10 @@ if (NOT MINGW) install(DIRECTORY elements DESTINATION share/qelectrotech) install(DIRECTORY examples DESTINATION share/qelectrotech) install(DIRECTORY titleblocks DESTINATION share/qelectrotech) + # The MCP server (misc/qet-mcp) finds this QElectroTech and its elements + # from here: /share/qelectrotech/mcp -> /bin. + install(PROGRAMS misc/qet-mcp/qet_mcp.py DESTINATION share/qelectrotech/mcp) + install(FILES misc/qet-mcp/README.md DESTINATION share/qelectrotech/mcp) install(FILES LICENSE ELEMENTS.LICENSE CREDIT README ChangeLog DESTINATION share/doc/qelectrotech) if(UNIX AND NOT APPLE) install(FILES misc/org.qelectrotech.qelectrotech.desktop DESTINATION share/applications) diff --git a/build-aux/windows/QET64.nsi b/build-aux/windows/QET64.nsi index cc1d81bd0..b1967b08f 100644 --- a/build-aux/windows/QET64.nsi +++ b/build-aux/windows/QET64.nsi @@ -274,6 +274,13 @@ Section "$(Fonts)" SEC05 File /nonfatal /r "./files/fonts" SectionEnd +; The MCP server lets an AI assistant open, check and edit drawings. It does +; nothing until a user sets up an assistant to start it. +Section "$(Mcp)" SEC06 + SetOutPath "$INSTDIR\mcp" + File /nonfatal "./files/mcp/*" +SectionEnd + ;-------------------------------- ; Component descriptions !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN @@ -282,6 +289,7 @@ SectionEnd !insertmacro MUI_DESCRIPTION_TEXT ${SEC03} $(var3) !insertmacro MUI_DESCRIPTION_TEXT ${SEC04} $(var4) !insertmacro MUI_DESCRIPTION_TEXT ${SEC05} $(var5) + !insertmacro MUI_DESCRIPTION_TEXT ${SEC06} $(var6) !insertmacro MUI_FUNCTION_DESCRIPTION_END ;-------------------------------- diff --git a/build-aux/windows/lang_extra.nsh b/build-aux/windows/lang_extra.nsh index c76f128fd..2f51fafa6 100644 --- a/build-aux/windows/lang_extra.nsh +++ b/build-aux/windows/lang_extra.nsh @@ -19,6 +19,8 @@ LangString var3 ${LANG_ENGLISH} "Examples of cartridges" LangString var4 ${LANG_ENGLISH} "Examples of diagrams" LangString var5 ${LANG_ENGLISH} "Fonts" + LangString Mcp ${LANG_ENGLISH} "AI assistant (MCP)" + LangString var6 ${LANG_ENGLISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_ENGLISH} "Uninstallation of the previous version failed.$\nPlease uninstall ${SOFT_NAME} manually before continuing." @@ -43,6 +45,8 @@ LangString var3 ${LANG_KOREAN} "표제란 예제" LangString var4 ${LANG_KOREAN} "도면 예제" LangString var5 ${LANG_KOREAN} "글꼴" + LangString Mcp ${LANG_KOREAN} "AI assistant (MCP)" + LangString var6 ${LANG_KOREAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_KOREAN} "이전 버전을 제거하지 못했습니다.$\n계속하기 전에 ${SOFT_NAME}을(를) 수동으로 제거해 주세요." @@ -67,6 +71,8 @@ LangString var3 ${LANG_POLISH} "Przykłady tabliczek rysunkowych" LangString var4 ${LANG_POLISH} "Przykłady schematów" LangString var5 ${LANG_POLISH} "Czcionki" + LangString Mcp ${LANG_POLISH} "AI assistant (MCP)" + LangString var6 ${LANG_POLISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_POLISH} "Odinstalowanie poprzedniej wersji nie powiodło się.$\nPrzed kontynuowaniem odinstaluj ręcznie program ${SOFT_NAME}." @@ -91,6 +97,8 @@ LangString var3 ${LANG_GREEK} "Παραδείγματα πινακίδων" LangString var4 ${LANG_GREEK} "Παραδείγματα διαγραμμάτων" LangString var5 ${LANG_GREEK} "Γραμματοσειρές" + LangString Mcp ${LANG_GREEK} "AI assistant (MCP)" + LangString var6 ${LANG_GREEK} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_GREEK} "Η απεγκατάσταση της προηγούμενης έκδοσης απέτυχε.$\nΠαρακαλώ απεγκαταστήστε χειροκίνητα το ${SOFT_NAME} πριν συνεχίσετε." @@ -115,6 +123,8 @@ LangString var3 ${LANG_CZECH} "Příklady popisových polí" LangString var4 ${LANG_CZECH} "Příklady schémat" LangString var5 ${LANG_CZECH} "Písma" + LangString Mcp ${LANG_CZECH} "AI assistant (MCP)" + LangString var6 ${LANG_CZECH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_CZECH} "Odinstalování předchozí verze se nezdařilo.$\nPřed pokračováním prosím odinstalujte ${SOFT_NAME} ručně." @@ -139,6 +149,8 @@ LangString var3 ${LANG_SPANISH} "Ejemplos de cartelas" LangString var4 ${LANG_SPANISH} "Ejemplos de esquemas" LangString var5 ${LANG_SPANISH} "Fuentes" + LangString Mcp ${LANG_SPANISH} "AI assistant (MCP)" + LangString var6 ${LANG_SPANISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SPANISH} "La desinstalación de la versión anterior ha fallado.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -163,6 +175,8 @@ LangString var3 ${LANG_GERMAN} "Schriftfeld-Beispiele" LangString var4 ${LANG_GERMAN} "Schaltplan-Beispiele" LangString var5 ${LANG_GERMAN} "Schriften" + LangString Mcp ${LANG_GERMAN} "AI assistant (MCP)" + LangString var6 ${LANG_GERMAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_GERMAN} "Die Deinstallation der vorherigen Version ist fehlgeschlagen.$\nBitte deinstallieren Sie ${SOFT_NAME} manuell, bevor Sie fortfahren." @@ -187,6 +201,8 @@ LangString var3 ${LANG_RUSSIAN} "Примеры штампов" LangString var4 ${LANG_RUSSIAN} "Примеры схем" LangString var5 ${LANG_RUSSIAN} "Шрифты" + LangString Mcp ${LANG_RUSSIAN} "AI assistant (MCP)" + LangString var6 ${LANG_RUSSIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_RUSSIAN} "Удаление предыдущей версии завершилось с ошибкой.$\nПожалуйста, удалите ${SOFT_NAME} вручную перед продолжением." @@ -211,6 +227,8 @@ LangString var3 ${LANG_ARABIC} "أمثلة على كتل العنوان" LangString var4 ${LANG_ARABIC} "أمثلة على المخططات" LangString var5 ${LANG_ARABIC} "الخطوط" + LangString Mcp ${LANG_ARABIC} "AI assistant (MCP)" + LangString var6 ${LANG_ARABIC} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_ARABIC} "فشل إلغاء تثبيت الإصدار السابق. يرجى إلغاء تثبيت ${SOFT_NAME} يدويًا قبل المتابعة." @@ -235,6 +253,8 @@ LangString var3 ${LANG_CATALAN} "Exemples de cartutxos" LangString var4 ${LANG_CATALAN} "Exemples d'esquemes" LangString var5 ${LANG_CATALAN} "Tipus de lletra" + LangString Mcp ${LANG_CATALAN} "AI assistant (MCP)" + LangString var6 ${LANG_CATALAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_CATALAN} "La desinstal·lació de la versió anterior ha fallat.$\nSi us plau, desinstal·leu ${SOFT_NAME} manualment abans de continuar." @@ -259,6 +279,8 @@ LangString var3 ${LANG_ITALIAN} "Cartigli di esempio" LangString var4 ${LANG_ITALIAN} "Schemi di esempio" LangString var5 ${LANG_ITALIAN} "Caratteri" + LangString Mcp ${LANG_ITALIAN} "AI assistant (MCP)" + LangString var6 ${LANG_ITALIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_ITALIAN} "La disinstallazione della versione precedente non è riuscita.$\nSi prega di disinstallare ${SOFT_NAME} manualmente prima di continuare." @@ -283,6 +305,8 @@ LangString var3 ${LANG_PORTUGUESE} "Exemplos de legendas" LangString var4 ${LANG_PORTUGUESE} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESE} "Fontes" + LangString Mcp ${LANG_PORTUGUESE} "AI assistant (MCP)" + LangString var6 ${LANG_PORTUGUESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_PORTUGUESE} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -307,6 +331,8 @@ LangString var3 ${LANG_ROMANIAN} "Exemple de cartușe" LangString var4 ${LANG_ROMANIAN} "Exemple de scheme" LangString var5 ${LANG_ROMANIAN} "Fonturi" + LangString Mcp ${LANG_ROMANIAN} "AI assistant (MCP)" + LangString var6 ${LANG_ROMANIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_ROMANIAN} "Dezinstalarea versiunii anterioare a eșuat.$\nVă rugăm să dezinstalați ${SOFT_NAME} manual înainte de a continua." @@ -331,6 +357,8 @@ LangString var3 ${LANG_CROATIAN} "Primjeri zaglavlja" LangString var4 ${LANG_CROATIAN} "Primjeri shema" LangString var5 ${LANG_CROATIAN} "Fontovi" + LangString Mcp ${LANG_CROATIAN} "AI assistant (MCP)" + LangString var6 ${LANG_CROATIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_CROATIAN} "Deinstalacija prethodne verzije nije uspjela.$\nMolimo deinstalirajte ${SOFT_NAME} ručno prije nastavka." @@ -355,6 +383,8 @@ LangString var3 ${LANG_DUTCH} "Voorbeelden van titelblokken" LangString var4 ${LANG_DUTCH} "Voorbeelden van schema's" LangString var5 ${LANG_DUTCH} "Lettertypen" + LangString Mcp ${LANG_DUTCH} "AI assistant (MCP)" + LangString var6 ${LANG_DUTCH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_DUTCH} "Het verwijderen van de vorige versie is mislukt.$\nVerwijder ${SOFT_NAME} handmatig voordat u verdergaat." @@ -379,6 +409,8 @@ LangString var3 ${LANG_DANISH} "Titel blokke eksempler" LangString var4 ${LANG_DANISH} "Diagram eksempler" LangString var5 ${LANG_DANISH} "Skrifttyper" + LangString Mcp ${LANG_DANISH} "AI assistant (MCP)" + LangString var6 ${LANG_DANISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_DANISH} "Afinstallation af den tidligere version mislykkedes.$\nAfinstaller venligst ${SOFT_NAME} manuelt, inden du fortsætter." diff --git a/build-aux/windows/lang_extra_fr.nsh b/build-aux/windows/lang_extra_fr.nsh index 322322b7d..dfb527009 100644 --- a/build-aux/windows/lang_extra_fr.nsh +++ b/build-aux/windows/lang_extra_fr.nsh @@ -19,4 +19,6 @@ LangString var3 ${LANG_FRENCH} "Exemples de cartouches" LangString var4 ${LANG_FRENCH} "Exemples de schémas" LangString var5 ${LANG_FRENCH} "Polices" + LangString Mcp ${LANG_FRENCH} "Assistant IA (MCP)" + LangString var6 ${LANG_FRENCH} "Permet à un assistant IA d'ouvrir, vérifier et modifier vos schémas (nécessite Python)" LangString uninstFailed ${LANG_FRENCH} "La désinstallation de la version précédente a échoué.$\nVeuillez désinstaller ${SOFT_NAME} manuellement avant de continuer." diff --git a/build-aux/windows/lang_extra_missing.nsh b/build-aux/windows/lang_extra_missing.nsh index 9313f6c90..a4a69bdd3 100644 --- a/build-aux/windows/lang_extra_missing.nsh +++ b/build-aux/windows/lang_extra_missing.nsh @@ -25,6 +25,8 @@ LangString var3 ${LANG_HUNGARIAN} "Rajzfejléc-példák" LangString var4 ${LANG_HUNGARIAN} "Kapcsolási rajz példák" LangString var5 ${LANG_HUNGARIAN} "Betűtípusok" + LangString Mcp ${LANG_HUNGARIAN} "AI assistant (MCP)" + LangString var6 ${LANG_HUNGARIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_HUNGARIAN} "Az előző verzió eltávolítása nem sikerült.$\nKérjük, távolítsa el manuálisan a ${SOFT_NAME} programot, mielőtt folytatná." @@ -52,6 +54,8 @@ LangString var3 ${LANG_JAPANESE} "表題欄の例" LangString var4 ${LANG_JAPANESE} "回路図の例" LangString var5 ${LANG_JAPANESE} "フォント" + LangString Mcp ${LANG_JAPANESE} "AI assistant (MCP)" + LangString var6 ${LANG_JAPANESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_JAPANESE} "前のバージョンのアンインストールに失敗しました。$\n続行する前に、${SOFT_NAME} を手動でアンインストールしてください。" @@ -79,6 +83,8 @@ LangString var3 ${LANG_MONGOLIAN} "Гарчгийн блокын жишээнүүд" LangString var4 ${LANG_MONGOLIAN} "Схемийн жишээнүүд" LangString var5 ${LANG_MONGOLIAN} "Фонт" + LangString Mcp ${LANG_MONGOLIAN} "AI assistant (MCP)" + LangString var6 ${LANG_MONGOLIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_MONGOLIAN} "Өмнөх хувилбарыг устгаж чадсангүй.$\nҮргэлжлүүлэхийн өмнө ${SOFT_NAME}-г гараар устгана уу." @@ -106,6 +112,8 @@ LangString var3 ${LANG_NORWEGIAN} "Eksempler på titelblokker" LangString var4 ${LANG_NORWEGIAN} "Eksempler på skjemaer" LangString var5 ${LANG_NORWEGIAN} "Skrifttyper" + LangString Mcp ${LANG_NORWEGIAN} "AI assistant (MCP)" + LangString var6 ${LANG_NORWEGIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_NORWEGIAN} "Avinstallasjon av forrige versjon mislyktes.$\nVennligst avinstaller ${SOFT_NAME} manuelt før du fortsetter." @@ -143,6 +151,8 @@ LangString var3 ${LANG_PORTUGUESEBR} "Exemplos de legendas" LangString var4 ${LANG_PORTUGUESEBR} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESEBR} "Fontes" + LangString Mcp ${LANG_PORTUGUESEBR} "AI assistant (MCP)" + LangString var6 ${LANG_PORTUGUESEBR} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_PORTUGUESEBR} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -170,6 +180,8 @@ LangString var3 ${LANG_SERBIAN} "Примери заглавља" LangString var4 ${LANG_SERBIAN} "Примери шема" LangString var5 ${LANG_SERBIAN} "Фонтови" + LangString Mcp ${LANG_SERBIAN} "AI assistant (MCP)" + LangString var6 ${LANG_SERBIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SERBIAN} "Деинсталација претходне верзије није успела.$\nМолимо деинсталирајте ${SOFT_NAME} ручно пре наставка." @@ -197,6 +209,8 @@ LangString var3 ${LANG_SLOVAK} "Príklady popisových polí" LangString var4 ${LANG_SLOVAK} "Príklady schém" LangString var5 ${LANG_SLOVAK} "Písma" + LangString Mcp ${LANG_SLOVAK} "AI assistant (MCP)" + LangString var6 ${LANG_SLOVAK} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SLOVAK} "Odinštalovanie predchádzajúcej verzie zlyhalo.$\nPred pokračovaním odinštalujte ${SOFT_NAME} ručne." @@ -224,6 +238,8 @@ LangString var3 ${LANG_SLOVENIAN} "Primeri glav risb" LangString var4 ${LANG_SLOVENIAN} "Primeri shem" LangString var5 ${LANG_SLOVENIAN} "Pisave" + LangString Mcp ${LANG_SLOVENIAN} "AI assistant (MCP)" + LangString var6 ${LANG_SLOVENIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SLOVENIAN} "Odstranitev prejšnje različice ni uspela.$\nPred nadaljevanjem ročno odstranite ${SOFT_NAME}." @@ -251,6 +267,8 @@ LangString var3 ${LANG_SWEDISH} "Exempel på ritningshuvuden" LangString var4 ${LANG_SWEDISH} "Exempel på scheman" LangString var5 ${LANG_SWEDISH} "Teckensnitt" + LangString Mcp ${LANG_SWEDISH} "AI assistant (MCP)" + LangString var6 ${LANG_SWEDISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SWEDISH} "Avinstallationen av den föregående versionen misslyckades.$\nAvinstallera ${SOFT_NAME} manuellt innan du fortsätter." @@ -278,6 +296,8 @@ LangString var3 ${LANG_TURKISH} "Başlık bloğu örnekleri" LangString var4 ${LANG_TURKISH} "Şema örnekleri" LangString var5 ${LANG_TURKISH} "Yazı Tipleri" + LangString Mcp ${LANG_TURKISH} "AI assistant (MCP)" + LangString var6 ${LANG_TURKISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_TURKISH} "Önceki sürümün kaldırılması başarısız oldu.$\nDevam etmeden önce lütfen ${SOFT_NAME}'i manuel olarak kaldırın." @@ -305,6 +325,8 @@ LangString var3 ${LANG_UKRAINIAN} "Приклади штампів" LangString var4 ${LANG_UKRAINIAN} "Приклади схем" LangString var5 ${LANG_UKRAINIAN} "Шрифти" + LangString Mcp ${LANG_UKRAINIAN} "AI assistant (MCP)" + LangString var6 ${LANG_UKRAINIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_UKRAINIAN} "Видалення попередньої версії завершилося помилкою.$\nБудь ласка, видаліть ${SOFT_NAME} вручну перед продовженням." @@ -332,4 +354,6 @@ LangString var3 ${LANG_SIMPCHINESE} "标题栏示例" LangString var4 ${LANG_SIMPCHINESE} "图纸示例" LangString var5 ${LANG_SIMPCHINESE} "字体" + LangString Mcp ${LANG_SIMPCHINESE} "AI assistant (MCP)" + LangString var6 ${LANG_SIMPCHINESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SIMPCHINESE} "卸载旧版本失败。$\n请在继续之前手动卸载 ${SOFT_NAME}。" diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index f0640ea4f..3c082ace9 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -82,6 +82,28 @@ Register it with an MCP client, for example: QElectroTech (as `/share/qelectrotech/mcp/qet_mcp.py`, which also finds the installed element collection). +## Installed with QElectroTech + +QElectroTech's packages install the server next to the program, and from +there it finds that QElectroTech and its element collection by itself: + +| Package | Server | Finds | +|---|---|---| +| `make install`, Linux distributions | `/share/qelectrotech/mcp/qet_mcp.py` | `/bin/qelectrotech`, `/share/qelectrotech/elements` | +| Windows installer, MSI, portable folder | `\mcp\qet_mcp.py` (the "AI assistant (MCP)" component) | `\bin\QElectroTech.exe`, `\elements` | + +So a client configuration needs only the path to the server and the +workspace, for example on Windows: + +```json +"args": ["C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"] +``` + +Snap and flatpak install it too. Their QElectroTech is built to run inside +the package's sandbox, and starting it from the server has not been tested: +the tools that read a file work, exports and edits may not. If they fail, +point `QET_BINARY` at a QElectroTech installed another way. + ## Using it from the Claude app A web chat in a browser cannot start a program on your computer, so it diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 485a53bf8..ce11cd467 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -3189,12 +3189,24 @@ def _env_paths(name: str) -> list: return out -def _installed_prefix() -> Path | None: - """The install prefix when this script is /share/qelectrotech/mcp/.""" +def _installation() -> tuple | None: + """(program directory, element collection) of the QElectroTech this + script was installed with, or None when it runs from anywhere else. + + Two layouts, both put there by QElectroTech's own packaging: + /share/qelectrotech/mcp/ -> /bin, /share/qelectrotech/elements + (make install: Linux, snap, flatpak, macOS) + /mcp/ -> /bin, /elements + (the Windows installers and portable folder) + """ here = Path(__file__).resolve().parent - if here.name == "mcp" and here.parent.name == "qelectrotech" \ - and here.parent.parent.name == "share": - return here.parent.parent.parent + if here.name != "mcp": + return None + if here.parent.name == "qelectrotech" and here.parent.parent.name == "share": + prefix = here.parent.parent.parent + return prefix / "bin", here.parent / "elements" + if (here.parent / "bin").is_dir(): + return here.parent / "bin", here.parent / "elements" return None @@ -3208,10 +3220,12 @@ def resolve_binary() -> Path | None: env = os.environ.get("QET_BINARY", "").strip() if env: return Path(env).expanduser().resolve() - prefix = _installed_prefix() - if prefix is not None: - for name in ("qelectrotech", "qelectrotech.exe"): - cand = prefix / "bin" / name + install = _installation() + if install is not None: + # The Windows build names it QElectroTech.exe; only a case-sensitive + # file system tells the spellings apart. + for name in ("qelectrotech", "qelectrotech.exe", "QElectroTech.exe"): + cand = install[0] / name if cand.is_file(): return cand.resolve() found = shutil.which("qelectrotech") @@ -3220,11 +3234,9 @@ def resolve_binary() -> Path | None: def default_elements_dir() -> Path | None: """The element collection of the install this script ships in, if any.""" - prefix = _installed_prefix() - if prefix is not None: - coll = prefix / "share" / "qelectrotech" / "elements" - if coll.is_dir(): - return coll.resolve() + install = _installation() + if install is not None and install[1].is_dir(): + return install[1].resolve() return None diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 6c98569d8..875da3324 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -2021,6 +2021,43 @@ class BinaryPolicy(unittest.TestCase): self.assertEqual(args["elements_dir"], str((prefix / "share" / "qelectrotech" / "elements").resolve())) + def installed_at(self, root: Path, mcp: Path): + """Import a copy of the server placed at mcp/qet_mcp.py.""" + import importlib.util + mcp.mkdir(parents=True) + shutil.copy2(HERE / "qet_mcp.py", mcp / "qet_mcp.py") + spec = importlib.util.spec_from_file_location(f"qet_mcp_{root.name}", mcp / "qet_mcp.py") + inst = importlib.util.module_from_spec(spec) + spec.loader.exec_module(inst) + return inst + + def test_the_windows_layout_is_found_too(self): + """The Windows installers and the portable folder put the server in + /mcp, the program in /bin as QElectroTech.exe, and the + collection in /elements.""" + root = Path(self.tmp.name) / "QElectroTech" + (root / "elements").mkdir(parents=True) + exe = fake_qet(root / "bin", "QElectroTech.exe") + inst = self.installed_at(root, root / "mcp") + del os.environ["QET_BINARY"] + args = {"project": str(self.root / "ok.qet")} + inst.enforce_path_policy("qet_query", args) + self.assertEqual(args["binary"], str(exe.resolve())) + self.assertEqual(args["elements_dir"], str((root / "elements").resolve())) + + def test_a_copy_saved_anywhere_is_not_an_install(self): + """A downloaded copy sitting beside some bin/ folder must not go + looking for QElectroTech there.""" + home = Path(self.tmp.name) / "home" + fake_qet(home / "bin") + inst = self.installed_at(home, home / "Downloads") + self.assertIsNone(inst._installation()) + + def test_a_folder_named_mcp_is_not_an_install(self): + """Without a bin/ beside it, an mcp/ folder is just a folder.""" + inst = self.installed_at(Path(self.tmp.name) / "x", Path(self.tmp.name) / "x" / "mcp") + self.assertIsNone(inst._installation()) + class ScriptingDisabledHint(unittest.TestCase): """QElectroTech may refuse to run scripts at all, and says so in French. From 6b9c5189b1695928f2a9f797972a3d9e05af1301 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 13:00:30 +1300 Subject: [PATCH 2/6] Offer Python with the Windows installer, for the MCP server Most Windows users have no Python, which the MCP server needs. The installer now offers "Python for the AI assistant", unticked by default: the official embeddable package from python.org (3.14.7, ~12 MB, no registry, removed with QElectroTech), in \mcp\python. The portable folder and the MSI, which pack all of files/, carry it. The workflow downloads it pinned by version and by python.org's own sha256 for the file (checked against python.org's download API), and fails the build on a mismatch. The script stays plain text beside it. Checked under Wine (64-bit, qet-wine-smoke image), on an installer built with makensis 3.10 (0 warnings, strings in all 29 languages): - a silent default install puts mcp\qet_mcp.py in place and no Python; the same installer with the section ticked by default installs it, so the check tells the two apart; - the installed Python runs the installed server: 15 tools listed, qet_project_info on an example answers as on Linux, and the server finds bin\QElectroTech.exe and elements\ by itself. Not checked: a real Windows machine; the CI download step (fork run). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/windows-build.yml | 14 ++++++ build-aux/windows/QET64.nsi | 9 ++++ build-aux/windows/lang_extra.nsh | 64 ++++++++++++++++++------ build-aux/windows/lang_extra_fr.nsh | 4 +- build-aux/windows/lang_extra_missing.nsh | 48 +++++++++++++----- misc/qet-mcp/README.md | 6 ++- 6 files changed, 115 insertions(+), 30 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 0338cfffd..b7ef44a9d 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -230,6 +230,20 @@ jobs: # The MCP server finds bin/QElectroTech.exe and elements/ from mcp/. mkdir -p "$FILES/mcp" cp "$GITHUB_WORKSPACE/misc/qet-mcp/qet_mcp.py" "$GITHUB_WORKSPACE/misc/qet-mcp/README.md" "$FILES/mcp/" + # Python for it, so Windows users need not install one: the official + # embeddable package, pinned by version and checksum (python.org's + # own sha256 for this file). The installer offers it unticked; the + # portable folder and the MSI carry it. + PY_EMBED_VERSION=3.14.7 + PY_EMBED_SHA256=d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15 + PY_ZIP="$GITHUB_WORKSPACE/python-embed.zip" + curl -fsSL -o "$PY_ZIP" \ + "https://www.python.org/ftp/python/$PY_EMBED_VERSION/python-$PY_EMBED_VERSION-embed-amd64.zip" + echo "$PY_EMBED_SHA256 $PY_ZIP" | sha256sum -c - \ + || { echo "ERROR: embeddable Python checksum mismatch"; exit 1; } + python3 -m zipfile -e "$PY_ZIP" "$FILES/mcp/python" + [ -f "$FILES/mcp/python/python.exe" ] \ + || { echo "ERROR: python.exe missing from the embeddable package"; exit 1; } # --- Translations --- # Since PR #751 the .qm files are no longer tracked in git: lrelease diff --git a/build-aux/windows/QET64.nsi b/build-aux/windows/QET64.nsi index b1967b08f..fe1522457 100644 --- a/build-aux/windows/QET64.nsi +++ b/build-aux/windows/QET64.nsi @@ -281,6 +281,14 @@ Section "$(Mcp)" SEC06 File /nonfatal "./files/mcp/*" SectionEnd +; Python for the MCP server, from python.org (about 12 MB). Unticked by +; default: only someone setting up an AI assistant needs it, and then only +; without a Python of their own. Uninstall removes it with $INSTDIR. +Section /o "$(McpPython)" SEC07 + SetOutPath "$INSTDIR\mcp\python" + File /nonfatal /r "./files/mcp/python/*" +SectionEnd + ;-------------------------------- ; Component descriptions !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN @@ -290,6 +298,7 @@ SectionEnd !insertmacro MUI_DESCRIPTION_TEXT ${SEC04} $(var4) !insertmacro MUI_DESCRIPTION_TEXT ${SEC05} $(var5) !insertmacro MUI_DESCRIPTION_TEXT ${SEC06} $(var6) + !insertmacro MUI_DESCRIPTION_TEXT ${SEC07} $(var7) !insertmacro MUI_FUNCTION_DESCRIPTION_END ;-------------------------------- diff --git a/build-aux/windows/lang_extra.nsh b/build-aux/windows/lang_extra.nsh index 2f51fafa6..2c6dcb45c 100644 --- a/build-aux/windows/lang_extra.nsh +++ b/build-aux/windows/lang_extra.nsh @@ -20,7 +20,9 @@ LangString var4 ${LANG_ENGLISH} "Examples of diagrams" LangString var5 ${LANG_ENGLISH} "Fonts" LangString Mcp ${LANG_ENGLISH} "AI assistant (MCP)" - LangString var6 ${LANG_ENGLISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_ENGLISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ENGLISH} "Python for the AI assistant" + LangString var7 ${LANG_ENGLISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ENGLISH} "Uninstallation of the previous version failed.$\nPlease uninstall ${SOFT_NAME} manually before continuing." @@ -46,7 +48,9 @@ LangString var4 ${LANG_KOREAN} "도면 예제" LangString var5 ${LANG_KOREAN} "글꼴" LangString Mcp ${LANG_KOREAN} "AI assistant (MCP)" - LangString var6 ${LANG_KOREAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_KOREAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_KOREAN} "Python for the AI assistant" + LangString var7 ${LANG_KOREAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_KOREAN} "이전 버전을 제거하지 못했습니다.$\n계속하기 전에 ${SOFT_NAME}을(를) 수동으로 제거해 주세요." @@ -72,7 +76,9 @@ LangString var4 ${LANG_POLISH} "Przykłady schematów" LangString var5 ${LANG_POLISH} "Czcionki" LangString Mcp ${LANG_POLISH} "AI assistant (MCP)" - LangString var6 ${LANG_POLISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_POLISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_POLISH} "Python for the AI assistant" + LangString var7 ${LANG_POLISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_POLISH} "Odinstalowanie poprzedniej wersji nie powiodło się.$\nPrzed kontynuowaniem odinstaluj ręcznie program ${SOFT_NAME}." @@ -98,7 +104,9 @@ LangString var4 ${LANG_GREEK} "Παραδείγματα διαγραμμάτων" LangString var5 ${LANG_GREEK} "Γραμματοσειρές" LangString Mcp ${LANG_GREEK} "AI assistant (MCP)" - LangString var6 ${LANG_GREEK} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_GREEK} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_GREEK} "Python for the AI assistant" + LangString var7 ${LANG_GREEK} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_GREEK} "Η απεγκατάσταση της προηγούμενης έκδοσης απέτυχε.$\nΠαρακαλώ απεγκαταστήστε χειροκίνητα το ${SOFT_NAME} πριν συνεχίσετε." @@ -124,7 +132,9 @@ LangString var4 ${LANG_CZECH} "Příklady schémat" LangString var5 ${LANG_CZECH} "Písma" LangString Mcp ${LANG_CZECH} "AI assistant (MCP)" - LangString var6 ${LANG_CZECH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_CZECH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CZECH} "Python for the AI assistant" + LangString var7 ${LANG_CZECH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CZECH} "Odinstalování předchozí verze se nezdařilo.$\nPřed pokračováním prosím odinstalujte ${SOFT_NAME} ručně." @@ -150,7 +160,9 @@ LangString var4 ${LANG_SPANISH} "Ejemplos de esquemas" LangString var5 ${LANG_SPANISH} "Fuentes" LangString Mcp ${LANG_SPANISH} "AI assistant (MCP)" - LangString var6 ${LANG_SPANISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SPANISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SPANISH} "Python for the AI assistant" + LangString var7 ${LANG_SPANISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SPANISH} "La desinstalación de la versión anterior ha fallado.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -176,7 +188,9 @@ LangString var4 ${LANG_GERMAN} "Schaltplan-Beispiele" LangString var5 ${LANG_GERMAN} "Schriften" LangString Mcp ${LANG_GERMAN} "AI assistant (MCP)" - LangString var6 ${LANG_GERMAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_GERMAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_GERMAN} "Python for the AI assistant" + LangString var7 ${LANG_GERMAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_GERMAN} "Die Deinstallation der vorherigen Version ist fehlgeschlagen.$\nBitte deinstallieren Sie ${SOFT_NAME} manuell, bevor Sie fortfahren." @@ -202,7 +216,9 @@ LangString var4 ${LANG_RUSSIAN} "Примеры схем" LangString var5 ${LANG_RUSSIAN} "Шрифты" LangString Mcp ${LANG_RUSSIAN} "AI assistant (MCP)" - LangString var6 ${LANG_RUSSIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_RUSSIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_RUSSIAN} "Python for the AI assistant" + LangString var7 ${LANG_RUSSIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_RUSSIAN} "Удаление предыдущей версии завершилось с ошибкой.$\nПожалуйста, удалите ${SOFT_NAME} вручную перед продолжением." @@ -228,7 +244,9 @@ LangString var4 ${LANG_ARABIC} "أمثلة على المخططات" LangString var5 ${LANG_ARABIC} "الخطوط" LangString Mcp ${LANG_ARABIC} "AI assistant (MCP)" - LangString var6 ${LANG_ARABIC} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_ARABIC} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ARABIC} "Python for the AI assistant" + LangString var7 ${LANG_ARABIC} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ARABIC} "فشل إلغاء تثبيت الإصدار السابق. يرجى إلغاء تثبيت ${SOFT_NAME} يدويًا قبل المتابعة." @@ -254,7 +272,9 @@ LangString var4 ${LANG_CATALAN} "Exemples d'esquemes" LangString var5 ${LANG_CATALAN} "Tipus de lletra" LangString Mcp ${LANG_CATALAN} "AI assistant (MCP)" - LangString var6 ${LANG_CATALAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_CATALAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CATALAN} "Python for the AI assistant" + LangString var7 ${LANG_CATALAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CATALAN} "La desinstal·lació de la versió anterior ha fallat.$\nSi us plau, desinstal·leu ${SOFT_NAME} manualment abans de continuar." @@ -280,7 +300,9 @@ LangString var4 ${LANG_ITALIAN} "Schemi di esempio" LangString var5 ${LANG_ITALIAN} "Caratteri" LangString Mcp ${LANG_ITALIAN} "AI assistant (MCP)" - LangString var6 ${LANG_ITALIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_ITALIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ITALIAN} "Python for the AI assistant" + LangString var7 ${LANG_ITALIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ITALIAN} "La disinstallazione della versione precedente non è riuscita.$\nSi prega di disinstallare ${SOFT_NAME} manualmente prima di continuare." @@ -306,7 +328,9 @@ LangString var4 ${LANG_PORTUGUESE} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESE} "Fontes" LangString Mcp ${LANG_PORTUGUESE} "AI assistant (MCP)" - LangString var6 ${LANG_PORTUGUESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_PORTUGUESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_PORTUGUESE} "Python for the AI assistant" + LangString var7 ${LANG_PORTUGUESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_PORTUGUESE} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -332,7 +356,9 @@ LangString var4 ${LANG_ROMANIAN} "Exemple de scheme" LangString var5 ${LANG_ROMANIAN} "Fonturi" LangString Mcp ${LANG_ROMANIAN} "AI assistant (MCP)" - LangString var6 ${LANG_ROMANIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_ROMANIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ROMANIAN} "Python for the AI assistant" + LangString var7 ${LANG_ROMANIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ROMANIAN} "Dezinstalarea versiunii anterioare a eșuat.$\nVă rugăm să dezinstalați ${SOFT_NAME} manual înainte de a continua." @@ -358,7 +384,9 @@ LangString var4 ${LANG_CROATIAN} "Primjeri shema" LangString var5 ${LANG_CROATIAN} "Fontovi" LangString Mcp ${LANG_CROATIAN} "AI assistant (MCP)" - LangString var6 ${LANG_CROATIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_CROATIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CROATIAN} "Python for the AI assistant" + LangString var7 ${LANG_CROATIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CROATIAN} "Deinstalacija prethodne verzije nije uspjela.$\nMolimo deinstalirajte ${SOFT_NAME} ručno prije nastavka." @@ -384,7 +412,9 @@ LangString var4 ${LANG_DUTCH} "Voorbeelden van schema's" LangString var5 ${LANG_DUTCH} "Lettertypen" LangString Mcp ${LANG_DUTCH} "AI assistant (MCP)" - LangString var6 ${LANG_DUTCH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_DUTCH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_DUTCH} "Python for the AI assistant" + LangString var7 ${LANG_DUTCH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_DUTCH} "Het verwijderen van de vorige versie is mislukt.$\nVerwijder ${SOFT_NAME} handmatig voordat u verdergaat." @@ -410,7 +440,9 @@ LangString var4 ${LANG_DANISH} "Diagram eksempler" LangString var5 ${LANG_DANISH} "Skrifttyper" LangString Mcp ${LANG_DANISH} "AI assistant (MCP)" - LangString var6 ${LANG_DANISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_DANISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_DANISH} "Python for the AI assistant" + LangString var7 ${LANG_DANISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_DANISH} "Afinstallation af den tidligere version mislykkedes.$\nAfinstaller venligst ${SOFT_NAME} manuelt, inden du fortsætter." diff --git a/build-aux/windows/lang_extra_fr.nsh b/build-aux/windows/lang_extra_fr.nsh index dfb527009..bcfd9988b 100644 --- a/build-aux/windows/lang_extra_fr.nsh +++ b/build-aux/windows/lang_extra_fr.nsh @@ -20,5 +20,7 @@ LangString var4 ${LANG_FRENCH} "Exemples de schémas" LangString var5 ${LANG_FRENCH} "Polices" LangString Mcp ${LANG_FRENCH} "Assistant IA (MCP)" - LangString var6 ${LANG_FRENCH} "Permet à un assistant IA d'ouvrir, vérifier et modifier vos schémas (nécessite Python)" + LangString var6 ${LANG_FRENCH} "Permet à un assistant IA d'ouvrir, vérifier et modifier vos schémas. Nécessite Python : le vôtre, ou celui proposé ci-dessous" + LangString McpPython ${LANG_FRENCH} "Python pour l'assistant IA" + LangString var7 ${LANG_FRENCH} "Python de python.org, utilisé uniquement par le composant Assistant IA. Inutile si Python est déjà installé (environ 12 Mo)" LangString uninstFailed ${LANG_FRENCH} "La désinstallation de la version précédente a échoué.$\nVeuillez désinstaller ${SOFT_NAME} manuellement avant de continuer." diff --git a/build-aux/windows/lang_extra_missing.nsh b/build-aux/windows/lang_extra_missing.nsh index a4a69bdd3..afae58e03 100644 --- a/build-aux/windows/lang_extra_missing.nsh +++ b/build-aux/windows/lang_extra_missing.nsh @@ -26,7 +26,9 @@ LangString var4 ${LANG_HUNGARIAN} "Kapcsolási rajz példák" LangString var5 ${LANG_HUNGARIAN} "Betűtípusok" LangString Mcp ${LANG_HUNGARIAN} "AI assistant (MCP)" - LangString var6 ${LANG_HUNGARIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_HUNGARIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_HUNGARIAN} "Python for the AI assistant" + LangString var7 ${LANG_HUNGARIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_HUNGARIAN} "Az előző verzió eltávolítása nem sikerült.$\nKérjük, távolítsa el manuálisan a ${SOFT_NAME} programot, mielőtt folytatná." @@ -55,7 +57,9 @@ LangString var4 ${LANG_JAPANESE} "回路図の例" LangString var5 ${LANG_JAPANESE} "フォント" LangString Mcp ${LANG_JAPANESE} "AI assistant (MCP)" - LangString var6 ${LANG_JAPANESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_JAPANESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_JAPANESE} "Python for the AI assistant" + LangString var7 ${LANG_JAPANESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_JAPANESE} "前のバージョンのアンインストールに失敗しました。$\n続行する前に、${SOFT_NAME} を手動でアンインストールしてください。" @@ -84,7 +88,9 @@ LangString var4 ${LANG_MONGOLIAN} "Схемийн жишээнүүд" LangString var5 ${LANG_MONGOLIAN} "Фонт" LangString Mcp ${LANG_MONGOLIAN} "AI assistant (MCP)" - LangString var6 ${LANG_MONGOLIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_MONGOLIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_MONGOLIAN} "Python for the AI assistant" + LangString var7 ${LANG_MONGOLIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_MONGOLIAN} "Өмнөх хувилбарыг устгаж чадсангүй.$\nҮргэлжлүүлэхийн өмнө ${SOFT_NAME}-г гараар устгана уу." @@ -113,7 +119,9 @@ LangString var4 ${LANG_NORWEGIAN} "Eksempler på skjemaer" LangString var5 ${LANG_NORWEGIAN} "Skrifttyper" LangString Mcp ${LANG_NORWEGIAN} "AI assistant (MCP)" - LangString var6 ${LANG_NORWEGIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_NORWEGIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_NORWEGIAN} "Python for the AI assistant" + LangString var7 ${LANG_NORWEGIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_NORWEGIAN} "Avinstallasjon av forrige versjon mislyktes.$\nVennligst avinstaller ${SOFT_NAME} manuelt før du fortsetter." @@ -152,7 +160,9 @@ LangString var4 ${LANG_PORTUGUESEBR} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESEBR} "Fontes" LangString Mcp ${LANG_PORTUGUESEBR} "AI assistant (MCP)" - LangString var6 ${LANG_PORTUGUESEBR} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_PORTUGUESEBR} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_PORTUGUESEBR} "Python for the AI assistant" + LangString var7 ${LANG_PORTUGUESEBR} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_PORTUGUESEBR} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -181,7 +191,9 @@ LangString var4 ${LANG_SERBIAN} "Примери шема" LangString var5 ${LANG_SERBIAN} "Фонтови" LangString Mcp ${LANG_SERBIAN} "AI assistant (MCP)" - LangString var6 ${LANG_SERBIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SERBIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SERBIAN} "Python for the AI assistant" + LangString var7 ${LANG_SERBIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SERBIAN} "Деинсталација претходне верзије није успела.$\nМолимо деинсталирајте ${SOFT_NAME} ручно пре наставка." @@ -210,7 +222,9 @@ LangString var4 ${LANG_SLOVAK} "Príklady schém" LangString var5 ${LANG_SLOVAK} "Písma" LangString Mcp ${LANG_SLOVAK} "AI assistant (MCP)" - LangString var6 ${LANG_SLOVAK} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SLOVAK} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SLOVAK} "Python for the AI assistant" + LangString var7 ${LANG_SLOVAK} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SLOVAK} "Odinštalovanie predchádzajúcej verzie zlyhalo.$\nPred pokračovaním odinštalujte ${SOFT_NAME} ručne." @@ -239,7 +253,9 @@ LangString var4 ${LANG_SLOVENIAN} "Primeri shem" LangString var5 ${LANG_SLOVENIAN} "Pisave" LangString Mcp ${LANG_SLOVENIAN} "AI assistant (MCP)" - LangString var6 ${LANG_SLOVENIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SLOVENIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SLOVENIAN} "Python for the AI assistant" + LangString var7 ${LANG_SLOVENIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SLOVENIAN} "Odstranitev prejšnje različice ni uspela.$\nPred nadaljevanjem ročno odstranite ${SOFT_NAME}." @@ -268,7 +284,9 @@ LangString var4 ${LANG_SWEDISH} "Exempel på scheman" LangString var5 ${LANG_SWEDISH} "Teckensnitt" LangString Mcp ${LANG_SWEDISH} "AI assistant (MCP)" - LangString var6 ${LANG_SWEDISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SWEDISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SWEDISH} "Python for the AI assistant" + LangString var7 ${LANG_SWEDISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SWEDISH} "Avinstallationen av den föregående versionen misslyckades.$\nAvinstallera ${SOFT_NAME} manuellt innan du fortsätter." @@ -297,7 +315,9 @@ LangString var4 ${LANG_TURKISH} "Şema örnekleri" LangString var5 ${LANG_TURKISH} "Yazı Tipleri" LangString Mcp ${LANG_TURKISH} "AI assistant (MCP)" - LangString var6 ${LANG_TURKISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_TURKISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_TURKISH} "Python for the AI assistant" + LangString var7 ${LANG_TURKISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_TURKISH} "Önceki sürümün kaldırılması başarısız oldu.$\nDevam etmeden önce lütfen ${SOFT_NAME}'i manuel olarak kaldırın." @@ -326,7 +346,9 @@ LangString var4 ${LANG_UKRAINIAN} "Приклади схем" LangString var5 ${LANG_UKRAINIAN} "Шрифти" LangString Mcp ${LANG_UKRAINIAN} "AI assistant (MCP)" - LangString var6 ${LANG_UKRAINIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_UKRAINIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_UKRAINIAN} "Python for the AI assistant" + LangString var7 ${LANG_UKRAINIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_UKRAINIAN} "Видалення попередньої версії завершилося помилкою.$\nБудь ласка, видаліть ${SOFT_NAME} вручну перед продовженням." @@ -355,5 +377,7 @@ LangString var4 ${LANG_SIMPCHINESE} "图纸示例" LangString var5 ${LANG_SIMPCHINESE} "字体" LangString Mcp ${LANG_SIMPCHINESE} "AI assistant (MCP)" - LangString var6 ${LANG_SIMPCHINESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SIMPCHINESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SIMPCHINESE} "Python for the AI assistant" + LangString var7 ${LANG_SIMPCHINESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SIMPCHINESE} "卸载旧版本失败。$\n请在继续之前手动卸载 ${SOFT_NAME}。" diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 3c082ace9..391956b4e 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -93,9 +93,13 @@ there it finds that QElectroTech and its element collection by itself: | Windows installer, MSI, portable folder | `\mcp\qet_mcp.py` (the "AI assistant (MCP)" component) | `\bin\QElectroTech.exe`, `\elements` | So a client configuration needs only the path to the server and the -workspace, for example on Windows: +workspace. On Windows, the installer also offers **Python for the AI +assistant** (unticked by default; always in the portable folder and the +MSI): Python from python.org in `\mcp\python`, for anyone without +a Python of their own. Then: ```json +"command": "C:\\Program Files\\QElectroTech\\mcp\\python\\python.exe", "args": ["C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"] ``` From c76ef10e0b1da93add02447443be02e1d0a7a53b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 13:29:50 +1300 Subject: [PATCH 3/6] Add Help > Connect an AI assistant A dialog showing the configuration an AI assistant needs to start QElectroTech's MCP server (misc/qet-mcp), with this installation's paths filled in: pick the assistant (Claude Desktop, Claude Code, GitHub Copilot in VS Code, Cursor, Gemini CLI, Codex CLI, LM Studio), the drawings folder the assistant may use, and whether it may edit (off by default). It says where the text goes, warns that text inside a project from someone else can steer an assistant, and copies the text. It writes nothing and starts nothing. AiAssistantSetup finds the server beside the running program, in the two places the packages put it (/mcp beside /bin on Windows, /share/qelectrotech/mcp beside /bin otherwise), and the Python the Windows installer can add. When there is none, the dialog says the server is not installed with this version and links the guide. The configuration is written with QJsonDocument, so Windows paths are escaped correctly; Codex CLI gets TOML. tst_aiassistantsetup (9 cases): both layouts, no server, the bundled Python on Windows only, valid JSON for every JSON client with paths that parse back unchanged, the "servers" key for VS Code, "type" only where wanted, editing off unless allowed, escaped TOML. Each of 8 rules was removed in turn and the test failed. ctest 32/32. Checked in the GUI (Xvfb): Help menu opens it; from a build tree it reports the server missing and disables Copy; from an install layout it fills the paths, and the text Copy put on the clipboard, used unchanged as a Claude Code configuration, ran a real qet_project_info call. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 4 + sources/qetmainwindow.cpp | 9 ++ sources/qetmainwindow.h | 1 + sources/ui/aiassistantdialog.cpp | 198 ++++++++++++++++++++++++++ sources/ui/aiassistantdialog.h | 61 ++++++++ sources/ui/aiassistantsetup.cpp | 141 ++++++++++++++++++ sources/ui/aiassistantsetup.h | 64 +++++++++ tests/qttest/CMakeLists.txt | 10 ++ tests/qttest/tst_aiassistantsetup.cpp | 149 +++++++++++++++++++ 9 files changed, 637 insertions(+) create mode 100644 sources/ui/aiassistantdialog.cpp create mode 100644 sources/ui/aiassistantdialog.h create mode 100644 sources/ui/aiassistantsetup.cpp create mode 100644 sources/ui/aiassistantsetup.h create mode 100644 tests/qttest/tst_aiassistantsetup.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 5b3f13eb7..d57817384 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -714,6 +714,10 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/aboutqetdialog.cpp ${QET_DIR}/sources/ui/aboutqetdialog.h + ${QET_DIR}/sources/ui/aiassistantdialog.cpp + ${QET_DIR}/sources/ui/aiassistantdialog.h + ${QET_DIR}/sources/ui/aiassistantsetup.cpp + ${QET_DIR}/sources/ui/aiassistantsetup.h ${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.cpp ${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.h ${QET_DIR}/sources/ui/alignmenttextdialog.cpp diff --git a/sources/qetmainwindow.cpp b/sources/qetmainwindow.cpp index b3553d9d6..1436f2668 100644 --- a/sources/qetmainwindow.cpp +++ b/sources/qetmainwindow.cpp @@ -26,6 +26,7 @@ #include "qetmainwindow.h" #include "qeticons.h" +#include "ui/aiassistantdialog.h" #include "shortcutmanager.h" #include "qetapp.h" #include "qetdiagrameditor.h" @@ -111,6 +112,13 @@ void QETMainWindow::initCommonActions() ShortcutManager::instance().registerAction(manual_online_, "mainwindow.manual_online", tr("Général"), Qt::Key_F1); + connect_ai_ = new QAction(tr("Connecter un assistant IA..."), this); + connect_ai_ -> setStatusTip(tr("Affiche la configuration qui permet à un assistant IA d'utiliser QElectroTech", "status bar tip")); + connect(connect_ai_, &QAction::triggered, this, [this]() { + AiAssistantDialog dialog(this); + dialog.exec(); + }); + youtube_ = new QAction(QET::Icons::QETVideo, tr("Chaine Youtube"), this); youtube_ -> setStatusTip(tr("Lance le navigateur par défaut vers la chaine Youtube de QElectroTech", "status bar tip")); @@ -170,6 +178,7 @@ void QETMainWindow::initCommonMenus() help_menu_ -> addAction(whatsthis_action_); help_menu_ -> addSeparator(); help_menu_ -> addAction(manual_online_); + help_menu_ -> addAction(connect_ai_); help_menu_ -> addAction(youtube_); help_menu_ -> addAction(upgrade_); help_menu_ -> addAction(upgrade_M); diff --git a/sources/qetmainwindow.h b/sources/qetmainwindow.h index 9ad1862f0..c3820c4bb 100644 --- a/sources/qetmainwindow.h +++ b/sources/qetmainwindow.h @@ -66,6 +66,7 @@ class QETMainWindow : public QMainWindow { QAction *donate_; ///< Launch browser to donate link QAction *about_qt_; ///< launch the "About Qt" dialog QAction *diagnostics_action_; ///< Open the diagnostics report dialog (discussion #644, step 5) + QAction *connect_ai_; ///< Show how to connect an AI assistant (MCP server) QMenu *settings_menu_; ///< Settings menu QMenu *help_menu_; ///< Help menu QMenu *display_toolbars_; ///< Show/hide toolbars/docks diff --git a/sources/ui/aiassistantdialog.cpp b/sources/ui/aiassistantdialog.cpp new file mode 100644 index 000000000..b9f7ac355 --- /dev/null +++ b/sources/ui/aiassistantdialog.cpp @@ -0,0 +1,198 @@ +/* + 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 "aiassistantdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using AiAssistantSetup::Client; + +namespace { +const char *const GUIDE_URL = + "https://github.com/qelectrotech/qelectrotech-source-mirror/wiki/ai_assistants"; +} + +AiAssistantDialog::AiAssistantDialog(QWidget *parent) : + QDialog(parent) +{ + setWindowTitle(tr("Connecter un assistant IA")); + resize(720, 560); + +#ifdef Q_OS_WIN + const bool windows = true; +#else + const bool windows = false; +#endif + m_paths = AiAssistantSetup::detect(QCoreApplication::applicationDirPath(), + QCoreApplication::applicationFilePath(), + windows); + + auto *layout = new QVBoxLayout(this); + + auto *intro = new QLabel( + tr("Un assistant IA (Claude, GitHub Copilot, Gemini…) peut ouvrir, " + "vérifier et modifier vos schémas grâce au serveur MCP de " + "QElectroTech, qui fonctionne sur cet ordinateur. Copiez le texte " + "ci-dessous dans la configuration de votre assistant. " + "Guide détaillé").arg(QLatin1String(GUIDE_URL)), + this); + intro->setWordWrap(true); + intro->setOpenExternalLinks(true); + layout->addWidget(intro); + + if (m_paths.server.isEmpty()) { + auto *missing = new QLabel( + tr("Le serveur MCP n'est pas installé avec cette version de " + "QElectroTech. Le guide explique comment l'obtenir."), + this); + missing->setWordWrap(true); + layout->addWidget(missing); + } + + auto *form = new QFormLayout(); + m_client = new QComboBox(this); + m_client->addItem(QStringLiteral("Claude Desktop"), int(Client::ClaudeDesktop)); + m_client->addItem(QStringLiteral("Claude Code"), int(Client::ClaudeCode)); + m_client->addItem(QStringLiteral("GitHub Copilot (VS Code)"), int(Client::VsCode)); + m_client->addItem(QStringLiteral("Cursor"), int(Client::Cursor)); + m_client->addItem(QStringLiteral("Gemini CLI"), int(Client::GeminiCli)); + m_client->addItem(QStringLiteral("Codex CLI"), int(Client::CodexCli)); + m_client->addItem(QStringLiteral("LM Studio"), int(Client::LmStudio)); + form->addRow(tr("Assistant :"), m_client); + + auto *workspace_row = new QHBoxLayout(); + m_workspace = new QLineEdit(this); + m_workspace->setPlaceholderText(tr("Le dossier de vos schémas")); + auto *browse = new QPushButton(tr("Parcourir…"), this); + workspace_row->addWidget(m_workspace); + workspace_row->addWidget(browse); + form->addRow(tr("Dossier accessible :"), workspace_row); + + m_allow_edit = new QCheckBox(tr("Autoriser l'assistant à modifier les schémas"), this); + form->addRow(QString(), m_allow_edit); + layout->addLayout(form); + + auto *scope = new QLabel( + tr("L'assistant ne peut lire et écrire que dans ce dossier. Sans " + "modification autorisée, il peut seulement lire, comparer et " + "exporter. Un assistant lit le texte des projets (repères, " + "notes…) : un texte écrit comme une instruction peut " + "l'influencer. Laissez les modifications désactivées pour les " + "schémas reçus d'autres personnes."), + this); + scope->setWordWrap(true); + layout->addWidget(scope); + + m_where = new QLabel(this); + m_where->setWordWrap(true); + m_where->setTextInteractionFlags(Qt::TextSelectableByMouse); + layout->addWidget(m_where); + + m_text = new QPlainTextEdit(this); + m_text->setReadOnly(true); + m_text->setLineWrapMode(QPlainTextEdit::NoWrap); + m_text->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + layout->addWidget(m_text); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); + m_copy = buttons->addButton(tr("Copier"), QDialogButtonBox::ActionRole); + layout->addWidget(buttons); + + connect(m_client, QOverload::of(&QComboBox::currentIndexChanged), + this, &AiAssistantDialog::refresh); + connect(m_workspace, &QLineEdit::textChanged, this, &AiAssistantDialog::refresh); + connect(m_allow_edit, &QCheckBox::toggled, this, &AiAssistantDialog::refresh); + connect(browse, &QPushButton::clicked, this, &AiAssistantDialog::chooseWorkspace); + connect(m_copy, &QPushButton::clicked, this, &AiAssistantDialog::copy); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + refresh(); +} + +void AiAssistantDialog::refresh() +{ + const auto client = Client(m_client->currentData().toInt()); + m_where->setText(whereItGoes(client) + QLatin1Char(' ') + + tr("Si le fichier contient déjà d'autres serveurs, " + "ajoutez seulement l'entrée « qet ».")); + + const QString workspace = m_workspace->text().trimmed(); + const bool ready = !m_paths.server.isEmpty() && !workspace.isEmpty(); + m_copy->setEnabled(ready); + if (m_paths.server.isEmpty()) + m_text->setPlainText(QString()); + else if (workspace.isEmpty()) + m_text->setPlainText(tr("Choisissez d'abord le dossier de vos schémas.")); + else + m_text->setPlainText(AiAssistantSetup::configuration( + client, m_paths, workspace, m_allow_edit->isChecked())); +} + +void AiAssistantDialog::chooseWorkspace() +{ + const QString dir = QFileDialog::getExistingDirectory( + this, tr("Dossier accessible à l'assistant"), m_workspace->text()); + if (!dir.isEmpty()) + m_workspace->setText(QDir::toNativeSeparators(dir)); +} + +void AiAssistantDialog::copy() +{ + QApplication::clipboard()->setText(m_text->toPlainText()); + m_copy->setText(tr("Copié")); +} + +QString AiAssistantDialog::whereItGoes(Client client) const +{ + switch (client) { + case Client::ClaudeDesktop: + return tr("Dans Claude Desktop : Paramètres → Développeur → " + "Modifier la configuration. Puis quittez et relancez Claude."); + case Client::ClaudeCode: + return tr("Enregistrez-le sous le nom .mcp.json dans le dossier de vos schémas."); + case Client::VsCode: + return tr("Enregistrez-le sous le nom .vscode/mcp.json dans le dossier " + "ouvert dans VS Code. Copilot utilise les outils en mode agent."); + case Client::Cursor: + return tr("Ajoutez-le au fichier .cursor/mcp.json de votre dossier " + "personnel."); + case Client::GeminiCli: + return tr("Ajoutez-le au fichier .gemini/settings.json de votre dossier " + "personnel. Gemini CLI demande de faire confiance au dossier " + "la première fois."); + case Client::CodexCli: + return tr("Ajoutez-le au fichier .codex/config.toml de votre dossier " + "personnel."); + case Client::LmStudio: + return tr("Dans LM Studio : onglet Program → Install → Edit mcp.json."); + } + return QString(); +} diff --git a/sources/ui/aiassistantdialog.h b/sources/ui/aiassistantdialog.h new file mode 100644 index 000000000..18a3f7f1a --- /dev/null +++ b/sources/ui/aiassistantdialog.h @@ -0,0 +1,61 @@ +/* + 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 AIASSISTANTDIALOG_H +#define AIASSISTANTDIALOG_H + +#include "aiassistantsetup.h" + +#include + +class QCheckBox; +class QComboBox; +class QLabel; +class QLineEdit; +class QPlainTextEdit; +class QPushButton; + +/** + @brief The AiAssistantDialog class + Help > Connect an AI assistant: shows the configuration an assistant + needs to start QElectroTech's MCP server, with this installation's + paths filled in, for the user to copy into that assistant. It writes + nothing and starts nothing. +*/ +class AiAssistantDialog : public QDialog +{ + Q_OBJECT + + public: + explicit AiAssistantDialog(QWidget *parent = nullptr); + + private: + void refresh(); + void chooseWorkspace(); + void copy(); + QString whereItGoes(AiAssistantSetup::Client client) const; + + AiAssistantSetup::Paths m_paths; + QComboBox *m_client = nullptr; + QLineEdit *m_workspace = nullptr; + QCheckBox *m_allow_edit = nullptr; + QLabel *m_where = nullptr; + QPlainTextEdit *m_text = nullptr; + QPushButton *m_copy = nullptr; +}; + +#endif // AIASSISTANTDIALOG_H diff --git a/sources/ui/aiassistantsetup.cpp b/sources/ui/aiassistantsetup.cpp new file mode 100644 index 000000000..d484fc6e0 --- /dev/null +++ b/sources/ui/aiassistantsetup.cpp @@ -0,0 +1,141 @@ +/* + 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 "aiassistantsetup.h" + +#include +#include +#include +#include +#include + +namespace { + +/** + The first of \a candidates that is a file, as a clean absolute path, + or an empty string. +*/ +QString firstFile(const QStringList &candidates) +{ + for (const QString &c : candidates) { + const QFileInfo info(c); + if (info.isFile()) + return QDir::cleanPath(info.absoluteFilePath()); + } + return QString(); +} + +QJsonObject environment(const AiAssistantSetup::Paths &paths, + const QString &workspace, + bool allow_edit) +{ + QJsonObject env; + env.insert(QStringLiteral("QET_MCP_WORKSPACE"), QDir::toNativeSeparators(workspace)); + env.insert(QStringLiteral("QET_BINARY"), QDir::toNativeSeparators(paths.qet_binary)); + if (allow_edit) + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + return env; +} + +/// A TOML basic string: backslashes and quotes escaped. +QString toml(const QString &s) +{ + QString out = s; + out.replace(QLatin1Char('\\'), QStringLiteral("\\\\")); + out.replace(QLatin1Char('"'), QStringLiteral("\\\"")); + return QLatin1Char('"') + out + QLatin1Char('"'); +} + +} // namespace + +QList AiAssistantSetup::clients() +{ + return {Client::ClaudeDesktop, Client::ClaudeCode, Client::VsCode, + Client::Cursor, Client::GeminiCli, Client::CodexCli, + Client::LmStudio}; +} + +/** + @brief AiAssistantSetup::detect + Finds the server beside this QElectroTech, in the two places its + packages put it: /mcp/ beside /bin/ (the Windows installers + and portable folder) and /share/qelectrotech/mcp/ beside + /bin/ (make install). On Windows the Python the installer can + add is preferred to one on PATH. + @param application_dir : QCoreApplication::applicationDirPath() + @param application_file : QCoreApplication::applicationFilePath() + @param windows : true on Windows, where Python is "python", not "python3" +*/ +AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir, + const QString &application_file, + bool windows) +{ + const QDir bin(application_dir); + Paths paths; + paths.qet_binary = QDir::cleanPath(application_file); + paths.server = firstFile({ + bin.filePath(QStringLiteral("../mcp/qet_mcp.py")), + bin.filePath(QStringLiteral("../share/qelectrotech/mcp/qet_mcp.py"))}); + + paths.python = windows ? QStringLiteral("python") : QStringLiteral("python3"); + if (windows) { + const QString bundled = firstFile({ + bin.filePath(QStringLiteral("../mcp/python/python.exe"))}); + if (!bundled.isEmpty()) + paths.python = bundled; + } + return paths; +} + +/** + @brief AiAssistantSetup::configuration + @return the text to paste into \a client's configuration: JSON for all + but Codex CLI, which reads TOML. Windows paths come out with native + separators, escaped as each format requires. +*/ +QString AiAssistantSetup::configuration(Client client, + const Paths &paths, + const QString &workspace, + bool allow_edit) +{ + const QString python = QDir::toNativeSeparators(paths.python); + const QString server = QDir::toNativeSeparators(paths.server); + const QJsonObject env = environment(paths, workspace, allow_edit); + + if (client == Client::CodexCli) { + QString text = QStringLiteral("[mcp_servers.qet]\n"); + text += QStringLiteral("command = ") + toml(python) + QLatin1Char('\n'); + text += QStringLiteral("args = [") + toml(server) + QStringLiteral("]\n\n"); + text += QStringLiteral("[mcp_servers.qet.env]\n"); + for (auto it = env.constBegin(); it != env.constEnd(); ++it) + text += it.key() + QStringLiteral(" = ") + toml(it.value().toString()) + QLatin1Char('\n'); + return text; + } + + QJsonObject server_entry; + if (client == Client::VsCode || client == Client::Cursor) + server_entry.insert(QStringLiteral("type"), QStringLiteral("stdio")); + server_entry.insert(QStringLiteral("command"), python); + server_entry.insert(QStringLiteral("args"), QJsonArray{server}); + server_entry.insert(QStringLiteral("env"), env); + + // VS Code names the list "servers"; every other client, "mcpServers". + const QString key = client == Client::VsCode ? QStringLiteral("servers") + : QStringLiteral("mcpServers"); + const QJsonObject root{{key, QJsonObject{{QStringLiteral("qet"), server_entry}}}}; + return QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)); +} diff --git a/sources/ui/aiassistantsetup.h b/sources/ui/aiassistantsetup.h new file mode 100644 index 000000000..144a74020 --- /dev/null +++ b/sources/ui/aiassistantsetup.h @@ -0,0 +1,64 @@ +/* + 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 AIASSISTANTSETUP_H +#define AIASSISTANTSETUP_H + +#include +#include + +/** + @brief What an AI assistant needs to start QElectroTech's MCP server + (misc/qet-mcp), and the configuration text each assistant expects. + + No widgets and no application object, so it can be tested alone. It + reads the file system and nothing else: it writes nothing and starts + nothing. AiAssistantDialog shows its result. +*/ +namespace AiAssistantSetup +{ + /// The assistants a configuration can be written for. + enum class Client { + ClaudeDesktop, + ClaudeCode, + VsCode, + Cursor, + GeminiCli, + CodexCli, + LmStudio + }; + + QList clients(); + + /// Where the pieces are. An empty server means none was found. + struct Paths { + QString server; ///< qet_mcp.py + QString python; ///< the Python to run it with + QString qet_binary; ///< this QElectroTech + }; + + Paths detect(const QString &application_dir, + const QString &application_file, + bool windows); + + QString configuration(Client client, + const Paths &paths, + const QString &workspace, + bool allow_edit); +} + +#endif // AIASSISTANTSETUP_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index e10e2ad16..b9422de60 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -212,6 +212,16 @@ add_test(NAME tst_scriptingsetting COMMAND tst_scriptingsetting) target_include_directories(tst_scriptingsetting PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_scriptingsetting PRIVATE Qt::Test Qt::Gui) +# AiAssistantSetup: finding the MCP server beside an installation, and the +# configuration text for each AI assistant. Compiles the one file alone. +add_executable( + tst_aiassistantsetup + tst_aiassistantsetup.cpp + ${QET_DIR}/sources/ui/aiassistantsetup.cpp) +add_test(NAME tst_aiassistantsetup COMMAND tst_aiassistantsetup) +target_include_directories(tst_aiassistantsetup PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_aiassistantsetup PRIVATE Qt::Test Qt::Core) + # QetSettings::sheetBackground() -- the sheet (folio) background the diagram # editor remembers between runs. Compiles qetsettings.cpp alone for the same # reason as the test above; Qt::Gui because the pair carries a QColor. diff --git a/tests/qttest/tst_aiassistantsetup.cpp b/tests/qttest/tst_aiassistantsetup.cpp new file mode 100644 index 000000000..09912077e --- /dev/null +++ b/tests/qttest/tst_aiassistantsetup.cpp @@ -0,0 +1,149 @@ +/* + 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 "ui/aiassistantsetup.h" + +#include +#include +#include +#include +#include +#include +#include + +using AiAssistantSetup::Client; + +class TestAiAssistantSetup : public QObject +{ + Q_OBJECT + + static void touch(const QString &path) + { + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile f(path); + QVERIFY(f.open(QIODevice::WriteOnly)); + } + + static AiAssistantSetup::Paths windowsPaths() + { + AiAssistantSetup::Paths p; + p.server = QStringLiteral("C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"); + p.python = QStringLiteral("C:\\Program Files\\QElectroTech\\mcp\\python\\python.exe"); + p.qet_binary = QStringLiteral("C:\\Program Files\\QElectroTech\\bin\\QElectroTech.exe"); + return p; + } + + static QJsonObject entry(Client client, const AiAssistantSetup::Paths &p, bool edit) + { + const QByteArray text = AiAssistantSetup::configuration( + client, p, QStringLiteral("D:\\Drawings"), edit).toUtf8(); + QJsonParseError error; + const QJsonDocument doc = QJsonDocument::fromJson(text, &error); + if (error.error != QJsonParseError::NoError) + qWarning() << error.errorString() << text; + const QString key = client == Client::VsCode ? QStringLiteral("servers") + : QStringLiteral("mcpServers"); + return doc.object().value(key).toObject().value(QStringLiteral("qet")).toObject(); + } + + private slots: + void windowsInstallIsFound() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("bin/QElectroTech.exe"))); + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + touch(root.filePath(QStringLiteral("mcp/python/python.exe"))); + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true); + QCOMPARE(p.server, QDir::cleanPath(root.filePath(QStringLiteral("mcp/qet_mcp.py")))); + QCOMPARE(p.python, QDir::cleanPath(root.filePath(QStringLiteral("mcp/python/python.exe")))); + QCOMPARE(p.qet_binary, QDir::cleanPath(root.filePath(QStringLiteral("bin/QElectroTech.exe")))); + } + + void windowsWithoutBundledPythonUsesPath() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + QDir().mkpath(root.filePath(QStringLiteral("bin"))); + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true); + QCOMPARE(p.python, QStringLiteral("python")); + } + + void unixInstallIsFound() + { + QTemporaryDir prefix; + touch(prefix.filePath(QStringLiteral("share/qelectrotech/mcp/qet_mcp.py"))); + // A bundled-Python layout is only a Windows thing. + touch(prefix.filePath(QStringLiteral("mcp/python/python.exe"))); + QDir().mkpath(prefix.filePath(QStringLiteral("bin"))); + const auto p = AiAssistantSetup::detect(prefix.filePath(QStringLiteral("bin")), + prefix.filePath(QStringLiteral("bin/qelectrotech")), false); + QCOMPARE(p.server, QDir::cleanPath(prefix.filePath(QStringLiteral("share/qelectrotech/mcp/qet_mcp.py")))); + QCOMPARE(p.python, QStringLiteral("python3")); + } + + void noServerIsReportedAsEmpty() + { + QTemporaryDir build; + const auto p = AiAssistantSetup::detect(build.path(), + build.filePath(QStringLiteral("qelectrotech")), false); + QVERIFY(p.server.isEmpty()); + } + + void everyJsonClientGetsValidJson() + { + for (Client c : AiAssistantSetup::clients()) { + if (c == Client::CodexCli) + continue; + const QJsonObject e = entry(c, windowsPaths(), false); + QVERIFY2(!e.isEmpty(), qPrintable(QString::number(int(c)))); + // Backslashes survive: parsing the text gives the path back. + QCOMPARE(e.value(QStringLiteral("args")).toArray().at(0).toString(), windowsPaths().server); + QCOMPARE(e.value(QStringLiteral("command")).toString(), windowsPaths().python); + const QJsonObject env = e.value(QStringLiteral("env")).toObject(); + QCOMPARE(env.value(QStringLiteral("QET_MCP_WORKSPACE")).toString(), QStringLiteral("D:\\Drawings")); + QCOMPARE(env.value(QStringLiteral("QET_BINARY")).toString(), windowsPaths().qet_binary); + const bool typed = c == Client::VsCode || c == Client::Cursor; + QCOMPARE(e.contains(QStringLiteral("type")), typed); + } + } + + void editingIsOffUnlessAllowed() + { + for (Client c : AiAssistantSetup::clients()) { + const QString off = AiAssistantSetup::configuration(c, windowsPaths(), QStringLiteral("D:\\x"), false); + const QString on = AiAssistantSetup::configuration(c, windowsPaths(), QStringLiteral("D:\\x"), true); + QVERIFY(!off.contains(QStringLiteral("QET_ENABLE_SCRIPTING"))); + QVERIFY(on.contains(QStringLiteral("QET_ENABLE_SCRIPTING"))); + } + } + + void codexGetsEscapedToml() + { + const QString t = AiAssistantSetup::configuration( + Client::CodexCli, windowsPaths(), QStringLiteral("D:\\Drawings"), true); + QVERIFY(t.startsWith(QStringLiteral("[mcp_servers.qet]\n"))); + QVERIFY(t.contains(QStringLiteral("args = [\"C:\\\\Program Files\\\\QElectroTech\\\\mcp\\\\qet_mcp.py\"]"))); + QVERIFY(t.contains(QStringLiteral("[mcp_servers.qet.env]\n"))); + QVERIFY(t.contains(QStringLiteral("QET_MCP_WORKSPACE = \"D:\\\\Drawings\""))); + QVERIFY(t.contains(QStringLiteral("QET_ENABLE_SCRIPTING = \"1\""))); + } +}; + +QTEST_GUILESS_MAIN(TestAiAssistantSetup) +#include "tst_aiassistantsetup.moc" From 06fc5153c1aaafa659b727120252d541f67e7a18 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 14:25:06 +1300 Subject: [PATCH 4/6] qet-mcp: make QElectroTech launches work on Windows On Windows every tool that starts QElectroTech (qet_export, qet_edit, qet_query, qet_check, qet_continuity, qet_project_new) failed, for two reasons: - The server ran a copy of the executable from a temporary folder, to get its own SingleApplication key. A Windows program loads its DLLs from its own folder, so the copy died before main() with 0xC0000135 (DLL not found). Every flag the server passes is a CLI export flag or --run, and main.cpp handles both before it constructs SingleApplication, so on Windows the original is now run. The copy stays elsewhere. - It set QT_QPA_PLATFORM=offscreen. The Windows packages ship only the qwindows platform plugin, so Qt found none and stopped at a message box nobody could close: every call hung until its timeout. Windows now keeps its default platform; the export flags and --run open no window. Checked under Wine (qet-wine-smoke) on the fork's CI Windows build, run through python.org's embeddable Python: before, qet_export ended with exit 3221225781; after, a PDF export, a qet_query (98 elements, as on Linux) and a qet_edit placing a common:// element all succeed. The hang was isolated by launching the same export from bash (works) and from Python with one change at a time: only dropping QT_QPA_PLATFORM made it work. Four unit tests pin both choices per platform; each fails with its fix removed. Suite 262/262 none skipped on Linux. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 47 ++++++++++++++++++++++++++++++------ misc/qet-mcp/test_qet_mcp.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index fa3363d7e..62675672f 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -884,6 +884,44 @@ def tool_element_info(path: str) -> dict: } +def _launch_executable(src: Path, sandbox: Path, windows: bool) -> Path: + """The executable _run_qet() starts: a private copy, except on Windows. + + The copy gives each run its own SingleApplication key, which is derived + from the executable's path. On Windows a program loads its DLLs from its + own folder, so a copy on its own dies before main() (0xC0000135, DLL not + found) and nothing could ever be exported or edited there. Run the + original instead: every flag this server passes is a CLI export flag or + --run, and QElectroTech handles both and returns before it constructs + SingleApplication (main.cpp), so there is no instance to be handed to. + The copy stays elsewhere for builds from before that early return. + """ + if windows: + return src + exe = sandbox / f"qet-mcp-{os.getpid()}" + shutil.copy2(src, exe) + return exe + + +def _launch_env(env: dict, home: Path, windows: bool) -> dict: + """The environment _run_qet() starts QElectroTech in. + + A private HOME and XDG directories, and, except on Windows, Qt's + offscreen platform so no display is needed. The Windows packages ship + only the qwindows platform plugin: asked for "offscreen", Qt finds no + plugin and stops at a message box nobody can close, so every call hung + until its timeout. The export flags and --run open no window there, so + the default platform is what they need. + """ + env = dict(env, + HOME=str(home), + XDG_CONFIG_HOME=str(home / ".config"), + XDG_DATA_HOME=str(home / ".local" / "share")) + if not windows: + env["QT_QPA_PLATFORM"] = "offscreen" + return env + + def _run_qet(binary: str, args: list[str], timeout: int = 180, elements_dir: str | None = None, script: str | None = None, tail: int = 4000) -> dict: @@ -925,8 +963,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, raise ValueError(f"not an executable: {src}") with tempfile.TemporaryDirectory(prefix="qet-mcp-") as tmp: sandbox = Path(tmp) - exe = sandbox / f"qet-mcp-{os.getpid()}" - shutil.copy2(src, exe) + exe = _launch_executable(src, sandbox, os.name == "nt") home = sandbox / "home" (home / ".config").mkdir(parents=True) (home / ".local" / "share").mkdir(parents=True) @@ -943,11 +980,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, script_path = sandbox / "qet-mcp-edit.js" script_path.write_text(script, encoding="utf-8") args = ["--run", str(script_path), *args] - env = dict(os.environ, - HOME=str(home), - XDG_CONFIG_HOME=str(home / ".config"), - XDG_DATA_HOME=str(home / ".local" / "share"), - QT_QPA_PLATFORM="offscreen") + env = _launch_env(dict(os.environ), home, os.name == "nt") try: p = subprocess.run([str(exe), *args], env=env, timeout=timeout, capture_output=True, text=True) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 24c1e2718..3c1815ac0 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -1902,6 +1902,45 @@ class PathPolicy(unittest.TestCase): f"{name} has no {arg!r} argument to guard") +class LaunchExecutable(unittest.TestCase): + """Windows cannot run a lone copy of QElectroTech (F065): its DLLs sit + beside the original. Everywhere else the private copy stays.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = Path(self.tmp.name) + self.src = base / "bin" / "qelectrotech" + self.src.parent.mkdir() + self.src.write_text("#!/bin/sh\nexit 0\n") + self.sandbox = base / "sandbox" + self.sandbox.mkdir() + + def tearDown(self): + self.tmp.cleanup() + + def test_windows_runs_the_original(self): + self.assertEqual(m._launch_executable(self.src, self.sandbox, True), self.src) + self.assertEqual(list(self.sandbox.iterdir()), [], "nothing is copied on Windows") + + def test_windows_keeps_its_own_qt_platform(self): + """The Windows packages have no offscreen plugin; asking for it + leaves QElectroTech stuck at a message box.""" + env = m._launch_env({"PATH": "x"}, self.sandbox, True) + self.assertNotIn("QT_QPA_PLATFORM", env) + self.assertEqual(env["PATH"], "x") + + def test_elsewhere_runs_offscreen(self): + env = m._launch_env({}, self.sandbox, False) + self.assertEqual(env["QT_QPA_PLATFORM"], "offscreen") + self.assertEqual(env["HOME"], str(self.sandbox)) + + def test_elsewhere_runs_a_private_copy(self): + exe = m._launch_executable(self.src, self.sandbox, False) + self.assertEqual(exe.parent, self.sandbox) + self.assertNotEqual(exe, self.src) + self.assertEqual(exe.read_text(), self.src.read_text()) + + class ScriptingDisabledHint(unittest.TestCase): """QElectroTech may refuse to run scripts at all, and says so in French. From ddc645174f7b8f896aea7ce29a6112b6b20914dc Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 15:17:16 +1300 Subject: [PATCH 5/6] Connect an AI assistant: say when Python is missing The configuration names "python" or "python3", or the Python the Windows installer can add. When none of those can run, the assistant fails with an error far from the cause, so the dialog now says so in bold, with what to do: tick "Python pour l'assistant IA" in the installer or install Python (Windows), or install Python 3 from the system's packages. Windows 10 and 11 put a python.exe in WindowsApps that only opens the Microsoft Store, so a PATH search alone reports Python where there is none. A result there is reported as possibly that shortcut. Backslashes are converted explicitly, not with QDir::fromNativeSeparators(), which leaves them alone off Windows and so could not be tested here. tst_aiassistantsetup: 17 cases (8 new: the bundled Python is taken without looking on PATH; none, python.org, the Store shortcut with either separator, and a Linux folder named WindowsApps). Each of the 4 new rules was removed in turn and the test failed. ctest 32/32. In the GUI, the same install layout shows the warning with no python3 on PATH and no warning with it. Co-Authored-By: Claude Opus 5.5 --- sources/ui/aiassistantdialog.cpp | 22 +++++++++++++ sources/ui/aiassistantsetup.cpp | 27 +++++++++++++-- sources/ui/aiassistantsetup.h | 18 +++++++++- tests/qttest/tst_aiassistantsetup.cpp | 47 +++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/sources/ui/aiassistantdialog.cpp b/sources/ui/aiassistantdialog.cpp index b9f7ac355..6d8542d7e 100644 --- a/sources/ui/aiassistantdialog.cpp +++ b/sources/ui/aiassistantdialog.cpp @@ -77,6 +77,28 @@ AiAssistantDialog::AiAssistantDialog(QWidget *parent) : layout->addWidget(missing); } + if (!m_paths.server.isEmpty() + && m_paths.python_status != AiAssistantSetup::PythonStatus::Found) { + QString text; + if (m_paths.python_status == AiAssistantSetup::PythonStatus::StoreShortcut) + text = tr("Python n'est peut-être pas installé. Seul le raccourci " + "« python » du Microsoft Store a été trouvé : sans Python, il " + "ouvre le Store au lieu de lancer le serveur."); + else + text = tr("Python est introuvable sur cet ordinateur (commande " + "« %1 »). Le serveur en a besoin.").arg(m_paths.python); + if (windows) + text += QLatin1Char(' ') + tr("Relancez l'installateur de QElectroTech et " + "cochez « Python pour l'assistant IA », ou " + "installez Python depuis python.org."); + else + text += QLatin1Char(' ') + tr("Installez Python 3 avec le gestionnaire " + "de paquets de votre système."); + auto *python = new QLabel(text, this); + python->setWordWrap(true); + layout->addWidget(python); + } + auto *form = new QFormLayout(); m_client = new QComboBox(this); m_client->addItem(QStringLiteral("Claude Desktop"), int(Client::ClaudeDesktop)); diff --git a/sources/ui/aiassistantsetup.cpp b/sources/ui/aiassistantsetup.cpp index d484fc6e0..6bb0923dc 100644 --- a/sources/ui/aiassistantsetup.cpp +++ b/sources/ui/aiassistantsetup.cpp @@ -22,6 +22,7 @@ #include #include #include +#include namespace { @@ -79,10 +80,13 @@ QList AiAssistantSetup::clients() @param application_dir : QCoreApplication::applicationDirPath() @param application_file : QCoreApplication::applicationFilePath() @param windows : true on Windows, where Python is "python", not "python3" + @param find_executable : looks a program up on PATH; empty means + QStandardPaths::findExecutable(). Given in tests. */ AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir, const QString &application_file, - bool windows) + bool windows, + const ExecutableFinder &find_executable) { const QDir bin(application_dir); Paths paths; @@ -95,9 +99,28 @@ AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir, if (windows) { const QString bundled = firstFile({ bin.filePath(QStringLiteral("../mcp/python/python.exe"))}); - if (!bundled.isEmpty()) + if (!bundled.isEmpty()) { paths.python = bundled; + paths.python_status = PythonStatus::Found; + return paths; + } } + + const ExecutableFinder find = find_executable + ? find_executable + : ExecutableFinder([](const QString &name) { + return QStandardPaths::findExecutable(name); }); + const QString found = find(paths.python); + if (found.isEmpty()) + paths.python_status = PythonStatus::Missing; + // Windows 10 and 11 put a python.exe in WindowsApps that only opens the + // Microsoft Store. A Python installed from the Store answers from there + // too, so this cannot tell the two apart: say which it may be. + else if (windows && QString(found).replace(QLatin1Char('\\'), QLatin1Char('/')) + .contains(QLatin1String("/WindowsApps/"), Qt::CaseInsensitive)) + paths.python_status = PythonStatus::StoreShortcut; + else + paths.python_status = PythonStatus::Found; return paths; } diff --git a/sources/ui/aiassistantsetup.h b/sources/ui/aiassistantsetup.h index 144a74020..799e91c63 100644 --- a/sources/ui/aiassistantsetup.h +++ b/sources/ui/aiassistantsetup.h @@ -21,6 +21,8 @@ #include #include +#include + /** @brief What an AI assistant needs to start QElectroTech's MCP server (misc/qet-mcp), and the configuration text each assistant expects. @@ -44,16 +46,30 @@ namespace AiAssistantSetup QList clients(); + /// Whether the Python the configuration names can actually run. + enum class PythonStatus { + Found, + Missing, + /// Only Windows' "python.exe" shortcut in WindowsApps, which opens + /// the Microsoft Store when Python is not installed. + StoreShortcut + }; + /// Where the pieces are. An empty server means none was found. struct Paths { QString server; ///< qet_mcp.py QString python; ///< the Python to run it with QString qet_binary; ///< this QElectroTech + PythonStatus python_status = PythonStatus::Missing; }; + /// Finds a program on PATH; QStandardPaths::findExecutable() by default. + using ExecutableFinder = std::function; + Paths detect(const QString &application_dir, const QString &application_file, - bool windows); + bool windows, + const ExecutableFinder &find_executable = ExecutableFinder()); QString configuration(Client client, const Paths &paths, diff --git a/tests/qttest/tst_aiassistantsetup.cpp b/tests/qttest/tst_aiassistantsetup.cpp index 09912077e..b7b566bcf 100644 --- a/tests/qttest/tst_aiassistantsetup.cpp +++ b/tests/qttest/tst_aiassistantsetup.cpp @@ -97,6 +97,53 @@ class TestAiAssistantSetup : public QObject QCOMPARE(p.python, QStringLiteral("python3")); } + void bundledPythonIsFoundWithoutLookingOnPath() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + touch(root.filePath(QStringLiteral("mcp/python/python.exe"))); + QDir().mkpath(root.filePath(QStringLiteral("bin"))); + bool looked = false; + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true, + [&looked](const QString &) { looked = true; return QString(); }); + QCOMPARE(p.python_status, AiAssistantSetup::PythonStatus::Found); + QVERIFY(!looked); + } + + void pythonStatusFromPath_data() + { + QTest::addColumn("windows"); + QTest::addColumn("found"); + QTest::addColumn("status"); + using S = AiAssistantSetup::PythonStatus; + QTest::newRow("windows, none") << true << QString() << int(S::Missing); + QTest::newRow("windows, python.org") << true + << QStringLiteral("C:/Users/me/AppData/Local/Programs/Python/Python314/python.exe") << int(S::Found); + QTest::newRow("windows, Store shortcut") << true + << QStringLiteral("C:/Users/me/AppData/Local/Microsoft/WindowsApps/python.exe") << int(S::StoreShortcut); + QTest::newRow("windows, Store shortcut, backslashes") << true + << QStringLiteral("C:\\Users\\me\\AppData\\Local\\Microsoft\\WINDOWSAPPS\\python.exe") << int(S::StoreShortcut); + QTest::newRow("linux, none") << false << QString() << int(S::Missing); + QTest::newRow("linux, python3") << false << QStringLiteral("/usr/bin/python3") << int(S::Found); + QTest::newRow("linux, a folder named WindowsApps") << false + << QStringLiteral("/opt/WindowsApps/python3") << int(S::Found); + } + + void pythonStatusFromPath() + { + QFETCH(bool, windows); + QFETCH(QString, found); + QFETCH(int, status); + QTemporaryDir build; + QString asked; + const auto p = AiAssistantSetup::detect(build.path(), + build.filePath(QStringLiteral("qelectrotech")), windows, + [&](const QString &name) { asked = name; return found; }); + QCOMPARE(int(p.python_status), status); + QCOMPARE(asked, windows ? QStringLiteral("python") : QStringLiteral("python3")); + } + void noServerIsReportedAsEmpty() { QTemporaryDir build; From 42fab5ad78266df9bcc2bfb99223a47ff4a2c429 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 15:56:32 +1300 Subject: [PATCH 6/6] Fix title blocks cutting project file names at the first dot A project saved as "my.project.qet" showed "my" in every title block cell using %{projectfilename}, and "my" again in %{savedfilename} after a save. QETProject used QFileInfo::baseName(), which stops at the first "."; completeBaseName() strips only the ".qet" suffix. Same fix as #725 made for the PDF export's file name. Verified headlessly on examples/industrial.qet copied to my.project.qet, with a title block cell set to %{projectfilename}: --export-svg shows "my" on master and "my.project" with this change, on all 50 folios. For %{savedfilename}, a --run script calling qet.save("") twice writes "my" on master and "my.project" with this change. ctest 32/32. Not changed: write() updates the saved* variables after the file is written, so the values stored in a file are those of the save before. Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/qetproject.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index aae5a8bae..22e926509 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -580,7 +580,7 @@ void QETProject::setFilePath(const QString &filepath) m_project_properties.addValue("saveddate-eu", QDate::currentDate().toString("dd-MM-yyyy")); m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd")); m_project_properties.addValue("savedtime", QDateTime::currentDateTime().toString("HH:mm")); - m_project_properties.addValue("savedfilename", QFileInfo(filePath()).baseName()); + m_project_properties.addValue("savedfilename", QFileInfo(filePath()).completeBaseName()); m_project_properties.addValue("savedfilepath", filePath()); @@ -1428,7 +1428,7 @@ QETResult QETProject::write() m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd")); m_project_properties.addValue("saveddate-eu", QDate::currentDate().toString("dd-MM-yyyy")); m_project_properties.addValue("savedtime", QDateTime::currentDateTime().toString("HH:mm")); - m_project_properties.addValue("savedfilename", QFileInfo(filePath()).baseName()); + m_project_properties.addValue("savedfilename", QFileInfo(filePath()).completeBaseName()); m_project_properties.addValue("savedfilepath", filePath()); emit projectInformationsChanged(this); @@ -2480,7 +2480,7 @@ void QETProject::updateDiagramsFolioData() DiagramContext project_wide_properties = m_project_properties; project_wide_properties.addValue("projecttitle", title()); project_wide_properties.addValue("projectpath", filePath()); - project_wide_properties.addValue("projectfilename", QFileInfo(filePath()).baseName()); + project_wide_properties.addValue("projectfilename", QFileInfo(filePath()).completeBaseName()); for (int i = 0 ; i < total_folio ; ++ i) {