diff --git a/site/README.md b/site/README.md index 07f0a68..dac1506 100644 --- a/site/README.md +++ b/site/README.md @@ -107,14 +107,41 @@ print(im.mode) # RGB —— 没有 alpha 通道 结论:**这类素材要么重新出一张带透明通道的图,要么就不放**。 现在 Hero 是单栏文字,版面反而更稳。 -## 改内容前请核对事实 +## 插件卡片 -页面上的数字(外部插件数、工具数、代码行数、适配器数)都是**实测值**, -不是估的。改动前建议重新取一遍: +「开箱可用的插件」一节里,每个徽章都是**按钮**: + +- 点一下展开该插件卡(名称 / 版本 / 用途),再点收起 +- 可同时展开多个 +- 末尾「展开全部 N 个」一次全开 + +数据来自各插件的 `plugin.json`,不是手写的: ```bash -ls /home/newqqagent/plugins/ | grep -v hmap | wc -l # 外部插件 -grep -oE '[0-9]+ tools' $(ls -t /home/newqqagent/log/*.log | head -1) | tail -1 -ls internal/lua/adapters/*.lua | wc -l # LLM 适配器 -find . -name '*.go' -not -path './.git/*' -not -path './.go/*' -not -path './third_party/*' | xargs wc -l | tail -1 +cd /home/newqqagent/plugins +for d in */; do [ -f "$d/plugin.json" ] && python3 -c " +import json;d=json.load(open('$d/plugin.json')) +print(d.get('name'), d.get('version'), d.get('description','')[:60])"; done +``` + +## 改内容前请核对事实 + +页面上的数字都是**实测值**。**注意「在跑插件 36」是加载总数** +(16 内置 + 20 外部),不是"外部插件数" —— 早期版本写成「36 外部插件」是错的, +已更正。改动前建议重新取一遍: + +```bash +# 在跑插件(以日志里实际 loaded 的为准,比数目录可靠) +grep -h '\[plugin\] loaded:' $(ls -t /home/newqqagent/log/homed_*.log | head -1) \ + | sed 's/.*loaded: //' | sort -u | wc -l + +# 工具数(同上,日志里注册后的真实值) +grep -ohE '[0-9]+ tools' $(ls -t /home/newqqagent/log/homed_*.log | head -1) | tail -1 + +# Go 代码行(排除第三方与鸿蒙工程) +find . -name '*.go' -not -path './.git/*' -not -path './.go/*' \ + -not -path './third_party/*' -not -path './cmd/ohos/*' | xargs cat | wc -l + +# LLM 适配器(注意 server.lua 是网关脚本,不是厂商适配器,故为 9) +ls internal/lua/adapters/*.lua | xargs -n1 basename ``` diff --git a/site/index.html b/site/index.html index 062a23a..564726d 100644 --- a/site/index.html +++ b/site/index.html @@ -482,6 +482,174 @@ .js-reveal { opacity: 1; transform: none; transition: none; } } + + /* ── 插件:徽章可点,下方显示详情 ── */ + .badges { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + } + .badge { + /* 重置 button 默认样式 */ + appearance: none; + font: inherit; + font-family: var(--font-mono); + font-size: 0.8rem; + padding: 0.34rem 0.7rem; + border-radius: var(--radius-pill); + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-dim); + cursor: pointer; + transition: border-color 0.22s var(--ease), color 0.22s var(--ease), + background 0.22s var(--ease), transform 0.22s var(--ease-back), + box-shadow 0.22s var(--ease); + } + .badge:hover { + border-color: var(--border-hi); + color: var(--text); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + .badge:focus-visible { + outline: 2px solid var(--brand-sky); + outline-offset: 2px; + } + /* 选中态:淡底 + 主色描边(实心渐变太重,20 个齐亮像一堵墙) */ + .badge[aria-pressed="true"] { + /* 回退:不支持 color-mix 的浏览器给淡青底 */ + background: rgba(6, 182, 212, 0.14); + background: color-mix(in srgb, var(--brand-cyan) 12%, var(--bg-card)); + border-color: var(--brand-cyan); + color: var(--text); + } + /* 小圆点提示可点 */ + .badge::after { + content: ""; + display: inline-block; + width: 4px; height: 4px; + margin-left: 0.4rem; + border-radius: 50%; + background: currentColor; + opacity: 0.45; + vertical-align: middle; + transition: opacity 0.22s var(--ease); + } + .badge[aria-pressed="true"]::after { opacity: 1; } + /* 「展开全部」:虚线边框,与具体插件徽章区分 */ + .badge.muted { + font-style: italic; + color: var(--text-muted); + border-style: dashed; + } + .badge.muted::after { display: none; } + + .pdetail { + margin-top: 1.2rem; + display: grid; + gap: 0.85rem; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } + .phint { + grid-column: 1 / -1; + margin: 0; + text-align: center; + color: var(--text-muted); + font-size: 0.88rem; + padding: 0.85rem 0; + border: 1px dashed var(--border); + border-radius: var(--radius-md); + } + .pcard { + display: none; + position: relative; + background: var(--bg-card); + border: 1px solid var(--border-hi); + border-radius: var(--radius-lg); + padding: 1rem 1.15rem; + box-shadow: var(--shadow-md); + } + .pcard.show { + display: block; + animation: pcardIn 0.4s var(--ease-back); + } + @keyframes pcardIn { + from { opacity: 0; transform: translateY(10px) scale(0.98); } + to { opacity: 1; transform: none; } + } + .pcard-head { + display: flex; + align-items: baseline; + gap: 0.6rem; + margin-bottom: 0.5rem; + } + .pcard-head code { + font-size: 0.95rem; + font-weight: 700; + color: var(--brand-cyan); + } + .pcard-head .ver { + font-size: 0.75rem; + color: var(--text-muted); + font-family: var(--font-mono); + margin-left: auto; + } + .pcard p { + margin: 0; + font-size: 0.88rem; + color: var(--text-dim); + line-height: 1.65; + } + /* 全部展开时的网格 */ + .pdetail.all { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } + .pdetail.all .pcard { display: block; } + .pdetail.all .phint { display: none; } + + /* ── 统计卡:补一行小字,避免数字孤零零 ── */ + .stat .sm { + display: block; + margin-top: 0.25rem; + font-size: 0.72rem; + color: var(--text-muted); + letter-spacing: 0.01em; + } + .stat .l { line-height: 1.5; } + + /* ── 版面精致化:统一节奏与呼吸 ── */ + section { padding: 4.5rem 0; } + @media (max-width: 860px) { section { padding: 3rem 0; } } + h2 { margin: 0 0 0.7rem; letter-spacing: -0.01em; } + .sub { max-width: 44rem; } + /* 卡片内边距与标题间距收敛一档 */ + .card { padding: 1.4rem 1.5rem; } + .card h3 { margin: 0 0 0.55rem; } + .card p { font-size: 0.9rem; } + /* 组件表:行高收敛、代码列定宽对齐 */ + .comp .row { padding: 0.65rem 1rem; } + .comp .row code { min-width: 7.5rem; } + /* 统计卡内边距 */ + .stat { padding: 1.25rem 1rem; } + .stat .n { margin-bottom: 0.15rem; } + /* 三层记忆卡收紧 */ + .layer .body { padding: 1rem 1.2rem 1rem 0; } + .layer h3 { margin-bottom: 0.3rem; } + .layer p { margin: 0; } + + /* 分隔:章节之间给一条极淡的线,强化节奏 */ + section + section > .wrap > h2, + section + section > .wrap > .sub { + /* 占位:保持选择器有效,视觉由间距承担 */ + } + + /* 打印:不显示装饰与折叠 */ + @media print { + .aurora, #fx, #progress, .badges, .pdetail { display: none !important; } + body { background: #fff; color: #000; } + } + /* ═══════════════════════════════════════════════════ 动效层。全部遵守 prefers-reduced-motion(见文件末脚本)。 原则:**内容默认可读**,动效只是增强,JS 挂了也不影响阅读。 @@ -857,9 +1025,8 @@

