Add 'docs/' from commit '51dddcb3f891d60bc38645e88b2dbd515344da81'

git-subtree-dir: docs
git-subtree-mainline: 0582d1b3d4
git-subtree-split: 51dddcb3f8
This commit is contained in:
RockChinQ
2026-10-27 08:04:35 +00:00
847 changed files with 87063 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# LangBot Blog → Wiki article synchronization
`langbot-app/langbot-landing-page/src/content/blog` is the canonical article source. The Wiki publishes generated copies under `{en,zh,ja}/articles/` and stores every rendered image locally under `images/articles/` so Wiki pages do not depend on third-party hotlinks.
## Run locally
From the Wiki checkout, with the landing-page repository available as a sibling checkout:
```bash
node scripts/sync-blog-articles.mjs --source ../langbot-landing-page
node scripts/sync-blog-articles.mjs --check --source ../langbot-landing-page
node scripts/sync-blog-articles.mjs --check
npm test
```
The synchronizer:
- requires matching English and Chinese slug sets;
- uses the canonical Japanese article when one exists and a clearly marked English fallback otherwise;
- groups navigation into product updates, engineering, tutorials/integrations, and announcements with localized labels;
- copies source images and downloads external images into the Wiki repository;
- rewrites Markdown image URLs to Wiki-local paths;
- escapes prose that would otherwise be parsed as MDX expressions;
- replaces the generated article directories and updates the `docs.json` article tabs deterministically.
Do not edit generated article pages or mirrored images manually. Fix the canonical Blog post or the synchronizer, then regenerate.
A daily GitHub Actions workflow provides drift recovery. Article publishing work should still synchronize and verify the Wiki in the same task so a new Blog post does not wait for the scheduled run.
+14
View File
@@ -0,0 +1,14 @@
# Runner tutorial verification
With the local SDK installed, run:
```bash
python scripts/verify-runner-tutorial.py
python scripts/verify-plugin-api-examples.py
npm test
npm run build
```
The tutorial checker extracts the published Chinese, English and Japanese examples, checks that their code and manifests match, and executes event handlers including disabled replies, empty input, private/group messages and delivery failure. Platform actions use Mock; these tests do not prove external message delivery.
The official `langbot-plugin-demo/RunnerDemo` plugin adds multi-step actions, configuration isolation and progress/error examples. Its `scripts/smoke.py` installs a built package and runs the scenarios against a local Host. Use the same SDK and Core revision when testing.
+594
View File
@@ -0,0 +1,594 @@
{
"source": "https://github.com/langbot-app/langbot-landing-page/tree/main/src/content/blog",
"articles": [
{
"locale": "en",
"slug": "connect-deepseek-to-wechat",
"page": "en/articles/connect-deepseek-to-wechat",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "en",
"slug": "deploy-ai-bot-in-5-minutes",
"page": "en/articles/deploy-ai-bot-in-5-minutes",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "en",
"slug": "dify-agent-discord-telegram-slack",
"page": "en/articles/dify-agent-discord-telegram-slack",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "en",
"slug": "dify-langbot-rag-knowledge-base",
"page": "en/articles/dify-langbot-rag-knowledge-base",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "en",
"slug": "langbot-cloud-multi-user-workspaces",
"page": "en/articles/langbot-cloud-multi-user-workspaces",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "en",
"slug": "langbot-plugin-system-deep-dive",
"page": "en/articles/langbot-plugin-system-deep-dive",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "en",
"slug": "langbot-v4100-sandbox-skills",
"page": "en/articles/langbot-v4100-sandbox-skills",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "en",
"slug": "langbot-v490-rag-plugin-architecture",
"page": "en/articles/langbot-v490-rag-plugin-architecture",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "en",
"slug": "langflow-drag-and-drop-ai-bot",
"page": "en/articles/langflow-drag-and-drop-ai-bot",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "en",
"slug": "langtars-remote-pc-control-dify-n8n",
"page": "en/articles/langtars-remote-pc-control-dify-n8n",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "en",
"slug": "n8n-multi-platform-ai-chatbot",
"page": "en/articles/n8n-multi-platform-ai-chatbot",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "en",
"slug": "welcome",
"page": "en/articles/welcome",
"sourceLocale": "en",
"fallback": false
},
{
"locale": "zh",
"slug": "connect-deepseek-to-wechat",
"page": "zh/articles/connect-deepseek-to-wechat",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "zh",
"slug": "deploy-ai-bot-in-5-minutes",
"page": "zh/articles/deploy-ai-bot-in-5-minutes",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "zh",
"slug": "dify-agent-discord-telegram-slack",
"page": "zh/articles/dify-agent-discord-telegram-slack",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "zh",
"slug": "dify-langbot-rag-knowledge-base",
"page": "zh/articles/dify-langbot-rag-knowledge-base",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "zh",
"slug": "langbot-cloud-multi-user-workspaces",
"page": "zh/articles/langbot-cloud-multi-user-workspaces",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "zh",
"slug": "langbot-plugin-system-deep-dive",
"page": "zh/articles/langbot-plugin-system-deep-dive",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "zh",
"slug": "langbot-v4100-sandbox-skills",
"page": "zh/articles/langbot-v4100-sandbox-skills",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "zh",
"slug": "langbot-v490-rag-plugin-architecture",
"page": "zh/articles/langbot-v490-rag-plugin-architecture",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "zh",
"slug": "langflow-drag-and-drop-ai-bot",
"page": "zh/articles/langflow-drag-and-drop-ai-bot",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "zh",
"slug": "langtars-remote-pc-control-dify-n8n",
"page": "zh/articles/langtars-remote-pc-control-dify-n8n",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "zh",
"slug": "n8n-multi-platform-ai-chatbot",
"page": "zh/articles/n8n-multi-platform-ai-chatbot",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "zh",
"slug": "welcome",
"page": "zh/articles/welcome",
"sourceLocale": "zh",
"fallback": false
},
{
"locale": "ja",
"slug": "connect-deepseek-to-wechat",
"page": "ja/articles/connect-deepseek-to-wechat",
"sourceLocale": "en",
"fallback": true
},
{
"locale": "ja",
"slug": "deploy-ai-bot-in-5-minutes",
"page": "ja/articles/deploy-ai-bot-in-5-minutes",
"sourceLocale": "en",
"fallback": true
},
{
"locale": "ja",
"slug": "dify-agent-discord-telegram-slack",
"page": "ja/articles/dify-agent-discord-telegram-slack",
"sourceLocale": "en",
"fallback": true
},
{
"locale": "ja",
"slug": "dify-langbot-rag-knowledge-base",
"page": "ja/articles/dify-langbot-rag-knowledge-base",
"sourceLocale": "en",
"fallback": true
},
{
"locale": "ja",
"slug": "langbot-cloud-multi-user-workspaces",
"page": "ja/articles/langbot-cloud-multi-user-workspaces",
"sourceLocale": "ja",
"fallback": false
},
{
"locale": "ja",
"slug": "langbot-plugin-system-deep-dive",
"page": "ja/articles/langbot-plugin-system-deep-dive",
"sourceLocale": "en",
"fallback": true
},
{
"locale": "ja",
"slug": "langbot-v4100-sandbox-skills",
"page": "ja/articles/langbot-v4100-sandbox-skills",
"sourceLocale": "en",
"fallback": true
},
{
"locale": "ja",
"slug": "langbot-v490-rag-plugin-architecture",
"page": "ja/articles/langbot-v490-rag-plugin-architecture",
"sourceLocale": "en",
"fallback": true
},
{
"locale": "ja",
"slug": "langflow-drag-and-drop-ai-bot",
"page": "ja/articles/langflow-drag-and-drop-ai-bot",
"sourceLocale": "en",
"fallback": true
},
{
"locale": "ja",
"slug": "langtars-remote-pc-control-dify-n8n",
"page": "ja/articles/langtars-remote-pc-control-dify-n8n",
"sourceLocale": "en",
"fallback": true
},
{
"locale": "ja",
"slug": "n8n-multi-platform-ai-chatbot",
"page": "ja/articles/n8n-multi-platform-ai-chatbot",
"sourceLocale": "en",
"fallback": true
},
{
"locale": "ja",
"slug": "welcome",
"page": "ja/articles/welcome",
"sourceLocale": "en",
"fallback": true
}
],
"images": [
{
"path": "/images/articles/external/03443176ae5c-08-n8n-webhook-node.png",
"sha256": "8b05859a0d3c77a11755e493b464eb12a04f1abd341f982432f06be5e737d72f",
"bytes": 92552
},
{
"path": "/images/articles/external/0c8aa2c940ab-01-langbot-initial-page.png",
"sha256": "5dadd6447bca26f2aea014cdb63d075f655c30194c0e46b15b3f79c7dab768ee",
"bytes": 38365
},
{
"path": "/images/articles/external/12d0e04600c5-05-n8n-setup.png",
"sha256": "52cc42c6f1e12c4df29ac5a646888154e9193b33b28cdd678e8d88a6123bf0eb",
"bytes": 30953
},
{
"path": "/images/articles/external/21fc47358066-05-langflow-api-keys-page.png",
"sha256": "758bf810158bf3fe5ec1b1571086371db457de809de91743ade776c2c7497693",
"bytes": 54223
},
{
"path": "/images/articles/external/2a3a0d5f4ae9-install-flow.png",
"sha256": "7fe8844aeb196a3cb3c6338b4b6b7cf8c5255780a37a7aedd2cd5e2adaf3f99c",
"bytes": 138088
},
{
"path": "/images/articles/external/2fb54ce0f7a2-11-langbot-langflow-config-filled.png",
"sha256": "3da8e3faf3984091df9b176c5d05d341cbd3427ac35a61047cb6353d20edf418",
"bytes": 100965
},
{
"path": "/images/articles/external/3c7d6bc96738-08-langbot-pipeline-ai-tab.png",
"sha256": "a0c20a44d45f20d7588f611eb1ac006be68360f5289b2a767f36a6a35b16449b",
"bytes": 88489
},
{
"path": "/images/articles/external/3d7fa393f210-13-langbot-chat-with-response.png",
"sha256": "e1c8d41b8efb17a31fc9b41206b44e0d2a1bf83099a2d4d29d3b2c59ed9142e9",
"bytes": 62219
},
{
"path": "/images/articles/external/3e5b1d8ec6ec-07-dify-create-app-types.png",
"sha256": "6f161d5de119721af085ed40ce86c50aca4d6566f97241f7eeba1f83aefd3f0d",
"bytes": 165144
},
{
"path": "/images/articles/external/3fe5bdc18716-icon.svg",
"sha256": "430b8fc2bbcd1799231285f9f9645becce0d4cf7bef4412769db425f52cfac23",
"bytes": 734399
},
{
"path": "/images/articles/external/471b316018e5-13-dify-api-key-created.png",
"sha256": "776930876f0c50ef7bd70ff81faa4fdae2a66de4dbec4a3b595d2db3213c7ff3",
"bytes": 128227
},
{
"path": "/images/articles/external/4f1f5c28fbfc-03-langbot-dashboard.png",
"sha256": "3d6769445112e80fddf079028f81e4df67d28374b20c4f3a94086a535ed38fa3",
"bytes": 38244
},
{
"path": "/images/articles/external/621f3d526678-09-n8n-webhook-configured.png",
"sha256": "e19bb4356edfb75f463928528f702ed8a765d879ea377bf9721c2a24088f457a",
"bytes": 48125
},
{
"path": "/images/articles/external/6613b3025b8b-usage-example.png",
"sha256": "387e811bf57f83b121842a0e9066f2c045ad18e2666f6c04ae5eb568c288efc1",
"bytes": 126628
},
{
"path": "/images/articles/external/6e24402fd4a8-03-langflow-workflow-editor.png",
"sha256": "53069258d131ed4e87e6467eb95e8b90764684d5c948bdae9b4109f07667b158",
"bytes": 212230
},
{
"path": "/images/articles/external/70b26e660a60-tars.gif",
"sha256": "ec59948d2c335c17f373186b1913703017c8664554b53f7fb49c3eae0ee19ed5",
"bytes": 1544475
},
{
"path": "/images/articles/external/70ee40a6d232-01-langbot-initialization.png",
"sha256": "b9f45c78b49bdfdb51e278b2e491b88ca35d1a4ed05f33fc4c15085baacf2391",
"bytes": 42702
},
{
"path": "/images/articles/external/74f71a8bf1dc-17-langbot-dify-api-key-entered.png",
"sha256": "225397739ad8458f38002b75dfbbdc6eea268e9e3d1dd478e8a498b7a302e0d4",
"bytes": 101173
},
{
"path": "/images/articles/external/7e7ebfc3ba58-01-langflow-welcome.png",
"sha256": "62876dd239a917eb88ba7de369f9444e1c2fa169f3a347715c86c9bbaa4fba36",
"bytes": 50425
},
{
"path": "/images/articles/external/82d5e2ffb9a0-10-langbot-langflow-config-form.png",
"sha256": "56d2046d64179008e6d0384bce1dd4797f9818d866df86bf20ce16f9db322bbd",
"bytes": 92112
},
{
"path": "/images/articles/external/8f04c7b00759-06-langflow-create-api-key-dialog.png",
"sha256": "19a51834168c350185b0d2de1dd83a025d14d75bd3dfe3278997f755a258b3ae",
"bytes": 72422
},
{
"path": "/images/articles/external/8f4b3820ed5b-14-langbot-pipeline-ai-capability.png",
"sha256": "b751c1d8eacca6b9f5ffe6645d3d2b2d2557dacb88002b85dd1bae2f9d81f6ed",
"bytes": 86022
},
{
"path": "/images/articles/external/9066d99b172c-11-dify-tools-panel.png",
"sha256": "b099d333ded4cdf4e3392de8f444b3e7e7be81804e717b9c0a9735f0aff35844",
"bytes": 176867
},
{
"path": "/images/articles/external/9999c12907eb-20-langbot-chat-test.png",
"sha256": "ee409e91f03415bda392e8d51d31ffea16a4489773255f8c3690a63d6d67ced7",
"bytes": 61568
},
{
"path": "/images/articles/external/a618d3d41416-security.png",
"sha256": "18bf5b073596354b64c0abd4692c79f73da24b2e69b400c9164e3b62da76e361",
"bytes": 91790
},
{
"path": "/images/articles/external/b304cebf8ce9-02-langflow-template-selection.png",
"sha256": "d28ff1118326a43cb089ce21fd7fd4bb8c181bd037ac33b461dbf6e10a00dd3c",
"bytes": 455482
},
{
"path": "/images/articles/external/b36d6dea3702-12-langbot-debug-chat-interface.png",
"sha256": "30a260b07cc96584a39de1419d53b17991b9d86c10a956ac5896a91ab5617f49",
"bytes": 55005
},
{
"path": "/images/articles/external/b93d97b5b830-04-langbot-dashboard.png",
"sha256": "ae9dfe886f8802e1d35fa185b94465d40a229145ed65279d9b89f7bb63bd430b",
"bytes": 35641
},
{
"path": "/images/articles/external/b9a33a8551a7-8dd37939-4f10-4046-bd84-cce798005cd4.png",
"sha256": "1dbae106a3306820c6364faa7e21cf57fee5ea76b1187ed809c6faa8f3985863",
"bytes": 140727
},
{
"path": "/images/articles/external/bcd91ef5e4b3-14-langflow-openai-api-key-configured.png",
"sha256": "785b4cf39a62539eb334a750bc8e53466077198f120786fdb9c078b7e062d0d6",
"bytes": 209881
},
{
"path": "/images/articles/external/be278ca435e8-12-dify-agent-configured.png",
"sha256": "5feda01e6419a40658786e906a59b5e1c7e27503842e5b08fe3990d2d5b44a1c",
"bytes": 141049
},
{
"path": "/images/articles/external/c6d52212ba91-02-langbot-dashboard.png",
"sha256": "43a4eabed41c1ac9a356d33b8a160afac7a74ad1d06452827b3dd6c03017195a",
"bytes": 36354
},
{
"path": "/images/articles/external/e36d04ca4a5b-07-n8n-workflow-editor.png",
"sha256": "f845f0d7fd7553af2406c0e3bc1b461b4a5dd76ebd108f8ff3b48c3de3804037",
"bytes": 41296
},
{
"path": "/images/articles/external/e5a019491ef8-07-langflow-api-key-generated.png",
"sha256": "50c2e5d99ad7c1bbd40e6f467b0bd3edc85454a8867b9c11077f9f5f2ddececa",
"bytes": 82662
},
{
"path": "/images/articles/external/ef2b2f83bd16-04-langbot-pipelines.png",
"sha256": "f1edae3941ae6b55085a84e9c290a95744015ef4db1bd9978e97109b8ab320d4",
"bytes": 58250
},
{
"path": "/images/articles/external/ef9bbef78165-09-langbot-runner-dropdown.png",
"sha256": "3fc0d26c86715a1e859925727bdd27b84671da158185ff6860a73fd4f5f3466f",
"bytes": 94483
},
{
"path": "/images/articles/external/efa6fcae9c58-15-langbot-runner-dropdown.png",
"sha256": "826a60ffe5198b5cef2620f9f4ba553b006c6b3ad2ee2e0b041f044b6536bc56",
"bytes": 92060
},
{
"path": "/images/articles/external/ff42857251ab-planner-mode.png",
"sha256": "1e2ee21c697ab9c2320094874889e2ac0bd2fc44efb529eafa4d895393ca5691",
"bytes": 161907
},
{
"path": "/images/articles/source/images/article-mcp/01-init-page.png",
"sha256": "b392e42d582c8c1723a2eb63596801b9d459df0465377246d9a4015edcae1ca0",
"bytes": 45011
},
{
"path": "/images/articles/source/images/article-mcp/05-pipelines.png",
"sha256": "66891ef15942b5c11c92a60208ffed491414a488a8352b1f255b65b4d95cd912",
"bytes": 54047
},
{
"path": "/images/articles/source/images/article-mcp/06-pipeline-ai.png",
"sha256": "41b60a85a29e4fa75c8cb59c7f55fe51912eddb961340b04153201fdc5ae9d06",
"bytes": 72714
},
{
"path": "/images/articles/source/images/article-mcp/07-debug-chat.png",
"sha256": "6bb15b2ebb29878b83882eaf17e6f5a39785dd17572b7f864e7e653bdfd59fce",
"bytes": 57403
},
{
"path": "/images/articles/source/images/article-mcp/08-model-select.png",
"sha256": "27100d6a84663da8b227a460841698233ed5f8e4323b23fec2608d0acbec5682",
"bytes": 89524
},
{
"path": "/images/articles/source/images/article-mcp/10-group-chat.png",
"sha256": "b87b7d219527aebedfb48e6269796bc2ecc6eb68ec1f661dd1b1b4903c5f0f66",
"bytes": 58454
},
{
"path": "/images/articles/source/images/article-mcp/11-private-chat.png",
"sha256": "3e79edd893da3cfc983d2d8ff451c828e5bcd704f3f75c36711f01a94058902b",
"bytes": 53834
},
{
"path": "/images/articles/source/images/article-mcp/13-bot-page.png",
"sha256": "1a2dfbf25b164df63cb87d7490474d45b273e5e33c121e75490d923eaa41fb8f",
"bytes": 341107
},
{
"path": "/images/articles/source/images/article-mcp/14-space-market.png",
"sha256": "49ccec9d7f98f74020573a9e1da30c3fc3729ebb32610dad672ccc800c1455ef",
"bytes": 110469
},
{
"path": "/images/articles/source/images/cloud-multi-user/cloud-invitation.png",
"sha256": "8102e7f3d2babb38d5f8f211167c23d119858924913c0e576d3cbe1f9cca02ca",
"bytes": 23712
},
{
"path": "/images/articles/source/images/cloud-multi-user/cloud-login.png",
"sha256": "aa49e8120707c2e4d2fb7fde346afb7aa0eaf46bfa985027c079f3984d76287b",
"bytes": 33810
},
{
"path": "/images/articles/source/images/cloud-multi-user/oss-dashboard.png",
"sha256": "c9f8a0402c2077571826fafe7c3587c695c7aaed12186fa4f86823f6fa99da98",
"bytes": 103726
},
{
"path": "/images/articles/source/images/covers/deepseek-wechat.png",
"sha256": "fd0f772d9cd0bfa4c33e2ea38a6ab09710f2df08c004af91f17e4882c60be146",
"bytes": 20896
},
{
"path": "/images/articles/source/images/covers/deploy-ai-bot.png",
"sha256": "2bc212631b8b072e214bb433e4da241aca0e4c99087bef9ea24f5fd368bf6c7d",
"bytes": 20798
},
{
"path": "/images/articles/source/images/covers/dify-agent.png",
"sha256": "114d06c78593aa66e693768435822cdde7bec356d39e17b74a78d012c123a3a0",
"bytes": 11651
},
{
"path": "/images/articles/source/images/covers/dify-rag.png",
"sha256": "7746be4bb44228165f00a65ca5b147559058a5c79f9a91ac80fb3f1277c34dfe",
"bytes": 15799
},
{
"path": "/images/articles/source/images/covers/langflow.png",
"sha256": "b577d13d95e4b5428a076fb68f17a020f75a5d5f0b581f51e94323f83fd922b1",
"bytes": 14706
},
{
"path": "/images/articles/source/images/covers/n8n.png",
"sha256": "bea4ff8a15517784ab0035483a43ace25b0b2ce5099d7c39f0f817520801750b",
"bytes": 14297
},
{
"path": "/images/articles/source/images/covers/plugin-system.png",
"sha256": "cdf9a0e2249901835f98fe94faa9071ef8ffaa57831a8af30dd69155aeac953c",
"bytes": 17004
},
{
"path": "/images/articles/source/images/covers/rag-plugin.png",
"sha256": "c4e24290b57b30f55d086b2557425f7e790bedd2c8fa17d09c9cbc23686bdab3",
"bytes": 14661
},
{
"path": "/images/articles/source/images/covers/sandbox-skills.png",
"sha256": "2a89789d61ffe229aeb20c069a0e63102374abcdfe0f99e799fc5563cfb1910d",
"bytes": 86846
},
{
"path": "/images/articles/source/images/covers/welcome.png",
"sha256": "5ff0078633e30e8d8d8efa5abf686fbe13993b06cee383d2e7b9afec16639712",
"bytes": 11535
},
{
"path": "/images/articles/source/images/plugin-system/architecture.png",
"sha256": "85028534d0a6246bd25f110189a6734f6176e1765fc64bf5eeb3248137e7292d",
"bytes": 30445
},
{
"path": "/images/articles/source/images/plugin-system/event-dispatch.png",
"sha256": "ba6780bfda4c250da30575e87570e6b3d1a2bd682265340c365b4d3ba575ea13",
"bytes": 31013
},
{
"path": "/images/articles/source/images/rag-plugin/data-flow.png",
"sha256": "9f6f331d4ed508a983e7cc9aeb34914ba47cf0e00a1bd2df87c7424eab93c156",
"bytes": 31760
},
{
"path": "/images/articles/source/images/rag-plugin/dir-structure.png",
"sha256": "f167f813eda287ba11b492adc2626f1340f54b2cb03fe77f496ab5f3bbc65db3",
"bytes": 20301
},
{
"path": "/images/articles/source/images/sandbox-skills/sandbox-tools.png",
"sha256": "3137100959db409a0fa5b70e25bded4bb47df20c90c3a65a578d7862a4e64a3f",
"bytes": 169802
},
{
"path": "/images/articles/source/images/sandbox-skills/sandbox.png",
"sha256": "3a1fd443f4f8008f58144c9e344c2dc50f6faa343c8f65732d9df7d35d93744a",
"bytes": 46498
},
{
"path": "/images/articles/source/images/sandbox-skills/skills.png",
"sha256": "6ad86d972c7438fbef898d9d339f3f17a30277bf1626e8a9dd11b0b46583c817",
"bytes": 362173
}
]
}
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Build a GitHub Wiki-compatible mirror from the Mintlify MDX sources."""
from __future__ import annotations
import argparse
import json
import re
import shutil
from pathlib import Path, PurePosixPath
from urllib.parse import urlsplit, urlunsplit
LANGUAGES = {"zh": "简体中文", "en": "English", "ja": "日本語"}
NAVIGATION_LANGUAGE_CODES = {"cn": "zh", "en": "en", "jp": "ja"}
DOC_EXTENSIONS = (".md", ".mdx", ".html")
CALLOUTS = {"Info": "NOTE", "Note": "NOTE", "Tip": "TIP", "Warning": "WARNING"}
MOVED_SECTIONS = {"platforms", "models", "pipelines", "knowledge", "mcp"}
def page_name(path: PurePosixPath) -> str:
return path.with_suffix("").as_posix().replace("/", "-")
def extract_frontmatter(text: str) -> tuple[dict[str, str], str]:
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---\n", 4)
if end == -1:
return {}, text
metadata: dict[str, str] = {}
for line in text[4:end].splitlines():
key, separator, value = line.partition(":")
if separator:
metadata[key.strip()] = value.strip().strip('"\'')
return metadata, text[end + 5 :]
def resolve_doc_target(source: PurePosixPath, target: str) -> str | None:
parsed = urlsplit(target)
if parsed.scheme or parsed.netloc or target.startswith("#"):
return None
raw_path = parsed.path
if not raw_path:
return None
resolved = PurePosixPath(raw_path.lstrip("/")) if raw_path.startswith("/") else source.parent / raw_path
parts: list[str] = []
for part in resolved.parts:
if part in ("", "."):
continue
if part == "..":
if parts:
parts.pop()
else:
parts.append(part)
if not parts or parts[0] not in LANGUAGES:
return None
normalized = PurePosixPath(*parts)
suffix = normalized.suffix.lower()
if suffix in DOC_EXTENSIONS:
normalized = normalized.with_suffix("")
elif suffix:
return None
# Mintlify redirects these sections from their old deploy location. Apply the
# same redirect in the flat Wiki mirror so historical source links still work.
if len(normalized.parts) >= 3 and normalized.parts[1] == "deploy" and normalized.parts[2] in MOVED_SECTIONS:
normalized = PurePosixPath(normalized.parts[0], "usage", *normalized.parts[2:])
return urlunsplit(("", "", page_name(normalized), parsed.query, parsed.fragment))
def rewrite_url(source: PurePosixPath, target: str, *, image: bool = False) -> str:
parsed = urlsplit(target)
if parsed.scheme or parsed.netloc or target.startswith(("#", "mailto:")):
return target
if image:
path = parsed.path.lstrip("/") if parsed.path.startswith("/") else (source.parent / parsed.path).as_posix()
return urlunsplit(("https", "raw.githubusercontent.com", f"/langbot-app/langbot-docs/main/{path}", parsed.query, parsed.fragment))
return resolve_doc_target(source, target) or target
def convert_callouts(text: str) -> str:
pattern = re.compile(
r"<(Info|Note|Tip|Warning)(?:\s+title=(?:\"([^\"]*)\"|'([^']*)'))?\s*>(.*?)</\1>",
re.DOTALL,
)
def replace(match: re.Match[str]) -> str:
component = match.group(1)
title = match.group(2) or match.group(3) or ""
body = match.group(4).strip()
lines = [f"> [!{CALLOUTS[component]}]"]
if title:
lines.append(f"> **{title}**")
lines.extend(">" if not line else f"> {line}" for line in body.splitlines())
return "\n".join(lines)
previous = None
while previous != text:
previous = text
text = pattern.sub(replace, text)
return text
def convert_mdx(source: PurePosixPath, text: str) -> tuple[str, str]:
metadata, body = extract_frontmatter(text)
title = metadata.get("title") or source.stem.replace("-", " ").title()
body = convert_callouts(body)
body = re.sub(
r'<Accordion\s+title=(?:"([^"]*)"|\'([^\']*)\')\s*>',
lambda m: f"\n<details>\n<summary><strong>{m.group(1) or m.group(2)}</strong></summary>\n",
body,
)
body = body.replace("</Accordion>", "\n</details>")
body = re.sub(r"</?AccordionGroup\s*>", "", body)
body = re.sub(
r"(!\[[^\]]*\]\()([^)\s]+)([^)]*\))",
lambda m: m.group(1) + rewrite_url(source, m.group(2), image=True) + m.group(3),
body,
)
body = re.sub(
r"(?<!!)\[([^\]]+)\]\(([^)\s]+)([^)]*)\)",
lambda m: f"[{m.group(1)}]({rewrite_url(source, m.group(2))}{m.group(3)})",
body,
)
body = re.sub(
r'(<img\b[^>]*?\bsrc=["\'])([^"\']+)(["\'])',
lambda m: m.group(1) + rewrite_url(source, m.group(2), image=True) + m.group(3),
body,
flags=re.IGNORECASE,
)
body = body.replace("<br />", "<br>")
body = re.sub(r"\n{3,}", "\n\n", body).strip()
return title, f"# {title}\n\n{body}\n"
def discover_documents(root: Path) -> list[Path]:
return sorted(path for language in LANGUAGES for path in (root / language).rglob("*.mdx"))
def render_page_items(
items: list[object], titles: dict[str, str], *, indent: int = 0
) -> tuple[list[str], set[str]]:
lines: list[str] = []
included: set[str] = set()
prefix = " " * indent
for item in items:
if isinstance(item, str):
source_name = item.removesuffix(".mdx").removesuffix(".md")
title = titles.get(source_name)
if title:
lines.append(f"{prefix}- [{title}]({page_name(PurePosixPath(source_name))})")
included.add(source_name)
elif isinstance(item, dict) and not item.get("hidden"):
group = item.get("group")
children = item.get("pages", [])
if group and isinstance(children, list):
child_lines, child_pages = render_page_items(children, titles, indent=indent + 2)
if child_lines:
lines.append(f"{prefix}- **{group}**")
lines.extend(child_lines)
included.update(child_pages)
return lines, included
def build_sidebar(root: Path, titles: dict[str, str]) -> str:
navigation_path = root / "docs.json"
if not navigation_path.is_file():
lines = ["## LangBot Documentation", ""]
for language, label in LANGUAGES.items():
lines.extend((f"### {label}", ""))
lines.extend(
f"- [{title}]({page_name(PurePosixPath(source))})"
for source, title in titles.items()
if source.startswith(f"{language}/")
)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
navigation = json.loads(navigation_path.read_text(encoding="utf-8"))["navigation"]["languages"]
nav_by_language = {
NAVIGATION_LANGUAGE_CODES[item["language"]]: item
for item in navigation
if item.get("language") in NAVIGATION_LANGUAGE_CODES
}
lines = ["## LangBot Documentation", "", "[Home](Home)", ""]
for language, label in LANGUAGES.items():
language_nav = nav_by_language.get(language, {})
open_attribute = " open" if language == "zh" else ""
lines.extend((f"<details{open_attribute}>", f"<summary><strong>{label}</strong></summary>", ""))
included: set[str] = set()
for tab_index, tab in enumerate(language_nav.get("tabs", [])):
tab_lines: list[str] = []
tab_pages: set[str] = set()
for group in tab.get("groups", []):
group_lines, group_pages = render_page_items(group.get("pages", []), titles, indent=2)
if group_lines:
tab_lines.append(f"- **{group['group']}**")
tab_lines.extend(group_lines)
tab_pages.update(group_pages)
if tab_lines:
tab_open = " open" if language == "zh" and tab_index == 0 else ""
lines.extend((f"<details{tab_open}>", f"<summary>{tab['tab']}</summary>", ""))
lines.extend(tab_lines)
lines.extend(("", "</details>", ""))
included.update(tab_pages)
unlisted = sorted(
(source, title)
for source, title in titles.items()
if source.startswith(f"{language}/") and source not in included
)
if unlisted:
lines.extend(("<details>", "<summary>Other pages</summary>", ""))
lines.extend(f"- [{title}]({page_name(PurePosixPath(source))})" for source, title in unlisted)
lines.extend(("", "</details>", ""))
lines.extend(("</details>", ""))
return "\n".join(lines).rstrip() + "\n"
def build(root: Path, output: Path) -> int:
documents = discover_documents(root)
if not documents:
raise SystemExit("No MDX documents found")
if output.exists():
shutil.rmtree(output)
output.mkdir(parents=True)
index: dict[str, list[tuple[str, str]]] = {language: [] for language in LANGUAGES}
titles: dict[str, str] = {}
for path in documents:
source = PurePosixPath(path.relative_to(root).as_posix())
title, converted = convert_mdx(source, path.read_text(encoding="utf-8"))
name = page_name(source)
(output / f"{name}.md").write_text(converted, encoding="utf-8")
index[source.parts[0]].append((name, title))
titles[source.with_suffix("").as_posix()] = title
home = [
"# LangBot Documentation",
"",
"This GitHub Wiki is automatically synchronized from [langbot-app/langbot-docs](https://github.com/langbot-app/langbot-docs).",
"",
]
for language, label in LANGUAGES.items():
pages = index[language]
guide_name = f"{language}-insight-guide"
guide = next(((name, title) for name, title in pages if name == guide_name), pages[0])
home.extend((f"## {label}", "", f"[{guide[1]}]({guide[0]})", ""))
(output / "Home.md").write_text("\n".join(home).rstrip() + "\n", encoding="utf-8")
(output / "_Sidebar.md").write_text(build_sidebar(root, titles), encoding="utf-8")
(output / "_Footer.md").write_text(
"Automatically synchronized from [langbot-app/langbot-docs](https://github.com/langbot-app/langbot-docs).\n",
encoding="utf-8",
)
return len(documents)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--output", type=Path, default=Path("dist/github-wiki"))
args = parser.parse_args()
count = build(args.root.resolve(), args.output.resolve())
print(f"Built {count} GitHub Wiki pages in {args.output}")
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
export function deploymentGuidance() { return "## Canonical agent entry\n\nRead https://langbot.app/llms.txt first for deployment choices, optional managed hosting, CLI installation and operation, and end-to-end verification. This documentation index supplies the detailed guides referenced by that workflow.\n"; }
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
import { readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PUBLIC_ROOT = path.join(ROOT, "dist/public");
const ORIGIN = "https://langbot.app";
const BASE_PATH = "/docs";
const TEXT_EXTENSIONS = new Set([".html", ".md", ".txt", ".xml", ".json", ".js", ".css"]);
async function collectTextFiles(directory) {
const files = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...await collectTextFiles(target));
else if (TEXT_EXTENSIONS.has(path.extname(entry.name)) || target.endsWith("/api/search")) files.push(target);
}
return files;
}
function prefixPublicAssets(content) {
return content
.replaceAll('"/images/', `"${BASE_PATH}/images/`)
.replaceAll("'/images/", `'${BASE_PATH}/images/`)
.replaceAll('"/favicon.ico"', `"${BASE_PATH}/favicon.ico"`)
.replaceAll("'/favicon.ico'", `'${BASE_PATH}/favicon.ico'`)
.replaceAll('"/langbot-logo.png"', `"${BASE_PATH}/langbot-logo.png"`)
.replaceAll("'/langbot-logo.png'", `'${BASE_PATH}/langbot-logo.png'`)
.replaceAll('"/rss.xml"', `"${BASE_PATH}/rss.xml"`)
.replaceAll("'/rss.xml'", `'${BASE_PATH}/rss.xml'`);
}
export async function finalizeBasePath(publicRoot = PUBLIC_ROOT) {
const sitemapPath = path.join(publicRoot, "sitemap.xml");
const originalSitemap = await readFile(sitemapPath, "utf8");
const sourceUrls = [...originalSitemap.matchAll(/<loc>(https:\/\/langbot\.app\/[^<]+)<\/loc>/g)]
.map((match) => match[1].replace(/\/$/, ""));
const replacements = sourceUrls
.map((source) => [source, `${ORIGIN}${BASE_PATH}${source.slice(ORIGIN.length)}`])
.sort((left, right) => right[0].length - left[0].length);
let changed = 0;
for (const file of await collectTextFiles(publicRoot)) {
let content = await readFile(file, "utf8");
const before = content;
for (const [source, destination] of replacements) content = content.replaceAll(source, destination);
content = content
.replaceAll(`${ORIGIN}/sitemap.xml`, `${ORIGIN}${BASE_PATH}/sitemap.xml`)
.replaceAll(`${ORIGIN}/sitemap-alternates.xml`, `${ORIGIN}${BASE_PATH}/sitemap-alternates.xml`);
content = prefixPublicAssets(content);
if (content !== before) {
await writeFile(file, content);
changed += 1;
}
}
const redirectsPath = path.join(publicRoot, "_redirects");
const redirects = await readFile(redirectsPath, "utf8");
const scopedRedirects = redirects
.split("\n")
.map((line) => {
if (!line.trim()) return line;
const [source, destination, status, ...rest] = line.trim().split(/\s+/);
const scope = (value) => value.startsWith("/") ? `${BASE_PATH}${value === "/" ? "" : value}` : value;
return [scope(source), scope(destination), status, ...rest].filter(Boolean).join(" ");
})
.join("\n");
await writeFile(redirectsPath, scopedRedirects);
return { changed, routes: replacements.length };
}
async function main() {
const result = await finalizeBasePath();
console.log(`Finalized ${result.routes} documentation routes under ${BASE_PATH} across ${result.changed} files`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exitCode = 1;
});
}
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env node
// Agent exports use canonical sources, not renderer-processed MDX: the latter
// replaces images with JS variables and returns no text for virtual API pages.
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkMdx from "remark-mdx";
import remarkGfm from "remark-gfm";
import remarkStringify from "remark-stringify";
import { parse as parseYaml } from "yaml";
import { collectMdxDocuments, normalizeMdxContent } from "./prepare-fumapress.mjs";
const ROOT = path.resolve(import.meta.dirname, "..");
export const BASE = "https://langbot.app/docs";
export const LOCALES = ["en", "zh", "ja"];
const METHODS = /^(get|put|post|delete|patch|options|head|trace)$/;
const encode = (value) => value.split("/").map(encodeURIComponent).join("/");
const slug = (value) => value.replace(/\s+/g, "-").toLowerCase();
const pageRoute = (file) => file.replace(/\.mdx$/, "").replace(/\/index$/, "");
const text = (value) => ({ type: "text", value });
const paragraph = (children) => ({ type: "paragraph", children });
const parser = unified().use(remarkParse).use(remarkMdx).use(remarkGfm);
const printer = unified().use(remarkStringify, { fences: true, bullet: "-" }).use(remarkGfm);
export function splitFrontmatter(source) {
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) throw new Error("Documentation source requires frontmatter");
return { metadata: parseYaml(match[1]), body: source.slice(match[0].length) };
}
export function canonicalLink(value, document, routes, redirects = [], seen = new Set()) {
if (!value || value.startsWith("#")) return value;
let local = value;
const absolute = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(value);
if (absolute) {
// Example URLs can contain prose punctuation; only parse our docs hosts.
if (!/^(?:https?:)?\/\/(?:docs\.langbot\.app|langbot\.app)(?:\/|$)/i.test(value)) return value;
const url = new URL(value, BASE);
if (url.hostname === "docs.langbot.app") local = `${url.pathname}${url.search}${url.hash}`;
else if (url.origin === "https://langbot.app" && (url.pathname === "/docs" || url.pathname.startsWith("/docs/"))) {
local = `${url.pathname.slice(5) || "/"}${url.search}${url.hash}`;
} else return value; // Marketing, API hosts, mailto and other external links.
}
local = local.replace(/^\/docs(?=\/|$)/, "");
const suffixAt = local.search(/[?#]/);
const suffix = suffixAt < 0 ? "" : local.slice(suffixAt);
let pathname = decodeURIComponent(suffixAt < 0 ? local : local.slice(0, suffixAt));
pathname = pathname.startsWith("/") ? pathname.slice(1) : path.posix.join(path.posix.dirname(document), pathname);
pathname = path.posix.normalize(pathname).replace(/^\.\//, "").replace(/\/$/, "");
if (pathname === "." || pathname === "" || LOCALES.includes(pathname)) pathname = `${LOCALES.includes(pathname) ? pathname : document.split("/")[0]}/insight/guide`;
const candidate = pathname.replace(/\.(?:html|mdx|md)$/, "").replace(/\/index$/, "");
if (routes.has(candidate)) return `${BASE}/${encode(candidate)}.md${suffix}`;
// Honor the same legacy aliases as the human site, without inventing
// locale fallbacks for documents that have no translation.
for (const redirect of redirects) {
const wildcard = redirect.source.match(/^(.*\/):([A-Za-z][A-Za-z0-9_]*)\*$/);
const matches = redirect.source === `/${candidate}` || (wildcard && `/${candidate}`.startsWith(wildcard[1]));
if (matches && redirect.destination !== redirect.source) {
if (seen.has(candidate)) throw new Error(`Redirect cycle: ${candidate}`);
seen.add(candidate);
const destination = wildcard
? redirect.destination.replace(`:${wildcard[2]}*`, `/${candidate}`.slice(wildcard[1].length))
: redirect.destination;
return canonicalLink(`${destination}${suffix}`, document, routes, redirects, seen);
}
}
return `${BASE}/${encode(pathname)}${suffix}`;
}
export function renderMdx(source, document, routes, redirects = []) {
const { metadata, body } = splitFrontmatter(source);
const tree = parser.parse(normalizeMdxContent(body, document));
const link = (value) => canonicalLink(value, document, routes, redirects);
function transform(node) {
if (["mdxjsEsm", "mdxFlowExpression", "mdxTextExpression"].includes(node.type)) {
if (/^\s*\/\*[\s\S]*\*\/\s*$/.test(node.value)) return [];
// Never silently drop an executable source value that might contain docs.
throw new Error(`${document}: unsupported ${node.type}: ${node.value}`);
}
if (node.children) node.children = node.children.flatMap(transform);
if (["link", "image", "definition"].includes(node.type)) node.url = link(node.url);
if (!node.type.startsWith("mdxJsx")) return [node];
const attributes = Object.fromEntries((node.attributes ?? [])
.filter((attribute) => attribute.type === "mdxJsxAttribute")
.map((attribute) => [attribute.name, attribute.value]));
const children = node.children ?? [];
if (node.name === "img") {
if (typeof attributes.src !== "string") throw new Error(`${document}: image requires a literal src`);
const image = { type: "image", url: link(attributes.src), alt: attributes.alt ?? "", title: attributes.title ?? null };
return [node.type === "mdxJsxFlowElement" ? paragraph([image]) : image];
}
if (node.name === "a" && typeof attributes.href === "string") {
const anchor = { type: "link", url: link(attributes.href), children };
return [node.type === "mdxJsxFlowElement" ? paragraph([anchor]) : anchor];
}
if (node.name === "br") return [node.type === "mdxJsxFlowElement" ? paragraph([text("")]) : { type: "break" }];
if (node.name === "summary") return [paragraph([{ type: "strong", children }])];
if (node.name === "Callout") {
return [{ type: "blockquote", children: [paragraph([text(`${attributes.title ?? attributes.type ?? "Note"}:`)]), ...children] }];
}
if (["Card", "Step", "Tab"].includes(node.name)) {
if (typeof attributes.title !== "string") throw new Error(`${document}: ${node.name} requires a literal title`);
const title = typeof attributes.href === "string"
? { type: "link", url: link(attributes.href), children: [text(attributes.title)] }
: { type: "strong", children: [text(attributes.title)] };
return [paragraph([title]), ...children];
}
// Audited source wrappers; fail closed when new semantic components appear.
if (["div", "details", "span", "Steps", "Tabs", "CardGroup"].includes(node.name)) return children;
throw new Error(`${document}: unsupported JSX component ${node.name}`);
}
tree.children = tree.children.flatMap(transform);
const title = metadata.title ?? pageRoute(document);
const url = `${BASE}/${encode(pageRoute(document))}`;
const description = metadata.description ? `${metadata.description}\n\n` : "";
return { title, description: metadata.description ?? "", url, markdown: `# ${title}\n\nSource: ${url}\n\n${description}${printer.stringify(tree)}` };
}
export function operationBundle(spec, apiPath, method) {
const item = spec.paths[apiPath];
const operation = item[method];
const common = Object.fromEntries(Object.entries(item).filter(([key]) => !METHODS.test(key)));
const bundle = {
openapi: spec.openapi,
info: spec.info,
servers: operation.servers ?? item.servers ?? spec.servers ?? [],
security: operation.security ?? spec.security ?? [],
paths: { [apiPath]: { ...common, [method]: operation } },
components: {},
};
const visited = new Set();
function include(ref) {
if (visited.has(ref)) return;
visited.add(ref);
if (!ref.startsWith("#/components/")) throw new Error(`Unsupported OpenAPI reference: ${ref}`);
const keys = ref.slice(2).split("/").map((key) => key.replaceAll("~1", "/").replaceAll("~0", "~"));
const value = keys.reduce((node, key) => node?.[key], spec);
if (value === undefined) throw new Error(`Missing OpenAPI reference: ${ref}`);
let parent = bundle;
for (const key of keys.slice(0, -1)) parent = parent[key] ??= {};
parent[keys.at(-1)] = value;
visit(value);
}
function visit(value) {
if (!value || typeof value !== "object") return;
if (value.$ref) include(value.$ref);
for (const child of Object.values(value)) visit(child);
}
visit(bundle);
for (const alternative of bundle.security) for (const name of Object.keys(alternative)) {
include(`#/components/securitySchemes/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`);
}
return bundle;
}
export function renderOperation(spec, apiPath, method, locale) {
const operation = spec.paths[apiPath][method];
if (!operation.summary) throw new Error(`Missing summary for ${method} ${apiPath}`);
const route = `${locale}/api-reference/${slug(operation.tags?.[0] ?? "unknown")}/${slug(operation.summary)}`;
const url = `${BASE}/${encode(route)}`;
const bundle = operationBundle(spec, apiPath, method);
const auth = bundle.security.length === 0 ? "No authentication required." :
`Authentication (OpenAPI security alternatives; OR between entries, AND within each entry): ${JSON.stringify(bundle.security)}. See components.securitySchemes below for header names, schemes and scopes.`;
const markdown = `# ${operation.summary}\n\nSource: ${url}\n\n## ${method.toUpperCase()} ${apiPath}\n\n${operation.description ?? ""}\n\n${auth}\n\nServers: ${bundle.servers.map((server) => server.url).join(", ") || "Relative to your LangBot instance"}. Use your own LangBot instance, not the documentation host.\n\n[Complete ${locale} OpenAPI specification](${BASE}/openapi/service-api-${locale}.json)\n\n## Operation contract\n\nThe OpenAPI subset below preserves path/operation parameters, request bodies, response codes, media types, examples and schemas. All referenced components are included transitively; resolve local JSON pointers against this document. The operation is preserved verbatim, including security overrides.\n\n\`\`\`json\n${JSON.stringify(bundle, null, 2)}\n\`\`\`\n`;
return { route, title: operation.summary, description: `${method.toUpperCase()} ${apiPath}`, url, markdown };
}
import { deploymentGuidance } from "./deployment-guidance.mjs";
function indexText(pages, locale) {
const languages = locale ? [locale] : LOCALES;
const prefix = locale ? `${locale}/` : "";
const lines = ["# LangBot Documentation", "", "> Canonical documentation for LangBot: deployment, messaging integrations, plugins, development and HTTP API reference.", "", "## Agent resources", "", `- [Complete documentation text](${BASE}/${prefix}llms-full.txt)`, `- [All-language index](${BASE}/llms.txt)`, ""];
lines.push(deploymentGuidance(locale), "");
for (const language of languages) {
lines.push(`## ${language}`, "", `- [${language} documentation index](${BASE}/${language}/llms.txt)`, `- [${language} complete documentation text](${BASE}/${language}/llms-full.txt)`, `- [${language} OpenAPI specification](${BASE}/openapi/service-api-${language}.json)`, "");
for (const page of pages.filter((page) => page.route.startsWith(`${language}/`))) {
const label = page.title.replace(/[\[\]\\]/g, "\\$&").replace(/\s+/g, " ");
lines.push(`- [${label}](${page.url}.md)${page.description ? `: ${page.description.replace(/\s+/g, " ")}` : ""}`);
}
lines.push("");
}
return `${lines.join("\n")}\n`;
}
export async function generateAgentDocs({ root = ROOT, output = path.join(root, "dist/public") } = {}) {
const documents = await collectMdxDocuments(root);
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
const pages = [];
for (const locale of LOCALES) {
const spec = JSON.parse(await readFile(path.join(root, `openapi/service-api-${locale}.json`), "utf8"));
for (const [apiPath, item] of Object.entries(spec.paths)) for (const method of Object.keys(item).filter((key) => METHODS.test(key))) {
pages.push(renderOperation(spec, apiPath, method, locale));
}
}
const routes = new Set([...documents.map(pageRoute), ...pages.map((page) => page.route)]);
for (const document of documents) {
pages.push({ route: pageRoute(document), ...renderMdx(await readFile(path.join(root, document), "utf8"), document, routes, docs.redirects) });
}
pages.sort((a, b) => a.route.localeCompare(b.route, "en"));
if (new Set(pages.map((page) => page.route)).size !== pages.length) throw new Error("Duplicate agent page routes");
// Use the real static sitemap as the acceptance boundary, not a guessed count.
const sitemap = await readFile(path.join(output, "sitemap.xml"), "utf8");
const actual = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => decodeURIComponent(match[1].replace(/\/$/, ""))).sort();
const expected = pages.map((page) => decodeURIComponent(page.url)).sort();
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("Agent sources do not match the exported sitemap");
for (const page of pages) {
const target = path.join(output, `${page.route}.md`);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, page.markdown);
}
for (const locale of [null, ...LOCALES]) {
const prefix = locale ? `${locale}/` : "";
const selected = pages.filter((page) => !locale || page.route.startsWith(prefix));
const index = indexText(selected, locale);
await writeFile(path.join(output, `${prefix}llms.txt`), index);
await writeFile(path.join(output, `${prefix}llm.txt`), index);
await writeFile(path.join(output, `${prefix}llms-full.txt`), `${deploymentGuidance(locale)}\n${selected.map((page) => page.markdown.trim()).join("\n\n---\n\n")}\n`);
}
return { pages: pages.length, documents: documents.length, operations: pages.length - documents.length };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
console.log(await generateAgentDocs());
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const BASE = "https://langbot.app/docs";
const LOCALES = ["en", "zh", "ja"];
const HREFLANG = { en: "en", zh: "zh-CN", ja: "ja" };
function escapeXml(value) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function decodeXml(value) {
return value
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'");
}
export function renderAlternateSitemap(sitemap, base = BASE, explicitGroups = []) {
const urls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => decodeXml(match[1]));
const groups = new Map();
for (const url of urls) {
for (const locale of LOCALES) {
const prefix = `${base}/${locale}/`;
if (!url.startsWith(prefix)) continue;
const suffix = url.slice(prefix.length).replace(/\/$/, "");
const group = groups.get(suffix) ?? {};
group[locale] = url.replace(/\/$/, "");
groups.set(suffix, group);
break;
}
}
for (const group of explicitGroups) groups.set(`explicit:${group.en}`, group);
const shared = [...groups.values()]
.filter((group) => LOCALES.every((locale) => group[locale]))
.sort((left, right) => left.en.localeCompare(right.en));
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
];
for (const group of shared) {
const alternates = [
...LOCALES.map((locale) => [HREFLANG[locale], group[locale]]),
["x-default", group.en],
];
for (const locale of LOCALES) {
lines.push(" <url>", ` <loc>${escapeXml(group[locale])}</loc>`);
for (const [language, href] of alternates) {
lines.push(` <xhtml:link rel="alternate" hreflang="${language}" href="${escapeXml(href)}" />`);
}
lines.push(" </url>");
}
}
lines.push("</urlset>");
return { xml: `${lines.join("\n")}\n`, groups: shared.length };
}
function legacyOpenApiSlug(value) {
return value.replace(/\s+/g, "-").toLowerCase();
}
function encodeRoute(route) {
return route.split("/").map((segment) => encodeURIComponent(segment)).join("/");
}
async function collectOpenApiAlternateGroups(root = ROOT, base = BASE) {
const groups = new Map();
for (const locale of LOCALES) {
const spec = JSON.parse(await readFile(path.join(root, `openapi/service-api-${locale}.json`), "utf8"));
for (const [apiPath, pathItem] of Object.entries(spec.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!/^(?:get|post|put|patch|delete|head|options)$/i.test(method) || !operation?.summary) continue;
const tag = operation.tags?.[0] ?? "unknown";
const route = encodeRoute(`api-reference/${legacyOpenApiSlug(tag)}/${legacyOpenApiSlug(operation.summary)}`);
const key = `${method.toLowerCase()} ${apiPath}`;
const group = groups.get(key) ?? {};
group[locale] = `${base}/${locale}/${route}`;
groups.set(key, group);
}
}
}
return [...groups.values()];
}
async function loadSource(source) {
if (/^https?:\/\//.test(source)) {
const response = await fetch(source, { headers: { "user-agent": "LangBot docs alternate-sitemap generator" } });
if (!response.ok) throw new Error(`Unable to fetch ${source}: ${response.status}`);
return response.text();
}
return readFile(source, "utf8");
}
async function main() {
const source = process.env.SITEMAP_SOURCE ?? path.join(ROOT, "dist/public/sitemap.xml");
const openApiGroups = await collectOpenApiAlternateGroups();
const { xml, groups } = renderAlternateSitemap(await loadSource(source), BASE, openApiGroups);
const outputs = process.env.SITEMAP_OUTPUT
? [path.resolve(process.env.SITEMAP_OUTPUT)]
: [path.join(ROOT, "dist/public/sitemap-alternates.xml"), path.join(ROOT, "sitemap-alternates.xml")];
for (const output of outputs) await writeFile(output, xml);
console.log(`Wrote ${groups} reciprocal language groups to ${outputs.join(", ")}`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exitCode = 1;
});
}
+23
View File
@@ -0,0 +1,23 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(import.meta.dirname, "..");
export async function generateLocaleEntry(publicRoot = path.join(root, "dist/public")) {
const helper = await readFile(path.join(root, "src/locale-preference.mjs"), "utf8");
// Inline a classic script: negotiation runs before rendering or module fetches.
const script = helper.replace(/^export /gm, "") + "\nenterDocs();";
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>LangBot Docs</title><script>${script}</script></head>
<body><main><h1>LangBot Docs</h1><noscript><p>Please choose a language / 请选择语言 / 言語を選択してください</p></noscript>
<ul><li><a lang="en" href="/docs/en/insight/guide">English</a></li><li><a lang="zh" href="/docs/zh/insight/guide">简体中文</a></li><li><a lang="ja" href="/docs/ja/insight/guide">日本語</a></li></ul>
</main></body></html>
`;
await writeFile(path.join(publicRoot, "index.html"), html);
return html;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await generateLocaleEntry();
console.log("Generated browser-negotiated /docs/ entry (langbot-docs-locale)");
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
import { access, readdir, rename } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
function decodePathSegment(segment) {
const decoded = decodeURIComponent(segment);
if (decoded.includes("/") || decoded.includes("\\") || decoded.includes("\0")) {
throw new Error(`Refusing to decode unsafe output path segment: ${segment}`);
}
return decoded;
}
async function exists(target) {
try {
await access(target);
return true;
} catch {
return false;
}
}
export async function normalizeStaticPaths(directory) {
let renamed = 0;
for (const entry of await readdir(directory, { withFileTypes: true })) {
const source = path.join(directory, entry.name);
if (entry.isDirectory()) renamed += await normalizeStaticPaths(source);
const decodedName = decodePathSegment(entry.name);
if (decodedName === entry.name) continue;
const destination = path.join(directory, decodedName);
if (await exists(destination)) {
throw new Error(`Static output path collision: ${source} -> ${destination}`);
}
await rename(source, destination);
renamed += 1;
}
return renamed;
}
async function main() {
const publicRoot = path.join(ROOT, "dist/public");
const renamed = await normalizeStaticPaths(publicRoot);
console.log(`Normalized ${renamed} percent-encoded static path segments`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exitCode = 1;
});
}
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env node
import { cp, lstat, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const LOCALES = ["en", "zh", "ja"];
const MINTLIFY_LOCALE_MAP = { en: "en", cn: "zh", jp: "ja" };
const ASSET_DIRECTORIES = ["images", "logo", "ui", "openapi"];
const ROOT_ASSET_PATTERN = /\.(?:gif|html|ico|jpe?g|png|svg|txt|webp|xml)$/i;
const MINTLIFY_CALLOUT_TYPES = { Info: "info", Note: "info", Tip: "idea", Warning: "warning" };
export function mapMintlifyLocale(locale) {
const mapped = MINTLIFY_LOCALE_MAP[locale];
if (!mapped) throw new Error(`Unsupported Mintlify locale: ${locale}`);
return mapped;
}
async function listFiles(directory, prefix = "") {
const files = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const relative = path.posix.join(prefix, entry.name);
const full = path.join(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error(`Generated inputs must not contain symlinks: ${full}`);
if (entry.isDirectory()) files.push(...await listFiles(full, relative));
else if (entry.isFile()) files.push(relative);
}
return files.sort();
}
function rootRelativeAssetUrl(destination, documentPath) {
const suffixIndex = destination.search(/[?#]/);
const pathname = suffixIndex === -1 ? destination : destination.slice(0, suffixIndex);
const suffix = suffixIndex === -1 ? "" : destination.slice(suffixIndex);
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(documentPath), pathname));
if (resolved.startsWith("../") || path.posix.isAbsolute(resolved)) return destination;
if (!ASSET_DIRECTORIES.includes(resolved.split("/", 1)[0])) return destination;
return `/${resolved}${suffix}`;
}
export function normalizeInternalHtmlUrl(destination) {
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(destination)) return destination;
const suffixIndex = destination.search(/[?#]/);
const pathname = suffixIndex === -1 ? destination : destination.slice(0, suffixIndex);
const suffix = suffixIndex === -1 ? "" : destination.slice(suffixIndex);
return pathname.endsWith(".html") ? `${pathname.slice(0, -5)}${suffix}` : destination;
}
export function normalizeMdxContent(content, documentPath) {
let fence;
return content.split(/(?<=\n)/).map((line) => {
const body = line.replace(/\r?\n$/, "");
if (fence) {
if (new RegExp(`^ {0,3}${fence.character}{${fence.length},}\\s*$`).test(body)) fence = undefined;
return line;
}
const opening = body.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
if (opening) {
fence = { character: opening[2][0], length: opening[2].length };
const normalizedInfo = opening[3].replace(/^(\s*)env(?=\s|$)/, "$1dotenv");
return `${opening[1]}${opening[2]}${normalizedInfo}${line.slice(body.length)}`;
}
return line
.replace(/<\/?AccordionGroup>/g, "")
.replace(/<Accordion\s+title=(["'])(.*?)\1\s*>/g, (_match, _quote, title) =>
`<details>\n<summary>${title}</summary>`)
.replace(/<\/Accordion>/g, "</details>")
.replace(/<(Info|Note|Tip|Warning)(?=\s|>)([^>]*)>/g, (_match, name, attributes) =>
`<Callout type="${MINTLIFY_CALLOUT_TYPES[name]}"${attributes}>`)
.replace(/<\/(?:Info|Note|Tip|Warning)>/g, "</Callout>")
.replace(/(!\[[^\]\n]*\]\(\s*)(<?)(\.\.?\/[^)\s>]+)(>?)([^)\n]*\))/g, (_match, prefix, open, destination, close, suffix) =>
`${prefix}${open}${rootRelativeAssetUrl(destination, documentPath)}${close}${suffix}`)
.replace(/(\]\(\s*<?)([^)\s>]+)(>?)([^)\n]*\))/g, (_match, prefix, destination, close, suffix) =>
`${prefix}${normalizeInternalHtmlUrl(destination)}${close}${suffix}`)
.replace(/(\bhref\s*=\s*["'])([^"']+)(["'])/g, (_match, prefix, destination, suffix) =>
`${prefix}${normalizeInternalHtmlUrl(destination)}${suffix}`)
.replace(/(\bsrc\s*=\s*["'])(\.\.?\/[^"']+)(["'])/g, (_match, prefix, destination, suffix) =>
`${prefix}${rootRelativeAssetUrl(destination, documentPath)}${suffix}`);
}).join("");
}
export async function collectMdxDocuments(root = ROOT) {
const pages = [];
for (const locale of LOCALES) {
const files = await listFiles(path.join(root, locale));
pages.push(...files.filter((file) => file.endsWith(".mdx")).map((file) => `${locale}/${file}`));
}
return pages.sort();
}
function visitNavigation(value, callback) {
if (Array.isArray(value)) {
for (const item of value) visitNavigation(item, callback);
return;
}
if (!value || typeof value !== "object") return;
callback(value);
for (const child of Object.values(value)) visitNavigation(child, callback);
}
export function collectOpenApiSources(docs) {
const sources = [];
visitNavigation(docs.navigation, (item) => {
if (!item.openapi || typeof item.openapi !== "object" || !item.openapi.source || !item.openapi.directory) return;
const locale = item.openapi.directory.split("/", 1)[0];
sources.push({ locale, source: item.openapi.source, directory: item.openapi.directory });
});
return sources.sort((left, right) => LOCALES.indexOf(left.locale) - LOCALES.indexOf(right.locale));
}
export function normalizeMintlifyNavigationForFumapress(docs) {
const normalized = structuredClone(docs);
for (const language of normalized.navigation?.languages ?? []) {
const locale = mapMintlifyLocale(language.language);
visitNavigation(language, (item) => {
if (!Array.isArray(item.pages)) return;
item.pages = item.pages.map((entry) =>
typeof entry === "string" && entry.startsWith(`${locale}/`)
? entry.slice(locale.length + 1)
: entry,
);
});
}
return normalized;
}
function cloudflarePattern(value, destination = false) {
return value.replace(/:([A-Za-z][A-Za-z0-9_]*)\*/g, destination ? ":splat" : "*");
}
export function renderCloudflareRedirects(docs) {
const lines = [
"/zh/develop/adapter/discord /zh/develop/adapter/discord/README 308",
"/scripts/README-blog-articles /en/articles 308",
];
for (const redirect of docs.redirects ?? []) {
const status = redirect.permanent === false ? 307 : 308;
lines.push(`${cloudflarePattern(redirect.source)} ${cloudflarePattern(redirect.destination, true)} ${status}`);
}
return `${lines.join("\n")}\n`;
}
async function copyDirectory(source, destination) {
const info = await lstat(source);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Expected a real directory: ${source}`);
await cp(source, destination, { recursive: true, force: true, errorOnExist: false });
}
export async function prepareFumapress({ root = ROOT, outRoot = root } = {}) {
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
const documents = await collectMdxDocuments(root);
const openapi = collectOpenApiSources(docs);
if (openapi.length !== LOCALES.length) {
throw new Error(`Expected one nested OpenAPI source per locale, found ${openapi.length}`);
}
const contentRoot = path.join(outRoot, "content");
const publicRoot = path.join(outRoot, "public");
await rm(contentRoot, { recursive: true, force: true });
await rm(publicRoot, { recursive: true, force: true });
await mkdir(path.join(contentRoot, "docs"), { recursive: true });
await mkdir(publicRoot, { recursive: true });
await writeFile(
path.join(contentRoot, "fumapress-docs.json"),
`${JSON.stringify(normalizeMintlifyNavigationForFumapress(docs), null, 2)}\n`,
);
const documentsByPath = new Map();
for (const document of documents) {
const [locale, ...relativeParts] = document.split("/");
const relative = relativeParts.join("/");
const translations = documentsByPath.get(relative) ?? new Map();
translations.set(locale, document);
documentsByPath.set(relative, translations);
}
let localeOnlyDocuments = 0;
for (const [relative, translations] of [...documentsByPath].sort(([left], [right]) => left.localeCompare(right))) {
if (!translations.has("en")) localeOnlyDocuments += 1;
for (const [locale, document] of translations) {
const extension = locale === "en" ? ".mdx" : `.${locale}.mdx`;
const destination = path.join(contentRoot, "docs", relative.replace(/\.mdx$/, extension));
await mkdir(path.dirname(destination), { recursive: true });
const source = await readFile(path.join(root, document), "utf8");
await writeFile(destination, normalizeMdxContent(source, document));
}
}
for (const directory of ASSET_DIRECTORIES) {
await copyDirectory(path.join(root, directory), path.join(publicRoot, directory));
}
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.isFile() && ROOT_ASSET_PATTERN.test(entry.name) && !["robots.txt", "sitemap-alternates.xml"].includes(entry.name)) {
await cp(path.join(root, entry.name), path.join(publicRoot, entry.name), { force: true });
}
}
await cp(path.join(root, "robots.txt"), path.join(publicRoot, "robots.txt"), { force: true });
await writeFile(path.join(publicRoot, "_redirects"), renderCloudflareRedirects(docs));
return {
documents: documents.length,
fallbackDefaults: 0,
localeOnlyDocuments,
redirects: (docs.redirects?.length ?? 0) + 2,
locales: [...LOCALES],
openapi,
};
}
async function main() {
const result = await prepareFumapress();
console.log(`Prepared ${result.documents} MDX documents, ${result.openapi.length} OpenAPI specs, and ${result.redirects} redirects.`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exitCode = 1;
});
}
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
const ROOT = path.resolve(import.meta.dirname, "..");
const ARTICLE_LOCALES = ["en", "zh", "ja"];
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
const LANGUAGE_CONFIG = {
en: { mintlify: "en", tab: "Articles", introTitle: "LangBot Articles", introDescription: "Product updates, engineering deep dives, tutorials, integrations, and announcements from the LangBot Blog.", sourceLabel: "This article is synchronized from the LangBot Blog.", sourceAction: "Read the canonical version", fallbackLabel: "", overviewGroup: "Browse", groups: { updates: "Product Updates", engineering: "Engineering", tutorials: "Tutorials & Integrations", announcements: "Announcements" } },
zh: { mintlify: "cn", tab: "文章", introTitle: "LangBot 文章", introDescription: "同步自 LangBot Blog 的产品动态、技术解析、教程、集成实践与公告。", sourceLabel: "本文同步自 LangBot Blog。", sourceAction: "阅读 Blog 原文", fallbackLabel: "", overviewGroup: "浏览", groups: { updates: "产品动态", engineering: "技术解析", tutorials: "教程与集成", announcements: "公告" } },
ja: { mintlify: "jp", tab: "記事", introTitle: "LangBot 記事", introDescription: "LangBot Blog の製品アップデート、技術解説、チュートリアル、連携事例、お知らせをまとめています。", sourceLabel: "この記事は LangBot Blog から同期されています。", sourceAction: "Blog の原文を読む", fallbackLabel: "この記事の日本語版はまだ公開されていないため、英語版を掲載しています。", overviewGroup: "一覧", groups: { updates: "製品アップデート", engineering: "エンジニアリング", tutorials: "チュートリアルと連携", announcements: "お知らせ" } },
};
const JAPANESE_TITLES = {
"connect-deepseek-to-wechat": "DeepSeek R1 を WeChat・Discord・Telegram に5分で接続する方法",
"deploy-ai-bot-in-5-minutes": "AI Bot を Discord・Telegram・WeChat に5分でデプロイ",
"dify-agent-discord-telegram-slack": "Dify Agent を Discord・Telegram・Slack で動かす",
"dify-langbot-rag-knowledge-base": "LangBot 4.6.0 外部ナレッジベース入門:Dify と連携した RAG 会話",
"langbot-plugin-system-deep-dive": "LangBot プラグインシステム詳解:プロセス分離・イベント駆動・コンポーネント設計",
"langbot-v4100-sandbox-skills": "LangBot v4.10.0:Agent Sandbox と Skills",
"langbot-v490-rag-plugin-architecture": "LangBot v4.9.0:完全プラグイン化された RAG アーキテクチャ",
"langflow-drag-and-drop-ai-bot": "Langflow のドラッグ&ドロップでマルチプラットフォーム AI Bot を構築",
"langtars-remote-pc-control-dify-n8n": "LangTARS:Dify・n8n と連携するオープンソース PC 操作 Agent",
"n8n-multi-platform-ai-chatbot": "n8n と LangBot でマルチプラットフォーム AI Chatbot を構築",
welcome: "LangBot Blog のご紹介",
};
function parseArgs(argv) {
const result = { check: false, source: process.env.LANGBOT_LANDING_REPO || "" };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--check") result.check = true;
else if (argv[i] === "--source") result.source = argv[++i];
else throw new Error(`Unknown argument: ${argv[i]}`);
}
if (result.source) result.source = path.resolve(result.source);
return result;
}
function parseScalar(value) {
const trimmed = value.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed.slice(1, -1).replaceAll('\\"', '"');
return trimmed;
}
function parseFrontmatter(markdown, file) {
if (!markdown.startsWith("---\n")) throw new Error(`${file}: missing frontmatter`);
const end = markdown.indexOf("\n---\n", 4);
if (end < 0) throw new Error(`${file}: unterminated frontmatter`);
const raw = markdown.slice(4, end);
const body = markdown.slice(end + 5).trimStart();
const lines = raw.split("\n");
const data = {};
for (let i = 0; i < lines.length; i += 1) {
const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(lines[i]);
if (!match) continue;
const [, key, value] = match;
if (key === "cover") {
const cover = {};
while (i + 1 < lines.length && /^\s+/.test(lines[i + 1])) {
const nested = /^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(lines[++i]);
if (nested) cover[nested[1]] = parseScalar(nested[2]);
}
data.cover = cover;
} else if (value.startsWith("[") && value.endsWith("]")) {
data[key] = [...value.matchAll(/"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'/g)].map((item) => item[1] ?? item[2]);
} else data[key] = parseScalar(value);
}
for (const required of ["title", "date", "description", "author"]) if (!data[required]) throw new Error(`${file}: missing ${required}`);
return { data, body };
}
function yamlQuote(value) { return JSON.stringify(String(value)); }
function classify(post) {
const category = String(post.data.categories?.[0] || "").toLowerCase();
if (category.includes("product") || category.includes("产品") || category.includes("製品")) return "updates";
if (category.includes("engineering") || category.includes("technical") || category.includes("技术") || category.includes("技術")) return "engineering";
if (category.includes("announcement") || category.includes("公告") || category.includes("お知らせ")) return "announcements";
return "tutorials";
}
function canonicalUrl(locale, slug) { return `https://langbot.app/${locale}/blog/${slug}`; }
function attribution(locale, post) {
if (locale === "zh") return `发布于 ${post.data.date} · 作者:${post.data.author}`;
if (locale === "ja") return `公開日:${post.data.date} · 著者:${post.data.author}`;
return `Published ${post.data.date} · Author: ${post.data.author}`;
}
function escapeMdxProse(markdown) {
const lines = markdown.split("\n");
let fenced = false;
return lines.map((line) => {
if (/^\s*```/.test(line) || /^\s*~~~/.test(line)) { fenced = !fenced; return line; }
if (fenced) return line.trimEnd();
return line.split(/(`[^`]*`)/g).map((piece, index) => index % 2 === 1 ? piece : piece.replaceAll("{", "&#123;").replaceAll("}", "&#125;")).join("").trimEnd();
}).join("\n");
}
function imageReferences(markdown) { return [...markdown.matchAll(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g)].map((match) => ({ alt: match[1], url: match[2], full: match[0] })); }
function contentTypeExtension(contentType) {
if (contentType.includes("image/png")) return ".png";
if (contentType.includes("image/jpeg")) return ".jpg";
if (contentType.includes("image/gif")) return ".gif";
if (contentType.includes("image/svg")) return ".svg";
if (contentType.includes("image/webp")) return ".webp";
return "";
}
function localImageTarget(sourceUrl) {
if (sourceUrl.startsWith("/")) return `/images/articles/source${sourceUrl}`;
const parsed = new URL(sourceUrl);
let base = path.posix.basename(parsed.pathname) || "image";
const digest = createHash("sha256").update(sourceUrl).digest("hex").slice(0, 12);
base = base.replace(/[^A-Za-z0-9._-]/g, "-");
return `/images/articles/external/${digest}-${base}`;
}
function assertWithin(base, candidate, label) {
const normalizedBase = path.resolve(base);
const normalizedCandidate = path.resolve(candidate);
if (!normalizedCandidate.startsWith(`${normalizedBase}${path.sep}`)) throw new Error(`${label} escapes ${normalizedBase}: ${normalizedCandidate}`);
}
async function assertDirectoryWithoutSymlinks(directory, label) {
if (!existsSync(directory)) throw new Error(`${label} does not exist: ${directory}`);
const info = await lstat(directory);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`${label} must be a real non-symlink directory: ${directory}`);
const resolved = await realpath(directory);
if (resolved !== path.resolve(directory)) throw new Error(`${label} path must not traverse symlinks: ${directory}`);
return resolved;
}
async function readRegularFileWithin(base, file, label, encoding = null) {
const baseReal = await assertDirectoryWithoutSymlinks(base, `${label} directory`);
if (!existsSync(file)) throw new Error(`${label} does not exist: ${file}`);
const info = await lstat(file);
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${label} must be a regular non-symlink file: ${file}`);
const fileReal = await realpath(file);
assertWithin(baseReal, fileReal, label);
return encoding ? readFile(fileReal, encoding) : readFile(fileReal);
}
function validateImageBytes(bytes, label) {
const ascii = bytes.subarray(0, 16).toString("ascii");
const trimmed = bytes.toString("utf8", 0, Math.min(bytes.length, 1024)).trimStart();
const valid =
bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) ||
(bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) ||
ascii.startsWith("GIF87a") || ascii.startsWith("GIF89a") ||
(ascii.startsWith("RIFF") && ascii.slice(8, 12) === "WEBP") ||
((trimmed.startsWith("<svg") || trimmed.startsWith("<?xml")) && trimmed.includes("<svg"));
if (!valid) throw new Error(`Invalid image signature: ${label}`);
}
async function prepareImageTarget(assetRoot, target) {
const imageRoot = path.join(assetRoot, "images/articles");
assertWithin(imageRoot, target, "Article image target");
await mkdir(path.dirname(target), { recursive: true });
const imageRootReal = await realpath(imageRoot);
const targetParentReal = await realpath(path.dirname(target));
if (targetParentReal !== path.resolve(path.dirname(target))) throw new Error(`Article image target directory must not be a symlink: ${path.dirname(target)}`);
assertWithin(imageRootReal, path.join(targetParentReal, path.basename(target)), "Resolved article image target");
if (existsSync(target) && (await lstat(target)).isSymbolicLink()) throw new Error(`Article image target must not be a symlink: ${target}`);
}
async function materializeImage(sourceRoot, sourceUrl, assetRoot) {
let targetUrl = localImageTarget(sourceUrl);
let target = path.join(assetRoot, targetUrl.slice(1));
await prepareImageTarget(assetRoot, target);
if (sourceUrl.startsWith("/")) {
const publicRoot = path.join(sourceRoot, "public");
const source = path.join(publicRoot, sourceUrl.slice(1));
assertWithin(publicRoot, source, "Blog image source");
if (!existsSync(source)) throw new Error(`Missing source image: ${sourceUrl} (${source})`);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) throw new Error(`Blog image source must be a regular non-symlink file: ${source}`);
const publicRootReal = await realpath(publicRoot);
const sourceReal = await realpath(source);
assertWithin(publicRootReal, sourceReal, "Resolved Blog image source");
const bytes = await readFile(sourceReal);
validateImageBytes(bytes, sourceUrl);
await writeFile(target, bytes);
return targetUrl;
}
const parsedSource = new URL(sourceUrl);
if (parsedSource.protocol !== "https:") throw new Error(`External images must use HTTPS: ${sourceUrl}`);
const response = await fetch(sourceUrl, { headers: { "User-Agent": "LangBot-Wiki-Article-Sync/1.0" }, redirect: "follow" });
if (!response.ok) throw new Error(`Failed to fetch image ${sourceUrl}: HTTP ${response.status}`);
const declaredSize = Number(response.headers.get("content-length") || 0);
if (declaredSize > MAX_IMAGE_BYTES) throw new Error(`Image exceeds ${MAX_IMAGE_BYTES} bytes: ${sourceUrl}`);
const bytes = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get("content-type") || "";
if (!bytes.length || bytes.length > MAX_IMAGE_BYTES || !contentType.toLowerCase().startsWith("image/")) throw new Error(`Invalid image ${sourceUrl}: ${contentType}, ${bytes.length} bytes`);
validateImageBytes(bytes, sourceUrl);
if (!path.extname(target)) {
const extension = contentTypeExtension(contentType);
if (!extension) throw new Error(`Unsupported image content type for extensionless URL: ${contentType}`);
target += extension;
targetUrl += extension;
await prepareImageTarget(assetRoot, target);
}
if (contentType.toLowerCase().includes("image/svg")) {
await writeFile(target, bytes.toString("utf8").replace(/[ \t]+$/gm, ""));
} else {
await writeFile(target, bytes);
}
return targetUrl;
}
async function loadPosts(sourceRoot) {
const byLocale = new Map();
const blogRoot = path.join(sourceRoot, "src/content/blog");
const blogRootReal = await assertDirectoryWithoutSymlinks(blogRoot, "Blog content root");
for (const locale of ARTICLE_LOCALES) {
const dir = path.join(blogRoot, locale);
const files = existsSync(dir) ? await readdir(dir) : [];
if (existsSync(dir)) {
const localeRootReal = await assertDirectoryWithoutSymlinks(dir, `Blog ${locale} content root`);
assertWithin(blogRootReal, localeRootReal, `Blog ${locale} content root`);
}
const posts = new Map();
for (const name of files.filter((item) => item.endsWith(".md")).sort()) {
const file = path.join(dir, name);
const parsed = parseFrontmatter(await readRegularFileWithin(dir, file, `Blog ${locale} article`, "utf8"), file);
const slug = name.slice(0, -3);
posts.set(slug, { ...parsed, slug, locale, file });
}
byLocale.set(locale, posts);
}
const enSlugs = [...byLocale.get("en").keys()].sort();
const zhSlugs = [...byLocale.get("zh").keys()].sort();
if (JSON.stringify(enSlugs) !== JSON.stringify(zhSlugs)) throw new Error("English and Chinese blog slug sets differ");
return { byLocale, slugs: enSlugs };
}
async function renderArticle(sourceRoot, post, locale, fallback, assetRoot, imageCache) {
const config = LANGUAGE_CONFIG[locale];
const title = locale === "ja" && fallback ? (JAPANESE_TITLES[post.slug] || post.data.title) : post.data.title;
let body = post.body;
const refs = imageReferences(body);
for (const ref of refs) if (!imageCache.has(ref.url)) imageCache.set(ref.url, await materializeImage(sourceRoot, ref.url, assetRoot));
if (post.data.cover?.image && !imageCache.has(post.data.cover.image)) imageCache.set(post.data.cover.image, await materializeImage(sourceRoot, post.data.cover.image, assetRoot));
for (const ref of refs) body = body.replaceAll(ref.full, `![${ref.alt}](${imageCache.get(ref.url)})`);
body = escapeMdxProse(body);
const cover = post.data.cover?.image ? imageCache.get(post.data.cover.image) : "";
const bodyHasCover = cover && body.includes(`](${cover})`);
const notice = fallback ? `\n> ${config.fallbackLabel}\n` : "";
const coverBlock = cover && !bodyHasCover ? `\n![${post.data.cover.alt || title}](${cover})\n` : "";
return `---\ntitle: ${yamlQuote(title)}\ndescription: ${yamlQuote(post.data.description)}\n---\n\n${config.sourceLabel} [${config.sourceAction}](${canonicalUrl(post.locale, post.slug)}).\n\n${attribution(locale, post)}\n${notice}${coverBlock}\n${body.trim()}\n`;
}
function articlePage(locale, slug) { return `${locale}/articles/${slug}`; }
function renderIndex(locale, posts) {
const config = LANGUAGE_CONFIG[locale];
const lines = ["---", `title: ${yamlQuote(config.introTitle)}`, `description: ${yamlQuote(config.introDescription)}`, "---", "", config.introDescription, ""];
for (const key of ["updates", "engineering", "tutorials", "announcements"]) {
const inGroup = posts.filter((post) => post.group === key);
if (!inGroup.length) continue;
lines.push(`## ${config.groups[key]}`, "");
for (const post of inGroup) lines.push(`- [${post.title}](/${articlePage(locale, post.slug)}) — ${post.date}`);
lines.push("");
}
return `${lines.join("\n").trim()}\n`;
}
function navigationTab(locale, posts) {
const config = LANGUAGE_CONFIG[locale];
const groups = [{ group: config.overviewGroup, pages: [`${locale}/articles/index`] }];
for (const key of ["updates", "engineering", "tutorials", "announcements"]) {
const pages = posts.filter((post) => post.group === key).map((post) => articlePage(locale, post.slug));
if (pages.length) groups.push({ group: config.groups[key], pages });
}
return { tab: config.tab, groups };
}
async function validateGenerated(manifest) {
const docs = JSON.parse(await readFile(path.join(ROOT, "docs.json"), "utf8"));
const failures = [];
const referencedImages = new Set();
for (const locale of ARTICLE_LOCALES) {
const config = LANGUAGE_CONFIG[locale];
const articleRoot = path.join(ROOT, locale, "articles");
await assertDirectoryWithoutSymlinks(articleRoot, `${locale} generated article root`);
const language = docs.navigation.languages.find((item) => item.language === config.mintlify);
const tabs = language?.tabs.filter((item) => item.tab === config.tab) || [];
if (tabs.length !== 1) failures.push(`${locale}: expected exactly one ${config.tab} navigation tab, found ${tabs.length}`);
for (const article of manifest.articles.filter((item) => item.locale === locale)) {
const file = path.join(ROOT, `${article.page}.mdx`);
if (!existsSync(file)) failures.push(`missing article: ${article.page}.mdx`);
else {
const content = await readRegularFileWithin(articleRoot, file, `${locale} generated article`, "utf8");
for (const match of content.matchAll(/!\[[^\]]*\]\((\/images\/[^)]+)\)/g)) {
referencedImages.add(match[1]);
const image = path.join(ROOT, match[1].slice(1));
if (!existsSync(image) || (await stat(image)).size === 0) failures.push(`${article.page}: missing image ${match[1]}`);
}
}
}
}
const mirroredMetadata = await imageMetadata(ROOT);
const mirroredImages = new Set(mirroredMetadata.map((image) => image.path));
for (const image of mirroredImages) if (!referencedImages.has(image)) failures.push(`unreferenced mirrored image: ${image}`);
for (const image of referencedImages) if (!mirroredImages.has(image)) failures.push(`referenced image is not mirrored: ${image}`);
const expectedImages = new Map((manifest.images || []).map((image) => [image.path, image]));
for (const image of mirroredMetadata) {
const expected = expectedImages.get(image.path);
if (!expected) failures.push(`mirrored image missing from manifest: ${image.path}`);
else if (expected.sha256 !== image.sha256 || expected.bytes !== image.bytes) failures.push(`mirrored image digest mismatch: ${image.path}`);
}
for (const image of expectedImages.keys()) if (!mirroredImages.has(image)) failures.push(`manifest image is missing: ${image}`);
if (failures.length) throw new Error(`Generated article validation failed:\n${failures.join("\n")}`);
}
async function listFiles(directory) {
if (!existsSync(directory)) return [];
const files = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const full = path.join(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error(`Generated content tree must not contain symlinks: ${full}`);
if (entry.isDirectory()) files.push(...await listFiles(full));
else if (entry.isFile()) files.push(full);
else throw new Error(`Generated content tree must contain only directories and regular files: ${full}`);
}
return files;
}
async function imageMetadata(assetRoot) {
const metadata = [];
for (const file of await listFiles(path.join(assetRoot, "images/articles"))) {
const bytes = await readFile(file);
validateImageBytes(bytes, file);
metadata.push({
path: `/${path.relative(assetRoot, file).split(path.sep).join("/")}`,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
});
}
return metadata.sort((a, b) => a.path.localeCompare(b.path));
}
async function referencedImagesIn(directory) {
const images = new Set();
for (const file of await listFiles(directory)) {
if (!file.endsWith(".mdx")) continue;
const content = await readFile(file, "utf8");
for (const match of content.matchAll(/!\[[^\]]*\]\((\/images\/articles\/[^)]+)\)/g)) images.add(match[1]);
}
return images;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const manifestPath = path.join(ROOT, "scripts/blog-articles-manifest.json");
if (args.check && !args.source) {
await validateGenerated(JSON.parse(await readFile(manifestPath, "utf8")));
console.log("Generated article pages, navigation, and local images are valid.");
return;
}
if (!args.source) throw new Error("Pass --source /path/to/langbot-landing-page or set LANGBOT_LANDING_REPO");
if (!existsSync(path.join(args.source, "src/content/blog"))) throw new Error(`Not a LangBot landing repository: ${args.source}`);
const { byLocale, slugs } = await loadPosts(args.source);
const outputRoot = await mkdtemp(path.join(tmpdir(), "langbot-docs-article-sync-"));
const assetRoot = args.check ? outputRoot : ROOT;
const imageCache = new Map();
const localePosts = new Map();
const manifest = { source: "https://github.com/langbot-app/langbot-landing-page/tree/main/src/content/blog", articles: [] };
for (const locale of ARTICLE_LOCALES) {
const renderedPosts = [];
for (const slug of slugs) {
const localized = byLocale.get(locale).get(slug);
const post = localized || byLocale.get("en").get(slug);
const fallback = !localized;
const title = locale === "ja" && fallback ? (JAPANESE_TITLES[slug] || post.data.title) : post.data.title;
renderedPosts.push({ slug, title, date: post.data.date, group: classify(byLocale.get("en").get(slug)), fallback });
const target = path.join(outputRoot, locale, "articles", `${slug}.mdx`);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, await renderArticle(args.source, post, locale, fallback, assetRoot, imageCache));
manifest.articles.push({ locale, slug, page: articlePage(locale, slug), sourceLocale: post.locale, fallback });
}
renderedPosts.sort((a, b) => b.date.localeCompare(a.date) || a.slug.localeCompare(b.slug));
localePosts.set(locale, renderedPosts);
await writeFile(path.join(outputRoot, locale, "articles", "index.mdx"), renderIndex(locale, renderedPosts));
}
if (!args.check) {
const referencedImages = await referencedImagesIn(outputRoot);
for (const file of await listFiles(path.join(ROOT, "images/articles"))) {
const publicPath = `/${path.relative(ROOT, file).split(path.sep).join("/")}`;
if (!referencedImages.has(publicPath)) await rm(file, { force: true });
}
}
manifest.images = await imageMetadata(assetRoot);
if (args.check) {
const mismatches = [];
for (const locale of ARTICLE_LOCALES) {
const generatedDir = path.join(outputRoot, locale, "articles");
const currentDir = path.join(ROOT, locale, "articles");
const generatedNames = (await readdir(generatedDir)).sort();
const currentEntries = existsSync(currentDir) ? await readdir(currentDir, { withFileTypes: true }) : [];
if (currentEntries.some((entry) => !entry.isFile() || entry.isSymbolicLink())) mismatches.push(`${locale}/articles contains non-regular files`);
const currentNames = currentEntries.map((entry) => entry.name).sort();
if (JSON.stringify(generatedNames) !== JSON.stringify(currentNames)) mismatches.push(`${locale}/articles file set`);
for (const name of generatedNames) {
const generated = await readFile(path.join(generatedDir, name), "utf8");
const currentFile = path.join(currentDir, name);
const current = existsSync(currentFile) ? await readRegularFileWithin(currentDir, currentFile, `${locale} generated article`, "utf8") : "";
if (generated !== current) mismatches.push(`${locale}/articles/${name}`);
}
}
const currentManifest = existsSync(manifestPath) ? JSON.parse(await readFile(manifestPath, "utf8")) : null;
if (JSON.stringify(currentManifest) !== JSON.stringify(manifest)) mismatches.push("scripts/blog-articles-manifest.json");
const currentDocs = JSON.parse(await readFile(path.join(ROOT, "docs.json"), "utf8"));
for (const locale of ARTICLE_LOCALES) {
const config = LANGUAGE_CONFIG[locale];
const language = currentDocs.navigation.languages.find((item) => item.language === config.mintlify);
const matchingTabs = language?.tabs.filter((item) => item.tab === config.tab) || [];
const currentTab = matchingTabs.length === 1 ? matchingTabs[0] : null;
const expectedTab = navigationTab(locale, localePosts.get(locale));
if (matchingTabs.length !== 1 || JSON.stringify(currentTab) !== JSON.stringify(expectedTab)) mismatches.push(`docs.json ${config.tab} tab`);
}
await rm(outputRoot, { recursive: true, force: true });
if (mismatches.length) throw new Error(`Blog articles are out of sync:\n${mismatches.join("\n")}`);
await validateGenerated(JSON.parse(await readFile(manifestPath, "utf8")));
console.log(`All ${manifest.articles.length} localized article pages are synchronized.`);
return;
}
for (const locale of ARTICLE_LOCALES) {
const targetDir = path.join(ROOT, locale, "articles");
await rm(targetDir, { recursive: true, force: true });
await mkdir(path.dirname(targetDir), { recursive: true });
await cp(path.join(outputRoot, locale, "articles"), targetDir, { recursive: true });
}
await rm(outputRoot, { recursive: true, force: true });
const docsPath = path.join(ROOT, "docs.json");
const docs = JSON.parse(await readFile(docsPath, "utf8"));
for (const locale of ARTICLE_LOCALES) {
const config = LANGUAGE_CONFIG[locale];
const language = docs.navigation.languages.find((item) => item.language === config.mintlify);
if (!language) throw new Error(`docs.json missing language ${config.mintlify}`);
language.tabs = language.tabs.filter((item) => item.tab !== config.tab);
const apiIndex = language.tabs.findIndex((item) => /API/.test(item.tab));
language.tabs.splice(apiIndex >= 0 ? apiIndex : language.tabs.length, 0, navigationTab(locale, localePosts.get(locale)));
}
await writeFile(docsPath, `${JSON.stringify(docs, null, 2)}\n`);
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
await validateGenerated(manifest);
console.log(`Synchronized ${slugs.length} posts into ${manifest.articles.length} localized Wiki pages.`);
}
main().catch((error) => { console.error(error.stack || error.message); process.exitCode = 1; });
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Advance only the landing docs gitlink, then sync its production fork."""
import argparse
import json
import os
import urllib.error
import urllib.request
SOURCE = "langbot-app/langbot-docs"
TARGET = "langbot-app/langbot-landing-page"
DEPLOY = "RockChinQ/LangBot-LandingPage-Vue"
def api(method, path, data=None):
token = os.environ["GH_TOKEN"]
request = urllib.request.Request(
"https://api.github.com/repos/" + path,
data=None if data is None else json.dumps(data).encode(),
headers={"Authorization": "Bea" + "rer " + token,
"Accept": "application/vnd.github+json",
"Content-Type": "application/json"},
method=method,
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
def head(call, repo):
return call("GET", repo + "/git/ref/heads/main")["object"]["sha"]
def sync(call=api, dry_run=False):
for attempt in range(5):
source = head(call, SOURCE) # Coalesce queued events onto current main.
parent = head(call, TARGET)
commit = call("GET", TARGET + "/git/commits/" + parent)
tree = call("GET", TARGET + "/git/trees/" + commit["tree"]["sha"])
entry = next(item for item in tree["tree"] if item["path"] == "docs")
if entry["mode"] != "160000" or entry["type"] != "commit":
raise RuntimeError("Landing docs is not a submodule")
if entry["sha"] == source:
print("Landing docs already at " + source)
break
comparison = call("GET", SOURCE + "/compare/" + entry["sha"] + "..." + source)
if comparison["status"] != "ahead":
raise RuntimeError("Refusing a docs rewind or divergent history")
print("Updating landing docs: " + entry["sha"] + " -> " + source)
if dry_run:
return
updated = call("POST", TARGET + "/git/trees", {
"base_tree": commit["tree"]["sha"],
"tree": [{"path": "docs", "mode": "160000", "type": "commit", "sha": source}],
})
candidate = call("POST", TARGET + "/git/commits", {
"message": "docs: sync langbot-docs@" + source[:12],
"tree": updated["sha"], "parents": [parent],
"author": {"name": "github-actions[bot]", "email": "41898282+github-actions[bot]@users.noreply.github.com"},
})
try:
call("PATCH", TARGET + "/git/refs/heads/main", {"sha": candidate["sha"], "force": False})
except urllib.error.HTTPError as error:
if error.code in (409, 422) and attempt < 4:
continue # Rebuild atop current target; never force-push.
raise
actual = call("GET", TARGET + "/contents/docs?ref=main")
if actual["sha"] != source:
raise RuntimeError("Landing submodule readback mismatch")
print("Published landing commit " + candidate["sha"])
break
else:
raise RuntimeError("Landing branch kept changing")
if dry_run:
return
# Also retry this on no-op runs, so failed deployment sync is recoverable.
call("POST", DEPLOY + "/merge-upstream", {"branch": "main"})
if head(call, DEPLOY) != head(call, TARGET):
raise RuntimeError("Production fork readback mismatch")
print("Production fork matches landing main")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true")
sync(dry_run=parser.parse_args().dry_run)
+320
View File
@@ -0,0 +1,320 @@
"""Run the API documentation snippets with the installed SDK and a mock Host.
Usage: python scripts/verify-plugin-api-examples.py
This checks SDK calls and result shapes, not external platform delivery.
"""
from __future__ import annotations
import asyncio
import argparse
import ast
import inspect
import re
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
from langbot_plugin.api.entities.builtin.runner.errors import (
AgentAPIError,
AgentAPIException,
)
from langbot_plugin.api.proxies.runner import RunnerAPIProxy
from langbot_plugin.api.proxies.langbot_api import LangBotAPIProxy
from langbot_plugin.api.proxies.event_context import EventContextProxy
from langbot_plugin.api.proxies.execute_context import ExecuteContextProxy
from langbot_plugin.api.entities.builtin.provider.session import Session
from langbot_plugin.api.proxies.invocation import bind_invocation
from langbot_plugin.api.entities.builtin.provider.message import Message
ROOT = Path(__file__).resolve().parents[1]
class Host:
def __init__(self):
self.calls = []
self.closed_streams = 0
async def call_action(self, action, data, timeout=15):
name = action.value
self.calls.append((name, data))
responses = {
"invoke_llm": {
"message": {"role": "assistant", "content": "Hello!"},
"usage": {"total_tokens": 8},
},
"invoke_rerank": {"results": [{"index": 0, "relevance_score": 0.9}]},
"list_parsers": {
"parsers": [{"plugin_author": "demo", "plugin_name": "Parser"}]
},
"invoke_parser": {"text": "guide", "sections": [], "metadata": {}},
"vector_list": {
"items": [{"id": "chunk", "document": "guide", "metadata": {}}],
"total": 1,
},
"list_plugins_manifest": {"plugins": []},
"list_commands": {"commands": []},
"call_platform_api": {"result": {"id": "123456", "name": "Example"}},
"call_tool": {"result": {"id": "alice"}},
"reply_stream": {"result": {"ok": True, "mock": True}},
"history_page": {
"items": [],
"has_more": not data.get("before_cursor"),
"prev_cursor": "older",
"next_cursor": None,
},
"reply_message": {},
"set_query_var": {},
"state_set": {"success": True},
"state_get": {"value": "setup"},
}
if name not in responses:
raise AssertionError(f"Unexpected Host action: {name}")
return responses[name]
async def call_action_generator(self, action, data, timeout=15):
assert action.value == "invoke_llm_stream"
self.calls.append((action.value, data))
try:
yield {"chunk": {"role": "assistant", "content": "Hello", "is_final": False}}
yield {"chunk": {"role": "assistant", "content": "!", "is_final": True}}
yield {"usage": {"total_tokens": 8}}
finally:
self.closed_streams += 1
def context():
return RunnerContext.model_validate(
{
"run_id": "docs-run",
"trigger": {"type": "user_message"},
"event": {
"event_id": "docs-event",
"event_type": "message.received",
"source": "test",
},
"input": {"content": "Hello"},
"delivery": {"surface": "test"},
"runtime": {},
"resources": {
"models": [{"model_id": "model"}],
"tools": [
{"tool_name": "event_get_actor"},
{"tool_name": "event_reply"},
],
},
"context": {
"available_apis": {
"history_page": True,
"state": True,
"reply_stream": True,
}
},
}
)
def snippets(locale, page):
source = (ROOT / locale / f"plugin/dev/apis/{page}.mdx").read_text(encoding="utf-8")
if page == "common":
headings = {
"zh": ("### 模型流式输出与用量", "## 其他资源 API"),
"en": ("### Model streaming and usage", "## Additional resource APIs"),
"ja": ("### モデルのストリーミングと使用量", "## その他のリソース API"),
}[locale]
start = source.index(headings[0])
end = source.index("\n### ", start + len(headings[0]))
source = source[start:end] + source[source.index(headings[1]) :]
return re.findall(r"^```python\n(.*?)^```", source, re.M | re.S)
def verify_context_reference():
source = (ROOT / "zh/plugin/dev/apis/agent-run.mdx").read_text(encoding="utf-8")
query_methods = {
"reply",
"get_bot_uuid",
"set_query_var",
"get_query_var",
"get_query_vars",
"create_new_conversation",
"list_pipeline_knowledge_bases",
"retrieve_knowledge",
}
runner_methods = {
name
for name, value in RunnerContext.__dict__.items()
if callable(value) and not name.startswith("_")
}
runner_methods.discard("model_post_init")
for prefix in ("event_context", "context"):
missing = sorted(
name for name in query_methods if f"{prefix}.{name}(" not in source
)
assert not missing, f"{prefix} methods missing from context reference: {missing}"
for name in ("prevent_default", "prevent_postorder"):
assert f"event_context.{name}(" in source
missing = sorted(name for name in runner_methods if f"ctx.{name}(" not in source)
assert not missing, f"RunnerContext methods missing from context reference: {missing}"
assert "`platform_event`" in source
required_fields = {
"instance_uuid",
"workspace_uuid",
"placement_generation",
"query_id",
"query_uuid",
"eid",
"event_name",
"event",
"is_prevent_default",
"is_prevent_postorder",
"session",
"command_text",
"full_command_text",
"command",
"crt_command",
"params",
"crt_params",
"privilege",
*RunnerContext.model_fields,
}
missing = sorted(name for name in required_fields if f"`{name}`" not in source)
assert not missing, f"Context fields missing from context reference: {missing}"
assert "spec.permissions" not in source
assert "spec.capabilities" not in source
async def execute(block, namespace):
# Examples are function bodies; signature reference blocks may include stubs.
function = "async def example():\n" + "\n".join(
" " + line for line in block.splitlines()
)
tree = ast.parse(function)
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
owner = ast.unparse(node.func.value)
cls = (
LangBotAPIProxy
if owner == "self.plugin"
else EventContextProxy
if owner == "event_context"
else ExecuteContextProxy
if owner == "context"
else RunnerContext
if owner == "ctx"
else RunnerAPIProxy
if owner == "api"
else None
)
if cls is not None:
signature = inspect.signature(getattr(cls, node.func.attr))
signature.bind(
None,
*([None] * len(node.args)),
**{kw.arg: None for kw in node.keywords},
)
exec(compile(tree, "plugin-api-doc-example", "exec"), namespace)
await namespace["example"]()
async def main(pages):
verify_context_reference()
total = 0
for page in pages:
blocks = snippets("zh", page)
for locale in ("en", "ja"):
assert blocks == snippets(locale, page), f"{page}: {locale} code differs"
for block in blocks:
host = Host()
ctx = context()
api = RunnerAPIProxy(ctx, host)
ctx._api = api
ctx._results = asyncio.Queue()
session = Session(launcher_type="group", launcher_id="docs-group", sender_id="docs-user")
namespace = {
"event_context": EventContextProxy.model_construct(
query_id=42,
event=SimpleNamespace(sender_id="docs-user"),
plugin_runtime_handler=host,
),
"context": ExecuteContextProxy.model_construct(
query_id=43,
plugin_runtime_handler=host,
crt_params=["Hello!"],
session=session,
),
"session": session,
"Any": Any,
"Message": Message,
"api": api,
"ctx": ctx,
"self": SimpleNamespace(
plugin=LangBotAPIProxy(host), get_run_api=lambda _: api
),
"model_uuid": "model",
"rerank_model_uuid": "model",
"collection_id": "collection",
"storage_path": "files/guide.pdf",
"bot_uuid": "bot",
"print": lambda *args: None,
}
with bind_invocation(host, api):
await execute(block, namespace)
if page == "llm-requests":
expected = 0 if "aclosing" in block else 1
llm_calls = [data for name, data in host.calls if name.startswith("invoke_llm")]
assert len(llm_calls) == 1
assert llm_calls[0]["extra_args"]["request_policy"]["retries"] == expected
if "aclosing" in block:
assert host.closed_streams == 1, "Early close did not reach the Host stream"
for name, data in host.calls:
if name in {"reply_message", "set_query_var"}:
expected_query = 42 if "event_context" in block else 43
assert data["query_id"] == expected_query
assert "run_id" not in data
if "reply.update" in block:
ops = [
data["operation"]
for name, data in host.calls
if name == "reply_stream"
]
assert ops == ["update", "update", "finish"], ops
if "older =" in block:
pages = [data for name, data in host.calls if name == "history_page"]
assert len(pages) == 2 and pages[1]["before_cursor"] == "older"
if "except PermissionDeniedError" in block:
# Exercise both documented error handlers with the real SDK proxy.
api._allowed_tool_names = frozenset()
await execute(block, namespace)
ctx.log.assert_awaited()
api._allowed_tool_names = frozenset({"event_get_actor"})
host.call_action = AsyncMock(
side_effect=AgentAPIException(
AgentAPIError(
code="host.action_error", message="Failed", retryable=False
)
)
)
await execute(block, namespace)
assert ctx.log.await_count == 2
total += 1
print(
f"{total} API examples passed; all three locales have identical code. Host delivery is mocked."
)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--page", action="append",
choices=("common", "agent-run", "platform", "llm-requests"),
help="Verify selected pages (repeatable); defaults to all pages.",
)
args = parser.parse_args()
asyncio.run(main(args.page or ("common", "agent-run", "platform", "llm-requests")))
+167
View File
@@ -0,0 +1,167 @@
"""Execute the published tutorial with the matching 4.11 SDK installed.
Run: python scripts/verify-runner-tutorial.py
The tool API is mocked; real Host/runtime checks are a separate validation layer.
"""
import asyncio
import re
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import yaml
from langbot_plugin.api.definition.components.runner import RunnerContext
ROOT = Path(__file__).resolve().parents[1]
def load_tutorial():
examples = []
for locale in ("zh", "en", "ja"):
path = ROOT / locale / "plugin/dev/components/runner.mdx"
source = path.read_text()
python = re.findall(r"^```python\n(.*?)^```", source, re.M | re.S)
manifests = [
block
for block in re.findall(r"^```yaml\n(.*?)^```", source, re.M | re.S)
if "kind: Runner" in block
]
python = [block for block in python if "class Welcome(Runner):" in block]
assert len(python) == len(manifests) == 1, path
examples.append((python[0], manifests[0]))
assert examples[0] == examples[1] == examples[2], "Locale examples differ"
code, manifest = examples[0]
namespace = {}
exec(compile(code, "runner.mdx", "exec"), namespace)
manifest = yaml.safe_load(manifest)
assert manifest["spec"]["events"] == ["group.member_joined", "message.received"]
assert manifest["spec"]["capabilities"]["tool_calling"] is True
assert manifest["spec"]["permissions"]["tools"] == ["detail", "call"]
defaults = {field["name"]: field["default"] for field in manifest["spec"]["config"]}
return namespace[manifest["execution"]["python"]["attr"]], defaults
async def main():
component_class, defaults = load_tutorial()
component = component_class()
await component.initialize()
cases = [
(
"join",
{
"type": "group.member_joined",
"member": {"id": "alice", "nickname": "Alice"},
},
{},
"Alice, Welcome aboard!",
),
(
"nickname-fallback",
{"type": "group.member_joined", "member": {"id": "alice"}},
{},
"alice, Welcome aboard!",
),
(
"join-disabled",
{"type": "group.member_joined"},
{"reply_enabled": False},
None,
),
(
"command-disabled",
{
"type": "message.received",
"message_chain": [{"type": "Plain", "text": "/hello"}],
},
{"reply_enabled": False},
None,
),
(
"ordinary-text",
{
"type": "message.received",
"message_chain": [{"type": "Plain", "text": "hello"}],
},
{},
None,
),
("empty-message", {"type": "message.received", "message_chain": []}, {}, None),
(
"image-only",
{
"type": "message.received",
"message_chain": [
{"type": "Image", "url": "https://example.com/image.png"}
],
},
{},
None,
),
]
for chat_type in ("private", "group"):
cases.append(
(
chat_type,
{
"type": "message.received",
"chat_type": chat_type,
"message_chain": [{"type": "Plain", "text": " /hello "}],
},
{"greeting": "你好 🧩"},
"你好 🧩",
)
)
async def execute(name, data, config, reply, *, failure=False):
api = SimpleNamespace(
call_tool=AsyncMock(
side_effect=RuntimeError("Delivery failed") if failure else None,
return_value={"mock": True},
)
)
component.get_run_api = Mock(return_value=api)
context = RunnerContext.model_validate({
"run_id": name, "trigger": {"type": data["type"]},
"event": {"event_id": name, "event_type": data["type"], "source": "test", "data": data},
"config": {**defaults, **config}, "input": {}, "delivery": {"surface": "test"}, "resources": {}, "runtime": {},
})
results = []
try:
async for result in component.invoke(context):
results.append(result)
except RuntimeError as exc:
assert failure and str(exc) == "Delivery failed"
else:
assert not failure, "Delivery failure was swallowed"
assert sum(r.type == "run.completed" for r in results) == int(not failure)
assert all(r.run_id == name for r in results)
if reply is None:
api.call_tool.assert_not_called()
else:
api.call_tool.assert_awaited_once_with("event_reply", {"text": reply})
started = [
r.data["tool_call_id"] for r in results if r.type == "tool.call.started"
]
completed = [
r.data["tool_call_id"]
for r in results
if r.type == "tool.call.completed"
]
assert len(started) == 1 and started == completed
if failure:
assert any(r.type == "processor.log" for r in results)
assert any(r.data.get("error") == "Delivery failed" for r in results)
print(f"PASS {name}")
for case in cases:
await execute(*case)
await execute("delivery-failure", cases[0][1], {}, cases[0][3], failure=True)
print(
"All 10 tutorial cases passed; Chinese, English and Japanese code is identical."
)
if __name__ == "__main__":
asyncio.run(main())