Merge remote-tracking branch 'upstream/master' into feat/database-from-document

# Conflicts:
#	sources/qetproject.cpp
This commit is contained in:
ispyisail
2026-09-29 19:00:16 +13:00
19 changed files with 1071 additions and 25 deletions
+18 -1
View File
@@ -227,6 +227,23 @@ 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/"
# 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
@@ -305,7 +322,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/"
+4
View File
@@ -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: <prefix>/share/qelectrotech/mcp -> <prefix>/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)
+17
View File
@@ -274,6 +274,21 @@ 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
; 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
@@ -282,6 +297,8 @@ 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_DESCRIPTION_TEXT ${SEC07} $(var7)
!insertmacro MUI_FUNCTION_DESCRIPTION_END
;--------------------------------
+64
View File
@@ -19,6 +19,10 @@
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: 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."
@@ -43,6 +47,10 @@
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: 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}을(를) 수동으로 제거해 주세요."
@@ -67,6 +75,10 @@
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: 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}."
@@ -91,6 +103,10 @@
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: 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} πριν συνεχίσετε."
@@ -115,6 +131,10 @@
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: 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ě."
@@ -139,6 +159,10 @@
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: 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."
@@ -163,6 +187,10 @@
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: 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."
@@ -187,6 +215,10 @@
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: 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} вручную перед продолжением."
@@ -211,6 +243,10 @@
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: 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} يدويًا قبل المتابعة."
@@ -235,6 +271,10 @@
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: 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."
@@ -259,6 +299,10 @@
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: 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."
@@ -283,6 +327,10 @@
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: 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."
@@ -307,6 +355,10 @@
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: 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."
@@ -331,6 +383,10 @@
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: 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."
@@ -355,6 +411,10 @@
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: 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."
@@ -379,6 +439,10 @@
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: 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."
+4
View File
@@ -19,4 +19,8 @@
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 : 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."
+48
View File
@@ -25,6 +25,10 @@
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: 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á."
@@ -52,6 +56,10 @@
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: 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} を手動でアンインストールしてください。"
@@ -79,6 +87,10 @@
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: 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}-г гараар устгана уу."
@@ -106,6 +118,10 @@
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: 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."
@@ -143,6 +159,10 @@
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: 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."
@@ -170,6 +190,10 @@
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: 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} ручно пре наставка."
@@ -197,6 +221,10 @@
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: 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."
@@ -224,6 +252,10 @@
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: 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}."
@@ -251,6 +283,10 @@
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: 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."
@@ -278,6 +314,10 @@
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: 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."
@@ -305,6 +345,10 @@
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: 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} вручну перед продовженням."
@@ -332,4 +376,8 @@
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: 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}。"
+4
View File
@@ -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
+26
View File
@@ -82,6 +82,32 @@ Register it with an MCP client, for example:
QElectroTech (as `<prefix>/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 | `<prefix>/share/qelectrotech/mcp/qet_mcp.py` | `<prefix>/bin/qelectrotech`, `<prefix>/share/qelectrotech/elements` |
| Windows installer, MSI, portable folder | `<folder>\mcp\qet_mcp.py` (the "AI assistant (MCP)" component) | `<folder>\bin\QElectroTech.exe`, `<folder>\elements` |
So a client configuration needs only the path to the server and the
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 `<folder>\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"]
```
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
+66 -21
View File
@@ -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)
@@ -3207,12 +3240,24 @@ def _env_paths(name: str) -> list:
return out
def _installed_prefix() -> Path | None:
"""The install prefix when this script is <prefix>/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:
<prefix>/share/qelectrotech/mcp/ -> <prefix>/bin, <prefix>/share/qelectrotech/elements
(make install: Linux, snap, flatpak, macOS)
<root>/mcp/ -> <root>/bin, <root>/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
@@ -3226,10 +3271,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")
@@ -3238,11 +3285,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
+76
View File
@@ -2562,6 +2562,82 @@ 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
<root>/mcp, the program in <root>/bin as QElectroTech.exe, and the
collection in <root>/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 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.
+9
View File
@@ -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);
+1
View File
@@ -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
+3 -3
View File
@@ -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);
@@ -2483,7 +2483,7 @@ DiagramContext QETProject::projectWideProperties()
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());
return project_wide_properties;
}
+220
View File
@@ -0,0 +1,220 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
#include "aiassistantdialog.h"
#include <QApplication>
#include <QCheckBox>
#include <QClipboard>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDir>
#include <QFileDialog>
#include <QFontDatabase>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QVBoxLayout>
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. "
"<a href=\"%1\">Guide détaillé</a>").arg(QLatin1String(GUIDE_URL)),
this);
intro->setWordWrap(true);
intro->setOpenExternalLinks(true);
layout->addWidget(intro);
if (m_paths.server.isEmpty()) {
auto *missing = new QLabel(
tr("<b>Le serveur MCP n'est pas installé avec cette version de "
"QElectroTech.</b> Le guide explique comment l'obtenir."),
this);
missing->setWordWrap(true);
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("<b>Python n'est peut-être pas installé.</b> 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("<b>Python est introuvable sur cet ordinateur</b> (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));
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<int>::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();
}
+61
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef AIASSISTANTDIALOG_H
#define AIASSISTANTDIALOG_H
#include "aiassistantsetup.h"
#include <QDialog>
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
+164
View File
@@ -0,0 +1,164 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
#include "aiassistantsetup.h"
#include <QDir>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
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::Client> 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: <root>/mcp/ beside <root>/bin/ (the Windows installers
and portable folder) and <prefix>/share/qelectrotech/mcp/ beside
<prefix>/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"
@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,
const ExecutableFinder &find_executable)
{
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;
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;
}
/**
@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));
}
+80
View File
@@ -0,0 +1,80 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
#ifndef AIASSISTANTSETUP_H
#define AIASSISTANTSETUP_H
#include <QString>
#include <QStringList>
#include <functional>
/**
@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<Client> 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<QString(const QString &)>;
Paths detect(const QString &application_dir,
const QString &application_file,
bool windows,
const ExecutableFinder &find_executable = ExecutableFinder());
QString configuration(Client client,
const Paths &paths,
const QString &workspace,
bool allow_edit);
}
#endif // AIASSISTANTSETUP_H
+10
View File
@@ -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.
+196
View File
@@ -0,0 +1,196 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
#include "ui/aiassistantsetup.h"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryDir>
#include <QtTest>
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 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<bool>("windows");
QTest::addColumn<QString>("found");
QTest::addColumn<int>("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;
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"