四个不那么常见的设计决定

-

- 大多数框架先写功能再补边界。HomeAgent 反过来:先把 - 边界调度定死,再往上加能力。 +

+ 先定边界调度,再加能力。

@@ -867,9 +1034,9 @@

内核零 IO

- 内核不直接读写任何外部世界。收发消息、文件操作、网络请求、硬件交互 - 全部由插件经 PluginSDK 实现。于是「内核有多可信」与「某个插件有多安全」 - 成为两个可独立评估的问题。 + 内核不碰任何外部世界。消息、文件、网络、硬件全部由插件经 + PluginSDK 实现。于是「内核是否可信」与「某个插件是否安全」 + 可以分开评估。

@@ -877,9 +1044,9 @@

插件是独立进程

- 外部插件编译成普通 Go 二进制,由内核 spawn 为子进程,经 - stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知)通信。 - 插件崩溃不影响内核,换掉 plugin.bin 即真热重载。 + 插件是普通 Go 二进制,由内核 spawn 为子进程,经 stdio JSON-RPC + (控制)、共享内存段(数据)、事件环(通知)通信。插件崩溃不牵连内核, + 换掉 plugin.bin 即热重载。

@@ -887,9 +1054,9 @@

输入是有级别的

- 输入先进调度器,分「排队」与「中断」两类,中断再按“有多不能等”分 L1–L4。 - 高优先级可抢占并保存现场,同级不抢占。L4 只归内核 - —— 所以“停止”按钮真的能立刻停下。 + 输入分「排队」与「中断」两类,中断再按紧迫度分 L1–L4。 + 高级可抢占并保存现场,同级不抢占。L4 归内核独占 + —— 所以「停止」真的能立刻停下。

