From 414f98fb5e83d221fee8fa0dff16028456ca3f0b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 16 Mar 2026 11:41:25 +0800 Subject: [PATCH 1/7] perf: onebot, satori docs improvement --- docs/.vitepress/config.mjs | 10 +- docs/zh/platform/aiocqhttp.md | 43 ++++++++ docs/zh/platform/aiocqhttp/lagrange.md | 61 ----------- docs/zh/platform/aiocqhttp/napcat.md | 134 ------------------------- docs/zh/platform/aiocqhttp/others.md | 1 - docs/zh/platform/satori/guide.md | 32 ++++++ docs/zh/platform/satori/llonebot.md | 78 -------------- 7 files changed, 77 insertions(+), 282 deletions(-) create mode 100644 docs/zh/platform/aiocqhttp.md delete mode 100644 docs/zh/platform/aiocqhttp/lagrange.md delete mode 100644 docs/zh/platform/aiocqhttp/napcat.md delete mode 100644 docs/zh/platform/aiocqhttp/others.md create mode 100644 docs/zh/platform/satori/guide.md delete mode 100644 docs/zh/platform/satori/llonebot.md diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index aad7a7027..ea96183ea 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -87,13 +87,7 @@ export default defineConfig({ }, { text: "OneBot v11", - base: "/platform/aiocqhttp", - collapsed: true, - items: [ - { text: "NapCat", link: "/napcat" }, - { text: "Lagrange", link: "/lagrange" }, - { text: "其他端", link: "/others" }, - ], + link: "/aiocqhttp" }, { text: "企微应用", link: "/wecom" }, { text: "企微智能机器人", link: "/wecom_ai_bot" }, @@ -111,7 +105,7 @@ export default defineConfig({ base: "/platform/satori", collapsed: true, items: [ - { text: "使用 LLOneBot", link: "/llonebot" }, + { text: "接入 Satori", link: "/guide" }, { text: "使用 server-satori", link: "/server-satori" }, ], }, diff --git a/docs/zh/platform/aiocqhttp.md b/docs/zh/platform/aiocqhttp.md new file mode 100644 index 000000000..a13fc4a89 --- /dev/null +++ b/docs/zh/platform/aiocqhttp.md @@ -0,0 +1,43 @@ +# 接入 OneBot v11 协议实现 + +OneBot 是一个聊天机器人应用接口标准,旨在统一不同聊天平台上的机器人应用开发接口,使开发者只需编写一次业务逻辑代码即可应用到多种机器人平台。 + +AstrBot 支持接入所有适配了 OneBotv11 反向 Websockets(AstrBot 做服务器端)的机器人协议端。 + +下文给出一些常见的 OneBot v11 协议实现端项目。 + +- [NapCat](https://github.com/NapNeko/NapCatQQ) +- [OneDisc](https://github.com/ITCraftDevelopmentTeam/OneDisc) +- [Tele-KiraLink](https://github.com/Echomirix/Tele-KiraLink) + +请参阅对应的协议实现端项目的部署文档。 + +## 1. 配置 OneBot v11 + +1. 进入 AstrBot 的 WebUI +2. 点击左边栏 `机器人` +3. 然后在右边的界面中,点击 `+ 创建机器人` +4. 选择 `OneBot v11` + +在出现的表单中,填写: + +- ID(id):随意填写,仅用于区分不同的消息平台实例。 +- 启用(enable): 勾选。 +- 反向 WebSocket 主机地址:请填写你的机器的 IP 地址,一般情况下请直接填写 `0.0.0.0` +- 反向 WebSocket 端口:填写一个端口,默认为 `6199`。 +- 反向 Websocket Token:只有当 NapCat 网络配置中配置了 token 才需填写。 + +点击 `保存`。 + +## 2. 配置协议实现端 + +请参阅对应的协议实现端项目的部署文档。 + +一些注意点: + +1. 协议实现端需要支持 `反向 WebSocket` 实现,及 AstrBot 端作为服务端,实现端作为客户端。 +2. `反向 WebSocket` 的 URL 为 `ws(s)://:6199/ws`。 + +## 3. 验证 + +前往 AstrBot WebUI `控制台`,如果出现 ` aiocqhttp(OneBot v11) 适配器已连接。` 蓝色的日志,说明连接成功。如果没有,若干秒后出现` aiocqhttp 适配器已被关闭` 则为连接超时(失败),请检查配置是否正确。 \ No newline at end of file diff --git a/docs/zh/platform/aiocqhttp/lagrange.md b/docs/zh/platform/aiocqhttp/lagrange.md deleted file mode 100644 index bf2ddc286..000000000 --- a/docs/zh/platform/aiocqhttp/lagrange.md +++ /dev/null @@ -1,61 +0,0 @@ -# 接入 Lagrange - -> [!TIP] -> -> - 请合理控制使用频率。过于频繁地发送消息可能会被判定为异常行为,增加触发风控机制的风险。 -> - 本项目严禁用于任何违反法律法规的用途。若您意图将 AstrBot 应用于非法产业或活动,我们**明确反对并拒绝**您使用本项目。 -> - 最新的部署方式请以 [Lagrange Doc](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E4%B8%8B%E8%BD%BD%E5%AE%89%E8%A3%85) 为准。 - -## 下载 - -从 [GitHub Release](https://github.com/LagrangeDev/Lagrange.Core/releases) 下载最新版的 `Lagrange.OneBot`。 - -对于 Windows 设备,请下载 `Lagrange.OneBot_win-x64_xxxx` 压缩包。 - -对于 X86 的 Linux 用户,下载 `Lagrange.OneBot_linux-x64_xxx` 压缩包。 - -对于 Arm 的 Linux 用户,下载 `Lagrange.OneBot_linux-arm64_xxx` 压缩包。 - -对于 M 芯片 Mac 用户,下载 `Lagrange.OneBot_osx-arm64_xxx` 压缩包。 - -对于 Intel 芯片 Mac 用户,下载 `Lagrange.OneBot_osx-x64_xxx` 压缩包。 - -## 部署 - -请参阅 [Lagrange Doc](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E8%BF%90%E8%A1%8C)。 - -运行完成后,请修改 [配置文件](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6), - -在 `Implementations` 字段下添加: - -```json -{ - "Type": "ReverseWebSocket", - "Host": "127.0.0.1", - "Port": 6199, - "Suffix": "/ws", - "ReconnectInterval": 5000, - "HeartBeatInterval": 5000, - "AccessToken": "" -} -``` - -一定要保证 `Suffix` 为 `/ws`。 - -## 连接到 AstrBot - -### 配置 aiocqhttp - -1. 进入 AstrBot 的管理面板 -2. 点击左边栏 `机器人` -3. 然后在右边的界面中,点击 `+ 创建机器人` -4. 选择 `aiocqhttp(OneBotv11)` - -弹出的配置项填写: - -配置项填写: - -- ID(id):随意填写,用于区分不同的消息平台实例。 -- 启用(enable): 勾选。 -- 反向 WebSocket 主机地址:请填写你的机器的 IP 地址。一般情况下请直接填写 `0.0.0.0` -- 反向 WebSocket 端口:填写一个端口,例如 `6199`。 diff --git a/docs/zh/platform/aiocqhttp/napcat.md b/docs/zh/platform/aiocqhttp/napcat.md deleted file mode 100644 index 042748dc4..000000000 --- a/docs/zh/platform/aiocqhttp/napcat.md +++ /dev/null @@ -1,134 +0,0 @@ -# 使用 NapCat - -> [!TIP] -> -> - 本项目严禁用于任何违反法律法规的用途。若您意图将 AstrBot 应用于非法产业或活动,我们**明确反对并拒绝**您使用本项目。 -> - AstrBot 通过 `aiocqhttp` 适配器接入 OneBot v11 协议。OneBot v11 协议是一个开放的通信协议,并不代表任何具体的软件或服务。 - -NapCat 的 GitHub 仓库:[NapCat](https://github.com/NapNeko/NapCatQQ) -NapCat 的文档:[NapCat 文档](https://napcat.napneko.icu/) - -NapCat 提供了大量的部署方式,包括 Docker、Windows 一键安装包等等。 - -## 通过一键脚本部署 - -推荐采用这种方式部署。 - -### Windows - -看这篇文章:[NapCat.Shell - Win手动启动教程](https://napneko.github.io/guide/boot/Shell#napcat-shell-win%E6%89%8B%E5%8A%A8%E5%90%AF%E5%8A%A8%E6%95%99%E7%A8%8B) - -### Linux - -看这篇文章:[NapCat.Installer - Linux一键使用脚本(支持Ubuntu 20+/Debian 10+/Centos9)](https://napneko.github.io/guide/boot/Shell#napcat-installer-linux%E4%B8%80%E9%94%AE%E4%BD%BF%E7%94%A8%E8%84%9A%E6%9C%AC-%E6%94%AF%E6%8C%81ubuntu-20-debian-10-centos9) - -> [!TIP] -> **Napcat WebUI 在哪打开**: -> 在 napcat 的日志里会显示 WebUI 链接。 -> -> 如果是 linux 命令行一键部署的napcat:`docker log <账号>`。 -> -> Docker部署的 NapCat:`docker logs napcat`。 - -## 通过 Docker Compose 部署 - -1. 下载或复制 [astrbot.yml](https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml) 内容 -2. 将刚刚下载的文件重命名为 `astrbot.yml` -3. 编辑 `astrbot.yml`,将 `# - "6199:6199"` 修改为 `- "6199:6199"`,移除开头的 `#` -4. 在 `astrbot.yml` 文件所在目录执行: - -```bash -NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose -f ./astrbot.yml up -d -``` - -## 通过 Docker 部署 - -此教程默认您安装了 Docker。 - -在终端执行以下命令即可一键部署。 - -```bash -docker run -d \ --e NAPCAT_GID=$(id -g) \ --e NAPCAT_UID=$(id -u) \ --p 3000:3000 \ --p 3001:3001 \ --p 6099:6099 \ ---name napcat \ ---restart=always \ -mlikiowa/napcat-docker:latest -``` - -执行成功后,需要查看日志以得到登录二维码和管理面板的 URL。 - -```bash -docker logs napcat -``` - -请复制管理面板的 URL,然后在浏览器中打开备用。 - -然后使用你要登录的账号扫描出现的二维码,即可登录。 - -如果登录阶段没有出现问题,即成功部署。 - -## 连接到 AstrBot - -## 在 AstrBot 配置 aiocqhttp - -1. 进入 AstrBot 的管理面板 -2. 点击左边栏 `机器人` -3. 然后在右边的界面中,点击 `+ 创建机器人` -4. 选择 `OneBot v11` - -弹出的配置项填写: -- ID(id):随意填写,仅用于区分不同的消息平台实例。 -- 启用(enable): 勾选。 -- 反向 WebSocket 主机地址:请填写你的机器的 IP 地址,一般情况下请直接填写 `0.0.0.0` -- 反向 WebSocket 端口:填写一个端口,默认为 `6199`。 -- 反向 Websocket Token:只有当 NapCat 网络配置中配置了 token 才需填写。 - -图例:(最快只需要点击启用,然后保存即可) - -xinjianya - - -点击 `保存`。 - -### 配置管理员 - -填写完毕后,进入 `配置文件` 页,点击 `平台配置` 选项卡,找到 `管理员 ID`,填写你的账号(不是机器人的账号)。 - -切记点击右下角 `保存`,AstrBot 重启并会应用配置。 - -### 在 NapCat 中添加 WebSocket 客户端 - -切换回 NapCat 的管理面板,点击 `网络配置->新建->WebSockets客户端`。 - -jiaochenXJY - - -在新弹出的窗口中: - -- 勾选 `启用`。 -- `URL` 填写 `ws://宿主机IP:端口/ws`。如 `ws://localhost:6199/ws` 或 `ws://127.0.0.1:6199/ws`。 - -> [!IMPORTANT] -> 1. 如果采用 Docker 部署并同时把 AstrBot 和 NapCat 两个容器接入了同一网络,`ws://astrbot:6199/ws`(参考本文档的 Docker 脚本)。 -> 2. 由于 Docker 网络隔离的原因,不在同一个网络时请使用内网 IP 地址或公网 IP 地址 ***(不安全)*** 进行连接,即 `ws://(内网/公网):6199/ws`。 - -- 消息格式:`Array` -- 心跳间隔: `5000` -- 重连间隔: `5000` - -> [!WARNING] -> -> 1. 切记后面加一个 `/ws`! -> 2. 这里的 IP 不能填为 `0.0.0.0` - -点击 `保存`。 - -前往 AstrBot WebUI `控制台`,如果出现 ` aiocqhttp(OneBot v11) 适配器已连接。` 蓝色的日志,说明连接成功。如果没有,若干秒后出现` aiocqhttp 适配器已被关闭` 则为连接超时(失败),请检查配置是否正确。 - -## 🎉 大功告成 - -此时,你的 AstrBot 和 NapCat 应该已经连接成功!使用 `私聊` 的方式对机器人发送 `/help` 以检查是否连接成功。 diff --git a/docs/zh/platform/aiocqhttp/others.md b/docs/zh/platform/aiocqhttp/others.md deleted file mode 100644 index fcce08327..000000000 --- a/docs/zh/platform/aiocqhttp/others.md +++ /dev/null @@ -1 +0,0 @@ -支持接入所有适配了 OneBotv11 反向 Websockets(AstrBot 做服务器端) 的机器人协议端。 \ No newline at end of file diff --git a/docs/zh/platform/satori/guide.md b/docs/zh/platform/satori/guide.md new file mode 100644 index 000000000..c60380de9 --- /dev/null +++ b/docs/zh/platform/satori/guide.md @@ -0,0 +1,32 @@ +# 接入 Satori 协议 + +## Satori 协议简介 + +> 摘录自:https://satori.chat/zh-CN/introduction.html + +Satori 是一个通用的聊天协议。Satori 协议希望能够抹平不同聊天平台之间的差异,让开发者以更低的成本开发出跨平台、可扩展、高性能的聊天应用。 + +Satori 的名称来源于游戏东方 Project 中的角色 [古明地觉 (Komeiji Satori)](https://zh.touhouwiki.net/wiki/%E5%8F%A4%E6%98%8E%E5%9C%B0%E8%A7%89)。古明地觉能够以心灵感应的方式与各种动物交流,取这个名字是希望 Satori 能够成为各个聊天平台之间的桥梁。 + +Satori 的开发团队长期从事聊天机器人开发,熟悉各种聊天平台的通信方式。经过长达 4 年的发展,Satori 有了健全的设计和完善的实现。目前,Satori 官方提供了超过 15 个聊天平台的适配器,完全覆盖了世界上主流的聊天平台,如 QQ、Discord、企业微信、KOOK 等等。 + +## 1. 配置协议实现端 + +请参阅对应的协议实现端项目的部署文档。 + +## 2. 配置 Satori 协议 + +1. 进入 AstrBot 的 WebUI +2. 点击左边栏 `机器人` +3. 然后在右边的界面中,点击 `+ 创建机器人` +4. 选择 `Satori` + +弹出的配置项填写: + +- 机器人名称 (id): `satori` (随意) +- 启用 (enable): 勾选 +- Satori API 终结点 (satori_api_base_url):`http://localhost:5600/v1`(端口和上面配置的协议端端口一致) +- Satori WebSocket 终结点 (satori_endpoint):`ws://localhost:5600/v1/events`(端口和上面配置的协议端端口一致) +- Satori 令牌 (satori_token):根据协议端配置情况选择填写 + +点击 `保存`。 diff --git a/docs/zh/platform/satori/llonebot.md b/docs/zh/platform/satori/llonebot.md deleted file mode 100644 index 994c5de97..000000000 --- a/docs/zh/platform/satori/llonebot.md +++ /dev/null @@ -1,78 +0,0 @@ -# 接入 LLTwoBot (Satori) - -> [!TIP] -> LLTwoBot 是一个基于 QQNT 的 Onebot v11、Satori 多协议实现端,可以让你在 QQ 平台使用 Satori 协议与 AstrBot 进行通信。 - -> [!TIP] -> -> - 请合理控制使用频率。过于频繁地发送消息可能会被判定为异常行为,增加触发风控机制的风险。 -> - 本项目严禁用于任何违反法律法规的用途。若您意图将 AstrBot 应用于非法产业或活动,我们**明确反对并拒绝**您使用本项目。 - -## 准备工作 - -请先参考 LLTwoBot 官方文档完成基础配置: -[LLTwoBot 文档](https://llonebot.com/guide/getting-started) - -完成文档中的步骤,确保你已经: - -1. 下载并安装了 LLTwoBot -2. 成功登录了 QQ 账号 - -## 配置 LLTwoBot 的 Satori 服务 - -在成功登录 QQ 后,先打开 LLTwoBot 的 WebUI 配置界面。 -> WebUI 默认地址为: - ---- - -在WebUI的配置界面侧边,选择【Satori】选项卡,进行如下配置: - -1. 确认【启用 Satori 协议】配置项已开启 -2. 端口默认为 5600(如需修改请记住新端口) -3. 如有必要,可填写【Satori Token】 -4. 点击右下角的【保存配置】 - -![image](https://files.astrbot.app/docs/source/images/satori/2025-10-10_15-52-32.png) - -## 在 AstrBot 中配置 Satori 适配器 - -1. 进入 AstrBot 的管理面板 -2. 点击左边栏 `机器人` -3. 然后在右边的界面中,点击 `+ 创建机器人` -4. 选择 `satori` - -弹出的配置项填写: - -- 机器人名称 (id): `LLTwoBot` -- 启用 (enable): 勾选 -- Satori API 终结点 (satori_api_base_url):`http://localhost:5600/v1` -- Satori WebSocket 终结点 (satori_endpoint):`ws://localhost:5600/v1/events` -- Satori 令牌 (satori_token):根据 LLTwoBot 配置填写(如有设置) - -> [!NOTE] -> -> - LLTwoBot 的 satori协议 默认在 `5600` 端口提供服务 -> - 因此完整的 URL 路径为 `http://localhost:5600/v1` -> -> 如果你的 satori协议运行在其他端口,请根据实际情况修改对应的配置! - -![image](https://files.astrbot.app/docs/source/images/satori/2025-10-10_16-10-54.png) - -点击右下角 `保存` 完成配置。 - -## 🎉 大功告成 - -此时,你的 AstrBot 应该已经通过 Satori 协议成功连接到 LLTwoBot。 - -在 QQ 中发送 `/help` 以检查是否连接成功。 - -如果成功回复,则配置成功。 - -## 常见问题 - -如果遇到连接问题,请检查: - -1. LLTwoBot 是否正常运行 -2. Satori 服务是否已启用 -3. 端口配置是否正确 -4. Token 是否匹配(如设置了 Token) From 6be8a4302dc93aa4556daa90eddcf0e30c8e678b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 17 Mar 2026 19:56:08 +0800 Subject: [PATCH 2/7] docs: add aiocqhttp and satori protocol documentation; remove outdated lagrange and napcat guides --- docs/.vitepress/config.mjs | 10 +- docs/en/platform/aiocqhttp.md | 44 ++++++++ docs/en/platform/aiocqhttp/lagrange.md | 55 ---------- docs/en/platform/aiocqhttp/napcat.md | 141 ------------------------- docs/en/platform/aiocqhttp/others.md | 1 - docs/en/platform/satori/guide.md | 32 ++++++ docs/en/platform/satori/llonebot.md | 73 ------------- docs/zh/platform/aiocqhttp.md | 50 ++++++++- 8 files changed, 123 insertions(+), 283 deletions(-) create mode 100644 docs/en/platform/aiocqhttp.md delete mode 100644 docs/en/platform/aiocqhttp/lagrange.md delete mode 100644 docs/en/platform/aiocqhttp/napcat.md delete mode 100644 docs/en/platform/aiocqhttp/others.md create mode 100644 docs/en/platform/satori/guide.md delete mode 100644 docs/en/platform/satori/llonebot.md diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index ea96183ea..9932d7a70 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -321,13 +321,7 @@ export default defineConfig({ }, { text: "OneBot v11", - base: "/en/platform/aiocqhttp", - collapsed: true, - items: [ - { text: "NapCat", link: "/napcat" }, - { text: "Lagrange", link: "/lagrange" }, - { text: "Other Clients", link: "/others" }, - ], + link: "/aiocqhttp", }, { text: "WeCom Application", link: "/wecom" }, { text: "WeCom AI Bot", link: "/wecom_ai_bot" }, @@ -344,7 +338,7 @@ export default defineConfig({ base: "/en/platform/satori", collapsed: true, items: [ - { text: "Using LLOneBot", link: "/llonebot" }, + { text: "Connect Satori", link: "/guide" }, { text: "Using server-satori", link: "/server-satori" }, ], }, diff --git a/docs/en/platform/aiocqhttp.md b/docs/en/platform/aiocqhttp.md new file mode 100644 index 000000000..a05a31dcc --- /dev/null +++ b/docs/en/platform/aiocqhttp.md @@ -0,0 +1,44 @@ +# Connect OneBot v11 Protocol Implementations + +OneBot is a standardized bot application interface designed to unify bot development across different chat platforms, so developers can write business logic once and use it on multiple platforms. + +AstrBot supports all client implementations that implement OneBot v11 reverse WebSocket (AstrBot acts as the server). + +Common OneBot v11 implementation projects are listed below: + +- [NapCat](https://github.com/NapNeko/NapCatQQ) +- [OneDisc](https://github.com/ITCraftDevelopmentTeam/OneDisc) +- [Tele-KiraLink](https://github.com/Echomirix/Tele-KiraLink) + +Please refer to each implementation project's deployment documentation. + +## 1. Configure OneBot v11 + +1. Open AstrBot's WebUI +2. Click `Bots` in the left sidebar +3. In the right panel, click `+ Create Bot` +4. Select `OneBot v11` + +Fill in the form: + +- ID (`id`): any value, used only to distinguish instances of different platforms. +- Enable (`enable`): check it. +- Reverse WebSocket host: fill your machine IP, usually `0.0.0.0`. +- Reverse WebSocket port: choose any port, default is `6199`. +- Reverse WebSocket token: fill this only when NapCat network configuration has a token set. + +Click `Save`. + +## 2. Configure the protocol implementation side + +Please refer to each protocol implementation project's deployment documentation. + +Notes: + +1. The implementation must support `Reverse WebSocket`, with AstrBot acting as the server and the implementation client as the client. +2. The reverse WebSocket URL is `ws(s)://:6199/ws`. + +## 3. Verify + +Go to AstrBot WebUI `Console`. If a blue log appears saying `aiocqhttp(OneBot v11) adapter connected.`, the connection is successful. +If after a few seconds you see `aiocqhttp adapter has been closed`, it means the connection timed out (failed). Please double-check your configuration. diff --git a/docs/en/platform/aiocqhttp/lagrange.md b/docs/en/platform/aiocqhttp/lagrange.md deleted file mode 100644 index c2be79b68..000000000 --- a/docs/en/platform/aiocqhttp/lagrange.md +++ /dev/null @@ -1,55 +0,0 @@ -# Connect to Lagrange - -> [!TIP] -> - Please control message frequency responsibly. Sending messages too frequently may trigger risk control. -> - This project must not be used for illegal purposes. -> - For the latest deployment steps, always refer to the official [Lagrange Docs](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E4%B8%8B%E8%BD%BD%E5%AE%89%E8%A3%85). - -## Download - -Download the latest `Lagrange.OneBot` from [GitHub Releases](https://github.com/LagrangeDev/Lagrange.Core/releases). - -- Windows: `Lagrange.OneBot_win-x64_xxxx` -- Linux x86_64: `Lagrange.OneBot_linux-x64_xxx` -- Linux ARM64: `Lagrange.OneBot_linux-arm64_xxx` -- macOS Apple Silicon: `Lagrange.OneBot_osx-arm64_xxx` -- macOS Intel: `Lagrange.OneBot_osx-x64_xxx` - -## Deploy - -Follow the official docs: - -- Run guide: -- Config file guide: - -In your config file, add this under `Implementations`: - -```json -{ - "Type": "ReverseWebSocket", - "Host": "127.0.0.1", - "Port": 6199, - "Suffix": "/ws", - "ReconnectInterval": 5000, - "HeartBeatInterval": 5000, - "AccessToken": "" -} -``` - -Make sure `Suffix` is exactly `/ws`. - -## Connect to AstrBot - -### Configure `aiocqhttp` Adapter - -1. Open AstrBot Dashboard. -2. Click `Bots` in the left sidebar. -3. Click `+ Create Bot`. -4. Select `aiocqhttp (OneBot v11)`. - -Fill in: - -- ID (`id`): any unique identifier. -- Enable (`enable`): checked. -- Reverse WebSocket host: your machine IP (usually `0.0.0.0`). -- Reverse WebSocket port: an available port, for example `6199`. diff --git a/docs/en/platform/aiocqhttp/napcat.md b/docs/en/platform/aiocqhttp/napcat.md deleted file mode 100644 index bc081721a..000000000 --- a/docs/en/platform/aiocqhttp/napcat.md +++ /dev/null @@ -1,141 +0,0 @@ -# Using NapCat - -> [!TIP] -> -> - Please control usage frequency appropriately. Sending messages too frequently may be identified as abnormal behavior, increasing the risk of triggering risk control mechanisms. -> - This project is strictly prohibited from being used for any purpose that violates laws and regulations. If you intend to use AstrBot for illegal industries or activities, we **explicitly oppose and refuse** your use of this project. -> - AstrBot connects to the OneBot v11 protocol through the `aiocqhttp` adapter. OneBot v11 protocol is an open communication protocol and does not represent any specific software or service. - -NapCat's GitHub Repository: [NapCat](https://github.com/NapNeko/NapCatQQ) -NapCat's Documentation: [NapCat Documentation](https://napcat.napneko.icu/) - -NapCat provides multiple deployment methods, including Docker, Windows one-click installation packages, and more. - -## Deploy via One-Click Script - -This deployment method is recommended. - -### Windows - -Refer to this article: [NapCat.Shell - Windows Manual Start Tutorial](https://napneko.github.io/guide/boot/Shell#napcat-shell-win%E6%89%8B%E5%8A%A8%E5%90%AF%E5%8A%A8%E6%95%99%E7%A8%8B) - -### Linux - -Refer to this article: [NapCat.Installer - Linux One-Click Script (Supports Ubuntu 20+/Debian 10+/Centos9)](https://napneko.github.io/guide/boot/Shell#napcat-installer-linux%E4%B8%80%E9%94%AE%E4%BD%BF%E7%94%A8%E8%84%9A%E6%9C%AC-%E6%94%AF%E6%8C%81ubuntu-20-debian-10-centos9) - -> [!TIP] -> **Where to open Napcat WebUI**: -> The WebUI link will be displayed in napcat's logs. -> -> If napcat is deployed via Linux command line one-click deployment: `docker log `. -> -> For Docker-deployed NapCat: `docker logs napcat`. - -## Deploy via Docker Compose - -> [!TIP] -> If deploying with Docker Compose, no configuration is needed on the NapCat side. Just log in via NapCat WebUI (running on port 6099) or `docker logs napcat`, enable the aiocqhttp adapter on the AstrBot side to connect, and you can directly implement normal receiving and sending of `voice data` and `file data`. - -1. Download or copy the content of [astrbot.yml](https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml) -2. Rename the downloaded file to `astrbot.yml` -3. Modify `astrbot.yml`, change `#- "6199:6199` to `- "6199:6199"`, remove the flag of "#" -4. Execute in the directory where the `astrbot.yml` file is located: - -```bash -NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose -f ./astrbot.yml up -d -``` - -## Deploy via Docker - -> [!TIP] -> If deploying with Docker, you will not be able to properly receive `voice data` and `file data`. This means voice-to-text and sandbox file input functions will not be available. You can receive text messages, image messages, and other types of messages. - -This tutorial assumes you have Docker installed. - -Execute the following command in the terminal for one-click deployment. - -```bash -docker run -d \ --e NAPCAT_GID=$(id -g) \ --e NAPCAT_UID=$(id -u) \ --p 3000:3000 \ --p 3001:3001 \ --p 6099:6099 \ ---name napcat \ ---restart=always \ -mlikiowa/napcat-docker:latest -``` - -After successful execution, you need to check the logs to get the login QR code and the management panel URL. - -```bash -docker logs napcat -``` - -Please copy the management panel URL and open it in your browser. - -Then use the account you want to log in with to scan the QR code that appears. - -If there are no issues during the login stage, deployment is successful. - -## Connect to AstrBot - -## Configure aiocqhttp in AstrBot - -1. Enter AstrBot's management panel -2. Click `Bots` in the left sidebar -3. Then in the interface on the right, click `+ Create Bot` -4. Select `OneBot v11` - -Fill in the configuration items that appear: -- ID(id): Fill in arbitrarily, only used to distinguish different messaging platform instances. -- Enable: Check this. -- Reverse WebSocket Host Address: Please fill in your machine's IP address, generally fill in `0.0.0.0` directly -- Reverse WebSocket Port: Fill in a port, default is `6199`. -- Reverse Websocket Token: Only needs to be filled when a token is configured in NapCat's network settings. - -Example image: (At the fastest, just check Enable, then save) - -xinjianya - - -Click `Save`. - -### Configure Administrator - -After filling in, go to the `Configuration File` page, click the `Platform Configuration` tab, find `Administrator ID`, and fill in your account number (not the bot's account number). - -Remember to click `Save` in the lower right corner, AstrBot will restart and apply the configuration. - -### Add WebSocket Client in NapCat - -Switch back to NapCat's management panel, click `Network Configuration->New->WebSockets Client`. - -jiaochenXJY - - -In the newly opened window: - -- Check `Enable`. -- Fill in `URL` with `ws://HostIP:Port/ws`. For example, `ws://localhost:6199/ws` or `ws://127.0.0.1:6199/ws`. - -> [!IMPORTANT] -> 1. If deploying with Docker and both AstrBot and NapCat containers are connected to the same network, use `ws://astrbot:6199/ws` (refer to the Docker script in this documentation). -> 2. Due to Docker network isolation, when not on the same network, please use the internal network IP address or public network IP address ***(unsafe)*** to connect, i.e., `ws://(internal/public IP):6199/ws`. - -- Message Format: `Array` -- Heartbeat Interval: `5000` -- Reconnection Interval: `5000` - -> [!WARNING] -> -> 1. Remember to add `/ws` at the end! -> 2. The IP here cannot be `0.0.0.0` - -Click `Save`. - -Go to AstrBot WebUI `Console`, if you see the blue log ` aiocqhttp(OneBot v11) adapter connected.`, it means the connection is successful. If not, and after several seconds ` aiocqhttp adapter has been closed` appears, it indicates connection timeout (failed), please check if the configuration is correct. - -## 🎉 All Done - -At this point, your AstrBot and NapCat should be successfully connected! Use `private message` to send `/help` to the bot to check if the connection is successful. diff --git a/docs/en/platform/aiocqhttp/others.md b/docs/en/platform/aiocqhttp/others.md deleted file mode 100644 index 8d1c560ce..000000000 --- a/docs/en/platform/aiocqhttp/others.md +++ /dev/null @@ -1 +0,0 @@ -AstrBot can connect to any bot protocol client that supports OneBot v11 reverse WebSocket (AstrBot acts as the server side). diff --git a/docs/en/platform/satori/guide.md b/docs/en/platform/satori/guide.md new file mode 100644 index 000000000..7c9b0b044 --- /dev/null +++ b/docs/en/platform/satori/guide.md @@ -0,0 +1,32 @@ +# Connect to Satori Protocol + +## Satori protocol overview + +> Excerpt from: https://satori.chat/introduction.html + +Satori is a unified chat protocol. It aims to reduce differences between chat platforms and let developers build cross-platform, extensible, high-performance chat applications with lower cost. + +The protocol is named after [Komeiji Satori](https://satori.js.org) in Touhou Project. The idea is that Satori can serve as a bridge between chat platforms, as Komeiji Satori communicates telepathically. + +The development team behind Satori has long worked on bot development and is familiar with the communication patterns of many platforms. After about 4 years, Satori now has a mature design and implementation. The official project currently provides adapters for more than 15 platforms, covering major messaging services worldwide such as QQ, Discord, WeCom, KOOK, and others. + +## 1. Configure the protocol server side + +Please refer to the deployment documentation of the chosen implementation project. + +## 2. Configure Satori protocol in AstrBot + +1. Open AstrBot WebUI. +2. Click `Bots` in the left sidebar. +3. In the right panel, click `+ Create Bot`. +4. Select `satori`. + +Fill in the form: + +- Bot ID (`id`): e.g. `satori` (any value is fine). +- Enable (`enable`): check it. +- Satori API base URL (`satori_api_base_url`): `http://localhost:5600/v1` (same port as the protocol implementation). +- Satori WebSocket endpoint (`satori_endpoint`): `ws://localhost:5600/v1/events` (same port as the protocol implementation). +- Satori token (`satori_token`): fill according to implementation settings. + +Click `Save`. diff --git a/docs/en/platform/satori/llonebot.md b/docs/en/platform/satori/llonebot.md deleted file mode 100644 index 3fc8df55f..000000000 --- a/docs/en/platform/satori/llonebot.md +++ /dev/null @@ -1,73 +0,0 @@ -# Connect LLTwoBot (Satori) - -> [!TIP] -> LLTwoBot is a multi-protocol implementation based on QQNT (OneBot v11 + Satori), allowing AstrBot to communicate with QQ via Satori. - -> [!TIP] -> - Please control message frequency responsibly. -> - This project must not be used for illegal purposes. - -## Preparation - -First complete basic setup using official LLTwoBot documentation: - -[LLTwoBot Docs](https://llonebot.com/guide/getting-started) - -Make sure you have: - -1. Installed LLTwoBot. -2. Logged into a QQ account successfully. - -## Configure Satori in LLTwoBot - -After QQ login succeeds, open LLTwoBot WebUI: - -> Default WebUI URL: - -In the WebUI sidebar, open the `Satori` tab and configure: - -1. Enable Satori protocol. -2. Port defaults to `5600`. -3. Set Satori token if needed. -4. Click Save. - -![image](https://files.astrbot.app/docs/source/images/satori/2025-10-10_15-52-32.png) - -## Configure Satori Adapter in AstrBot - -1. Open AstrBot Dashboard. -2. Click `Bots`. -3. Click `+ Create Bot`. -4. Select `satori`. - -Fill in: - -- Bot ID (`id`): `LLTwoBot` -- Enable (`enable`): checked -- Satori API endpoint (`satori_api_base_url`): `http://localhost:5600/v1` -- Satori WebSocket endpoint (`satori_endpoint`): `ws://localhost:5600/v1/events` -- Satori token (`satori_token`): from LLTwoBot config if set - -> [!NOTE] -> - LLTwoBot Satori service defaults to port `5600`. -> - The complete API base path is `http://localhost:5600/v1`. -> - If your Satori service runs on another port/path, adjust these values. - -![image](https://files.astrbot.app/docs/source/images/satori/2025-10-10_16-10-54.png) - -Click `Save`. - -## Done - -AstrBot should now be connected to LLTwoBot via Satori. - -Send `/help` in QQ to verify. - -## Troubleshooting - -If connection fails, check: - -1. LLTwoBot is running. -2. Satori service is enabled. -3. Port/path are configured correctly. -4. Token matches (if configured). diff --git a/docs/zh/platform/aiocqhttp.md b/docs/zh/platform/aiocqhttp.md index a13fc4a89..0d2033720 100644 --- a/docs/zh/platform/aiocqhttp.md +++ b/docs/zh/platform/aiocqhttp.md @@ -1,17 +1,19 @@ # 接入 OneBot v11 协议实现 -OneBot 是一个聊天机器人应用接口标准,旨在统一不同聊天平台上的机器人应用开发接口,使开发者只需编写一次业务逻辑代码即可应用到多种机器人平台。 +OneBot 是一个**聊天机器人应用接口标准**,旨在统一不同聊天平台上的机器人应用开发接口。 AstrBot 支持接入所有适配了 OneBotv11 反向 Websockets(AstrBot 做服务器端)的机器人协议端。 下文给出一些常见的 OneBot v11 协议实现端项目。 -- [NapCat](https://github.com/NapNeko/NapCatQQ) -- [OneDisc](https://github.com/ITCraftDevelopmentTeam/OneDisc) -- [Tele-KiraLink](https://github.com/Echomirix/Tele-KiraLink) +- [NapCat](https://github.com/NapNeko/NapCatQQ) (连接到 QQ) +- [OneDisc](https://github.com/ITCraftDevelopmentTeam/OneDisc) (连接到 Discord) +- [Tele-KiraLink](https://github.com/Echomirix/Tele-KiraLink) (连接到 Telegram) 请参阅对应的协议实现端项目的部署文档。 +对于 Napcat 项目,请参考下文的 `附录:部署 Napcat` + ## 1. 配置 OneBot v11 1. 进入 AstrBot 的 WebUI @@ -40,4 +42,42 @@ AstrBot 支持接入所有适配了 OneBotv11 反向 Websockets(AstrBot 做服 ## 3. 验证 -前往 AstrBot WebUI `控制台`,如果出现 ` aiocqhttp(OneBot v11) 适配器已连接。` 蓝色的日志,说明连接成功。如果没有,若干秒后出现` aiocqhttp 适配器已被关闭` 则为连接超时(失败),请检查配置是否正确。 \ No newline at end of file +前往 AstrBot WebUI `控制台`,如果出现 ` aiocqhttp(OneBot v11) 适配器已连接。` 蓝色的日志,说明连接成功。如果没有,若干秒后出现` aiocqhttp 适配器已被关闭` 则为连接超时(失败),请检查配置是否正确。 + +## 附录:部署 Napcat + +### 通过一键启动脚本部署 + +推荐采用这种方式部署。 + +#### Windows + +看这篇文章:[NapCat.Shell - Win手动启动教程](https://napneko.github.io/guide/boot/Shell#napcat-shell-win%E6%89%8B%E5%8A%A8%E5%90%AF%E5%8A%A8%E6%95%99%E7%A8%8B) + +#### Linux + +看这篇文章:[NapCat.Installer - Linux一键使用脚本(支持Ubuntu 20+/Debian 10+/Centos9)](https://napneko.github.io/guide/boot/Shell#napcat-installer-linux%E4%B8%80%E9%94%AE%E4%BD%BF%E7%94%A8%E8%84%9A%E6%9C%AC-%E6%94%AF%E6%8C%81ubuntu-20-debian-10-centos9) + +> [!TIP] +> **Napcat WebUI 在哪打开**: +> 在 napcat 的日志里会显示 WebUI 链接。 +> +> 如果是 linux 命令行一键部署的napcat:`docker log <账号>`。 +> +> Docker部署的 NapCat:`docker logs napcat`。 + +## 通过 Docker Compose 部署 + +1. 下载或复制 [astrbot.yml](https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml) 内容 +2. 将刚刚下载的文件重命名为 `astrbot.yml` +3. 编辑 `astrbot.yml`,将 `# - "6199:6199"` 修改为 `- "6199:6199"`,移除开头的 `#` +4. 在 `astrbot.yml` 文件所在目录执行: + +```bash +NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose -f ./astrbot.yml up -d +``` + +部署完毕之后,可以去 Napcat 的 WebUI(默认端口 6099)中新增 OneBot 连接实例:点击`网络配置->新建->WebSockets客户端`,在新弹出的窗口中:勾选`启用`, +URL 填写 `ws://宿主机IP:端口/ws`。如 `ws://127.0.0.1:6199/ws`。如果采用上面的 Docker Compose 部署,可以填写 `ws://astrbot:6199/ws`(参考本文档的 Docker 脚本)。心跳间隔和重连间隔可以改为 `1000`(1 秒)。点击保存,然后去 AstrBot WebUI 的控制台中检查是否连接成功,出现 `aiocqhttp(OneBot v11) 适配器已连接` 日志即代表成功。 + +如果您对部署、网络配置不了解,请千万不要在公网暴露 Napcat 的端口。 \ No newline at end of file From 718215425d1ec02e5f53e7796777f1f19fc19284 Mon Sep 17 00:00:00 2001 From: shuiping233 <49360196+shuiping233@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:58:40 +0800 Subject: [PATCH 3/7] refactor: downgrade StrEnum to (str, Enum) in kook_type for backward compatibility (#6512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 我那时候搓 #5719 的时候 #5729 已经合并了, 既然ruff的py限制版本里是`3.12`,那我那时候干脆用的StrEnum,现在发现那个pr revert了,那我也降级回旧Enum写法好了 --- astrbot/core/platform/sources/kook/kook_types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_types.py b/astrbot/core/platform/sources/kook/kook_types.py index 7256fbbd4..5efaf2a14 100644 --- a/astrbot/core/platform/sources/kook/kook_types.py +++ b/astrbot/core/platform/sources/kook/kook_types.py @@ -1,5 +1,5 @@ import json -from enum import IntEnum, StrEnum +from enum import Enum, IntEnum from typing import Annotated, Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -36,7 +36,7 @@ class KookMessageType(IntEnum): SYSTEM = 255 -class KookModuleType(StrEnum): +class KookModuleType(str, Enum): PLAIN_TEXT = "plain-text" KMARKDOWN = "kmarkdown" IMAGE = "image" @@ -311,7 +311,7 @@ class KookMessageSignal(IntEnum): """server->client resume ack""" -class KookChannelType(StrEnum): +class KookChannelType(str, Enum): GROUP = "GROUP" PERSON = "PERSON" BROADCAST = "BROADCAST" From 51c8d22d05f37acd2e7c192feaaa02beb999e823 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:23:59 +0800 Subject: [PATCH 4/7] feat: install plugin using metadata name and validate importable identifiers (#6530) * feat: install plugin using metadata name and validate importable identifiers * fix: cleanup temporary upload extraction directory on plugin install failure * Update astrbot/core/star/star_manager.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix: avoid unnecessary install when repository directory already exists --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- astrbot/core/star/star_manager.py | 125 ++++++++++++++++++++---------- 1 file changed, 82 insertions(+), 43 deletions(-) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 57be1e9a9..25df73f64 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -5,6 +5,7 @@ import contextlib import functools import inspect import json +import keyword import logging import os import sys @@ -421,6 +422,43 @@ class PluginManager: return metadata + @staticmethod + def _normalize_plugin_dir_name(plugin_name: str) -> str: + return plugin_name.strip() + + @staticmethod + def _validate_importable_name(plugin_name: str) -> None: + if "/" in plugin_name or "\\" in plugin_name: + raise ValueError( + "metadata.yaml 中 name 含有路径分隔符,不可用于 importlib 加载。" + ) + if not plugin_name.isidentifier() or keyword.iskeyword(plugin_name): + raise Exception( + "metadata.yaml 中 name 不是合法的模块名称(应为合法 Python 标识符且非关键字)。" + ) + + @staticmethod + def _get_plugin_dir_name_from_metadata(plugin_path: str) -> str: + metadata_path = os.path.join(plugin_path, "metadata.yaml") + if not os.path.exists(metadata_path): + raise Exception("未找到 metadata.yaml,无法获取插件目录名。") + + with open(metadata_path, encoding="utf-8") as f: + metadata = yaml.safe_load(f) + + if not isinstance(metadata, dict): + raise Exception("metadata.yaml 格式错误。") + + plugin_name = metadata.get("name") + if not isinstance(plugin_name, str) or not plugin_name.strip(): + raise Exception("metadata.yaml 中缺少 name 字段。") + + plugin_dir_name = PluginManager._normalize_plugin_dir_name(plugin_name) + if not plugin_dir_name: + raise Exception("metadata.yaml 中 name 字段内容非法。") + PluginManager._validate_importable_name(plugin_dir_name) + return plugin_dir_name + @staticmethod def _validate_astrbot_version_specifier( version_spec: str | None, @@ -1201,10 +1239,30 @@ class PluginManager: plugin_path = "" dir_name = "" try: + _, repo_name, _ = self.updator.parse_github_url(repo_url) + repo_name = self.updator.format_name(repo_name) + plugin_path = os.path.join(self.plugin_store_path, repo_name) + if os.path.exists(plugin_path): + raise Exception( + f"安装失败:目录 {os.path.basename(plugin_path)} 已存在。" + ) plugin_path = await self.updator.install(repo_url, proxy) # reload the plugin dir_name = os.path.basename(plugin_path) + metadata_dir_name = self._get_plugin_dir_name_from_metadata(plugin_path) + target_plugin_path = os.path.join( + self.plugin_store_path, + metadata_dir_name, + ) + if target_plugin_path != plugin_path and os.path.exists( + target_plugin_path + ): + raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。") + if target_plugin_path != plugin_path: + os.rename(plugin_path, target_plugin_path) + plugin_path = target_plugin_path + dir_name = metadata_dir_name await self._ensure_plugin_requirements( plugin_path, dir_name, @@ -1573,52 +1631,25 @@ class PluginManager: async def install_plugin_from_file( self, zip_file_path: str, ignore_version_check: bool = False ): - dir_name = os.path.basename(zip_file_path).replace(".zip", "") - dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower() - desti_dir = os.path.join(self.plugin_store_path, dir_name) - - # 第一步:检查是否已安装同目录名的插件,先终止旧插件 - existing_plugin = None - for star in self.context.get_all_stars(): - if star.root_dir_name == dir_name: - existing_plugin = star - break - - if existing_plugin: - logger.info(f"检测到插件 {existing_plugin.name} 已安装,正在终止旧插件...") - try: - await self._terminate_plugin(existing_plugin) - except Exception: - logger.warning(traceback.format_exc()) - if existing_plugin.name and existing_plugin.module_path: - await self._unbind_plugin( - existing_plugin.name, existing_plugin.module_path - ) + dir_name = os.path.splitext(os.path.basename(zip_file_path))[0] + desti_dir = tempfile.mkdtemp( + dir=self.plugin_store_path, prefix="plugin_upload_" + ) + temp_desti_dir = desti_dir try: self.updator.unzip_file(zip_file_path, desti_dir) - - # 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件 - try: - new_metadata = self._load_plugin_metadata(desti_dir) - if new_metadata and new_metadata.name: - for star in self.context.get_all_stars(): - if ( - star.name == new_metadata.name - and star.root_dir_name != dir_name - ): - logger.warning( - f"检测到同名插件 {star.name} 存在于不同目录 {star.root_dir_name},正在终止..." - ) - try: - await self._terminate_plugin(star) - except Exception: - logger.warning(traceback.format_exc()) - if star.name and star.module_path: - await self._unbind_plugin(star.name, star.module_path) - break # 只处理第一个匹配的 - except Exception as e: - logger.debug(f"读取新插件 metadata.yaml 失败,跳过同名检查: {e!s}") + metadata_dir_name = self._get_plugin_dir_name_from_metadata(desti_dir) + target_plugin_path = os.path.join( + self.plugin_store_path, + metadata_dir_name, + ) + if target_plugin_path != desti_dir and os.path.exists(target_plugin_path): + raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。") + if target_plugin_path != desti_dir: + os.rename(desti_dir, target_plugin_path) + dir_name = metadata_dir_name + desti_dir = target_plugin_path # remove the zip try: @@ -1686,3 +1717,11 @@ class PluginManager: f"安装插件 {dir_name} 失败,插件安装目录:{desti_dir}", ) raise + finally: + if temp_desti_dir != desti_dir and os.path.isdir(temp_desti_dir): + try: + remove_dir(temp_desti_dir) + except Exception as e: + logger.warning( + f"清理临时插件解压目录失败: {temp_desti_dir},原因: {e!s}", + ) From c22e1f6a4d3928784b66560109de35481e1e57a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B8=A6=E7=BE=BD?= Date: Tue, 17 Mar 2026 23:35:16 +0800 Subject: [PATCH 5/7] fix: restrict workflows to upstream repo (#6531) --- .github/workflows/coverage_test.yml | 1 + .github/workflows/dashboard_ci.yml | 1 + .github/workflows/docker-image.yml | 4 ++-- .github/workflows/pr-title-check.yml | 1 + .github/workflows/release.yml | 3 +++ .github/workflows/stale.yml | 1 + .github/workflows/sync-wiki.yml | 1 + 7 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage_test.yml b/.github/workflows/coverage_test.yml index f0019ee7e..bbbf1e403 100644 --- a/.github/workflows/coverage_test.yml +++ b/.github/workflows/coverage_test.yml @@ -40,6 +40,7 @@ jobs: pytest --cov=astrbot -v -o log_cli=true -o log_level=DEBUG - name: Upload results to Codecov + if: github.repository == 'AstrBotDevs/AstrBot' uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index b6cd9aa2c..916f43f97 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -8,6 +8,7 @@ on: jobs: build: + if: github.repository == 'AstrBotDevs/AstrBot' runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index ccf560435..f80e8588f 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -11,7 +11,7 @@ on: jobs: build-nightly-image: - if: github.event_name == 'schedule' + if: github.repository == 'AstrBotDevs/AstrBot' && github.event_name == 'schedule' runs-on: ubuntu-latest env: DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -109,7 +109,7 @@ jobs: run: echo "Test Docker image has been built and pushed successfully" build-release-image: - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) + if: github.repository == 'AstrBotDevs/AstrBot' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))) runs-on: ubuntu-latest env: DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index ff7e79e61..d5faf88a5 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -6,6 +6,7 @@ on: jobs: title-format: + if: github.repository == 'AstrBotDevs/AstrBot' runs-on: ubuntu-latest permissions: pull-requests: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0cfe18261..09f3c7e27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,7 @@ permissions: jobs: build-dashboard: name: Build Dashboard + if: github.repository == 'AstrBotDevs/AstrBot' runs-on: ubuntu-24.04 env: R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} @@ -104,6 +105,7 @@ jobs: publish-release: name: Publish GitHub Release + if: github.repository == 'AstrBotDevs/AstrBot' runs-on: ubuntu-24.04 needs: - build-dashboard @@ -183,6 +185,7 @@ jobs: publish-pypi: name: Publish PyPI + if: github.repository == 'AstrBotDevs/AstrBot' runs-on: ubuntu-24.04 needs: - publish-release diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c6c41a890..c0ca4978a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,6 +18,7 @@ on: jobs: stale: + if: github.repository == 'AstrBotDevs/AstrBot' runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/sync-wiki.yml b/.github/workflows/sync-wiki.yml index 2fe0d3153..78052c2ed 100644 --- a/.github/workflows/sync-wiki.yml +++ b/.github/workflows/sync-wiki.yml @@ -18,6 +18,7 @@ concurrency: jobs: sync: + if: github.repository == 'AstrBotDevs/AstrBot' runs-on: ubuntu-latest permissions: contents: read From 412e2e07cc263a2519a384d5031d2c0a87a9c49a Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:34:48 +0800 Subject: [PATCH 6/7] Clarify FileUpload/DownloadTool descriptions to fix LLM tool selection (#6527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple models (Gemini 3, GPT-5.2, Claude Sonnet, Kimi K2.5) consistently pick FileDownloadTool when they should pick FileUploadTool. The old descriptions used "upload/download" which is ambiguous from the LLM's perspective — it doesn't know which side is "local" vs "remote". Rewrite descriptions to use explicit directional language: - Upload: "Transfer FROM host INTO sandbox" + "when user sends a file" - Download: "Transfer FROM sandbox OUT to host" + "ONLY when user asks to retrieve/export" Also improve parameter descriptions with the same directional clarity. Fixes #6497 Co-authored-by: Yufeng He <40085740+universeplayer@users.noreply.github.com> --- astrbot/core/computer/tools/fs.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py index 31b7f3f51..f2a698f76 100644 --- a/astrbot/core/computer/tools/fs.py +++ b/astrbot/core/computer/tools/fs.py @@ -80,14 +80,19 @@ from .permissions import check_admin_permission @dataclass class FileUploadTool(FunctionTool): name: str = "astrbot_upload_file" - description: str = "Upload a local file to the sandbox. The file must exist on the local filesystem." + description: str = ( + "Transfer a file FROM the host machine INTO the sandbox so that sandbox " + "code can access it. Use this when the user sends/attaches a file and you " + "need to process it inside the sandbox. The local_path must point to an " + "existing file on the host filesystem." + ) parameters: dict = field( default_factory=lambda: { "type": "object", "properties": { "local_path": { "type": "string", - "description": "The local file path to upload. This must be an absolute path to an existing file on the local filesystem.", + "description": "Absolute path to the file on the host filesystem that will be copied into the sandbox.", }, # "remote_path": { # "type": "string", @@ -140,14 +145,18 @@ class FileUploadTool(FunctionTool): @dataclass class FileDownloadTool(FunctionTool): name: str = "astrbot_download_file" - description: str = "Download a file from the sandbox. Only call this when user explicitly need you to download a file." + description: str = ( + "Transfer a file FROM the sandbox OUT to the host and optionally send it " + "to the user. Use this ONLY when the user asks to retrieve/export a file " + "that was created or modified inside the sandbox." + ) parameters: dict = field( default_factory=lambda: { "type": "object", "properties": { "remote_path": { "type": "string", - "description": "The path of the file in the sandbox to download.", + "description": "Path of the file inside the sandbox to copy out to the host.", }, "also_send_to_user": { "type": "boolean", From 4e5587998be09ad3871ce54fffa60e0d33af0e47 Mon Sep 17 00:00:00 2001 From: camera-2018 <40380042+camera-2018@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:32:19 +0800 Subject: [PATCH 7/7] perf(dashboard): subset MDI icon font and self-host Google Fonts (#6532) * perf(dashboard): subset MDI icon font and self-host Google Fonts * perf(dashboard): subset MDI icon font and self-host Google Fonts * perf(dashboard): subset MDI icon font and self-host Google Fonts * perf(dashboard): subset MDI icon font cr fix * chore: update lockfile --- dashboard/package.json | 6 +- dashboard/pnpm-lock.yaml | 176 ++- dashboard/scripts/subset-mdi-font.mjs | 291 ++++ .../mdi-subset/materialdesignicons-subset.css | 1193 +++++++++++++++++ .../materialdesignicons-webfont-subset.woff | Bin 0 -> 16172 bytes .../materialdesignicons-webfont-subset.woff2 | Bin 0 -> 12996 bytes dashboard/src/plugins/vuetify.ts | 2 +- dashboard/tests/subsetMdiFont.test.mjs | 250 ++++ dashboard/vite.config.ts | 23 +- 9 files changed, 1914 insertions(+), 27 deletions(-) create mode 100644 dashboard/scripts/subset-mdi-font.mjs create mode 100644 dashboard/src/assets/mdi-subset/materialdesignicons-subset.css create mode 100644 dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff create mode 100644 dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 create mode 100644 dashboard/tests/subsetMdiFont.test.mjs diff --git a/dashboard/package.json b/dashboard/package.json index 9d6b7f866..d8ce1c7af 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -5,6 +5,7 @@ "author": "CodedThemes", "scripts": { "dev": "vite --host", + "subset-icons": "node scripts/subset-mdi-font.mjs", "build": "vue-tsc --noEmit && vite build", "build-stage": "vue-tsc --noEmit && vite build --base=/vue/free/stage/", "build-prod": "vue-tsc --noEmit && vite build --base=/vue/free/", @@ -33,7 +34,6 @@ "monaco-editor": "^0.52.2", "pinia": "2.1.6", "pinyin-pro": "^3.26.0", - "remixicon": "3.5.0", "shiki": "^3.20.0", "stream-markdown": "^0.0.13", "vee-validate": "4.11.3", @@ -62,8 +62,10 @@ "prettier": "3.0.2", "sass": "1.66.1", "sass-loader": "13.3.2", + "subset-font": "^2.4.0", "typescript": "5.1.6", "vite": "6.4.1", + "vite-plugin-webfont-dl": "^3.12.0", "vue-cli-plugin-vuetify": "2.5.8", "vue-tsc": "1.8.8", "vuetify-loader": "^2.0.0-alpha.9" @@ -74,4 +76,4 @@ "lodash-es": "4.17.23" } } -} +} \ No newline at end of file diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 74f3eb9b3..a3926a953 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -72,9 +72,6 @@ importers: pinyin-pro: specifier: ^3.26.0 version: 3.28.0 - remixicon: - specifier: 3.5.0 - version: 3.5.0 shiki: specifier: ^3.20.0 version: 3.22.0 @@ -154,12 +151,18 @@ importers: sass-loader: specifier: 13.3.2 version: 13.3.2(sass@1.66.1)(webpack@5.105.0) + subset-font: + specifier: ^2.4.0 + version: 2.4.0 typescript: specifier: 5.1.6 version: 5.1.6 vite: specifier: 6.4.1 version: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0) + vite-plugin-webfont-dl: + specifier: ^3.12.0 + version: 3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)) vue-cli-plugin-vuetify: specifier: 2.5.8 version: 2.5.8(sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0) @@ -199,6 +202,12 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@cacheable/memory@2.0.8': + resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} + + '@cacheable/utils@2.4.0': + resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} + '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} @@ -454,6 +463,15 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@mdi/font@7.2.96': resolution: {integrity: sha512-e//lmkmpFUMZKhmCY9zdjRe4zNXfbOIJnn6xveHbaV2kSw5aJ5dLXUxcRt1Gxfi7ZYpFLUWlkG2MGSFAiqAu7w==} @@ -519,79 +537,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1258,6 +1263,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + cacheable@2.3.3: + resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1304,6 +1312,10 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1760,6 +1772,9 @@ packages: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} + flat-cache@6.1.20: + resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -1772,6 +1787,9 @@ packages: debug: optional: true + fontverter@2.0.0: + resolution: {integrity: sha512-DFVX5hvXuhi1Jven1tbpebYTCT9XYnvx6/Z+HFUPb7ZRMCW+pj2clU9VMhoTPgWKPhAs7JJDSk3CW1jNUvKCZQ==} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -1831,6 +1849,9 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + harfbuzzjs@0.4.15: + resolution: {integrity: sha512-p1edvnlc+vpRe2kz7OKzcscf0gyFiDZpco+miDxAiiZ67cu1oNlbuOkmP/A/i1l/w938VrkF2FdZ8scNcnkPrQ==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1843,6 +1864,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hashery@1.5.0: + resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} + engines: {node: '>=20'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1861,6 +1886,9 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -1970,6 +1998,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} @@ -2218,6 +2249,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2385,6 +2419,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qified@0.6.0: + resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} + engines: {node: '>=20'} + querystring@0.2.1: resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==} engines: {node: '>=0.4.x'} @@ -2410,9 +2448,6 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} - remixicon@3.5.0: - resolution: {integrity: sha512-wNzWGKf4frb3tEmgvP5shry0n1OdTjjEk9RHLuRuAhfA50bvEdpKH1XWNUYrHUSjAQQkkdyIm+lf4mOuysIKTQ==} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2564,6 +2599,9 @@ packages: stylis@4.3.6: resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + subset-font@2.4.0: + resolution: {integrity: sha512-DA/45nIj4NiseVdfHxVdVGL7hvNo3Ol6HjEm3KSYtPyDcsr6jh8Q37vSgz+A722wMfUd6nL8kgsi7uGv9DExXQ==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2760,6 +2798,11 @@ packages: vue: ^3.0.0 vuetify: '>=3' + vite-plugin-webfont-dl@3.12.0: + resolution: {integrity: sha512-0jxsr8ycuoK/uV5Y3ytttTRhgvfZo8v3O4JZBlVc4C7QWIws/vCLVR4B3ag+TGVkLNQya6hXfY3UnZge3M8vmA==} + peerDependencies: + vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2914,6 +2957,10 @@ packages: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} + wawoff2@2.0.1: + resolution: {integrity: sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==} + hasBin: true + webpack-sources@3.3.4: resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} engines: {node: '>=10.13.0'} @@ -2933,6 +2980,9 @@ packages: engines: {node: '>= 8'} hasBin: true + woff2sfnt-sfnt2woff@1.0.0: + resolution: {integrity: sha512-edK4COc1c1EpRfMqCZO1xJOvdUtM5dbVb9iz97rScvnTevqEB3GllnLWCmMVp1MfQBdF1DFg/11I0rSyAdS4qQ==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2978,6 +3028,18 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@cacheable/memory@2.0.8': + dependencies: + '@cacheable/utils': 2.4.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.4.0': + dependencies: + hashery: 1.5.0 + keyv: 5.6.0 + '@chevrotain/cst-dts-gen@11.0.3': dependencies: '@chevrotain/gast': 11.0.3 @@ -3165,6 +3227,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + '@mdi/font@7.2.96': {} '@mermaid-js/parser@0.6.3': @@ -4067,6 +4137,14 @@ snapshots: buffer-from@1.1.2: {} + cacheable@2.3.3: + dependencies: + '@cacheable/memory': 2.0.8 + '@cacheable/utils': 2.4.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.6.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4119,6 +4197,10 @@ snapshots: chrome-trace-event@1.0.4: {} + clean-css@5.3.3: + dependencies: + source-map: 0.6.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4627,10 +4709,21 @@ snapshots: keyv: 4.5.4 rimraf: 3.0.2 + flat-cache@6.1.20: + dependencies: + cacheable: 2.3.3 + flatted: 3.3.3 + hookified: 1.15.1 + flatted@3.3.3: {} follow-redirects@1.15.11: {} + fontverter@2.0.0: + dependencies: + wawoff2: 2.0.1 + woff2sfnt-sfnt2woff: 1.0.0 + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -4704,6 +4797,8 @@ snapshots: hachure-fill@0.5.2: {} + harfbuzzjs@0.4.15: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -4712,6 +4807,10 @@ snapshots: dependencies: has-symbols: 1.1.0 + hashery@1.5.0: + dependencies: + hookified: 1.15.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -4738,6 +4837,8 @@ snapshots: highlight.js@11.11.1: {} + hookified@1.15.1: {} + html-void-elements@3.0.0: {} iconv-lite@0.6.3: @@ -4822,6 +4923,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + khroma@2.1.0: {} langium@3.3.1: @@ -5078,6 +5183,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5263,6 +5370,10 @@ snapshots: punycode@2.3.1: {} + qified@0.6.0: + dependencies: + hookified: 1.15.1 + querystring@0.2.1: {} queue-microtask@1.2.3: {} @@ -5285,8 +5396,6 @@ snapshots: dependencies: regex-utilities: 2.3.0 - remixicon@3.5.0: {} - require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -5457,6 +5566,13 @@ snapshots: stylis@4.3.6: {} + subset-font@2.4.0: + dependencies: + fontverter: 2.0.0 + harfbuzzjs: 0.4.15 + lodash: 4.17.23 + p-limit: 3.1.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -5635,6 +5751,16 @@ snapshots: transitivePeerDependencies: - supports-color + vite-plugin-webfont-dl@3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)): + dependencies: + axios: 1.13.5 + clean-css: 5.3.3 + flat-cache: 6.1.20 + picocolors: 1.1.1 + vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0) + transitivePeerDependencies: + - debug + vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0): dependencies: esbuild: 0.25.12 @@ -5765,6 +5891,10 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + wawoff2@2.0.1: + dependencies: + argparse: 2.0.1 + webpack-sources@3.3.4: {} webpack@5.105.0: @@ -5803,6 +5933,10 @@ snapshots: dependencies: isexe: 2.0.0 + woff2sfnt-sfnt2woff@1.0.0: + dependencies: + pako: 1.0.11 + word-wrap@1.2.5: {} wrappy@1.0.2: {} diff --git a/dashboard/scripts/subset-mdi-font.mjs b/dashboard/scripts/subset-mdi-font.mjs new file mode 100644 index 000000000..ee8ca831f --- /dev/null +++ b/dashboard/scripts/subset-mdi-font.mjs @@ -0,0 +1,291 @@ +/** + * subset-mdi-font.mjs + * + * Build script that: + * 1. Scans src/ for all mdi-* icon names used in .vue/.ts files + * 2. Resolves their Unicode codepoints from @mdi/font CSS + * 3. Subsets the MDI font to include only those glyphs (via subset-font, pure JS) + * 4. Generates a minimal CSS file with only the needed icon classes + * 5. Outputs to src/assets/mdi-subset/ + * + * Fallback: if any step fails, copies the original full @mdi/font CSS and fonts + * so the build never breaks. + */ +import { readFileSync, writeFileSync, copyFileSync, readdirSync, statSync, existsSync, mkdirSync } from "fs"; +import { join, resolve, extname } from "path"; +import { fileURLToPath } from "url"; + +// Derive __dirname portably from import.meta.url (works across all Node ESM versions) +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const ROOT = resolve(__dirname, ".."); +const SRC = join(ROOT, "src"); +const MDI_CSS_PATH = join(ROOT, "node_modules/@mdi/font/css/materialdesignicons.css"); +const MDI_TTF_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"); +const MDI_WOFF2_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"); +const MDI_WOFF_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"); +const OUT_DIR = join(ROOT, "src/assets/mdi-subset"); + +// Utility classes that should not be treated as icon names +const UTILITY_CLASSES = new Set([ + "mdi-set", "mdi-spin", "mdi-rotate-45", "mdi-rotate-90", "mdi-rotate-135", + "mdi-rotate-180", "mdi-rotate-225", "mdi-rotate-270", "mdi-rotate-315", + "mdi-flip-h", "mdi-flip-v", "mdi-light", "mdi-dark", "mdi-inactive", + "mdi-18px", "mdi-24px", "mdi-36px", "mdi-48px", +]); + +// Regex to match individual icon class definitions in MDI CSS +export const ICON_CLASS_PATTERN = /\.(mdi-[a-z][a-z0-9-]*)::before\s*\{\s*content:\s*"\\([0-9A-Fa-f]+)"\s*;?\s*}/g; + +// ── Helper functions ──────────────────────────────────────────────────────── + +/** Recursively collect files with given extensions, skipping node_modules. */ +export function* collectFiles(dir, exts) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory() && entry.name !== "node_modules") { + yield* collectFiles(full, exts); + } else if (exts.includes(extname(entry.name))) { + yield full; + } + } +} + +/** Scan source files and return a Set of used mdi-* icon names. */ +export function scanUsedIcons(sourceFiles) { + const iconPattern = /mdi-[a-z][a-z0-9-]*/g; + const usedIcons = new Set(); + for (const file of sourceFiles) { + const content = readFileSync(file, "utf-8"); + for (const match of content.matchAll(iconPattern)) { + if (!UTILITY_CLASSES.has(match[0])) { + usedIcons.add(match[0]); + } + } + } + return usedIcons; +} + +/** Parse @mdi/font CSS and return a Map of icon-name → hex codepoint. */ +export function parseIconCodepoints(mdiCSS) { + const iconMap = new Map(); + for (const match of mdiCSS.matchAll(ICON_CLASS_PATTERN)) { + iconMap.set(match[1], match[2]); + } + return iconMap; +} + +/** Resolve used icons against the codepoint map, returning resolved/missing/subsetChars. */ +export function resolveUsedIcons(usedIcons, iconMap) { + const resolvedIcons = []; + const missingIcons = []; + const subsetChars = []; + for (const icon of usedIcons) { + const cp = iconMap.get(icon); + if (cp) { + resolvedIcons.push(icon); + subsetChars.push(String.fromCodePoint(parseInt(cp, 16))); + } else { + missingIcons.push(icon); + } + } + return { resolvedIcons, missingIcons, subsetChars }; +} + +/** + * Extract utility CSS rules (size, rotation, flip, spin, etc.) from the original MDI CSS. + * Uses a subtraction approach: removes the parts we regenerate (icon definitions, + * @font-face, base .mdi rules) and keeps everything else. This is more robust than + * relying on a fixed start marker, as it tolerates CSS reordering in future versions. + */ +export function extractUtilityCss(mdiCSS, iconClassPattern) { + let utilityCss = mdiCSS + .replace(iconClassPattern, "") // Remove icon definitions + .replace(/@font-face\s*\{[\s\S]*?}/g, "") // Remove @font-face + .replace(/\.mdi:before,\s*\.mdi-set\s*\{[\s\S]*?}/g, "") // Remove base rules + .replace(/\/\*# sourceMappingURL=.*\*\//, "") // Remove source map + .trim(); + + // Clean up excess blank lines left after removals + utilityCss = utilityCss.replace(/(\r\n|\n){3,}/g, "\n\n"); + + return utilityCss; +} + +/** Build a fallback CSS that rewrites font URLs to use subset filenames. */ +function buildFallbackCss() { + const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8"); + return mdiCSS + // Rewrite woff/woff2 URLs to point at subset filenames + .replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(woff2?)\?[^"]*"\)/g, + (_, ext) => `url("./materialdesignicons-webfont-subset.${ext}")`) + // Remove legacy eot/ttf sources + .replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(eot|ttf)\?[^"]*"\)[^,]*/g, "") + // Clean up dangling commas/separators + .replace(/src:\s*,/g, "src:") + .replace(/,\s*;/g, ";"); +} + +// ── Fallback: copy original full MDI font if subsetting fails ─────────────── +function fallbackToFullFont(reason) { + console.warn(`\n⚠️ Subsetting failed: ${reason}`); + console.warn(`⚠️ Falling back to full @mdi/font (build will not break)\n`); + + // Copy original font files + if (existsSync(MDI_WOFF2_PATH)) { + copyFileSync(MDI_WOFF2_PATH, join(OUT_DIR, "materialdesignicons-webfont-subset.woff2")); + } + if (existsSync(MDI_WOFF_PATH)) { + copyFileSync(MDI_WOFF_PATH, join(OUT_DIR, "materialdesignicons-webfont-subset.woff")); + } + + writeFileSync(join(OUT_DIR, "materialdesignicons-subset.css"), buildFallbackCss()); + + const size = existsSync(MDI_WOFF2_PATH) ? statSync(MDI_WOFF2_PATH).size : 0; + console.warn(`⚠️ Fallback complete: using full font (${(size / 1024).toFixed(1)} KB woff2)`); +} + +// ── Exported entry point ──────────────────────────────────────────────────── + +export async function runMdiSubset() { + mkdirSync(OUT_DIR, { recursive: true }); + + try { + // Pre-checks + if (!existsSync(MDI_CSS_PATH)) { + throw new Error(`@mdi/font CSS not found at ${MDI_CSS_PATH}. Run 'pnpm install' first.`); + } + if (!existsSync(MDI_TTF_PATH)) { + throw new Error(`@mdi/font TTF not found at ${MDI_TTF_PATH}. Run 'pnpm install' first.`); + } + + // Dynamic import subset-font (may not be installed in all environments) + let subsetFont; + try { + subsetFont = (await import("subset-font")).default; + } catch (e) { + throw new Error(`subset-font package not available: ${e.message}. Run 'pnpm install' first.`); + } + + // Step 1: Scan source files for mdi-* icon names + const sourceFiles = collectFiles(SRC, [".vue", ".ts", ".js"]); + const usedIcons = scanUsedIcons(sourceFiles); + if (usedIcons.size === 0) { + throw new Error("No mdi-* icons found in source files. Something is wrong with scanning."); + } + console.log(`✅ Found ${usedIcons.size} unique mdi-* icons in source files`); + + // Step 2: Parse @mdi/font CSS to get codepoints for each icon + const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8"); + const iconMap = parseIconCodepoints(mdiCSS); + if (iconMap.size === 0) { + throw new Error("Could not parse any icon definitions from @mdi/font CSS. Format may have changed."); + } + console.log(`📦 MDI font CSS contains ${iconMap.size} icon definitions`); + + // Step 3: Resolve codepoints for used icons + const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap); + if (missingIcons.length > 0) { + console.warn(`⚠️ ${missingIcons.length} icons not found in MDI CSS:`, missingIcons.join(", ")); + } + if (resolvedIcons.length === 0) { + throw new Error("No icon codepoints could be resolved. Icon name format may have changed."); + } + console.log(`🔍 Resolved ${resolvedIcons.length} codepoints for subsetting`); + + // Add space character + subsetChars.push(" "); + const subsetText = subsetChars.join(""); + + // Step 4: Subset font with subset-font (pure JS/WASM) + const fontBuffer = readFileSync(MDI_TTF_PATH); + + console.log(`🔧 Subsetting font to woff2...`); + const woff2Buffer = await subsetFont(fontBuffer, subsetText, { + targetFormat: "woff2", + }); + + console.log(`🔧 Subsetting font to woff...`); + const woffBuffer = await subsetFont(fontBuffer, subsetText, { + targetFormat: "woff", + }); + + if (woff2Buffer.length === 0 || woffBuffer.length === 0) { + throw new Error("subset-font produced empty output. Font file may be corrupted."); + } + + const outWoff2 = join(OUT_DIR, "materialdesignicons-webfont-subset.woff2"); + const outWoff = join(OUT_DIR, "materialdesignicons-webfont-subset.woff"); + writeFileSync(outWoff2, woff2Buffer); + writeFileSync(outWoff, woffBuffer); + + // Step 5: Generate subset CSS + let css = `/* Auto-generated MDI subset – ${resolvedIcons.length} icons */ +/* Do not edit manually. Run: pnpm run subset-icons */ + +@font-face { + font-family: "Material Design Icons"; + src: url("./materialdesignicons-webfont-subset.woff2") format("woff2"), + url("./materialdesignicons-webfont-subset.woff") format("woff"); + font-weight: normal; + font-style: normal; +} + +.mdi:before, +.mdi-set { + display: inline-block; + font: normal normal normal 24px/1 "Material Design Icons"; + font-size: inherit; + text-rendering: auto; + line-height: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +`; + + for (const icon of resolvedIcons.sort()) { + const cp = iconMap.get(icon); + css += `.${icon}::before {\n content: "\\${cp}";\n}\n\n`; + } + + const utilityCss = extractUtilityCss(mdiCSS, ICON_CLASS_PATTERN); + if (utilityCss) { + css += `/* Utility classes (extracted from @mdi/font) */\n${utilityCss}\n`; + } else { + console.warn("⚠️ Could not find MDI utility classes in original CSS, skipping"); + } + + const outCSS = join(OUT_DIR, "materialdesignicons-subset.css"); + writeFileSync(outCSS, css); + + // Report + const origSize = statSync(MDI_TTF_PATH).size; + const subsetWoff2Size = woff2Buffer.length; + console.log(`\n📊 Results:`); + console.log(` Original TTF font: ${(origSize / 1024).toFixed(1)} KB`); + console.log(` Subset WOFF2: ${(subsetWoff2Size / 1024).toFixed(1)} KB`); + console.log(` Reduction: ${((1 - subsetWoff2Size / origSize) * 100).toFixed(1)}%`); + console.log(` Icons included: ${resolvedIcons.length}`); + console.log(` CSS file: ${outCSS}`); + console.log(`\n✅ MDI font subset generated successfully!`); + + } catch (err) { + // Fallback: any failure → use original full font so build never breaks + try { + fallbackToFullFont(err.message); + } catch (fallbackErr) { + console.error(`❌ Fallback also failed: ${fallbackErr.message}`); + console.error(`❌ Please ensure @mdi/font is installed: pnpm install`); + throw fallbackErr; + } + } +} + +// ── CLI entry point: allows running directly via `node scripts/subset-mdi-font.mjs` ── + +if (import.meta.url.startsWith('file:') && process.argv[1] === fileURLToPath(import.meta.url)) { + runMdiSubset().catch(err => { + console.error(err); + process.exit(1); + }); +} diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css new file mode 100644 index 000000000..7f734b049 --- /dev/null +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -0,0 +1,1193 @@ +/* Auto-generated MDI subset – 229 icons */ +/* Do not edit manually. Run: pnpm run subset-icons */ + +@font-face { + font-family: "Material Design Icons"; + src: url("./materialdesignicons-webfont-subset.woff2") format("woff2"), + url("./materialdesignicons-webfont-subset.woff") format("woff"); + font-weight: normal; + font-style: normal; +} + +.mdi:before, +.mdi-set { + display: inline-block; + font: normal normal normal 24px/1 "Material Design Icons"; + font-size: inherit; + text-rendering: auto; + line-height: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.mdi-account::before { + content: "\F0004"; +} + +.mdi-account-circle::before { + content: "\F0009"; +} + +.mdi-account-edit-outline::before { + content: "\F0FFB"; +} + +.mdi-account-heart::before { + content: "\F0899"; +} + +.mdi-account-voice::before { + content: "\F05CB"; +} + +.mdi-alert::before { + content: "\F0026"; +} + +.mdi-alert-circle::before { + content: "\F0028"; +} + +.mdi-alert-circle-outline::before { + content: "\F05D6"; +} + +.mdi-alert-outline::before { + content: "\F002A"; +} + +.mdi-api-off::before { + content: "\F1257"; +} + +.mdi-arrow-down::before { + content: "\F0045"; +} + +.mdi-arrow-down-thin::before { + content: "\F19B3"; +} + +.mdi-arrow-left::before { + content: "\F004D"; +} + +.mdi-arrow-right::before { + content: "\F0054"; +} + +.mdi-arrow-top-right-thick::before { + content: "\F09C6"; +} + +.mdi-arrow-up::before { + content: "\F005D"; +} + +.mdi-arrow-up-bold::before { + content: "\F0737"; +} + +.mdi-arrow-up-circle::before { + content: "\F0CE1"; +} + +.mdi-arrow-up-thin::before { + content: "\F19B2"; +} + +.mdi-backup-restore::before { + content: "\F006F"; +} + +.mdi-book-open-page-variant::before { + content: "\F05DA"; +} + +.mdi-book-open-variant::before { + content: "\F14F7"; +} + +.mdi-brain::before { + content: "\F09D1"; +} + +.mdi-bug::before { + content: "\F00E4"; +} + +.mdi-calendar::before { + content: "\F00ED"; +} + +.mdi-calendar-edit::before { + content: "\F08A7"; +} + +.mdi-calendar-plus::before { + content: "\F00F3"; +} + +.mdi-calendar-range::before { + content: "\F0679"; +} + +.mdi-chat::before { + content: "\F0B79"; +} + +.mdi-chat-processing::before { + content: "\F0B7B"; +} + +.mdi-chat-remove::before { + content: "\F1411"; +} + +.mdi-check::before { + content: "\F012C"; +} + +.mdi-check-all::before { + content: "\F012D"; +} + +.mdi-check-circle::before { + content: "\F05E0"; +} + +.mdi-check-circle-outline::before { + content: "\F05E1"; +} + +.mdi-checkbox-blank-outline::before { + content: "\F0131"; +} + +.mdi-checkbox-marked::before { + content: "\F0132"; +} + +.mdi-checkbox-multiple-marked-outline::before { + content: "\F0139"; +} + +.mdi-chevron-double-left::before { + content: "\F013D"; +} + +.mdi-chevron-double-right::before { + content: "\F013E"; +} + +.mdi-chevron-down::before { + content: "\F0140"; +} + +.mdi-chevron-left::before { + content: "\F0141"; +} + +.mdi-chevron-right::before { + content: "\F0142"; +} + +.mdi-chevron-up::before { + content: "\F0143"; +} + +.mdi-circle::before { + content: "\F0765"; +} + +.mdi-circle-small::before { + content: "\F09DF"; +} + +.mdi-clock-outline::before { + content: "\F0150"; +} + +.mdi-close::before { + content: "\F0156"; +} + +.mdi-close-circle::before { + content: "\F0159"; +} + +.mdi-close-circle-outline::before { + content: "\F015A"; +} + +.mdi-cloud-upload::before { + content: "\F0167"; +} + +.mdi-code-json::before { + content: "\F0626"; +} + +.mdi-code-tags::before { + content: "\F0174"; +} + +.mdi-code-tags-check::before { + content: "\F0694"; +} + +.mdi-cog::before { + content: "\F0493"; +} + +.mdi-cog-outline::before { + content: "\F08BB"; +} + +.mdi-cogs::before { + content: "\F08D6"; +} + +.mdi-comment-question::before { + content: "\F0817"; +} + +.mdi-compare-vertical::before { + content: "\F1493"; +} + +.mdi-connection::before { + content: "\F1616"; +} + +.mdi-console::before { + content: "\F018D"; +} + +.mdi-console-line::before { + content: "\F07B7"; +} + +.mdi-content-copy::before { + content: "\F018F"; +} + +.mdi-content-save::before { + content: "\F0193"; +} + +.mdi-creation::before { + content: "\F0674"; +} + +.mdi-cursor-default-click::before { + content: "\F0CFD"; +} + +.mdi-cursor-move::before { + content: "\F01BE"; +} + +.mdi-database::before { + content: "\F01BC"; +} + +.mdi-database-cog::before { + content: "\F164B"; +} + +.mdi-database-off::before { + content: "\F1640"; +} + +.mdi-delete::before { + content: "\F01B4"; +} + +.mdi-delete-outline::before { + content: "\F09E7"; +} + +.mdi-dots-hexagon::before { + content: "\F15FF"; +} + +.mdi-dots-horizontal::before { + content: "\F01D8"; +} + +.mdi-dots-vertical::before { + content: "\F01D9"; +} + +.mdi-download::before { + content: "\F01DA"; +} + +.mdi-emoticon::before { + content: "\F0C68"; +} + +.mdi-emoticon-confused::before { + content: "\F10DE"; +} + +.mdi-emoticon-confused-outline::before { + content: "\F10DF"; +} + +.mdi-export::before { + content: "\F0207"; +} + +.mdi-eye::before { + content: "\F0208"; +} + +.mdi-eye-off::before { + content: "\F0209"; +} + +.mdi-eye-outline::before { + content: "\F06D0"; +} + +.mdi-file::before { + content: "\F0214"; +} + +.mdi-file-chart::before { + content: "\F0215"; +} + +.mdi-file-code::before { + content: "\F022E"; +} + +.mdi-file-document::before { + content: "\F0219"; +} + +.mdi-file-document-edit-outline::before { + content: "\F0DC9"; +} + +.mdi-file-document-multiple::before { + content: "\F1517"; +} + +.mdi-file-document-outline::before { + content: "\F09EE"; +} + +.mdi-file-excel-box::before { + content: "\F021C"; +} + +.mdi-file-outline::before { + content: "\F0224"; +} + +.mdi-file-pdf-box::before { + content: "\F0226"; +} + +.mdi-file-powerpoint-box::before { + content: "\F0228"; +} + +.mdi-file-question-outline::before { + content: "\F1036"; +} + +.mdi-file-upload::before { + content: "\F0A4D"; +} + +.mdi-file-upload-outline::before { + content: "\F0A4E"; +} + +.mdi-file-word-box::before { + content: "\F022D"; +} + +.mdi-filter-remove::before { + content: "\F0234"; +} + +.mdi-filter-variant::before { + content: "\F0236"; +} + +.mdi-flash::before { + content: "\F0241"; +} + +.mdi-flash-off::before { + content: "\F0243"; +} + +.mdi-folder::before { + content: "\F024B"; +} + +.mdi-folder-move::before { + content: "\F0252"; +} + +.mdi-folder-multiple::before { + content: "\F0253"; +} + +.mdi-folder-open::before { + content: "\F0770"; +} + +.mdi-folder-open-outline::before { + content: "\F0DCF"; +} + +.mdi-folder-outline::before { + content: "\F0256"; +} + +.mdi-folder-plus::before { + content: "\F0257"; +} + +.mdi-folder-zip-outline::before { + content: "\F07B9"; +} + +.mdi-format-list-bulleted::before { + content: "\F0279"; +} + +.mdi-frequently-asked-questions::before { + content: "\F0EB4"; +} + +.mdi-fullscreen::before { + content: "\F0293"; +} + +.mdi-fullscreen-exit::before { + content: "\F0294"; +} + +.mdi-function-variant::before { + content: "\F0871"; +} + +.mdi-github::before { + content: "\F02A4"; +} + +.mdi-grain::before { + content: "\F0D7C"; +} + +.mdi-hand-heart::before { + content: "\F10F1"; +} + +.mdi-hand-wave-outline::before { + content: "\F1822"; +} + +.mdi-heart::before { + content: "\F02D1"; +} + +.mdi-help-circle::before { + content: "\F02D7"; +} + +.mdi-help-circle-outline::before { + content: "\F0625"; +} + +.mdi-home::before { + content: "\F02DC"; +} + +.mdi-identifier::before { + content: "\F0EFE"; +} + +.mdi-import::before { + content: "\F02FA"; +} + +.mdi-information::before { + content: "\F02FC"; +} + +.mdi-information-outline::before { + content: "\F02FD"; +} + +.mdi-key::before { + content: "\F0306"; +} + +.mdi-key-outline::before { + content: "\F0DD6"; +} + +.mdi-key-plus::before { + content: "\F0309"; +} + +.mdi-keyboard-outline::before { + content: "\F097B"; +} + +.mdi-label::before { + content: "\F0315"; +} + +.mdi-lan-connect::before { + content: "\F0318"; +} + +.mdi-language-markdown::before { + content: "\F0354"; +} + +.mdi-layers-outline::before { + content: "\F09FE"; +} + +.mdi-lightbulb-outline::before { + content: "\F0336"; +} + +.mdi-lightning-bolt::before { + content: "\F140B"; +} + +.mdi-link::before { + content: "\F0337"; +} + +.mdi-link-variant::before { + content: "\F0339"; +} + +.mdi-loading::before { + content: "\F0772"; +} + +.mdi-lock::before { + content: "\F033E"; +} + +.mdi-lock-check-outline::before { + content: "\F16A8"; +} + +.mdi-lock-outline::before { + content: "\F0341"; +} + +.mdi-lock-plus-outline::before { + content: "\F16B2"; +} + +.mdi-magnify::before { + content: "\F0349"; +} + +.mdi-memory::before { + content: "\F035B"; +} + +.mdi-menu::before { + content: "\F035C"; +} + +.mdi-message-off-outline::before { + content: "\F164E"; +} + +.mdi-message-text::before { + content: "\F0369"; +} + +.mdi-message-text-outline::before { + content: "\F036A"; +} + +.mdi-microphone::before { + content: "\F036C"; +} + +.mdi-microphone-message::before { + content: "\F050A"; +} + +.mdi-minus::before { + content: "\F0374"; +} + +.mdi-note-text-outline::before { + content: "\F11D7"; +} + +.mdi-numeric-1::before { + content: "\F0B3A"; +} + +.mdi-numeric-1-circle::before { + content: "\F0CA0"; +} + +.mdi-numeric-2::before { + content: "\F0B3B"; +} + +.mdi-numeric-2-circle::before { + content: "\F0CA2"; +} + +.mdi-numeric-3::before { + content: "\F0B3C"; +} + +.mdi-open-in-new::before { + content: "\F03CC"; +} + +.mdi-package-variant::before { + content: "\F03D6"; +} + +.mdi-pause::before { + content: "\F03E4"; +} + +.mdi-pause-circle-outline::before { + content: "\F03E6"; +} + +.mdi-pencil::before { + content: "\F03EB"; +} + +.mdi-pencil-outline::before { + content: "\F0CB6"; +} + +.mdi-pencil-plus::before { + content: "\F0DEB"; +} + +.mdi-pencil-ruler::before { + content: "\F1353"; +} + +.mdi-phone-in-talk::before { + content: "\F03F6"; +} + +.mdi-play::before { + content: "\F040A"; +} + +.mdi-play-circle-outline::before { + content: "\F040D"; +} + +.mdi-plus::before { + content: "\F0415"; +} + +.mdi-pound::before { + content: "\F0423"; +} + +.mdi-progress-check::before { + content: "\F0995"; +} + +.mdi-puzzle::before { + content: "\F0431"; +} + +.mdi-puzzle-outline::before { + content: "\F0A66"; +} + +.mdi-refresh::before { + content: "\F0450"; +} + +.mdi-rename-box::before { + content: "\F0455"; +} + +.mdi-reply::before { + content: "\F045A"; +} + +.mdi-reply-outline::before { + content: "\F0F20"; +} + +.mdi-restart::before { + content: "\F0709"; +} + +.mdi-restore::before { + content: "\F099B"; +} + +.mdi-robot::before { + content: "\F06A9"; +} + +.mdi-robot-off::before { + content: "\F16A7"; +} + +.mdi-send::before { + content: "\F048A"; +} + +.mdi-server::before { + content: "\F048B"; +} + +.mdi-server-network::before { + content: "\F048D"; +} + +.mdi-server-off::before { + content: "\F048F"; +} + +.mdi-shape-outline::before { + content: "\F0832"; +} + +.mdi-shield-check::before { + content: "\F0565"; +} + +.mdi-shield-check-outline::before { + content: "\F0CC8"; +} + +.mdi-shuffle-variant::before { + content: "\F049F"; +} + +.mdi-skip-next-circle-outline::before { + content: "\F0662"; +} + +.mdi-sort::before { + content: "\F04BA"; +} + +.mdi-sort-ascending::before { + content: "\F04BC"; +} + +.mdi-sort-variant::before { + content: "\F04BF"; +} + +.mdi-source-branch::before { + content: "\F062C"; +} + +.mdi-square-edit-outline::before { + content: "\F090C"; +} + +.mdi-star::before { + content: "\F04CE"; +} + +.mdi-star-four-points-small::before { + content: "\F1C55"; +} + +.mdi-stop::before { + content: "\F04DB"; +} + +.mdi-stop-circle::before { + content: "\F0666"; +} + +.mdi-store::before { + content: "\F04DC"; +} + +.mdi-subdirectory-arrow-right::before { + content: "\F060D"; +} + +.mdi-text::before { + content: "\F09A8"; +} + +.mdi-text-box::before { + content: "\F021A"; +} + +.mdi-text-box-outline::before { + content: "\F09ED"; +} + +.mdi-text-box-search::before { + content: "\F0EAE"; +} + +.mdi-text-box-search-outline::before { + content: "\F0EAF"; +} + +.mdi-text-search::before { + content: "\F13B8"; +} + +.mdi-timeline-text-outline::before { + content: "\F0BD4"; +} + +.mdi-tools::before { + content: "\F1064"; +} + +.mdi-translate::before { + content: "\F05CA"; +} + +.mdi-trash-can-outline::before { + content: "\F0A7A"; +} + +.mdi-update::before { + content: "\F06B0"; +} + +.mdi-upload::before { + content: "\F0552"; +} + +.mdi-vector-intersection::before { + content: "\F055D"; +} + +.mdi-vector-link::before { + content: "\F0FE8"; +} + +.mdi-vector-point::before { + content: "\F01C4"; +} + +.mdi-view-dashboard::before { + content: "\F056E"; +} + +.mdi-view-grid::before { + content: "\F0570"; +} + +.mdi-view-list::before { + content: "\F0572"; +} + +.mdi-volume-high::before { + content: "\F057E"; +} + +.mdi-weather-night::before { + content: "\F0594"; +} + +.mdi-web::before { + content: "\F059F"; +} + +.mdi-webhook::before { + content: "\F062F"; +} + +.mdi-white-balance-sunny::before { + content: "\F05A8"; +} + +.mdi-wrench::before { + content: "\F05B7"; +} + +.mdi-wrench-outline::before { + content: "\F0BE0"; +} + +.mdi-zip-box::before { + content: "\F05C4"; +} + +/* Utility classes (extracted from @mdi/font) */ +/* MaterialDesignIcons.com */ + +.mdi-blank::before { + content: "\F68C"; + visibility: hidden; +} + +.mdi-18px.mdi-set, .mdi-18px.mdi:before { + font-size: 18px; +} + +.mdi-24px.mdi-set, .mdi-24px.mdi:before { + font-size: 24px; +} + +.mdi-36px.mdi-set, .mdi-36px.mdi:before { + font-size: 36px; +} + +.mdi-48px.mdi-set, .mdi-48px.mdi:before { + font-size: 48px; +} + +.mdi-dark:before { + color: rgba(0, 0, 0, 0.54); +} + +.mdi-dark.mdi-inactive:before { + color: rgba(0, 0, 0, 0.26); +} + +.mdi-light:before { + color: white; +} + +.mdi-light.mdi-inactive:before { + color: rgba(255, 255, 255, 0.3); +} + +.mdi-rotate-45 { + /* + // Not included in production + &.mdi-flip-h:before { + -webkit-transform: scaleX(-1) rotate(45deg); + transform: scaleX(-1) rotate(45deg); + filter: FlipH; + -ms-filter: "FlipH"; + } + &.mdi-flip-v:before { + -webkit-transform: scaleY(-1) rotate(45deg); + -ms-transform: rotate(45deg); + transform: scaleY(-1) rotate(45deg); + filter: FlipV; + -ms-filter: "FlipV"; + } + */ +} + +.mdi-rotate-45:before { + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); +} + +.mdi-rotate-90 { + /* + // Not included in production + &.mdi-flip-h:before { + -webkit-transform: scaleX(-1) rotate(90deg); + transform: scaleX(-1) rotate(90deg); + filter: FlipH; + -ms-filter: "FlipH"; + } + &.mdi-flip-v:before { + -webkit-transform: scaleY(-1) rotate(90deg); + -ms-transform: rotate(90deg); + transform: scaleY(-1) rotate(90deg); + filter: FlipV; + -ms-filter: "FlipV"; + } + */ +} + +.mdi-rotate-90:before { + -webkit-transform: rotate(90deg); + -ms-transform: rotate(90deg); + transform: rotate(90deg); +} + +.mdi-rotate-135 { + /* + // Not included in production + &.mdi-flip-h:before { + -webkit-transform: scaleX(-1) rotate(135deg); + transform: scaleX(-1) rotate(135deg); + filter: FlipH; + -ms-filter: "FlipH"; + } + &.mdi-flip-v:before { + -webkit-transform: scaleY(-1) rotate(135deg); + -ms-transform: rotate(135deg); + transform: scaleY(-1) rotate(135deg); + filter: FlipV; + -ms-filter: "FlipV"; + } + */ +} + +.mdi-rotate-135:before { + -webkit-transform: rotate(135deg); + -ms-transform: rotate(135deg); + transform: rotate(135deg); +} + +.mdi-rotate-180 { + /* + // Not included in production + &.mdi-flip-h:before { + -webkit-transform: scaleX(-1) rotate(180deg); + transform: scaleX(-1) rotate(180deg); + filter: FlipH; + -ms-filter: "FlipH"; + } + &.mdi-flip-v:before { + -webkit-transform: scaleY(-1) rotate(180deg); + -ms-transform: rotate(180deg); + transform: scaleY(-1) rotate(180deg); + filter: FlipV; + -ms-filter: "FlipV"; + } + */ +} + +.mdi-rotate-180:before { + -webkit-transform: rotate(180deg); + -ms-transform: rotate(180deg); + transform: rotate(180deg); +} + +.mdi-rotate-225 { + /* + // Not included in production + &.mdi-flip-h:before { + -webkit-transform: scaleX(-1) rotate(225deg); + transform: scaleX(-1) rotate(225deg); + filter: FlipH; + -ms-filter: "FlipH"; + } + &.mdi-flip-v:before { + -webkit-transform: scaleY(-1) rotate(225deg); + -ms-transform: rotate(225deg); + transform: scaleY(-1) rotate(225deg); + filter: FlipV; + -ms-filter: "FlipV"; + } + */ +} + +.mdi-rotate-225:before { + -webkit-transform: rotate(225deg); + -ms-transform: rotate(225deg); + transform: rotate(225deg); +} + +.mdi-rotate-270 { + /* + // Not included in production + &.mdi-flip-h:before { + -webkit-transform: scaleX(-1) rotate(270deg); + transform: scaleX(-1) rotate(270deg); + filter: FlipH; + -ms-filter: "FlipH"; + } + &.mdi-flip-v:before { + -webkit-transform: scaleY(-1) rotate(270deg); + -ms-transform: rotate(270deg); + transform: scaleY(-1) rotate(270deg); + filter: FlipV; + -ms-filter: "FlipV"; + } + */ +} + +.mdi-rotate-270:before { + -webkit-transform: rotate(270deg); + -ms-transform: rotate(270deg); + transform: rotate(270deg); +} + +.mdi-rotate-315 { + /* + // Not included in production + &.mdi-flip-h:before { + -webkit-transform: scaleX(-1) rotate(315deg); + transform: scaleX(-1) rotate(315deg); + filter: FlipH; + -ms-filter: "FlipH"; + } + &.mdi-flip-v:before { + -webkit-transform: scaleY(-1) rotate(315deg); + -ms-transform: rotate(315deg); + transform: scaleY(-1) rotate(315deg); + filter: FlipV; + -ms-filter: "FlipV"; + } + */ +} + +.mdi-rotate-315:before { + -webkit-transform: rotate(315deg); + -ms-transform: rotate(315deg); + transform: rotate(315deg); +} + +.mdi-flip-h:before { + -webkit-transform: scaleX(-1); + transform: scaleX(-1); + filter: FlipH; + -ms-filter: "FlipH"; +} + +.mdi-flip-v:before { + -webkit-transform: scaleY(-1); + transform: scaleY(-1); + filter: FlipV; + -ms-filter: "FlipV"; +} + +.mdi-spin:before { + -webkit-animation: mdi-spin 2s infinite linear; + animation: mdi-spin 2s infinite linear; +} + +@-webkit-keyframes mdi-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} + +@keyframes mdi-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff new file mode 100644 index 0000000000000000000000000000000000000000..20cd8f5c89e20145b7b71d6d4945f994e33da535 GIT binary patch literal 16172 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
C^f^d^C6p0aRu+E(=;8XfklW*UjvRbgp9 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 literal 0 HcmV?d00001 diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5ddf299e2e5732a119e94a28a6e69731fe6de79c GIT binary patch literal 12996 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 G00022_Fpgn literal 0 HcmV?d00001 diff --git a/dashboard/src/plugins/vuetify.ts b/dashboard/src/plugins/vuetify.ts index 3e9bae9c1..e38fd388e 100644 --- a/dashboard/src/plugins/vuetify.ts +++ b/dashboard/src/plugins/vuetify.ts @@ -1,5 +1,5 @@ import { createVuetify } from 'vuetify'; -import '@mdi/font/css/materialdesignicons.css'; +import '@/assets/mdi-subset/materialdesignicons-subset.css'; import * as components from 'vuetify/components'; import * as directives from 'vuetify/directives'; import { PurpleTheme } from '@/theme/LightTheme'; diff --git a/dashboard/tests/subsetMdiFont.test.mjs b/dashboard/tests/subsetMdiFont.test.mjs new file mode 100644 index 000000000..85b0ad0ba --- /dev/null +++ b/dashboard/tests/subsetMdiFont.test.mjs @@ -0,0 +1,250 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +import { + collectFiles, + scanUsedIcons, + parseIconCodepoints, + resolveUsedIcons, + extractUtilityCss, + ICON_CLASS_PATTERN, +} from '../scripts/subset-mdi-font.mjs'; + +// ── Helper: create a temporary directory tree for file-system tests ───────── + +function makeTmpDir() { + const base = join(tmpdir(), `mdi-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(base, { recursive: true }); + return base; +} + +// ── collectFiles ──────────────────────────────────────────────────────────── + +test('collectFiles yields files matching given extensions', () => { + const tmp = makeTmpDir(); + writeFileSync(join(tmp, 'a.vue'), ''); + writeFileSync(join(tmp, 'b.ts'), ''); + writeFileSync(join(tmp, 'c.txt'), ''); + + const files = [...collectFiles(tmp, ['.vue', '.ts'])]; + assert.equal(files.length, 2); + assert.ok(files.some(f => f.endsWith('a.vue'))); + assert.ok(files.some(f => f.endsWith('b.ts'))); + + rmSync(tmp, { recursive: true }); +}); + +test('collectFiles recurses into subdirectories', () => { + const tmp = makeTmpDir(); + const sub = join(tmp, 'sub'); + mkdirSync(sub); + writeFileSync(join(sub, 'deep.vue'), ''); + + const files = [...collectFiles(tmp, ['.vue'])]; + assert.equal(files.length, 1); + assert.ok(files[0].endsWith('deep.vue')); + + rmSync(tmp, { recursive: true }); +}); + +test('collectFiles skips node_modules directories', () => { + const tmp = makeTmpDir(); + const nm = join(tmp, 'node_modules'); + mkdirSync(nm); + writeFileSync(join(nm, 'pkg.vue'), ''); + writeFileSync(join(tmp, 'app.vue'), ''); + + const files = [...collectFiles(tmp, ['.vue'])]; + assert.equal(files.length, 1); + assert.ok(files[0].endsWith('app.vue')); + + rmSync(tmp, { recursive: true }); +}); + +test('collectFiles yields nothing for empty directory', () => { + const tmp = makeTmpDir(); + const files = [...collectFiles(tmp, ['.vue'])]; + assert.equal(files.length, 0); + + rmSync(tmp, { recursive: true }); +}); + +// ── scanUsedIcons ─────────────────────────────────────────────────────────── + +test('scanUsedIcons extracts mdi-* icon names from files', () => { + const tmp = makeTmpDir(); + writeFileSync(join(tmp, 'A.vue'), 'mdi-homemdi-close'); + writeFileSync(join(tmp, 'B.vue'), 'icon="mdi-home"'); + + const icons = scanUsedIcons(collectFiles(tmp, ['.vue'])); + assert.ok(icons instanceof Set); + assert.ok(icons.has('mdi-home')); + assert.ok(icons.has('mdi-close')); + assert.equal(icons.size, 2); // mdi-home deduplicated + + rmSync(tmp, { recursive: true }); +}); + +test('scanUsedIcons excludes utility classes', () => { + const tmp = makeTmpDir(); + writeFileSync(join(tmp, 'A.vue'), 'mdi-spin mdi-rotate-90 mdi-flip-h mdi-home'); + + const icons = scanUsedIcons(collectFiles(tmp, ['.vue'])); + assert.ok(icons.has('mdi-home')); + assert.ok(!icons.has('mdi-spin')); + assert.ok(!icons.has('mdi-rotate-90')); + assert.ok(!icons.has('mdi-flip-h')); + + rmSync(tmp, { recursive: true }); +}); + +test('scanUsedIcons returns empty set when no icons found', () => { + const tmp = makeTmpDir(); + writeFileSync(join(tmp, 'A.vue'), '
Hello
'); + + const icons = scanUsedIcons(collectFiles(tmp, ['.vue'])); + assert.equal(icons.size, 0); + + rmSync(tmp, { recursive: true }); +}); + +// ── parseIconCodepoints ───────────────────────────────────────────────────── + +test('parseIconCodepoints parses icon definitions from CSS', () => { + const css = ` +.mdi-home::before { content: "\\F02DC"; } +.mdi-close::before { content: "\\F0156"; } +`; + const map = parseIconCodepoints(css); + assert.equal(map.size, 2); + assert.equal(map.get('mdi-home'), 'F02DC'); + assert.equal(map.get('mdi-close'), 'F0156'); +}); + +test('parseIconCodepoints handles CSS with semicolons inside braces', () => { + const css = `.mdi-check::before { content: "\\F012C"; }`; + const map = parseIconCodepoints(css); + assert.equal(map.get('mdi-check'), 'F012C'); +}); + +test('parseIconCodepoints returns empty map for non-matching CSS', () => { + const css = `.some-other-class { color: red; }`; + const map = parseIconCodepoints(css); + assert.equal(map.size, 0); +}); + +// ── resolveUsedIcons ──────────────────────────────────────────────────────── + +test('resolveUsedIcons separates resolved and missing icons', () => { + const usedIcons = new Set(['mdi-home', 'mdi-close', 'mdi-nonexistent']); + const iconMap = new Map([ + ['mdi-home', 'F02DC'], + ['mdi-close', 'F0156'], + ]); + + const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap); + + assert.ok(resolvedIcons.includes('mdi-home')); + assert.ok(resolvedIcons.includes('mdi-close')); + assert.equal(resolvedIcons.length, 2); + + assert.deepEqual(missingIcons, ['mdi-nonexistent']); + + // Verify subsetChars contains correct Unicode characters + assert.equal(subsetChars.length, 2); + assert.equal(subsetChars[0], String.fromCodePoint(0xF02DC)); + assert.equal(subsetChars[1], String.fromCodePoint(0xF0156)); +}); + +test('resolveUsedIcons returns all missing when iconMap is empty', () => { + const usedIcons = new Set(['mdi-home']); + const iconMap = new Map(); + + const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap); + assert.equal(resolvedIcons.length, 0); + assert.deepEqual(missingIcons, ['mdi-home']); + assert.equal(subsetChars.length, 0); +}); + +// ── extractUtilityCss ─────────────────────────────────────────────────────── + +test('extractUtilityCss removes icon definitions and keeps utility rules', () => { + const css = ` +@font-face { + font-family: "Material Design Icons"; + src: url("../fonts/materialdesignicons-webfont.woff2") format("woff2"); +} + +.mdi:before, +.mdi-set { + display: inline-block; + font: normal normal normal 24px/1 "Material Design Icons"; +} + +.mdi-home::before { content: "\\F02DC"; } +.mdi-close::before { content: "\\F0156"; } + +.mdi-spin:before { + animation: mdi-spin 2s infinite linear; +} + +.mdi-18px.mdi-set, .mdi-18px.mdi:before { + font-size: 18px; +} +/*# sourceMappingURL=materialdesignicons.css.map */ +`; + + const result = extractUtilityCss(css, ICON_CLASS_PATTERN); + + // Should NOT contain icon definitions + assert.ok(!result.includes('mdi-home')); + assert.ok(!result.includes('mdi-close')); + + // Should NOT contain @font-face + assert.ok(!result.includes('@font-face')); + + // Should NOT contain base .mdi rules + assert.ok(!result.includes('display: inline-block')); + + // Should NOT contain source map + assert.ok(!result.includes('sourceMappingURL')); + + // SHOULD contain utility classes + assert.ok(result.includes('mdi-spin')); + assert.ok(result.includes('mdi-18px')); +}); + +test('extractUtilityCss returns empty string when only icon defs exist', () => { + const css = ` +@font-face { font-family: "MDI"; src: url("font.woff2"); } +.mdi:before, .mdi-set { display: inline-block; } +.mdi-home::before { content: "\\F02DC"; } +`; + + const result = extractUtilityCss(css, ICON_CLASS_PATTERN); + assert.equal(result, ''); +}); + +test('extractUtilityCss handles empty CSS input', () => { + const result = extractUtilityCss('', ICON_CLASS_PATTERN); + assert.equal(result, ''); +}); + +// ── ICON_CLASS_PATTERN ────────────────────────────────────────────────────── + +test('ICON_CLASS_PATTERN matches standard MDI icon definitions', () => { + const css = `.mdi-home::before { content: "\\F02DC"; }`; + const matches = [...css.matchAll(ICON_CLASS_PATTERN)]; + assert.equal(matches.length, 1); + assert.equal(matches[0][1], 'mdi-home'); + assert.equal(matches[0][2], 'F02DC'); +}); + +test('ICON_CLASS_PATTERN does not match non-icon classes', () => { + const css = `.some-class::before { content: "hello"; }`; + const matches = [...css.matchAll(ICON_CLASS_PATTERN)]; + assert.equal(matches.length, 0); +}); diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index b53e0310d..45a62d94f 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -2,10 +2,26 @@ import { fileURLToPath, URL } from 'url'; import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import vuetify from 'vite-plugin-vuetify'; +import webfontDl from 'vite-plugin-webfont-dl'; +// @ts-ignore — .mjs not in TS project scope; Vite resolves this at runtime +import { runMdiSubset } from './scripts/subset-mdi-font.mjs'; + +// Vite plugin: run MDI icon font subsetting (build only) +function mdiSubset() { + return { + name: 'vite-plugin-mdi-subset', + async buildStart() { + console.log('\n🔧 Running MDI icon font subsetting...'); + await runMdiSubset(); + }, + }; +} // https://vitejs.dev/config/ -export default defineConfig({ +export default defineConfig(({ command }) => ({ plugins: [ + // Only run MDI subsetting during production builds, skip in dev server + ...(command === 'build' ? [mdiSubset()] : []), vue({ template: { compilerOptions: { @@ -15,7 +31,8 @@ export default defineConfig({ }), vuetify({ autoImport: true - }) + }), + webfontDl() ], resolve: { alias: { @@ -47,4 +64,4 @@ export default defineConfig({ } } } -}); +}));