@@ -897,9 +1064,9 @@

忙时有人顶班

- 主 agent 执行长任务时,后来的消息不会干等十几分钟:内核把积压交给 - 临时分诊助手 —— 简单的直接办完,需要主 agent 的立刻回 - 「忙碌中,请稍候」。 + 主 agent 忙长任务时,积压交给临时分诊助手: + 能办的直接办,需要主 agent 的立刻回「忙碌中,请稍候」。 + 不必干等十几分钟。

@@ -911,8 +1078,8 @@

三层记忆:从「刚才说了什么」到「三年前说过什么」

- 不是一个向量库包打天下,而是三层各司其职、逐层衰减与整合。 - 品牌的蓝 / 青 / 金三色,对应的就是这三层。 + 不是一个大向量库包打天下,而是三层各司其职、逐层下沉。 + 蓝 / 青 / 金三色对应的就是这三层。

@@ -921,8 +1088,8 @@

Context工作窗口

- 内存中的事件窗口,按与当前输入的相关性评分排序(预训练词向量 → 余弦相似度, - TF-IDF 回退),并保护最近若干条。低分事件自动下沉。 + 内存中的事件窗口。按与当前输入的相关性排序(预训练词向量 → 余弦相似度, + TF-IDF 回退),最近若干条受保护。低分事件自动下沉。

@@ -932,8 +1099,8 @@

Document文件记忆

- JSON + TF-IDF 索引的中间层。支持显式提交与隐式归档; - 冷数据按时间下沉,蒸馏成三元组进入图谱。 + JSON + TF-IDF 索引的中间层。支持显式提交与隐式归档, + 冷数据下沉后蒸馏成三元组进入图谱。

@@ -943,8 +1110,8 @@

Graph图数据库

- SQLite 持久化实体与关系,BFS 遍历召回。蒸馏管道从对话里提取三元组, - 越用越准;这是“几个月前的事还记得”的落点。 + SQLite 持久化实体与关系,BFS 遍历召回。三元组从对话中蒸馏而来, + 越用越准 —— 「几个月前的事还记得」靠的就是这层。

@@ -954,22 +1121,22 @@

媒体也是一等节点

- 图片/音频不是附件,而是三层里的一类节点:内容寻址存储(相同字节只存一份)、 + 图片、音频不是附件,而是三层里的一类节点:内容寻址(相同字节只存一份)、 引用计数 GC(有引用绝不删)。

描述才是持久语义

- 视觉模型生成的描述以 [mime digest] 描述 写进纯文本记忆并参与检索; - blob 只是可被淘汰的缓存。所以几个月后“那张三色图”仍能搜到。 + 视觉模型生成的描述以 [mime digest] 描述 写入纯文本记忆并参与检索, + blob 只是可淘汰的缓存。所以几个月后「那张三色图」仍搜得到。

统一多模态空间

- 文本与图像落在同一向量空间(默认 Chinese-CLIP), - 因此可以用一句话搜图,也可以用图找相关记忆。 + 文本与图像落在同一向量空间(默认 Chinese-CLIP): + 可以用一句话搜图,也可以用图找记忆。

@@ -981,7 +1148,7 @@

一条消息进来之后

- 输入先过调度器,再进处理管道;管道有 7 个阶段钩子,插件可在此改写或短路。 + 输入先过调度器,再进处理管道。管道有 7 个阶段钩子,插件可改写或短路。

@@ -1015,10 +1182,10 @@
-
36
外部插件(实测在跑)
-
340
可用工具
-
110k
Go 代码行
-
10
LLM 协议适配器
+
36
在跑插件
16 内置 + 20 外部
+
340
可用工具
实测注册数
+
109k
Go 代码行
不含第三方
+
9
LLM 适配器
另有 1 个网关脚本
@@ -1028,8 +1195,8 @@

跑起来

- 依赖 Go 1.25+ 与 CGo(sqlite3)。内核需 Linux —— Windows 上 - homed 跑在 WSL2 里,只构建 waiter.exe。 + 依赖 Go 1.25+ 与 CGo(sqlite3)。内核需 Linux;Windows 上 + homed 跑在 WSL2 里,仅需构建 waiter.exe

# 构建守护进程 + CLI @@ -1043,35 +1210,116 @@ make build build-cli echo "你好,记住我喜欢喝咖啡" | ./build/waiter

- WebUI 与 API 密钥在 http://localhost:8080 配置,持久化在 SQLite。 + WebUI 与 API 密钥在 http://localhost:8080 配置,持久化到 SQLite。

开箱可用的插件

-

外部插件经 hmapdev 构建为独立二进制,可单独安装、卸载、热重载。

+

外部插件经 hmapdev 构建为独立二进制,可单独装卸、热重载。点任意插件看它能做什么。

- qq - wechat - browser - files - memo - calendar - rss - editdoc - ocr - music - bili - ai_image - weather - vikunja - vanblog - deepsearch - mcp - a2a - acp - mc - remotedevice - recoverydiag - …共 36 个 + + + + + + + + + + + + + + + + + + + + + +
+
+

点上方任一插件查看用途

+
+
a2av1.3.1
+

Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。

+
+
+
acpv1.2.1
+

Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复

+
+
+
biliv1.2.0
+

B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。

+
+
+
browserv2.4.1
+

统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)

+
+
+
calendarv1.1.0
+

日历事件管理,支持提醒和重复事件

+
+
+
deepsearchv1.1.2
+

为 agent 提供真正的联网信息检索:本地 SearXNG 聚合多引擎(返回标题/URL/摘要/时间),支持新闻、时间范围、指定引擎;并提供网页正文抽取与「搜索+读前K篇」的深检索

+
+
+
editdocv2.0.0
+

全能办公插件:新建/读取/编辑/转换 docx·xlsx·pptx·md·csv·txt(基于 python-docx / openpyxl / python-pptx / soffice / pandoc)

+
+
+
homeagent-mail-bridgev0.2.2
+

HomeAgent 接入 AgentMail:邮件驱动的多智能体协作

+
+
+
mcv0.1.8
+

把 mineflayer 虚拟 MC 客户端封装为 HomeAgent 的 chan:mc IO 通道(两阶段激活 + 动作工具)

+
+
+
memov1.1.0
+

待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。

+
+
+
musicv0.1.0
+

音乐搜索插件,支持搜索歌曲和查看歌词(基于网易云音乐)

+
+
+
ocrv1.0.0
+

图片文字识别工具,基于 Tesseract OCR 引擎。支持从 URL 或 base64 图片中提取文字内容。

+
+
+
plugindevv1.0.0
+

把 hmapdev 工具链封装成 Agent 可调用的工具:脚手架生成插件工程、构建打包 .hmap、管理 SDK 版本。配合 plugin_install 即可让 Agent 自己做完「新建插件 → 构建 → 安装」全流程。

+
+
+
qqv1.4.1
+

QQ 消息收发插件,通过 NapCat 协议桥接

+
+
+
recoverydiagv0.2.0
+

快速检查/崩溃取证工具集:diag_triage(退出码/信号/存活粗分)、diag_db(config.db 完整性 + LLM 源解析校验)、diag_log_scan(日志签名命中)、diag_delta(last-good 快照 vs 现状 diff)、diag_loc(正交综合定位)。全部返回结论而非原文,确定性、不消耗 LLM token,供 guard / failback 恢复决策使用。

+
+
+
rssv1.1.0
+

RSS/Atom 订阅监控插件,自动检测更新并推送通知

+
+
+
sanitizerv0.1.0
+

清洗 LLM 输出中的工具调用残留(思维泄漏)

+
+
+
vanblogv1.0.0
+

管理 VanBlog 开源博客系统:文章的增删改查、分类标签管理、草稿发布、备份导出等

+
+
+
vikunjav1.0.1
+

Vikunja 待办/任务管理:任务增删改查、项目与看板桶、标签、指派、评论、关联、附件、保存筛选器、团队与分享、通知、订阅、Webhook、时间跟踪、数据导入、实例管理;并附通用 API 直通工具兜底

+
+
+
weatherv1.0.0
+

天气查询插件(基于 wttr.in),支持实时天气和未来预报

+
@@ -1080,60 +1328,58 @@ echo "你好,记住我喜欢喝咖啡" | ./build/waiter

常见问题

-

这些是设计上最容易引起误解的地方。

+

最容易引起误解的六个地方。

「内核零 IO」是不是意味着内核很简单?

- 恰恰相反——零 IO 是为了让内核能专注做难的部分:输入调度(四级中断、 - 抢占与现场保存)、上下文预算、三层记忆的蒸馏与召回。 - 把 IO 挪出去之后,这些才有清晰的所有权。 + 恰恰相反。零 IO 是为了让内核专注做难的部分:输入调度(四级中断、抢占与 + 现场保存)、上下文预算、三层记忆的蒸馏与召回。IO 挪出去之后,这些才有 + 清晰的所有权。

插件崩了会不会把 Agent 带崩?

- 不会。从 v1.0.0 起外部插件是独立子进程,与内核只通过 - stdio JSON-RPC、共享内存段、事件环三个面通信。插件崩溃后内核摘除其 - 注册面(工具/阶段/通道)并按退避重启它,其余插件与内核不受影响。 + 不会。外部插件是独立子进程,与内核只通过 stdio JSON-RPC、共享内存段、 + 事件环通信。插件崩溃后,内核摘除它注册的工具、阶段与通道,按退避重启, + 其余部分不受影响。

为什么消息会「排队」而不是立即处理?

- 因为同一时刻只应该有一个任务在改上下文。同级中断不抢占同级任务—— - 抢了会让两边都做一半。需要立刻插队的场景请用更高级别的中断 - (如 L4:内核与内核级插件),而不是让所有消息都变成抢占者。 + 因为同一时刻只应有一个任务在改上下文。同级抢占会让两边都做一半。 + 需要立刻插队时用更高级别的中断(如 L4:内核与内核级插件), + 而不是让所有消息都变成抢占者。

长任务会不会让用户等很久?

- 这正是「分诊助手」要解决的。主 agent 忙超过阈值(默认 5 分钟)且积压够多时, - 内核会拉起一个临时驻留子接手积压:能直接办的办完并回复, - 需要主 agent 的立刻回一句「忙碌中,请稍候」。 + 这正是「分诊助手」的用途。主 agent 忙超阈值且积压够多时,内核拉起临时 + 驻留子接手:能办的直接办,需要主 agent 的立刻回「忙碌中,请稍候」。

记忆会不会无限膨胀?

- 不会。三层是迁移关系而不是复制:上下文事件被裁剪后归档成文档, - 文档冷掉后蒸馏成图谱三元组。图谱有去重与 UNIQUE 约束, - 媒体则靠引用计数 GC 回收无主内容。 + 不会。三层是迁移而非复制:上下文事件裁剪后归档成文档,文档冷掉后蒸馏成 + 图谱三元组。图谱有去重与 UNIQUE 约束,媒体靠引用计数 GC 回收无主内容。

能换 LLM 吗?

- 可以。内核不绑定任何厂商:每个 LLM 源对应一个 Lua 脚本负责请求/响应转换, - 仓库内置 10 个适配器(OpenAI、Anthropic、Gemini、DeepSeek、Groq、Ollama 等), - 也可以自己写一个接任意兼容 API。 + 可以。内核不绑定厂商:每个 LLM 源对应一个 Lua 脚本负责请求与响应转换。 + 内置 9 个适配器(OpenAI、Anthropic、Gemini、DeepSeek、Groq、Mistral、 + Ollama、GitHub、KimiCode),也可自己写一个接任意兼容 API。

@@ -1180,12 +1426,11 @@ echo "你好,记住我喜欢喝咖啡" | ./build/waiter

- AI 辅助编程声明:本项目代码、文档与提交历史中,部分内容由 AI 辅助生成或修改。 - 人工已审阅关键改动,使用时请自行评估与验证。 + AI 辅助声明:本项目代码、文档与提交历史含 AI 生成或修改的内容。 + 关键改动经人工审阅,使用前请自行评估验证。

- HomeAgent 以 AGPL-3.0-only 发布:通过 HTTP 提供交互式服务时, - 须向使用者提供完整对应源码(§13)。 + AGPL-3.0-only。通过 HTTP 提供交互式服务时,须向使用者提供完整对应源码(§13)。

@@ -1341,6 +1586,66 @@ echo "你好,记住我喜欢喝咖啡" | ./build/waiter }); } + /* ── 插件徽章:点击展开详情(可多开;「全部」按钮切全展开) ── */ + (function () { + var badges = [].slice.call(document.querySelectorAll(".badge[data-plugin]")); + var hint = document.getElementById("phint"); + var detail = document.getElementById("pdetail"); + if (!badges.length || !detail) return; + + function sync(btn) { + var id = "p-" + btn.dataset.plugin; + var card = document.getElementById(id); + var on = btn.getAttribute("aria-pressed") === "true"; + if (card) card.classList.toggle("show", on); + } + badges.forEach(function (b) { + b.setAttribute("aria-pressed", "false"); + b.addEventListener("click", function () { + var on = b.getAttribute("aria-pressed") === "true"; + b.setAttribute("aria-pressed", on ? "false" : "true"); + sync(b); + // 有任何一张打开就藏起提示 + var any = badges.some(function (x) { + return x.getAttribute("aria-pressed") === "true"; + }); + if (hint) hint.style.display = any ? "none" : ""; + // 首次打开时把详情滚进视口 + if (!on) { + var c = document.getElementById("p-" + b.dataset.plugin); + if (c && c.getBoundingClientRect().top > window.innerHeight - 120) { + c.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "center" }); + } + } + }); + }); + + // 「…共 N 个」按钮:一次全展开 + var all = document.querySelector(".badge.muted"); + if (all && !all.dataset.bound) { + all.dataset.bound = "1"; + var expanded = false; + var total = badges.length; + all.setAttribute("role", "button"); + all.setAttribute("tabindex", "0"); + all.textContent = "展开全部 " + total + " 个"; + function toggleAll() { + expanded = !expanded; + detail.classList.toggle("all", expanded); + badges.forEach(function (b) { + b.setAttribute("aria-pressed", expanded ? "true" : "false"); + sync(b); + }); + all.textContent = expanded ? "收起" : "展开全部 " + total + " 个"; + if (hint) hint.style.display = expanded ? "none" : ""; + } + all.addEventListener("click", toggleAll); + all.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleAll(); } + }); + } + })(); + /* ── 粒子网络 ── */ var cv = document.getElementById("fx"); if (!reduce && cv && cv.getContext) {