diff --git a/.gitignore b/.gitignore index 2b6a8e8..3253484 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ sdkconfig.old runtime/dist/ logs/ tmp/ +out/ server/ site-experience/ __pycache__/ @@ -33,3 +34,10 @@ services/weclawbot-agent/out/ # Local research archive from the pre-open-source workspace. # Keep it locally for investigation, but do not publish it without a secrets audit. history/ + +# The Kindle poll adapter is a reviewed, dependency-free public reference +# implementation. Keep its generated per-device images out of Git. +!integrations/kindle/server/ +!integrations/kindle/server/poll-server.mjs +!integrations/kindle/server/data/ +!integrations/kindle/server/data/.gitignore diff --git a/CMakeLists.txt b/CMakeLists.txt index fa1241e..2b559fa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.16) -set(PROJECT_VER "0.1.83") +set(PROJECT_VER "0.1.89") include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(weclawbot) diff --git a/dependencies.lock b/dependencies.lock index bbf345f..82218f3 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -97,6 +97,6 @@ direct_dependencies: - espressif/mqtt - idf - lvgl/lvgl -manifest_hash: 932c1aedb7e766664220d75c09f64907db12aeb4629a82f286de269eea192fe3 +manifest_hash: 84ebef8642f676ca94d52ab6c48e6434542a77bad5cb9b6de65d4facad369669 target: esp32s3 version: 2.0.0 diff --git a/docs/current-progress.md b/docs/current-progress.md index 7a8eb7f..3db5931 100644 --- a/docs/current-progress.md +++ b/docs/current-progress.md @@ -1,6 +1,6 @@ # WeClawBot 当前进展记录 -记录日期:2026-07-17 +记录日期:2026-07-18 这份文档用于交接当前项目状态。它不是最终产品说明,而是把已经形成的 产品判断、实现边界、已验证路径和待处理问题集中放在一个入口,方便后续 @@ -1181,3 +1181,110 @@ weclawbotctl clear 本轮首次读取直接成功,因此“读取失败后每 2 秒重试”只标记为代码与回归检查通过, 未标记为故障注入真机验证。开发版标题的构建配置和标题映射已验证,最终屏幕实拍 仍由用户目视确认。 + +## 2026-07-18 双硬件绑定码提示统一 + +- BYOA 未接管页统一使用“绑定码”术语。绑定卡片在两款硬件上都显示十分钟有效期, + 并把实体操作写清为“按上下键切换”(Note 4)或“按左右键切换”(微雪 RLCD), + 不再使用含糊且不一致的“上下循环 / 左右循环”。绑定码刷新期间也保留同一按键提示。 +- 底部引导统一为“请为智能体安装 weclawbotctl 插件,输入上方绑定码”,明确安装位置 + 是用户自己的智能体,并直接指向当前屏幕上的六位绑定码。绑定卡片改为两行详情布局, + 两款硬件共用同一渲染路径,只有真实按键方向由硬件适配常量决定。 +- 开发版本提升到 `0.1.84`。固件回归检查通过;ESP-IDF 5.5.2 隔离开发构建结果为 + Note 4 `0x633530`(应用分区余 22%)、微雪 `0x622e50`(余 23%)。两台真机均已 + 保留 NVS 刷入且全部镜像通过 esptool 哈希校验;启动串口分别确认 Note 4 的 + `ZectrixEpd`、微雪的 `RlcdDisplay`,两台均运行 `0.1.84`,联网后均成功进入 + `BYOA code shown`。具体字形、换行和边界是否理想仍待用户目视真机确认。 + +## 2026-07-18 接管页与留言页按键提示、码区局部刷新 + +- 开发版 `0.1.85` 继续按真实键位适配文案。官方二维码页底部固定为 Note 4 + “微信扫码接入微笺智能体 按上/下键切换智能体”、微雪 RLCD + “微信扫码接入微笺智能体 按左/右键切换智能体”。绑定卡片沿用相同的带斜杠方向 + 和“切换智能体”语义。接管后的留言页改为 Note 4“按上/下键切换屏幕”、微雪 + “按左/右键切换屏幕”,并保留微信端或智能体可更新/清除的来源提示。 +- 微信二维码到期不再显示“二维码已过期”错误页,也不等待 1.2 秒;登录任务直接请求 + 新码,UI 在现有二维码图像对象上替换资源。绑定码更新同样不再清屏重建,只修改 + 六位数字和详情标签。两条路径都把失效范围收敛到码区,最终物理刷新窗口仍由显示 + 适配层根据像素差确定。 +- 固件回归检查与 ESP-IDF 5.5.2 双目标隔离开发构建通过:微雪 `0x623110` + (应用分区余 23%),Note 4 `0x6337e0`(余 22%)。两台真机均保留 NVS 刷入 + `0.1.85`,全部镜像哈希校验通过,启动日志确认正确板型与版本。两台都实际跨过 + 一轮微信二维码有效期并直接请求新码;Note 4 新码的物理脏区为 + `x=40, y=131, 128×123`,没有出现独立失效页或整屏刷新。绑定码的对象级局部 + 替换已通过双目标编译和契约检查,本轮未等待十分钟做真机超时计时。 + +## 2026-07-24 Note 4 长按全清后绑定码恢复 + +- 用户报告 Note 4 长按前面板大键全清后停在“已全清,正在准备智能体绑定码”的启动页。 + 该动作已经完成本地清除,但按钮路径只依赖 BYOA 主循环随后观察到状态变化并重新 + bootstrap,没有像接管方式切换那样把重置后的启动链明确收束到一次干净重启。 +- 开发版 `0.1.86` 将全清动作统一为“清文本/照片/登录和绑定凭据、显示重置页、提交 + 当前帧、等待显示空闲、重启”。按钮路径调用 `ClearAgentCredentials(false)`,避免 + 在即将重启时再次被 `esp_mqtt_client_stop()` 的无界等待拖住;旧 MQTT 连接交给重启 + 回收。Note 4 仍额外请求 SSD2683 整屏刷新,微雪 RLCD 同步提交当前帧,两款硬件保持 + 相同产品语义:重置后回到当前接管模式的官方二维码或 BYOA 绑定码入口。 +- 同版还补齐 `lv_image_cache_drop()` 所需的 LVGL cache 头,并把 `esp_tinyusb` 写入 + `dependencies.lock`,修复公开 CI 干净构建中二维码资源释放路径的编译失败。 +- 固件回归检查通过;ESP-IDF 5.5.2 双目标隔离公开构建通过:微雪 `0x6364a0` + (应用分区余 22%),Note 4 `0x6469f0`(余 22%)。两台真机均保留 NVS 刷入 + `0.1.86` 且全部镜像哈希校验通过。串口 `WEC:GET` 确认微雪运行 + `Waveshare ESP32-S3-RLCD-4.2` / `0.1.86`,Note 4 运行 `Zectrix Note 4` / + `0.1.86`;Note 4 当前为 `dashboard_view=byoa_takeover`、 + `agent_transport_state=awaiting_pairing`,已经拿到新的六位绑定码。实体长按大键 + 的最终目视复测仍需用户现场操作确认。 + +## 2026-07-25 Note 4 电池态长时间停画面自恢复 + +- 用户报告 Note 4 未插 USB 供电一段时间后停在 BYOA 绑定码页,屏幕时钟和日历与真实 + 时间相差约 18 小时。局域网检查显示设备仍可 ping 通,但固件没有 HTTP 状态服务, + 因此 IP 在线只能证明网络栈仍活着,不能证明运行时 UI 或墨水屏物理刷新仍在推进。 +- 开发版 `0.1.87` 把运行时 UI 心跳提前到 60 秒电池/校时块与云天气轮询之前,并在 + 非板载温湿度硬件上校时后立即补一次 UI tick,避免 Note 4 的网络天气或校时路径把 + 屏幕刷新长期排在后面。日历页新增本地日期 key,跨天时会重绘右侧月历和当天高亮, + 不再只更新左侧时间文字。 +- BYOA 绑定码页新增本地刷新桶:微雪 RLCD 仍按秒更新有效期,Note 4 按分钟更新有效期, + 降低墨水屏无意义刷新压力;绑定码到期后继续在当前码区换新码,不进入单独失效页。 + 墨水屏物理刷新若在转场或 `DisplayFrame` 阶段失败,会在 15 秒后调度一次整屏恢复 + 刷新,避免一次 busy/供电瞬态故障把待显示帧静默丢掉。 +- 固件回归检查与 ESP-IDF 5.5.2 双目标隔离公开构建通过:微雪 `0x6365c0` + (应用分区余 22%),Note 4 `0x646c20`(余 22%)。本轮尚未刷入长时间电池态复测; + 后续若要不插 USB 远程判断“进程活着但屏未刷新”,需要新增不暴露凭据的只读 LAN + 健康状态端点。 + +## 2026-07-26 真机自动刷机引导 + +- 用户要求两台真机后续开发版刷新不再依赖人工按键配合。开发版 `0.1.88` 在串口 + `WEC:*` 协议中新增 `WEC:BOOTLOADER` / `WEC:DOWNLOAD_MODE`,ESP32-S3 目标通过 + `RTC_CNTL_FORCE_DOWNLOAD_BOOT` 标记重启后进入 ROM 下载模式;不支持该路径的目标会 + 返回 `bootloader_unsupported`。这为已经运行 `0.1.88+` 的设备提供“从应用态自动进入 + 刷机态”的主路径。 +- 新增 `scripts/flash_via_serial.py`:扫描 USB CDC 端口、用 `WEC:GET` 识别 + `Waveshare ESP32-S3-RLCD-4.2` / `Zectrix Note 4`,映射到对应构建目录,发送 + `WEC:BOOTLOADER` 后按构建目录的 `flash_args` 刷入,保留 NVS。脚本同时处理旧固件 + 兼容:串口交互保持 DTR=true、周期性重发 `WEC:GET`,若 bootloader 命令不可用则 + 尝试 CDC line toggle 和 esptool `default-reset`,单台失败不会阻断后续设备,最终汇总 + 失败列表。 +- 固件回归检查通过;ESP-IDF 5.5.2 双目标隔离开发构建通过:Note 4 `0x633c60` + (应用分区余 22%),微雪开发配置 `0x6233e0`(余 23%)。本轮尝试直接给两台仍运行 + `0.1.86` 的真机刷入 `0.1.88`,但旧固件没有 bootloader 串口命令;esptool + `default-reset` / `usb-reset` 均报 ROM 连接无串口数据,OpenOCD 也未发现 + ESP32-S3 USB-JTAG 设备。结论:这两台 `0.1.86` 真机需要先完成一次可进入 ROM + 下载模式的引导升级;升级到 `0.1.88+` 后,后续开发版刷新可走脚本自动化路径。 + +## 2026-07-26 开发源码统一公开与取消 USB MSC + +- 按 2026-07-26 更新的公开发布边界,开发版固件、脱敏后的官方 Agent、确定性测试/证明用例、 + Kindle KUAL 客户端和明确标注为非生产的轮询参考服务统一进入公开源码。`out/`、 + Kindle 生成图片、运行日志、候选账本、真实设备数据、凭据和生产部署配置继续排除。 + Kindle 讨论稿移除了真实主机信息,参考服务也限制 `device_id` 格式,避免图片路径 + 被构造为目录穿越。 +- 开发版提升到 `0.1.89`,完全移除 USB MSC 产品盘:删除产品盘源码与设计文档、 + `usbdrive` FAT 分区、TinyUSB MSC 依赖和发布 defaults,同时清除屏幕与帮助文案中的 + U 盘入口。USB Serial/JTAG、`WEC:*` 配置协议和串口自动刷机能力继续保留;设备连接 + 用户电脑后不再挂载 WeClawBot U 盘。 +- 固件回归契约、Kindle Node/Shell 语法、Agent Python 编译、离线 e2e 和 SkillRepo + display shadow proof 均通过。ESP-IDF 5.5.2 两款公开配置隔离构建通过:微雪 + `0x6233a0`(应用分区余 23%),Note 4 `0x633bb0`(余 22%)。两份生成的分区表 + 都只含 NVS、OTA、PHY、factory 和 SPIFFS,且都保留 USB Serial/JTAG;本轮没有刷写 + 真机,也没有创建 `0.1.89` GitHub Release 或更新安装页固件资产。 diff --git a/docs/hardware.md b/docs/hardware.md index 2f10a02..1abad4f 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -28,6 +28,19 @@ content is skipped, and a multi-page message keeps its page order. Cached message/photo content is never exposed by navigation while the device is unclaimed. +The unclaimed takeover screens make the physical control explicit. The WeChat +QR footer is `微信扫码接入微笺智能体 按上/下键切换智能体` on Note 4 and +`微信扫码接入微笺智能体 按左/右键切换智能体` on the Waveshare RLCD. The +BYOA screen names the six-digit value a binding code and uses the same +board-specific switch hint. Both targets share the binding-card layout and +footer guidance to install the `weclawbotctl` plugin in the user's agent and +enter the binding code; only the real button direction differs. + +After takeover, note footers use `按上/下键切换屏幕` on Note 4 and +`按左/右键切换屏幕` on the Waveshare RLCD. QR and binding-code expiry replace +the code object in place instead of showing an expiry page or rebuilding the +whole screen, allowing the display adapter to refresh only the changed region. + RLCD pins used by the driver: | Signal | GPIO | @@ -63,3 +76,20 @@ Battery sensing: | Signal | GPIO / ADC | Notes | WeClawBot use | | --- | --- | --- | --- | | Battery voltage | GPIO4 / ADC1_CH3 | Board example documents battery voltage through a 3x divider. USB power can still make this rail look like a lithium cell, so `CONFIG_WEC_BATTERY_INSTALLED` gates whether the UI treats it as a real battery. | Top status battery/no-battery icon | + +Development flashing: + +Once a board is running firmware with the serial bootloader command, the host +can refresh both hardware targets without physical button timing: + +```sh +./scripts/flash_via_serial.py \ + --waveshare-build build-dev-waveshare \ + --note4-build build-dev-note4 +``` + +The helper identifies each USB CDC port with `WEC:GET`, enters ROM download +mode with `WEC:BOOTLOADER`, and flashes the matching build directory using +`flash_args` without erasing NVS. For pre-bootloader-command firmware, the +script can try CDC line toggles and esptool `default-reset`, but those paths +depend on the board exposing host-controlled reset/boot straps. diff --git a/docs/kindle-display-client-discussion.md b/docs/kindle-display-client-discussion.md new file mode 100644 index 0000000..2fdd74a --- /dev/null +++ b/docs/kindle-display-client-discussion.md @@ -0,0 +1,319 @@ +# Kindle 作为 WeClawBot 显示终端讨论稿 + +Date: 2026-07-10 + +## 实现状态(2026-07-10 更新) + +**拉取模型的最小可行实现已经在真机上跑通**,代码在 [`integrations/kindle/`](../integrations/kindle/): + +- 服务端参考实现 `integrations/kindle/server/poll-server.mjs`(内存态 demo,不是生产网关) +- 设备端 KUAL 扩展 `integrations/kindle/client/`(六位配对码 + 轮询 + fbink 刷新) +- 真机验证:配对、`weclawbotctl screen` 等价调用、图片下载、`fbink -g` 刷新、 + crond keepalive 保活(强制关闭 preventScreenSaver/WiFi PM 后确认 60 秒内被 cron 恢复)全部在 + 局域网内一台真实越狱 Kindle Basic 2 上跑通,不是纸面设计。 +- 本文档下方"MQTT push"相关的架构草案已被拉取模型取代,保留仅供参考背景;权威方案见 + `integrations/kindle/README.md`,其中列了真机验证到的具体坑(fbink 无 CJK 字形、 + WiFi 芯片是 Broadcom 不是 Atheros、这套固件的 `initctl`/`mntroot` 不在默认 PATH 等) + 和生产网关还需要补的五件事。 + +本文讨论一个假设:用户已经拥有一台可运行自定义程序的越狱 Kindle,我们是否应该把它纳入 +WeClawBot / 微笺屏体系。 + +核心结论先放前面:**值得做,但第一版不应把 Kindle 当第三块固件硬件板卡,而应把它当 +BYOA 优先的 WeClawBot display client**。也就是说,Kindle 运行一个本地小客户端,复用现有 +配对、MQTT、`activity`、`screen_document` 和预览反馈体系;WeClawBot 项目不负责 Kindle +越狱本身,不接管 Kindle 系统,也不把微信/iLink 凭证搬到 Kindle 上。 + +## 为什么值得做 + +Kindle 和现在已支持的 RLCD / 墨水屏开发板有明显互补: + +- 市面存量大,二手设备多,用户可能已经有闲置 Kindle。 +- 屏幕尺寸更大,适合家庭看板、长清单、日程、阅读摘记、状态墙。 +- E Ink 长时间显示天然适合“安静地把有用的东西留在那里”。 +- 设备自带外壳、电池、Wi-Fi 和屏幕,不需要用户焊接或购买开发板。 +- 对 Agent 来说,它仍然可以是一个“把内容放到屏上”的能力。 + +但 Kindle 也不是 ESP32: + +- 它是一个越狱后的 Linux 阅读器,不是我们完整控制的固件设备。 +- 不同型号屏幕尺寸、刷新能力、系统命令和休眠策略差异很大。 +- Wi-Fi 省电和系统休眠可能让 live-only MQTT 长连接不稳定。 +- 凭证存储无法达到 ESP32 本地固件边界那种可审计程度。 +- 维护越狱教程、系统补丁、各型号兼容表会迅速变成另一个项目。 + +所以第一版的产品语义应该是:“**已越狱 Kindle 可以安装一个 WeClawBot 显示客户端**”,而不是 +“WeClawBot 官方支持帮你越狱 Kindle”。 + +## 推荐定位 + +### 第一阶段:BYOA / 自定义智能体显示终端 + +Kindle 只做用户 Agent 的屏幕,不做微信官方入口。 + +推荐路径: + +1. Kindle 客户端启动后访问 WeClawBot BYOA bootstrap。 +2. Kindle 屏幕显示 6 位绑定码。 +3. 用户让 Codex、Hermes、OpenClaw 或脚本运行 `weclawbotctl bind `。 +4. Agent 获得 scoped MQTT 凭据。 +5. Kindle 获得 device scoped MQTT 凭据。 +6. Agent 下发 `activity`、`screen_document`、`screen_clear`。 +7. Kindle 客户端渲染到本机 E Ink 屏幕,并发布 `applied/rejected/status`。 + +这条路径和现有 BYOA 心智一致:用户自己的 Agent 拥有屏幕;WeChat/iLink 不参与。 + +### 第二阶段:官方模式再评审 + +官方微信模式不建议一开始做。原因不是技术上绝对不行,而是边界会变复杂: + +- Kindle 没有 ESP32 固件里的 iLink token 隔离模型。 +- 官方模式当前假设“微信登录、轮询、回复由设备固件持有”,Kindle 客户端是否有资格承担这个角色需要重新审计。 +- 多用户官方服务对稳定性、版本、凭证保护和回滚要求更高。 + +如果未来确实要支持,建议也不要把 WeChat token 暴露给用户 Agent。可以设计成官方云端账号下的一块 +“官方显示设备”,但那已经是独立产品线,不应阻塞第一阶段。 + +## 架构草案 + +```mermaid +flowchart LR + A["用户 Agent / weclawbotctl"] -->|"MQTT control"| M["MQTT/WSS broker"] + K["越狱 Kindle\nweclawbot-kindled"] -->|"status/events"| M + M -->|"activity / screen_document / screen_clear"| K + K --> D["Kindle E Ink display"] +``` + +Kindle 侧新增一个客户端,暂称 `weclawbot-kindled`。 + +职责: + +- 生成或持有 `device_id`。 +- 走 BYOA bootstrap,显示绑定码。 +- 保存最小权限 MQTT device credential。 +- 连接 MQTT/WSS control topic。 +- 校验 `activity`、`screen_document`、`screen_clear`。 +- 根据 Kindle 屏幕 profile 把页面刷新到本地 E Ink。 +- 发布 `online`、`device_context`、`applied`、`rejected`。 +- 处理本地按键或触摸:上一页、下一页、清屏、重配对。 + +不做: + +- 不负责越狱。 +- 不保存 Wi-Fi 密码。 +- 不保存 WeChat/iLink token。 +- 不执行 Agent 发来的 shell 命令。 +- 不做离线 retained mailbox。 +- 不把旧控制消息跨重连补播到屏幕。 + +## 协议复用与需要补的地方 + +现有 `screen_document` 可以复用,但要解除“只有 400x300 / 368x206”的隐含假设。 + +Kindle 客户端应在 `device_context` 中声明真实能力,例如: + +```json +{ + "schema": "weclawbot.device_context.v1", + "canvas": { + "width": 758, + "height": 1024, + "color": "mono1", + "refresh": "reflective_slow" + }, + "content_viewport": { + "id": "content", + "x": 32, + "y": 48, + "width": 694, + "height": 900, + "format": "mono1", + "max_pages": 8, + "auto_page_seconds": 30 + }, + "display_capabilities": { + "schema": "weclawbot.display_capabilities.v1", + "product_id": "kindle-eink-userland", + "kind": "eink", + "technology": "electrophoretic", + "pixel": { + "width": 758, + "height": 1024, + "color_mode": "mono1", + "native_format": "mono1", + "bit_order": "msb_first", + "black_bit": 1 + }, + "refresh": { + "class": "reflective_slow", + "partial": true, + "recommended_min_update_interval_ms": 60000, + "ghosting_risk": "medium" + } + } +} +``` + +第一版仍建议只支持 `mono1`,不要急着引入灰阶图片协议。灰阶、抖动和照片优化可以留到第二阶段。 + +需要改造的地方: + +- `weclawbotctl` 的 validator / preview 不能只认 ESP32 内容区,应能接收 live `device_context` + 或显式 `--profile kindle-*`。 +- Agent 排版器应根据 `content_viewport` 响应式生成页面,而不是硬塞 368x206。 +- 固件/客户端都应发布 `display_capabilities`,让 Agent 不再靠型号猜尺寸。 +- 预览 manifest 要记录目标 profile,避免把 Kindle 预览误当 ESP32 预览。 + +## Kindle 客户端实现方向 + +优先考虑一个小型常驻 daemon,而不是让 Agent SSH 到 Kindle 上操作。 + +候选实现: + +- 静态二进制:Go / Rust / C。优点是依赖少;缺点是交叉编译和 TLS 体积需要评估。 +- Python 脚本:开发快;但不同 Kindle 环境不一定有可用 Python 3 和现代 TLS。 +- Shell + `mosquitto_sub`:原型快;但安装依赖、JSON 校验、TLS 错误处理和状态回执都会粗糙。 + +建议 MVP: + +1. 先在 Mac/Linux 上写一个 `kindle-display-sim`,模拟 Kindle profile,消费 MQTT control, + 输出 PNG/PBM 文件和 status。 +2. 再做 Kindle 真机 proof:最小客户端显示绑定码、收到一页 `mono1`、刷新屏幕、回传 `applied`。 +3. 最后封装成 KUAL extension 或简单安装包,面向“已经越狱且愿意自己安装插件”的用户。 + +显示刷新层要做成 backend: + +- `kindle_eips_backend`:调用设备上可用的 E Ink 刷新命令。 +- `framebuffer_backend`:直接写 framebuffer 或中间位图。 +- `file_backend`:开发/CI 用,输出 PNG/PBM,不碰真机。 + +具体 Kindle 命令差异不要写死进协议层。 + +## 交互设计 + +Kindle 首页可以比 ESP32 更像“屏幕终端”: + +- 未配对:大号显示 6 位绑定码、设备名、剩余时间、简短安装提示。 +- 已配对空闲:显示 Agent 名称、在线状态、最后更新时间。 +- thinking:显示静态思考态,不做高频动画。 +- 内容页:支持更多页面,左右翻页或触摸翻页。 +- 长按/菜单:重置配对、清屏、显示诊断信息。 + +由于 Kindle 屏幕更大,Agent 不应简单把 ESP32 小卡片放大;应允许: + +- 长清单; +- 日程表; +- 家庭留言板; +- 阅读摘记; +- 低频状态面板; +- 多页文档摘要。 + +但仍要坚持一个原则:Kindle 客户端只是显示终端,排版审美和内容选择在 Agent 端。 + +## 安全与隐私 + +越狱 Kindle 的安全假设弱于 ESP32 固件。需要明确写进文档: + +- WeClawBot 不提供越狱教程,不鼓励用户绕过设备安全边界。 +- 只支持用户已经能运行自定义程序的设备。 +- Kindle 本地只保存最小权限 MQTT device credential。 +- 旧 Agent 被重新配对取代后,云端 owner / binding 仍是权威。 +- 凭证泄漏时的影响范围必须限制在单个 device topic。 +- 不允许 Kindle 客户端接收任意 shell / file execution control。 +- 不把官方微信 token 或用户 Wi-Fi 明文交给 Agent。 + +老 Kindle 可能缺现代 CA / TLS 能力。客户端如果需要内置 CA bundle,也应只服务于 MQTT/WSS 和 +BYOA HTTPS bootstrap,不应绕过证书校验。 + +## 主要风险 + +1. **型号碎片化**:不同 Kindle 分辨率、刷新命令、系统版本差异大。 +2. **休眠与 Wi-Fi**:Kindle 为省电可能断网,live-only MQTT 会频繁掉线。 +3. **安装门槛**:越狱用户少,安装包和排障会消耗维护精力。 +4. **刷新体验**:局刷/全刷、残影、对比度、字体渲染都要真机调。 +5. **协议泛化成本**:一旦支持 Kindle,现有工具中写死 ESP32 尺寸的地方都要清理。 +6. **边界误解**:用户可能以为 WeClawBot 负责 Kindle 越狱和系统维护。 + +这些风险不应否定方向,但说明第一版要小:只做 BYOA 显示客户端,只支持少数明确 profile,只保证 +`screen_document` 能可靠落屏。 + +## 最小验收标准 + +第一版 proof 可以这样定义: + +- Kindle 客户端能显示 6 位配对码。 +- `weclawbotctl bind ` 成功。 +- `weclawbotctl doctor --online` 能看到 Kindle online 和 display profile。 +- `weclawbotctl thinking --id ...` 能显示静态思考态,并能用匹配 id 回落。 +- `weclawbotctl screen kindle-doc.json` 能落屏,客户端返回 `applied`。 +- `weclawbotctl clear` 能清屏。 +- 重新配对后旧 Agent 发布失败,真机不被旧凭据修改。 +- 断网/休眠重连后不会显示过期 retained/queued 控制消息。 + +## 建议下一步 + +1. 新增一个 Kindle device profile 文档,先不要写死具体型号。 +2. 把 `weclawbotctl` 的 preview / validator 改成 profile-aware。 +3. 做一个本机 `kindle-display-sim`,验证非 400x300 的 `screen_document` 链路。 +4. 找一台已越狱 Kindle 做最小刷新 backend proof。 +5. 讨论是否把客户端放在 `integrations/kindle/`,还是单独仓库,避免主固件仓库被越狱生态复杂度拖住。 + +我的建议是:**立项时把名字定成 Kindle Display Client,而不是 Kindle Firmware Support**。前者是一个 +协议终端和 Agent 显示面,和 WeClawBot 当前方向一致;后者会把我们带进设备破解、系统移植和型号维护。 + +## 本地样机探测记录 + +2026-07-10 对一台以 `kindle-eink` 为脱敏代号的越狱 Kindle 做了只读探测和最小刷屏验证。这里不记录 +真实主机名、具体局域网地址和 MAC,避免把本地环境细节带进公开文档。 + +结论:**作为 Kindle Display Client 是可行的,且难点不在刷屏,而在客户端打包、常驻、休眠和协议泛化。** + +已确认: + +- 局域网 DNS 可解析该设备,设备在线。 +- SSH 可进入 root shell;Telnet 也开放。HTTP 80/443/8080 等常见端口未开放。 +- 系统为 Kindle 5.13.6,Linux 3.10.53-lab126,ARMv7 / i.MX6 SoloLite。 +- FBInk 已安装,版本为 `v1.25.0-179-gacf31a3`,这是非常适合做 E Ink 后端的现成组件。 +- FBInk 检测为 Kindle Basic 2,屏幕变量信息为 `600x800`、`8bpp`、rotation `270°`,framebuffer 为 + `/dev/fb0` / `mxc_epdc_fb`。 +- 设备有 `curl`、`wget`、`nc`、`openssl`、BusyBox;没有发现 Python、Node、mosquitto CLI。 +- 设备可从 Kindle 端 HTTPS 访问 `https://weclawbot.link/`,说明出站 TLS 基础可用。 +- `/mnt/us`、`/mnt/us/extensions` 和 `/var/local` 可写;rootfs 大部分为只读。 +- `fbink` 文本探针成功:远程执行后 Kindle 屏幕可见更新。 +- `fbink` 图片探针成功:本地生成一张 `600x800` 单色 PNG,传到 Kindle `/tmp`,通过 + `fbink -g file=/tmp/... -W GC16 -w` 全屏显示成功。 + +这说明第一版 Kindle 客户端可以走: + +```text +weclawbot-kindled + -> BYOA bootstrap / MQTT over its own TLS stack + -> receive screen_document + -> convert mono1 page to PNG/PNM or direct framebuffer buffer + -> FBInk image refresh + -> publish applied/rejected +``` + +不建议第一版依赖 Kindle 自带 Python/Node/npm,也不建议让 Agent 通过 SSH 临时控制 Kindle。更稳的方向是 +交叉编译一个小的 Linux ARMv7 客户端,放到 `/mnt/us/extensions/weclawbot/` 或同类用户可写位置;客户端 +只调用 FBInk 做显示后端。 + +本机样机的初始 profile 可以先按下面收敛: + +```json +{ + "product_id": "kindle-basic2-eanab", + "kind": "eink", + "width": 600, + "height": 800, + "native_format": "gray8_framebuffer", + "accepted_screen_document_format": "mono1", + "display_backend": "fbink", + "refresh_class": "reflective_slow", + "recommended_min_update_interval_ms": 60000 +} +``` + +协议上仍建议让 Agent 发送 `mono1` 页面。Kindle 客户端内部可以把 `mono1` 展开成 FBInk 能显示的 PNG/PNM +或 framebuffer 数据。这样可以保持和 ESP32 屏幕相同的安全边界:Agent 提供像素文档,显示客户端校验尺寸、 +页数、revision 和刷新节奏。 diff --git a/docs/public-release-boundary.md b/docs/public-release-boundary.md index d33fba2..933e39d 100644 --- a/docs/public-release-boundary.md +++ b/docs/public-release-boundary.md @@ -25,7 +25,13 @@ If a file is not clearly inside the public boundary, do not publish it. - User-facing documentation under `docs/`, including architecture, protocol, privacy boundary, BYOA reliability notes, and open discussion records. - Agent integration source and packaging metadata under `integrations/`, such - as the Hermes plugin source and generic AI-assistant integration docs. + as the Hermes plugin source, the reviewed Kindle poll-adapter reference + implementation, and generic AI-assistant integration docs. Generated + per-device data remains excluded. +- Sanitized official Agent source, tests, fixtures, and deterministic proof + cases under `services/weclawbot-agent/`. Runtime state, candidate ledgers, + model credentials, production tenant data, and deployment secrets remain + excluded. - Rules-first runtime examples under `runtime/` only after a privacy audit. `runtime/dist/`, generated bundles, raw logs, and private eval ledgers stay out. @@ -34,10 +40,11 @@ If a file is not clearly inside the public boundary, do not publish it. ## Public Repository Must Not Contain -- Official hosted gateway/cloud implementation, provisioning scripts, broker - admin scripts, server logs, or private deployment code. In this workspace, - `server/` is private unless explicitly reviewed and split into a separate - public artifact. +- Production gateway/cloud deployment code, provisioning scripts, broker admin + scripts, server logs, private infrastructure, or tenant data. A small + dependency-free reference adapter may live under an integration when it is + explicitly labelled non-production, reviewed, and contains no credentials or + generated device data. - The private `weclawbot.link` website/gateway source. Public files may document protocol requirements such as BYOA active binding and MQTT credential revocation, but the hosted implementation stays private unless separately @@ -45,9 +52,11 @@ If a file is not clearly inside the public boundary, do not publish it. - The private website deployment workspace. In this workspace, `site-experience/` is local/private; the public repo only publishes `web/firmware-contract.json` and the sync rules. -- `history/`, `logs/`, `tmp/`, `build/`, `managed_components/`, `node_modules/`, - `__pycache__/`, local `sdkconfig`, local `.env` files, packaged archives, or - generated firmware/debug artifacts from an unverified local build. +- `history/`, `logs/`, `tmp/`, `out/`, `build/`, `managed_components/`, + `node_modules/`, `__pycache__/`, local `sdkconfig`, local `.env` files, + packaged archives, generated device images, candidate/evaluation ledgers + containing runtime observations, or generated firmware/debug artifacts from + an unverified local build. - Wi-Fi SSIDs/passwords, WeChat bot tokens, QR tokens, context tokens, iLink login state, MQTT broker admin credentials, npm/GitHub tokens, model provider keys, Tencent Cloud/COS/SCF resource IDs, raw user messages, raw media URLs, @@ -93,7 +102,7 @@ Before pushing to `openbrt/weclawbot`: 3. Confirm no banned path is staged: ```sh - git diff --cached --name-only | rg '(^server/|^site-experience/|^history/|^build/|node_modules|__pycache__|\.env$|\.log$|runtime/dist/)' + git diff --cached --name-only | rg '(^server/|^site-experience/|^history/|^out/|^build/|node_modules|__pycache__|\.env$|\.log$|runtime/dist/|integrations/kindle/server/data/.+\.png$)' ``` 4. Run a staged secret scan for token/key/password/private-key shapes. diff --git a/docs/usb-msc-product-disk.md b/docs/usb-msc-product-disk.md deleted file mode 100644 index 480e200..0000000 --- a/docs/usb-msc-product-disk.md +++ /dev/null @@ -1,119 +0,0 @@ -# USB MSC Product Disk - -WeClawBot should be able to introduce and configure itself without depending on -the user finding the correct website first. When the board is plugged into a -computer, the firmware should expose a small USB Mass Storage Class disk named -`WECLAWBOT`. That disk is part of the product surface, not a development-only -artifact. - -## Goals - -- Provide offline help and product orientation from the device itself. -- Include browser entry pages for firmware installation, Wi-Fi configuration, - official/custom Agent mode, and troubleshooting. -- Keep the public firmware auditable: the disk must not expose WeChat tokens, - Wi-Fi credentials, API keys, local note cache, or cloud implementation details. -- Preserve the browser-based setup flow used by VoxStick-like projects: users - open an HTML page, then the page talks to the device through a browser USB - interface. - -## Disk Contents - -Initial disk contents should be generated from the public repository at build -time: - -- `README.html`: quick start, safety notes, and device status meanings. -- `README.zh-CN.html`: Chinese quick start. -- `install.html`: ESP Web Tools firmware installation and update entry. -- `config.html`: Web Serial/WebUSB configuration UI for Wi-Fi, official/custom - Agent mode, and basic device state. -- `help/`: static troubleshooting pages. -- `firmware/manifest.json`: local firmware manifest when the image bundle fits. -- `firmware/`: optional local firmware binaries for offline flashing. -- `VERSION.txt`: firmware version, board target, build date, and public source - URL. - -The pages may link to `https://weclawbot.link/` for the newest online docs, but -the mounted disk must remain useful without network access. - -## Entry Recovery - -Users will forget the website after the device has been running for a while. -The product must therefore provide multiple memory-free recovery paths: - -- The USB MSC disk is the primary recovery path. Plugging the device into a - computer reveals the `WECLAWBOT` drive and its start page. -- The firmware should show `weclawbot.link` on USB configuration and recovery - screens. -- The WeChat bot should reply to `帮助`, `官网`, `/help`, and similar messages - with the product URL and the USB-disk setup path. -- Printed packaging and public docs may mention the URL, but the product must - not depend on packaging being kept. - -## Firmware Architecture - -ESP-IDF 5.5 includes a TinyUSB composite device example that supports MSC plus -CDC serial on ESP32-S3. WeClawBot should follow that direction instead of using -MSC alone, because the existing local configuration page already speaks the -line-based `WEC:` protocol over USB serial. - -Recommended device functions: - -- MSC: exposes the `WECLAWBOT` help/install/config disk. -- CDC ACM: exposes the same `WEC:` configuration protocol currently carried by - USB-Serial-JTAG/UART. -- ROM download mode remains unchanged for recovery flashing. - -The current firmware uses USB-Serial-JTAG for configuration. Migrating to the -TinyUSB composite stack will require adapting `SerialConfig` to read and write -through TinyUSB CDC while keeping UART fallback for development logs. - -## Partition Plan - -Do not reuse the existing `storage` SPIFFS partition. That partition holds local -note state, screen-frame cache, idle-photo cache, and other runtime data. - -Add a dedicated FAT partition, for example: - -```text -usbdrive, data, fat, , 0x180000, -``` - -The exact size depends on whether firmware binaries are bundled locally: - -- Help/config pages only: 512 KiB to 1 MiB is enough. -- Pages plus split firmware binaries: 2 MiB or more is safer. -- Full merged firmware image: usually too large to be worth bundling on every - build unless the app partition budget is adjusted deliberately. - -The first implementation should treat the MSC disk as a generated product disk. -Runtime writes from the host are not required. If writable behavior is enabled -later, the firmware must avoid concurrent filesystem access while the host has -the disk mounted. - -## Build Pipeline - -The build should generate disk contents deterministically: - -1. Build firmware. -2. Run `scripts/prepare_web_firmware.sh`. -3. Render/copy static pages from `web/` and `docs/` into a staging directory. -4. Generate `VERSION.txt`. -5. Create a FAT image for the `usbdrive` partition. -6. Flash the app and the `usbdrive` image as separate artifacts. - -The release assets should include the application binaries, web installer -bundle, and the product-disk image. The public repository should not include -private cloud endpoints beyond the stable public product URL, and it must never -include credentials. - -## Open Questions - -- Whether `install.html` should embed local firmware binaries or always fetch - the newest public release from GitHub/weclawbot.link. -- Whether configuration should continue using Web Serial over TinyUSB CDC or - move to WebUSB for a cleaner browser permission flow. -- Whether macOS/Windows should see the disk as read-only at the USB protocol - level, or as a writable FAT disk whose writes are ignored/reset by firmware. -- How much flash should be reserved for `usbdrive` without constraining future - app growth, fonts, photo rendering, and Layout VM experiments. diff --git a/integrations/kindle/README.md b/integrations/kindle/README.md new file mode 100644 index 0000000..80cdf2a --- /dev/null +++ b/integrations/kindle/README.md @@ -0,0 +1,161 @@ +# WeClawBot Kindle Display Client (poll model) — reference implementation + +Status as of 2026-07-10: **proven end to end on a real jailbroken Kindle Basic 2** +(SSH access, KUAL, FBInk already installed by the owner). Not yet wired into +the production WeClawBot gateway — see "What production needs to add" below. + +This directory intentionally does **not** reuse the ESP32 firmware's +MQTT/WSS push protocol. See `docs/kindle-display-client-discussion.md` for +why: every established jailbroken-Kindle dashboard project (TRMNL, KOReader, +kindle-dash-client, weather_kindle, kindledashboard) uses a plain HTTP +polling loop, not a long-lived push connection, and that's what a Kindle +jailbreak player will recognize and be able to copy without reading our BYOA +protocol docs at all. + +## Layout + +``` +integrations/kindle/ + server/poll-server.mjs reference implementation of the poll contract + (NOT production — see below) + client/ the actual KUAL extension, drop-in installable + config.xml + menu.json + bin/ + weclawbot-poll.sh main loop: pair, then poll + fbink + start.sh / stop.sh / status.sh + power-tune.sh one-shot: preventScreenSaver=1, wl PM 0 + keepalive.sh same, silent, meant for cron (see below) + toggle-autostart.sh sets a marker file only, see caveat below + reset-pairing.sh forgets local device_id, does not revoke server-side +``` + +## Protocol (proven working) + +``` +POST /kindle/bind/request { device_id? } -> { device_id, code, expires_in } +POST /kindle/bind/confirm { code, name? } -> { ok, device_id, name } +GET /kindle/status?device_id=... -> { bound, name } +POST /kindle/screen { device_id, title, body } -> { ok, revision } +GET /kindle/poll?device_id=... -> { status, image_url, revision, refresh_rate } +GET /kindle/image/.png -> image/png +``` + +`weclawbot-poll.sh` on the device: generates/caches a `device_id`, calls +`bind/request`, shows the returned 6-digit code via `fbink` text mode, polls +`/kindle/status` every 5s until bound, then switches to the steady-state +loop: `GET /kindle/poll` → if `revision` changed, download `image_url` → +`fbink -g file=... -W GC16 -w` → `sleep refresh_rate`. + +This mirrors the existing ESP32 BYOA flow on purpose: an Agent still runs + +``` +weclawbotctl bind --name my-kindle +weclawbotctl doctor --online +weclawbotctl screen /path/to/screen-document.json +``` + +with **no Kindle-specific knowledge required on the Agent side** — the +translation from `screen_document` to `{image_url, refresh_rate}` happens +entirely on the gateway. + +## Real-hardware findings baked into this implementation + +- **fbink's default bitmap font (IBM 8x8) has no CJK glyph coverage.** + The pairing-code screen (`show_pairing_code` in `weclawbot-poll.sh`) is + therefore ASCII-only by design. CJK content works fine on the *image* + path (`fbink -g`), because that PNG is rendered server-side through + `rsvg-convert` (cairo/pango, real fonts) — confirmed rendering Chinese + correctly on-device. +- **`preventScreenSaver` and WiFi `PM` (power-save) do not survive + reboots or, in the case of `wl PM`, driver reassociation**, and + `preventScreenSaver` was observed getting cleared over time even without + an obvious trigger. `power-tune.sh` is therefore not a one-shot: it's + also installed as `keepalive.sh` in `/etc/crontab/root`, firing every + minute. This was live-tested: forcing both settings off and waiting + confirmed cron puts them back within 60s. +- **WiFi chip is Broadcom (`bcmdhd`/4343w), not Atheros.** Use `wl PM 0`, + not `wmiconfig` (which isn't installed on this device at all — that's an + Atheros-only tool from older Kindles). +- **The physical stand holds this unit landscape, but the panel's own + framebuffer is natively portrait (600x800, `rotation: 3`)** — `fbink -g` + has no per-call rotation flag, so content must be pre-rotated into the + portrait buffer server-side. Determined empirically, not assumed: pulled + the sibling `kindle-dock` project's known-good cached image + (`/mnt/us/kindle-dock/p.png`, proven correct in this exact stand), + rotated it both directions with `sips`, and visually confirmed which + rotation produces upright text. Answer: landscape content -> rotate 90° + **clockwise** -> portrait buffer. `poll-server.mjs` authors content in an + `LOGICAL_W=800 x LOGICAL_H=600` space and wraps it in + `translate(600,0) rotate(90)` before rendering to the `PANEL_W=600 x + PANEL_H=800` output. The pairing-code screen was moved from `fbink` text + mode to this same image pipeline for the same reason — text mode can't + be rotated per-call either, so it would otherwise stay sideways even + after the content path was fixed. +- **`kindle-dock`, a pre-existing unrelated auto-rotating info-screen + project, was already running on the validation unit** (the private LAN + service address has been omitted; its device script lived at + `/mnt/us/kindle-dock/vps_pull.sh`). + Pausing it to hand the screen to WeClawBot hit the same footgun its own + `dockctl.sh` is written to avoid: `pgrep -f vps_pull.sh` matches *any* + process whose command line contains that literal substring, including + an SSH-invoked shell that has the pattern text pasted into its own + argv — killing your own remote shell mid-script. Fix: write the kill + logic to a file on the device first and execute *that* (`sh + /tmp/pause_dock.sh`), so the invoking process's argv never contains the + target pattern. +- **This firmware does use Upstart**, but `initctl` and `mntroot` are not + on the default non-interactive SSH `PATH` — call them as `/sbin/initctl` + and `/usr/sbin/mntroot`. +- **USB OTG host mode is not worth pursuing.** `/sys/.../ci_hdrc.0` only + exposes a fixed UDC (device-controller) node, no role-switch file — this + port is device-mode only in practice. The `usbnet` KUAL extension + (already installed on the test unit) gives working USB gadget networking, + but only as an SSH/recovery fallback — it requires tethering to another + always-on host and defeats the point of a freestanding plugged-in + kiosk, so it's deliberately not part of the primary network path. +- **Editing `/etc/crontab/root` needs `mntroot rw` / `mntroot ro` + bracketing** since `/` is normally mounted read-only; a backup is taken + automatically before appending (`crontab.root.bak` in the extension's + state dir). + +## Known gaps / honestly not done + +- **`toggle-autostart.sh` does not actually wire an Upstart job.** It only + writes a marker file. Making WeClawBot survive an unattended reboot needs + a real `/etc/upstart/weclawbot.conf` job (mntroot rw, needs a reboot to + verify) — left for whoever picks this up next, since it's a persistent + root-filesystem change that deserves its own explicit sign-off rather + than being silently bundled into a "toggle" button. +- **`server/poll-server.mjs` is an in-memory, single-process demo.** It + proved the contract; it must not be pointed at from a real device long + term. See "What production needs to add" below. +- **No TLS.** The demo talks plain HTTP on the LAN. Production must serve + `/kindle/*` over HTTPS, and the Kindle's outdated CA bundle should not be + trusted blindly — see the "TLS trust" discussion in + `docs/kindle-display-client-discussion.md`. +- **"Online" status is not implemented server-side beyond `bound`.** For + parity with `weclawbotctl doctor --online` on ESP32 devices, production + needs a poll-recency check (e.g. "online" iff last successful poll < + 2× refresh_rate ago), since there is no persistent connection to check. + +## What production needs to add + +1. Fold the six `/kindle/*` endpoints above into `server/curator-proxy/server.mjs` + (or the official agent, if BYOA vs official routing matters here), + reusing `renderScreenSvg`/`renderSvgToPng`/`pngChunk` instead of this + directory's standalone `renderSvg`/`renderPngForDevice`. +2. Add a `transport` column/field to the BYOA device table (`mqtt` vs + `poll`), so `weclawbotctl bind/screen/doctor` route correctly without + the Agent needing to know or care. +3. Real auth: `bind/request` and `bind/confirm` here are unauthenticated + in-memory demo state. Production needs the same scoped-credential model + the ESP32 BYOA path already uses. +4. Decide the thinking-state UX: MQTT devices get near-instant `thinking` + overlays; poll devices are bounded by `refresh_rate`. Either accept the + latency difference in product copy, or drop `refresh_rate` briefly + (e.g. 5s) while a `thinking` state is active. +5. Package `client/` as a distributable zip following KUAL convention + (confirmed against real installed extensions `usbnet`/`renameotabin` on + the test device: a folder with `config.xml` + `menu.json` + `bin/`, + dropped into `/mnt/us/extensions/`). diff --git a/integrations/kindle/client/bin/keepalive.sh b/integrations/kindle/client/bin/keepalive.sh new file mode 100755 index 0000000..ad080a3 --- /dev/null +++ b/integrations/kindle/client/bin/keepalive.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Silent re-assertion of the power-tune settings, meant to run from cron +# every minute. preventScreenSaver does not survive a powerd/framework +# restart on this firmware, so this is not a one-time step (see +# power-tune.sh and docs/kindle-display-client-discussion.md). +lipc-set-prop com.lab126.powerd preventScreenSaver 1 2>/dev/null +wl PM 0 2>/dev/null diff --git a/integrations/kindle/client/bin/power-tune.sh b/integrations/kindle/client/bin/power-tune.sh new file mode 100755 index 0000000..1fdf9a2 --- /dev/null +++ b/integrations/kindle/client/bin/power-tune.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Apply the power/WiFi settings verified by hand during real-device bring-up +# (see docs/kindle-display-client-discussion.md, "本地样机探测记录"). +# +# NOTE: neither setting survives a reboot on this firmware. Re-run this +# (or better: call it from weclawbot-poll.sh's startup and every N minutes +# from cron) rather than assuming it's a one-time step. +lipc-set-prop com.lab126.powerd preventScreenSaver 1 +wl PM 0 + +STATE_DIR="/mnt/us/extensions/weclawbot" +mkdir -p "$STATE_DIR" +echo "$(date '+%Y-%m-%d %H:%M:%S') power-tune applied: preventScreenSaver=1 wl PM=0" >>"$STATE_DIR/weclawbot.log" + +fbink -c -m -y -2 "Power tuning applied" >/dev/null 2>&1 diff --git a/integrations/kindle/client/bin/reset-pairing.sh b/integrations/kindle/client/bin/reset-pairing.sh new file mode 100755 index 0000000..607123c --- /dev/null +++ b/integrations/kindle/client/bin/reset-pairing.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Local-only reset: forgets the cached device_id so the next start shows a +# fresh pairing code. This does NOT revoke the old device_id server-side — +# that authority lives on the gateway, same boundary as the ESP32 BYOA +# reset flow described in the main README. +STATE_DIR="/mnt/us/extensions/weclawbot" +PID_FILE="$STATE_DIR/weclawbot.pid" + +if [ -f "$PID_FILE" ]; then + kill "$(cat "$PID_FILE")" 2>/dev/null + rm -f "$PID_FILE" +fi +rm -f "$STATE_DIR/device_id" +fbink -c -m -y -2 "Pairing reset. Start WeClawBot again for a new code." >/dev/null 2>&1 diff --git a/integrations/kindle/client/bin/start.sh b/integrations/kindle/client/bin/start.sh new file mode 100755 index 0000000..1fbfe40 --- /dev/null +++ b/integrations/kindle/client/bin/start.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Launch the WeClawBot poll loop in the background. +DIR=$(dirname "$0") +STATE_DIR="/mnt/us/extensions/weclawbot" +PID_FILE="$STATE_DIR/weclawbot.pid" +mkdir -p "$STATE_DIR" + +if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + fbink -c -m -y -2 "WeClawBot already running" >/dev/null 2>&1 + exit 0 +fi + +nohup sh "$DIR/weclawbot-poll.sh" >/dev/null 2>&1 & +echo $! >"$PID_FILE" +fbink -c -m -y -2 "WeClawBot started" >/dev/null 2>&1 diff --git a/integrations/kindle/client/bin/status.sh b/integrations/kindle/client/bin/status.sh new file mode 100755 index 0000000..e5e8892 --- /dev/null +++ b/integrations/kindle/client/bin/status.sh @@ -0,0 +1,20 @@ +#!/bin/sh +STATE_DIR="/mnt/us/extensions/weclawbot" +DEVICE_ID=$(cat "$STATE_DIR/device_id" 2>/dev/null || echo "(unpaired)") +PID_FILE="$STATE_DIR/weclawbot.pid" +if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + RUNNING="running (pid $(cat "$PID_FILE"))" +else + RUNNING="stopped" +fi +LAST_LOG=$(tail -n 1 "$STATE_DIR/weclawbot.log" 2>/dev/null) + +fbink -c -m -y 8 -W GC16 -w \ + "WeClawBot status" \ + "" \ + "$RUNNING" \ + "device_id:" \ + "$DEVICE_ID" \ + "" \ + "$LAST_LOG" \ + >/dev/null 2>&1 diff --git a/integrations/kindle/client/bin/stop.sh b/integrations/kindle/client/bin/stop.sh new file mode 100755 index 0000000..319895e --- /dev/null +++ b/integrations/kindle/client/bin/stop.sh @@ -0,0 +1,12 @@ +#!/bin/sh +STATE_DIR="/mnt/us/extensions/weclawbot" +PID_FILE="$STATE_DIR/weclawbot.pid" + +if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + kill "$PID" 2>/dev/null + rm -f "$PID_FILE" + fbink -c -m -y -2 "WeClawBot stopped" >/dev/null 2>&1 +else + fbink -c -m -y -2 "WeClawBot was not running" >/dev/null 2>&1 +fi diff --git a/integrations/kindle/client/bin/toggle-autostart.sh b/integrations/kindle/client/bin/toggle-autostart.sh new file mode 100755 index 0000000..5204b62 --- /dev/null +++ b/integrations/kindle/client/bin/toggle-autostart.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Sets/clears a marker file only. Actually wiring this into an upstart job +# so WeClawBot survives a Kindle reboot unattended is intentionally left as +# a documented follow-up (see integrations/kindle/README.md) rather than +# silently claimed here: it requires `mntroot rw` and a reboot to test, +# which this extension does not perform on its own. +STATE_DIR="/mnt/us/extensions/weclawbot" +mkdir -p "$STATE_DIR" + +case "$1" in +on) + touch "$STATE_DIR/auto" + fbink -c -m -y -2 "Autostart flag set (see README for boot wiring)" >/dev/null 2>&1 + ;; +off) + rm -f "$STATE_DIR/auto" + fbink -c -m -y -2 "Autostart flag cleared" >/dev/null 2>&1 + ;; +esac diff --git a/integrations/kindle/client/bin/weclawbot-poll.sh b/integrations/kindle/client/bin/weclawbot-poll.sh new file mode 100755 index 0000000..c3a175f --- /dev/null +++ b/integrations/kindle/client/bin/weclawbot-poll.sh @@ -0,0 +1,128 @@ +#!/bin/sh +# WeClawBot Kindle BYOA display client (poll model). +# +# Mirrors the pattern used by community Kindle dashboard clients +# (e.g. usetrmnl/trmnl-kindle's TRMNL.sh): a plain shell loop that polls an +# HTTP endpoint for {image_url, refresh_rate} and shells out to fbink to +# refresh the panel. No daemon, no long-lived connection, no compiled +# binary — deliberately copy-paste-able by anyone who already reads +# MobileRead KUAL extension scripts. +# +# See ../../README.md for install/config, and +# docs/kindle-display-client-discussion.md in the main repo for the +# architecture rationale (why poll instead of MQTT push for Kindle). + +STATE_DIR="/mnt/us/extensions/weclawbot" +DEVICE_ID_FILE="$STATE_DIR/device_id" +CONFIG_FILE="$STATE_DIR/config" +LOG_FILE="$STATE_DIR/weclawbot.log" +IMAGE_FILE="/tmp/weclawbot_page.png" + +mkdir -p "$STATE_DIR" +[ -f "$CONFIG_FILE" ] && . "$CONFIG_FILE" +BASE_URL="${WECLAWBOT_POLL_BASE_URL:-http://127.0.0.1:8799}" + +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >>"$LOG_FILE" +} + +json_field() { + # $1 = field name, reads JSON from stdin, crude but dependency-free + # (device has no python/node/jq). + sed -n "s/.*\"$1\":\"\{0,1\}\([^,\"}]*\)\"\{0,1\}.*/\1/p" | head -n1 +} + +if [ -f "$DEVICE_ID_FILE" ]; then + DEVICE_ID=$(cat "$DEVICE_ID_FILE") +else + DEVICE_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null) + [ -z "$DEVICE_ID" ] && DEVICE_ID="kindle-$(date +%s)" + echo "$DEVICE_ID" >"$DEVICE_ID_FILE" +fi +log "start device_id=$DEVICE_ID base_url=$BASE_URL" + +is_bound() { + curl -fsS "$BASE_URL/kindle/status?device_id=$DEVICE_ID" 2>>"$LOG_FILE" | json_field bound | grep -q true +} + +show_pairing_code() { + code="$1" + # Rendered server-side as an image, same as steady-state content, for + # two reasons: (1) fbink's default bitmap font has no CJK glyph + # coverage, so a text-mode screen can't show "配对码"; (2) this device + # sits landscape in a kindle-dock-style stand, and only the image path + # applies the 90deg rotation needed for that orientation (see + # server/poll-server.mjs PANEL_W/LOGICAL_W comment). fbink text mode + # always draws in the panel's native (portrait) orientation and has no + # per-call rotation flag, so it can't be made to match. + if curl -fsS "$BASE_URL/kindle/pairing-image.png?code=$code" -o "$IMAGE_FILE" 2>>"$LOG_FILE"; then + fbink -c -f -g "file=$IMAGE_FILE" -W GC16 -w >>"$LOG_FILE" 2>&1 + else + log "pairing-image fetch failed, falling back to text (will be sideways/ASCII-only)" + fbink -c -m -y 10 -W GC16 -w "WeClawBot" "" "Pairing code:" "$code" "" "weclawbotctl bind $code" >>"$LOG_FILE" 2>&1 + fi +} + +wait_for_binding() { + while :; do + if is_bound; then + log "bound" + return 0 + fi + sleep 5 + done +} + +request_pairing_code() { + RESP=$(curl -fsS -X POST "$BASE_URL/kindle/bind/request" \ + -H 'content-type: application/json' \ + -d "{\"device_id\":\"$DEVICE_ID\"}" 2>>"$LOG_FILE") + CODE=$(echo "$RESP" | json_field code) + if [ -z "$CODE" ]; then + log "bind/request failed, retrying in 10s: $RESP" + sleep 10 + return 1 + fi + log "pairing code=$CODE" + show_pairing_code "$CODE" + return 0 +} + +poll_once() { + POLL=$(curl -fsS "$BASE_URL/kindle/poll?device_id=$DEVICE_ID" 2>>"$LOG_FILE") + STATUS=$(echo "$POLL" | json_field status) + REFRESH=$(echo "$POLL" | json_field refresh_rate) + [ -z "$REFRESH" ] && REFRESH=30 + + if [ "$STATUS" = "bound" ]; then + IMAGE_URL=$(echo "$POLL" | json_field image_url) + REVISION=$(echo "$POLL" | json_field revision) + if [ -n "$IMAGE_URL" ] && [ "$REVISION" != "$LAST_REVISION" ]; then + if curl -fsS "$BASE_URL$IMAGE_URL" -o "$IMAGE_FILE" 2>>"$LOG_FILE"; then + # -f forces a full black-flash refresh. Without it, -W GC16 + # alone does a higher-quality but non-flashing update, which + # left visible ghosting from whatever content (e.g. another + # app's dashboard) was on screen before WeClawBot took over. + fbink -c -f -g "file=$IMAGE_FILE" -W GC16 -w >>"$LOG_FILE" 2>&1 + LAST_REVISION="$REVISION" + log "applied revision=$REVISION" + else + log "image download failed for $IMAGE_URL" + fi + fi + fi + echo "$REFRESH" +} + +# --- pairing phase --- +if ! is_bound; then + until request_pairing_code; do :; done + wait_for_binding +fi + +# --- steady-state poll loop --- +LAST_REVISION="" +while :; do + REFRESH=$(poll_once) + sleep "${REFRESH:-30}" +done diff --git a/integrations/kindle/client/config.xml b/integrations/kindle/client/config.xml new file mode 100644 index 0000000..675fb91 --- /dev/null +++ b/integrations/kindle/client/config.xml @@ -0,0 +1,9 @@ + + + + WeClawBot + WeClawBot + WeClawBot BYOA poll-based display client for jailbroken Kindles. + 0.1.0 + + diff --git a/integrations/kindle/client/menu.json b/integrations/kindle/client/menu.json new file mode 100644 index 0000000..61a90c3 --- /dev/null +++ b/integrations/kindle/client/menu.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "name": "WeClawBot", + "priority": -900, + "items": [ + { + "name": "* Start WeClawBot *", + "action": "bin/start.sh", + "exitmenu": true + }, + { + "name": "Stop WeClawBot", + "action": "bin/stop.sh", + "exitmenu": true + }, + { + "name": "Show status on screen", + "action": "bin/status.sh", + "exitmenu": true + }, + { + "name": "Enable autostart at boot", + "if": "\"/mnt/us/extensions/weclawbot/auto\" -f!", + "action": "bin/toggle-autostart.sh", + "params": "on", + "exitmenu": true + }, + { + "name": "Disable autostart at boot (default)", + "if": "\"/mnt/us/extensions/weclawbot/auto\" -f", + "action": "bin/toggle-autostart.sh", + "params": "off", + "exitmenu": true + }, + { + "name": "Apply power tuning (stay awake, WiFi PM off)", + "action": "bin/power-tune.sh", + "exitmenu": true + }, + { + "name": "Reset pairing", + "action": "bin/reset-pairing.sh", + "exitmenu": true + } + ] + } + ] +} diff --git a/integrations/kindle/server/data/.gitignore b/integrations/kindle/server/data/.gitignore new file mode 100644 index 0000000..e33609d --- /dev/null +++ b/integrations/kindle/server/data/.gitignore @@ -0,0 +1 @@ +*.png diff --git a/integrations/kindle/server/poll-server.mjs b/integrations/kindle/server/poll-server.mjs new file mode 100644 index 0000000..9b97d14 --- /dev/null +++ b/integrations/kindle/server/poll-server.mjs @@ -0,0 +1,342 @@ +#!/usr/bin/env node +// WeClawBot Kindle poll-adapter reference server. +// +// This is a minimal, dependency-free reference implementation of the +// pull-based contract described in docs/kindle-display-client-discussion.md. +// It exists to prove the client/server round trip end to end on real +// hardware. It is NOT the production gateway: production should fold this +// contract into server/curator-proxy/server.mjs (or the official agent), +// reusing renderScreenSvg/renderSvgToPng and the real BYOA device table +// instead of the in-memory maps used here. +// +// Endpoints: +// POST /kindle/bind/request { device_id? } -> { device_id, code, expires_in } +// POST /kindle/bind/confirm { code, name? } -> { ok, device_id, name } +// GET /kindle/status?device_id=... -> { bound, name } +// POST /kindle/screen { device_id, title, body } -> { ok, revision } +// GET /kindle/poll?device_id=... -> { status, image_url, refresh_rate } +// GET /kindle/image/.png -> image/png +// GET /kindle/pairing-image.png?code=...&name=... -> image/png (rendered on the fly, not cached) + +import http from "node:http"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import fssync from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const PORT = Number(process.env.PORT || 8799); +const RSVG_CONVERT_BIN = process.env.RSVG_CONVERT_BIN || "rsvg-convert"; +// PANEL_W x PANEL_H is the physical framebuffer fbink writes to on this +// device (`fbink -v` reports 600x800 @ rotation 3). The kindle-dock stand +// this unit sits in holds the panel landscape, so content must be authored +// in a landscape logical space and rotated 90deg CW into the portrait +// buffer -- verified against /mnt/us/kindle-dock/p.png (a known-good image +// from the sibling project already running correctly in this same stand): +// rotating that raw file 90deg CCW is what makes it read upright, which +// means the raw-file-producing transform is landscape-content -> rotate +// 90deg CW -> portrait buffer. See README.md "Landscape mount" section. +const PANEL_W = 600; +const PANEL_H = 800; +const LOGICAL_W = 800; +const LOGICAL_H = 600; +const DATA_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), "data"); +const CODE_TTL_MS = 10 * 60 * 1000; +const IDLE_REFRESH_S = 45; +const PENDING_REFRESH_S = 4; // poll faster while waiting for bind confirm + +await fs.mkdir(DATA_DIR, { recursive: true }); + +/** @type {Map} */ +const devices = new Map(); +/** @type {Map} */ +const pendingCodes = new Map(); + +function nowIso() { + return new Date().toISOString().replace("T", " ").slice(0, 19); +} + +function genCode() { + return String(Math.floor(100000 + Math.random() * 900000)); +} + +function getOrCreateDevice(deviceId) { + let d = devices.get(deviceId); + if (!d) { + d = { bound: false, name: null, revision: 0, title: "WeClawBot", body: "等待配对…", updatedAt: Date.now() }; + devices.set(deviceId, d); + } + return d; +} + +function validDeviceId(value) { + return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value); +} + +function escapeXml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +function wrapLines(text, maxCharsPerLine) { + const words = String(text).split(/\s+/).filter(Boolean); + const lines = []; + let cur = ""; + for (const w of words) { + if ((cur + " " + w).trim().length > maxCharsPerLine) { + if (cur) lines.push(cur); + cur = w; + } else { + cur = (cur + " " + w).trim(); + } + } + if (cur) lines.push(cur); + return lines; +} + +// CJK-aware-ish line wrap: treat the body as a sequence of characters when it +// contains CJK, since space-based wrapping does nothing useful there. +function wrapBody(text, maxCharsPerLine, maxLines) { + const hasCjk = /[ -鿿]/.test(text); + let lines; + if (hasCjk) { + // Tokenize into CJK chars (atomic) and Latin/digit runs (kept whole, so + // "WeClawBot" never splits mid-word) plus whitespace. + const tokens = String(text).match(/[a-zA-Z0-9]+|[^\x00-\x7f]|\s+|[^\sa-zA-Z0-9\x00-\x7f]/g) || []; + lines = []; + let cur = ""; + for (const tok of tokens) { + if (tok === "\n") { + lines.push(cur); + cur = ""; + continue; + } + if (cur.length + tok.length > maxCharsPerLine && cur.length > 0) { + lines.push(cur); + cur = tok.trim() === "" ? "" : tok; + } else { + cur += tok; + } + } + if (cur.trim()) lines.push(cur); + } else { + lines = wrapLines(text, maxCharsPerLine); + } + return lines.slice(0, maxLines).map((l) => l.trim()); +} + +// Wrap landscape-authored content (LOGICAL_W x LOGICAL_H, normal reading +// orientation) in the 90deg-CW rotation that maps it onto the physical +// PANEL_W x PANEL_H buffer. See the PANEL_W/LOGICAL_W comment above for how +// this direction was determined against a known-good reference image. +function svgDocument(innerContent) { + return ` + + + + ${innerContent} + +`; +} + +function renderContentScene({ title, body, footer }) { + const titleLines = wrapBody(title, 22, 2); + const bodyLines = wrapBody(body, 23, 10); + const margin = 40; + let y = 90; + const titleSvg = titleLines + .map((line, i) => `${escapeXml(line)}`) + .join("\n"); + y += titleLines.length * 52 + 24; + const ruleY = y; + y += 44; + const bodySvg = bodyLines + .map((line, i) => `${escapeXml(line)}`) + .join("\n"); + + return ` + ${titleSvg} + + ${bodySvg} + ${escapeXml(footer)}`; +} + +function renderPairingScene({ code, name }) { + const margin = 40; + return ` + WeClawBot + 配对码 / pairing code + ${escapeXml(code)} + weclawbotctl bind ${escapeXml(code)}${name ? escapeXml(" --name " + name) : ""} + waiting for bind…`; +} + +async function renderPngForDevice(deviceId, device) { + const svg = svgDocument( + renderContentScene({ + title: device.title, + body: device.body, + footer: `WeClawBot demo · rev ${device.revision} · ${nowIso()}`, + }) + ); + const svgPath = path.join(os.tmpdir(), `weclawbot_kindle_${deviceId}.svg`); + const pngPath = path.join(DATA_DIR, `${deviceId}.png`); + await fs.writeFile(svgPath, svg, "utf8"); + await execFileAsync(RSVG_CONVERT_BIN, ["-w", String(PANEL_W), "-h", String(PANEL_H), "-o", pngPath, svgPath]); + await fs.unlink(svgPath).catch(() => {}); + return pngPath; +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + if (raw.length > 1_000_000) req.destroy(); + }); + req.on("end", () => { + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); +} + +function sendJson(res, status, obj) { + const body = JSON.stringify(obj); + res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(body) }); + res.end(body); +} + +function cleanupExpiredCodes() { + const now = Date.now(); + for (const [code, entry] of pendingCodes) { + if (entry.expiresAt < now) pendingCodes.delete(code); + } +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`); + const { pathname } = url; + + if (req.method === "POST" && pathname === "/kindle/bind/request") { + const body = await readJsonBody(req); + if (body.device_id && !validDeviceId(body.device_id)) { + return sendJson(res, 400, { error: "invalid_device_id" }); + } + const deviceId = body.device_id || crypto.randomUUID(); + cleanupExpiredCodes(); + const code = genCode(); + pendingCodes.set(code, { deviceId, expiresAt: Date.now() + CODE_TTL_MS }); + getOrCreateDevice(deviceId); + console.log(`[bind/request] device_id=${deviceId} code=${code}`); + return sendJson(res, 200, { device_id: deviceId, code, expires_in: CODE_TTL_MS / 1000 }); + } + + if (req.method === "POST" && pathname === "/kindle/bind/confirm") { + const body = await readJsonBody(req); + const entry = pendingCodes.get(String(body.code || "")); + if (!entry || entry.expiresAt < Date.now()) { + return sendJson(res, 404, { ok: false, error: "code_not_found_or_expired" }); + } + const device = getOrCreateDevice(entry.deviceId); + device.bound = true; + device.name = body.name || "kindle"; + device.title = "WeClawBot"; + device.body = "已配对,等待 Agent 推送内容…"; + device.revision += 1; + pendingCodes.delete(String(body.code)); + console.log(`[bind/confirm] device_id=${entry.deviceId} name=${device.name}`); + return sendJson(res, 200, { ok: true, device_id: entry.deviceId, name: device.name }); + } + + if (req.method === "GET" && pathname === "/kindle/status") { + const deviceId = url.searchParams.get("device_id"); + if (deviceId && !validDeviceId(deviceId)) { + return sendJson(res, 400, { error: "invalid_device_id" }); + } + const device = deviceId ? devices.get(deviceId) : null; + return sendJson(res, 200, { bound: !!device?.bound, name: device?.name || null }); + } + + if (req.method === "POST" && pathname === "/kindle/screen") { + const body = await readJsonBody(req); + if (!validDeviceId(body.device_id)) { + return sendJson(res, 400, { ok: false, error: "invalid_device_id" }); + } + const device = body.device_id ? devices.get(body.device_id) : null; + if (!device || !device.bound) return sendJson(res, 404, { ok: false, error: "device_not_bound" }); + device.title = body.title || device.title; + device.body = body.body || device.body; + device.revision += 1; + device.updatedAt = Date.now(); + console.log(`[screen] device_id=${body.device_id} rev=${device.revision} title=${JSON.stringify(device.title)}`); + return sendJson(res, 200, { ok: true, revision: device.revision }); + } + + if (req.method === "GET" && pathname === "/kindle/poll") { + const deviceId = url.searchParams.get("device_id"); + if (deviceId && !validDeviceId(deviceId)) { + return sendJson(res, 400, { status: "invalid_device_id" }); + } + const device = deviceId ? devices.get(deviceId) : null; + if (!device) return sendJson(res, 404, { status: "unknown_device" }); + if (!device.bound) return sendJson(res, 200, { status: "pending", refresh_rate: PENDING_REFRESH_S }); + await renderPngForDevice(deviceId, device); + return sendJson(res, 200, { + status: "bound", + image_url: `/kindle/image/${deviceId}.png?rev=${device.revision}`, + revision: device.revision, + refresh_rate: IDLE_REFRESH_S, + }); + } + + if (req.method === "GET" && pathname.startsWith("/kindle/image/")) { + const deviceId = pathname.slice("/kindle/image/".length).replace(/\.png$/, ""); + if (!validDeviceId(deviceId)) { + return sendJson(res, 400, { error: "invalid_device_id" }); + } + const pngPath = path.join(DATA_DIR, `${deviceId}.png`); + if (!fssync.existsSync(pngPath)) return sendJson(res, 404, { error: "no_image" }); + const data = await fs.readFile(pngPath); + res.writeHead(200, { "content-type": "image/png", "content-length": data.length }); + return res.end(data); + } + + if (req.method === "GET" && pathname === "/kindle/pairing-image.png") { + const code = url.searchParams.get("code") || ""; + const name = url.searchParams.get("name") || ""; + const svg = svgDocument(renderPairingScene({ code, name })); + const svgPath = path.join(os.tmpdir(), `weclawbot_kindle_pairing_${code}.svg`); + await fs.writeFile(svgPath, svg, "utf8"); + const pngPath = path.join(os.tmpdir(), `weclawbot_kindle_pairing_${code}.png`); + await execFileAsync(RSVG_CONVERT_BIN, ["-w", String(PANEL_W), "-h", String(PANEL_H), "-o", pngPath, svgPath]); + await fs.unlink(svgPath).catch(() => {}); + const data = await fs.readFile(pngPath); + await fs.unlink(pngPath).catch(() => {}); + res.writeHead(200, { "content-type": "image/png", "content-length": data.length }); + return res.end(data); + } + + if (req.method === "GET" && pathname === "/healthz") { + return sendJson(res, 200, { ok: true, devices: devices.size }); + } + + sendJson(res, 404, { error: "not_found" }); + } catch (err) { + console.error(err); + sendJson(res, 500, { error: "internal_error", message: String(err?.message || err) }); + } +}); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`weclawbot kindle poll-server demo listening on :${PORT}`); +}); diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index aa9d830..5deb196 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -39,16 +39,6 @@ endfunction() find_component_by_pattern("xiaozhi-fonts" WEC_FONTS_COMPONENT) find_component_by_pattern("esp_lvgl_port" WEC_LVGL_PORT_COMPONENT) -if(CONFIG_WEC_PRODUCT_DISK) - list(APPEND SOURCES "usb_product_disk.cc") - find_component_by_pattern("esp_tinyusb" WEC_TINYUSB_COMPONENT) - set(WEC_PRODUCT_DISK_REQUIRES - fatfs - wear_levelling - ${WEC_TINYUSB_COMPONENT} - ) -endif() - idf_component_register( SRCS ${SOURCES} INCLUDE_DIRS "." "../third_party/qrcodegen" @@ -74,7 +64,6 @@ idf_component_register( lvgl ${WEC_LVGL_PORT_COMPONENT} ${WEC_FONTS_COMPONENT} - ${WEC_PRODUCT_DISK_REQUIRES} ) target_compile_features(${COMPONENT_LIB} PRIVATE cxx_std_17) diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 2ec013f..1dad234 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -87,14 +87,6 @@ config WEC_DEVELOPMENT_BUILD Show the development label on the device header. Public firmware releases should keep this disabled. -config WEC_PRODUCT_DISK - bool "Expose WeClawBot USB product disk" - default n - help - Enable the TinyUSB MSC product disk used by release builds to expose - help files and website links. Keep this disabled for development builds - so USB-Serial-JTAG remains available for flashing and live logs. - config WEC_BATTERY_INSTALLED bool "Device has an installed 18650 battery" default n diff --git a/main/app_config.h b/main/app_config.h index c11a86c..aff6fbb 100644 --- a/main/app_config.h +++ b/main/app_config.h @@ -3,7 +3,7 @@ #include #include -#define WEC_FIRMWARE_VERSION "0.1.83" +#define WEC_FIRMWARE_VERSION "0.1.89" #define WEC_PRODUCT_URL "https://weclawbot.link" #define WEC_PRODUCT_HOST "weclawbot.link" diff --git a/main/app_main.cc b/main/app_main.cc index e89d269..369a852 100644 --- a/main/app_main.cc +++ b/main/app_main.cc @@ -32,9 +32,6 @@ #include "serial_config.h" #include "shtc3_sensor.h" #include "ui.h" -#if CONFIG_WEC_PRODUCT_DISK -#include "usb_product_disk.h" -#endif #include "wechat_bot.h" #include "wifi_manager.h" @@ -422,11 +419,10 @@ void EnvironmentTask(void*) { } ProcessDashboardTimers(); - if (!WEC_BOARD_HAS_SHTC3) { - bot.PollCloudWeather(); + if (tick % kRuntimeUiTickPeriodSec == 0) { + TickRuntimeUi(); } if (tick % 60 == 0) { - bot.RetryTimeSync(); const BatteryReading battery_reading = battery.Read(); if (battery_reading.valid) { ui.SetBatteryStatus(battery_reading.present, battery_reading.percent); @@ -445,13 +441,13 @@ void EnvironmentTask(void*) { "\"ok\":false,\"type\":\"event\"}\n"); } + bot.RetryTimeSync(); if (!WEC_BOARD_HAS_SHTC3) { TickRuntimeUi(); } - } else { - if (tick % kRuntimeUiTickPeriodSec == 0) { - TickRuntimeUi(); - } + } + if (!WEC_BOARD_HAS_SHTC3) { + bot.PollCloudWeather(); } const bool regular_sensor_sample = tick % 60 == 0; @@ -526,14 +522,11 @@ extern "C" void app_main(void) { notes.Load(); bot.PrepareStartupUi(); buttons.Init(); -#if CONFIG_WEC_PRODUCT_DISK - StartUsbProductDisk(); -#endif serial_config.Start(); wifi.LoadCredentials(); if (!wifi.HasConfiguredSsid()) { - ui.ShowUsbConfig("串口发送 WEC:SET 写入 Wi-Fi"); + ui.ShowSetup("串口发送 WEC:SET 写入 Wi-Fi"); while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); } @@ -545,7 +538,7 @@ extern "C" void app_main(void) { message += ": "; message += wifi.LastDisconnectReasonText(); } - ui.ShowUsbConfig(message.c_str()); + ui.ShowSetup(message.c_str()); while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); } diff --git a/main/button_manager.cc b/main/button_manager.cc index 581eaa7..2a4126c 100644 --- a/main/button_manager.cc +++ b/main/button_manager.cc @@ -6,6 +6,7 @@ #include #include +#include #include #include "app_config.h" @@ -19,6 +20,7 @@ constexpr uint16_t kLeftLongMs = 3000; constexpr uint16_t kRightLongMs = 5000; constexpr size_t kMaxManualNotePages = 3; constexpr int64_t kEpdManualPageTurnCooldownMs = 250; +constexpr uint32_t kFullClearRestartPreviewTimeoutMs = 7000; constexpr UBaseType_t kButtonActionQueueLength = 8; constexpr uint32_t kButtonActionTaskStack = 8192; constexpr UBaseType_t kButtonActionTaskPriority = 4; @@ -446,7 +448,7 @@ void ButtonManager::ClearTextNotes() { void ButtonManager::ClearAllAndRelogin() { notes_.ClearAll(); notes_.ClearIdlePhoto(); - bot_.ClearAgentCredentials(); + bot_.ClearAgentCredentials(false); if (bot_.WechatIngressEnabled()) { ui_.ShowBoot("已全清,正在准备微信扫码"); bot_.ClearSavedCredentials(); @@ -457,5 +459,16 @@ void ButtonManager::ClearAllAndRelogin() { // image can survive a normal transition even though the software buffers // are already correct, so explicitly drive the new reset page with the // SSD2683 full-refresh waveform. + ui_.CommitPageTransition(); ui_.RequestFullRefresh(); + const bool reset_page_ready = ui_.WaitForDisplayIdle(kFullClearRestartPreviewTimeoutMs); + std::printf( + "WEC:{\"scope\":\"button\",\"stage\":\"full_clear_restart\"," + "\"display_ready\":%s,\"timeout_ms\":%u," + "\"ok\":true,\"type\":\"event\"}\n", + reset_page_ready ? "true" : "false", + static_cast(kFullClearRestartPreviewTimeoutMs)); + std::fflush(stdout); + vTaskDelay(pdMS_TO_TICKS(50)); + esp_restart(); } diff --git a/main/idf_component.yml b/main/idf_component.yml index e9f88cc..8bb65a2 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -8,7 +8,3 @@ dependencies: 78/xiaozhi-fonts: "~1.5.5" espressif/esp_codec_dev: "~1.5" espressif/button: "~4.1.3" - espressif/esp_tinyusb: - version: "^2.0.0" - matches: - - if: "$CONFIG{WEC_PRODUCT_DISK} == True" diff --git a/main/serial_config.cc b/main/serial_config.cc index c6c8bed..6c3142c 100644 --- a/main/serial_config.cc +++ b/main/serial_config.cc @@ -18,6 +18,11 @@ #include #include #include +#include + +#if CONFIG_IDF_TARGET_ESP32S3 +#include +#endif #if CONFIG_TINYUSB_CDC_ENABLED #define WEC_HAS_TINYUSB_CDC 1 @@ -91,6 +96,21 @@ std::string Trim(std::string value) { return value; } +bool CanEnterRomDownloadMode() { +#if CONFIG_IDF_TARGET_ESP32S3 + return true; +#else + return false; +#endif +} + +void EnterRomDownloadMode() { +#if CONFIG_IDF_TARGET_ESP32S3 + REG_WRITE(RTC_CNTL_OPTION1_REG, RTC_CNTL_FORCE_DOWNLOAD_BOOT); + esp_restart(); +#endif +} + std::string LowerAscii(std::string value) { for (char& ch : value) { ch = static_cast(std::tolower(static_cast(ch))); @@ -590,7 +610,7 @@ void SerialConfig::HandleLine(const char* line) { if (input == "CLEAR_WIFI") { if (WifiManager::ClearCredentials()) { - ui_.ShowUsbConfig("Wi-Fi 配置已清除"); + ui_.ShowSetup("Wi-Fi 配置已清除"); SendOk("clear_wifi"); } else { SendError("nvs_write_failed", "清除 Wi-Fi 配置失败"); @@ -600,14 +620,14 @@ void SerialConfig::HandleLine(const char* line) { if (input == "CLEAR_WECHAT") { bot_.ClearSavedCredentials(); - ui_.ShowUsbConfig(bot_.WechatIngressEnabled() ? "微信登录状态已清除" : "微信入口未启用"); + ui_.ShowSetup(bot_.WechatIngressEnabled() ? "微信登录状态已清除" : "微信入口未启用"); SendOk("clear_wechat"); return; } if (input == "CLEAR_AGENT") { bot_.ClearAgentCredentials(); - ui_.ShowUsbConfig("智能体绑定已清除"); + ui_.ShowSetup("智能体绑定已清除"); SendOk("clear_agent"); return; } @@ -750,6 +770,18 @@ void SerialConfig::HandleLine(const char* line) { return; } + if (input == "BOOTLOADER" || input == "DOWNLOAD_MODE") { + if (!CanEnterRomDownloadMode()) { + SendError("bootloader_unsupported", "当前芯片暂不支持串口进入刷机模式"); + return; + } + SendOk("bootloader", "设备正在进入刷机模式"); + std::fflush(stdout); + vTaskDelay(pdMS_TO_TICKS(250)); + EnterRomDownloadMode(); + return; + } + SendError("unknown_command", "未知命令"); } @@ -1067,6 +1099,6 @@ bool SerialConfig::HandleSet(const char* json) { } } - ui_.ShowUsbConfig("配置已保存,请重启设备"); + ui_.ShowSetup("配置已保存,请重启设备"); return true; } diff --git a/main/ui.cc b/main/ui.cc index 535607b..224d5c1 100644 --- a/main/ui.cc +++ b/main/ui.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include "app_config.h" #include "qrcodegen.h" @@ -22,10 +23,14 @@ extern const lv_font_t font_puhui_30_4; namespace { constexpr char kTag[] = "Ui"; #if CONFIG_WEC_BOARD_ZECTRIX_NOTE4 -constexpr const char* kTakeoverSwitchHint = "上下循环"; +constexpr const char* kTakeoverSwitchHint = "按上/下键切换智能体"; +constexpr const char* kDashboardSwitchHint = "按上/下键切换屏幕"; #else -constexpr const char* kTakeoverSwitchHint = "左右循环"; +constexpr const char* kTakeoverSwitchHint = "按左/右键切换智能体"; +constexpr const char* kDashboardSwitchHint = "按左/右键切换屏幕"; #endif +constexpr const char* kAgentBindingFooter = + "请为智能体安装 weclawbotctl 插件,输入上方绑定码"; lv_color_t Black() { return lv_color_black(); @@ -99,12 +104,14 @@ bool IsAgentSource(const Note& note) { std::string NoteFooter(const Note& note) { const char* action = IsAgentSource(note) ? "智能体可更新或清除" : "微信端可修改或替换"; + char footer[128]; if (!note.time_label.empty()) { - char footer[96]; - std::snprintf(footer, sizeof(footer), "%s %s", note.time_label.c_str(), action); + std::snprintf(footer, sizeof(footer), "%s %s %s", note.time_label.c_str(), + kDashboardSwitchHint, action); return footer; } - return action; + std::snprintf(footer, sizeof(footer), "%s %s", kDashboardSwitchHint, action); + return footer; } std::vector NoteLines(const std::string& value) { @@ -170,6 +177,13 @@ std::string DateString() { return buf; } +int CalendarDateKey(const std::tm& tm) { + if (tm.tm_year < 120) { + return 0; + } + return (tm.tm_year + 1900) * 10000 + (tm.tm_mon + 1) * 100 + tm.tm_mday; +} + std::string QrFooterText(const char* status, int seconds_left) { const char* label = (status && status[0]) ? status : "微信扫码连接"; const bool waiting_scan = std::strcmp(label, "等待扫码") == 0 || @@ -179,8 +193,8 @@ std::string QrFooterText(const char* status, int seconds_left) { } char footer[96]; if (waiting_scan) { - std::snprintf(footer, sizeof(footer), "%s %d:%02d %s", - label, seconds_left / 60, seconds_left % 60, kTakeoverSwitchHint); + std::snprintf(footer, sizeof(footer), "微信扫码接入微笺智能体 %s", + kTakeoverSwitchHint); } else { std::snprintf(footer, sizeof(footer), "%s 有效期 %d:%02d", label, seconds_left / 60, seconds_left % 60); @@ -395,16 +409,16 @@ void Ui::ShowWifi(const char* ssid, const char* status) { display_.Unlock(); } -void Ui::ShowUsbConfig(const char* status) { +void Ui::ShowSetup(const char* status) { screensaver_active_ = false; display_.Lock(); ClearLocked(); - HeaderLocked(WEC_SCREEN_TITLE, "USB 配置"); + HeaderLocked(WEC_SCREEN_TITLE, "设备配置"); auto* title = LabelLocked(lv_screen_active(), "需要配置 Wi-Fi", &font_puhui_30_4, Black(), 34, 66, 332); lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0); - auto* line1 = LabelLocked(lv_screen_active(), "USB-C 连接电脑", &font_puhui_20_4, Black(), 46, 132, 308); + auto* line1 = LabelLocked(lv_screen_active(), "请访问官网", &font_puhui_20_4, Black(), 46, 132, 308); lv_obj_set_style_text_align(line1, LV_TEXT_ALIGN_CENTER, 0); auto* line2 = LabelLocked(lv_screen_active(), WEC_PRODUCT_HOST, &font_puhui_20_4, Black(), 34, 172, 332); @@ -413,7 +427,7 @@ void Ui::ShowUsbConfig(const char* status) { auto* line3 = LabelLocked(lv_screen_active(), status, &font_puhui_14_1, Black(), 28, 214, 344); lv_obj_set_style_text_align(line3, LV_TEXT_ALIGN_CENTER, 0); - FooterLocked("打开 U 盘或官网进入安装/配置"); + FooterLocked("官网提供安装、配置与帮助"); display_.Unlock(); } @@ -490,6 +504,34 @@ void Ui::ShowQr(const char* qr_url, int seconds_left) { dsc->data = pixel_buf; display_.Lock(); + if (qr_calendar_active_ && qr_image_) { + lv_image_dsc_t* old_dsc = qr_dsc_; + uint8_t* old_buf = qr_buf_; + qr_dsc_ = dsc; + qr_buf_ = pixel_buf; + lv_image_set_src(qr_image_, qr_dsc_); + constexpr int panel_x = 16; + constexpr int panel_y = 136; + constexpr int panel_w = 176; + constexpr int panel_h = 112; + lv_obj_set_pos(qr_image_, + panel_x + (panel_w - static_cast(qr_dsc_->header.w)) / 2, + panel_y + (panel_h - static_cast(qr_dsc_->header.h)) / 2); + qr_status_ = "等待扫码"; + calendar_footer_ = QrFooterText("微信扫码连接", seconds_left); + if (footer_label_) { + lv_label_set_text(footer_label_, calendar_footer_.c_str()); + } + if (old_dsc) { + lv_image_cache_drop(old_dsc); + heap_caps_free(old_dsc); + } + if (old_buf) { + heap_caps_free(old_buf); + } + display_.Unlock(); + return; + } ClearLocked(); ReleaseQrAssets(); qr_buf_ = pixel_buf; @@ -574,33 +616,35 @@ void Ui::ShowQrStatus(const char* status, int seconds_left) { void Ui::ShowAgentPairingCode(const char* code, int seconds_left) { screensaver_active_ = false; const char* value_text = code && code[0] ? code : "------"; + char detail[64]; + if (seconds_left > 0) { + std::snprintf(detail, sizeof(detail), "有效期 %02d:%02d\n%s", + seconds_left / 60, seconds_left % 60, kTakeoverSwitchHint); + } else { + std::snprintf(detail, sizeof(detail), "绑定码刷新中\n%s", kTakeoverSwitchHint); + } + display_.Lock(); -#if CONFIG_WEC_BOARD_ZECTRIX_NOTE4 if (calendar_overlay_ == CalendarOverlay::kAgentPairing && - calendar_overlay_text_ == value_text) { + calendar_overlay_value_label_ && calendar_overlay_detail_label_) { + if (calendar_overlay_text_ != value_text) { + calendar_overlay_text_ = value_text; + lv_label_set_text(calendar_overlay_value_label_, calendar_overlay_text_.c_str()); + } + if (calendar_overlay_detail_ != detail) { + calendar_overlay_detail_ = detail; + lv_label_set_text(calendar_overlay_detail_label_, calendar_overlay_detail_.c_str()); + } display_.Unlock(); return; } -#endif ClearLocked(); takeover_view_ = TakeoverView::kByoa; - char detail[48]; -#if CONFIG_WEC_BOARD_ZECTRIX_NOTE4 - std::snprintf(detail, sizeof(detail), "%s", kTakeoverSwitchHint); -#else - if (seconds_left > 0) { - std::snprintf(detail, sizeof(detail), "有效期 %02d:%02d %s", - seconds_left / 60, seconds_left % 60, kTakeoverSwitchHint); - } else { - std::snprintf(detail, sizeof(detail), "正在刷新绑定码 %s", kTakeoverSwitchHint); - } -#endif - calendar_overlay_ = CalendarOverlay::kAgentPairing; calendar_overlay_text_ = value_text; calendar_overlay_detail_ = detail; - ShowCalendarHomeLocked("智能体", "Agent 端输入绑定码完成连接", false, false); + ShowCalendarHomeLocked("智能体", kAgentBindingFooter, false, false); RenderCalendarOverlayLocked(); display_.Unlock(); } @@ -925,7 +969,11 @@ void Ui::Tick() { std::tm tm = {}; localtime_r(&now, &tm); const bool time_ready = tm.tm_year >= 120; - if (calendar_home_active_ && !qr_calendar_active_ && time_ready != calendar_time_ready_) { + const int date_key = CalendarDateKey(tm); + const bool date_changed = + time_ready && calendar_date_key_ != 0 && date_key != calendar_date_key_; + if (calendar_home_active_ && !qr_calendar_active_ && + (time_ready != calendar_time_ready_ || date_changed)) { const std::string header_detail = header_detail_; const std::string footer = calendar_footer_; const bool thinking = calendar_thinking_; @@ -943,6 +991,7 @@ void Ui::Tick() { RenderCalendarOverlayLocked(); } else { calendar_time_ready_ = time_ready; + calendar_date_key_ = date_key; UpdateStatusLabelsLocked(); } display_.Unlock(); @@ -1194,6 +1243,7 @@ void Ui::ShowCalendarHomeLocked(const char* header_detail, std::tm tm = {}; localtime_r(&now, &tm); calendar_time_ready_ = tm.tm_year >= 120; + calendar_date_key_ = CalendarDateKey(tm); DrawCalendarLocked(tm); if (show_pet) { @@ -1209,17 +1259,19 @@ void Ui::ShowCalendarHomeLocked(const char* header_detail, void Ui::RenderCalendarOverlayLocked() { if (calendar_overlay_ == CalendarOverlay::kAgentPairing) { - RectObj(lv_screen_active(), 20, 146, 164, 94, White(), Black(), 0, 2); + RectObj(lv_screen_active(), 20, 146, 164, 98, White(), Black(), 0, 2); auto* label = LabelLocked(lv_screen_active(), "智能体绑定码", &font_puhui_14_1, - Black(), 28, 154, 148); + Black(), 28, 152, 148); lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - auto* value = LabelLocked(lv_screen_active(), calendar_overlay_text_.c_str(), - &font_puhui_30_4, Black(), 28, 174, 148); - lv_obj_set_style_text_align(value, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_text_letter_space(value, 3, 0); - auto* detail = LabelLocked(lv_screen_active(), calendar_overlay_detail_.c_str(), - &font_puhui_14_1, Black(), 28, 216, 148); - lv_obj_set_style_text_align(detail, LV_TEXT_ALIGN_CENTER, 0); + calendar_overlay_value_label_ = + LabelLocked(lv_screen_active(), calendar_overlay_text_.c_str(), + &font_puhui_30_4, Black(), 28, 171, 148); + lv_obj_set_style_text_align(calendar_overlay_value_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_letter_space(calendar_overlay_value_label_, 3, 0); + calendar_overlay_detail_label_ = + LabelLocked(lv_screen_active(), calendar_overlay_detail_.c_str(), + &font_puhui_14_1, Black(), 28, 205, 148); + lv_obj_set_style_text_align(calendar_overlay_detail_label_, LV_TEXT_ALIGN_CENTER, 0); return; } if (calendar_overlay_ == CalendarOverlay::kAgentDashboard) { @@ -1490,13 +1542,13 @@ void Ui::DrawQrPanelLocked(const char* status, int seconds_left) { constexpr int panel_h = 112; if (qr_dsc_) { - auto* img = lv_image_create(lv_screen_active()); - lv_image_set_src(img, qr_dsc_); + qr_image_ = lv_image_create(lv_screen_active()); + lv_image_set_src(qr_image_, qr_dsc_); const int qr_w = static_cast(qr_dsc_->header.w); const int qr_h = static_cast(qr_dsc_->header.h); const int x = panel_x + (panel_w - qr_w) / 2; const int y = panel_y + (panel_h - qr_h) / 2; - lv_obj_set_pos(img, x, y); + lv_obj_set_pos(qr_image_, x, y); } (void)status; @@ -1535,9 +1587,13 @@ void Ui::ClearLocked() { part = nullptr; } qr_status_label_ = nullptr; + qr_image_ = nullptr; + calendar_overlay_value_label_ = nullptr; + calendar_overlay_detail_label_ = nullptr; header_detail_.clear(); calendar_footer_.clear(); calendar_time_ready_ = false; + calendar_date_key_ = 0; calendar_thinking_ = false; calendar_pet_visible_ = true; qr_calendar_active_ = false; @@ -1556,6 +1612,7 @@ void Ui::ClearLocked() { void Ui::ReleaseQrAssets() { if (qr_dsc_) { + lv_image_cache_drop(qr_dsc_); heap_caps_free(qr_dsc_); qr_dsc_ = nullptr; } diff --git a/main/ui.h b/main/ui.h index 8ec5e00..8637043 100644 --- a/main/ui.h +++ b/main/ui.h @@ -31,7 +31,7 @@ class Ui { void ShowAgentTakeover(const char* detail = "智能体已接管"); void ShowAgentDashboard(const char* detail = "智能体已接管"); void ShowLoginSuccess(); - void ShowUsbConfig(const char* status); + void ShowSetup(const char* status); void ShowIdleHome(const char* status, const char* footer); void ShowThinking(const char* status, const char* footer); void ShowNotes(const std::vector& notes, size_t index); @@ -127,6 +127,7 @@ class Ui { bool usb_host_power_connected_ = false; int battery_percent_ = 0; bool calendar_time_ready_ = false; + int calendar_date_key_ = 0; bool calendar_thinking_ = false; bool calendar_pet_visible_ = true; bool qr_calendar_active_ = false; @@ -164,6 +165,9 @@ class Ui { lv_obj_t* home_sensor_label_ = nullptr; lv_obj_t* home_sensor_shadow_label_ = nullptr; lv_obj_t* qr_status_label_ = nullptr; + lv_obj_t* qr_image_ = nullptr; + lv_obj_t* calendar_overlay_value_label_ = nullptr; + lv_obj_t* calendar_overlay_detail_label_ = nullptr; lv_image_dsc_t* qr_dsc_ = nullptr; uint8_t* qr_buf_ = nullptr; lv_image_dsc_t* content_dsc_ = nullptr; diff --git a/main/usb_product_disk.cc b/main/usb_product_disk.cc deleted file mode 100644 index 29110c1..0000000 --- a/main/usb_product_disk.cc +++ /dev/null @@ -1,146 +0,0 @@ -#include "usb_product_disk.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "app_config.h" - -namespace { -constexpr char kTag[] = "UsbProductDisk"; -char kBasePath[] = "/wecusb"; -constexpr char kReadmePath[] = "/wecusb/README.TXT"; -constexpr char kVersionPath[] = "/wecusb/VERSION.TXT"; -constexpr char kShortHtmlPath[] = "/wecusb/WECLAWBT.HTM"; -constexpr char kLongHtmlPath[] = "/wecusb/WeClawBot.html"; -constexpr char kPartitionLabel[] = "usbdrive"; - -tinyusb_msc_storage_handle_t g_storage = nullptr; -wl_handle_t g_wl_handle = WL_INVALID_HANDLE; - -bool WriteTextFile(const char* path, const char* content) { - FILE* file = std::fopen(path, "w"); - if (!file) { - ESP_LOGW(kTag, "open %s failed", path); - return false; - } - const bool ok = std::fputs(content, file) >= 0; - return std::fclose(file) == 0 && ok; -} - -void EnsureProductFiles() { - const FRESULT label_result = f_setlabel("WeClawBot"); - if (label_result != FR_OK) { - ESP_LOGW(kTag, "set FAT label failed: %d", static_cast(label_result)); - } - - WriteTextFile(kReadmePath, - "WeClawBot / 微笺屏\n" - "\n" - "官网: " WEC_PRODUCT_URL "\n" - "\n" - "打开 WeClawBot.html 或 WECLAWBT.HTM 进入安装、配置和帮助入口。\n" - "也可以在微信里发送“帮助”或“官网”找回入口。\n"); - - WriteTextFile(kVersionPath, - "name=WeClawBot\n" - "board=" WEC_BOARD_NAME "\n" - "firmware=" WEC_FIRMWARE_VERSION "\n" - "url=" WEC_PRODUCT_URL "\n"); - - constexpr char kHtml[] = - "" - "" - "WeClawBot" - "" - "

WeClawBot 微笺屏

" - "

这是设备自带的帮助入口。官网、安装、配置和文档都从这里找回。

" - "

打开 weclawbot.link

" - "

如果浏览器没有自动打开链接,请复制:

" - "

" WEC_PRODUCT_URL "

" - "

微信里发送 帮助官网/help 也可以找回入口。

" - "

Firmware " WEC_FIRMWARE_VERSION "

\n"; - - WriteTextFile(kShortHtmlPath, kHtml); - WriteTextFile(kLongHtmlPath, kHtml); -} - -bool InitCdcAcm() { -#if CONFIG_TINYUSB_CDC_ENABLED - tinyusb_config_cdcacm_t acm_cfg = {}; - acm_cfg.cdc_port = TINYUSB_CDC_ACM_0; - esp_err_t err = tinyusb_cdcacm_init(&acm_cfg); - if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { - ESP_LOGW(kTag, "tinyusb_cdcacm_init failed: %s", esp_err_to_name(err)); - return false; - } - ESP_LOGI(kTag, "TinyUSB CDC ACM ready"); -#endif - return true; -} - -} // namespace - -bool StartUsbProductDisk() { - if (g_storage) { - return true; - } - - const esp_partition_t* partition = esp_partition_find_first( - ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_FAT, kPartitionLabel); - if (!partition) { - ESP_LOGW(kTag, "FAT partition %s not found", kPartitionLabel); - return false; - } - - esp_err_t err = wl_mount(partition, &g_wl_handle); - if (err != ESP_OK) { - ESP_LOGW(kTag, "wl_mount failed: %s", esp_err_to_name(err)); - return false; - } - - tinyusb_msc_storage_config_t storage_cfg = {}; - storage_cfg.mount_point = TINYUSB_MSC_STORAGE_MOUNT_APP; - storage_cfg.medium.wl_handle = g_wl_handle; - storage_cfg.fat_fs.base_path = kBasePath; - storage_cfg.fat_fs.config.format_if_mount_failed = true; - storage_cfg.fat_fs.config.max_files = 4; - storage_cfg.fat_fs.config.allocation_unit_size = 4096; - storage_cfg.fat_fs.format_flags = 0; - - err = tinyusb_msc_new_storage_spiflash(&storage_cfg, &g_storage); - if (err != ESP_OK) { - ESP_LOGW(kTag, "tinyusb_msc_new_storage_spiflash failed: %s", esp_err_to_name(err)); - return false; - } - - EnsureProductFiles(); - - const tinyusb_config_t tusb_cfg = TINYUSB_DEFAULT_CONFIG(); - err = tinyusb_driver_install(&tusb_cfg); - if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { - ESP_LOGW(kTag, "tinyusb_driver_install failed: %s", esp_err_to_name(err)); - return false; - } - - InitCdcAcm(); - - err = tinyusb_msc_set_storage_mount_point(g_storage, TINYUSB_MSC_STORAGE_MOUNT_USB); - if (err != ESP_OK) { - ESP_LOGW(kTag, "set MSC mount USB failed: %s", esp_err_to_name(err)); - return false; - } - - ESP_LOGI(kTag, "WeClawBot USB MSC disk ready"); - return true; -} diff --git a/main/usb_product_disk.h b/main/usb_product_disk.h deleted file mode 100644 index f1984c3..0000000 --- a/main/usb_product_disk.h +++ /dev/null @@ -1,3 +0,0 @@ -#pragma once - -bool StartUsbProductDisk(); diff --git a/main/wechat_bot.cc b/main/wechat_bot.cc index 6bf9d08..bb9d54d 100644 --- a/main/wechat_bot.cc +++ b/main/wechat_bot.cc @@ -92,6 +92,24 @@ int64_t MonotonicSeconds() { return esp_timer_get_time() / 1000000; } +int PairingUiBucket(int seconds_left) { + seconds_left = std::max(0, seconds_left); +#if CONFIG_WEC_BOARD_ZECTRIX_NOTE4 + return (seconds_left + 59) / 60; +#else + return seconds_left; +#endif +} + +int PairingUiDisplaySeconds(int seconds_left) { + seconds_left = std::max(0, seconds_left); +#if CONFIG_WEC_BOARD_ZECTRIX_NOTE4 + return PairingUiBucket(seconds_left) * 60; +#else + return seconds_left; +#endif +} + std::string JsonString(const cJSON* object, const char* key, const char* fallback = "") { if (!object || !key) { return fallback ? fallback : ""; @@ -648,7 +666,7 @@ bool IsRecaptureCommand(const std::string& input) { std::string HelpReply() { return std::string("WeClawBot 入口:") + WEC_PRODUCT_URL + - "\n\n也可以直接把设备 USB-C 连到电脑,打开 WECLAWBOT U 盘里的安装页或配置页。" + "\n\n安装、配置和帮助均从官网进入。" "\n常用命令:/next 下一页,/prev 上一页,/clear 或“清屏”清除当前微笺,“修改为...”修正,+ 或“贴上”回捞上一条,/clear all 或“全清”清空全部微笺。"; } @@ -1328,6 +1346,7 @@ void WechatBot::ResetAgentRuntimeState() { agent_online_announced_ = false; agent_pairing_code_.clear(); agent_pairing_expires_at_ = 0; + agent_pairing_ui_bucket_ = -1; agent_last_bootstrap_attempt_ = 0; agent_last_status_kind_.clear(); agent_last_status_detail_.clear(); @@ -1539,8 +1558,10 @@ void WechatBot::SaveAgentCredentials() { nvs_close(nvs); } -void WechatBot::ClearAgentCredentials() { - agent_mqtt_.Disconnect(); +void WechatBot::ClearAgentCredentials(bool disconnect_mqtt) { + if (disconnect_mqtt) { + agent_mqtt_.Disconnect(); + } agent_credentials_ = {}; agent_paired_ = false; if (custom_agent_mode_.load()) { @@ -1549,6 +1570,7 @@ void WechatBot::ClearAgentCredentials() { agent_online_announced_ = false; agent_pairing_code_.clear(); agent_pairing_expires_at_ = 0; + agent_pairing_ui_bucket_ = -1; agent_last_bootstrap_attempt_ = 0; agent_last_status_kind_.clear(); agent_last_status_detail_.clear(); @@ -1676,10 +1698,13 @@ bool WechatBot::BeginByoaPairing() { return false; } agent_pairing_expires_at_ = std::time(nullptr) + 10 * 60; + const int seconds_left = AgentPairingSecondsLeft(); + agent_pairing_ui_bucket_ = PairingUiBucket(seconds_left); const UiDashboardView pairing_view = ui_.DashboardView(); if (pairing_view == UiDashboardView::kByoaTakeover || pairing_view == UiDashboardView::kOther) { - ui_.ShowAgentPairingCode(agent_pairing_code_.c_str(), AgentPairingSecondsLeft()); + ui_.ShowAgentPairingCode(agent_pairing_code_.c_str(), + PairingUiDisplaySeconds(seconds_left)); } ESP_LOGI(kTag, "BYOA code shown for device %s", agent_device_id_.c_str()); return true; @@ -1709,8 +1734,22 @@ void WechatBot::RunByoaMode() { const int64_t now = std::time(nullptr); const bool pairing_code_still_valid = !agent_pairing_code_.empty() && agent_pairing_expires_at_ > now; + if (!agent_paired_ && pairing_code_still_valid) { + const int seconds_left = AgentPairingSecondsLeft(); + const int ui_bucket = PairingUiBucket(seconds_left); + if (ui_bucket != agent_pairing_ui_bucket_) { + agent_pairing_ui_bucket_ = ui_bucket; + const UiDashboardView pairing_view = ui_.DashboardView(); + if (pairing_view == UiDashboardView::kByoaTakeover || + pairing_view == UiDashboardView::kOther) { + ui_.ShowAgentPairingCode(agent_pairing_code_.c_str(), + PairingUiDisplaySeconds(seconds_left)); + } + } + } if (!agent_paired_ && !pairing_code_still_valid && (agent_last_bootstrap_attempt_ == 0 || now - agent_last_bootstrap_attempt_ >= kByoaBootstrapRetrySeconds)) { + agent_pairing_ui_bucket_ = -1; BeginByoaPairing(); } MaintainAgentMqtt(); @@ -1821,6 +1860,7 @@ void WechatBot::ProcessAgentMessages() { takeover_active_ = true; agent_pairing_code_.clear(); agent_pairing_expires_at_ = 0; + agent_pairing_ui_bucket_ = -1; agent_online_announced_ = false; if (agent_mqtt_.Connect(agent_credentials_)) { RenderAgentCurrentOrDashboard(); @@ -2741,16 +2781,21 @@ void WechatBot::ClearCredentials() { bool WechatBot::DoQrLogin() { connected_ = false; while (!relogin_requested_) { - if (FetchQrCode() && PollQrStatus()) { + if (!FetchQrCode()) { + vTaskDelay(pdMS_TO_TICKS(1200)); + continue; + } + if (PollQrStatus()) { SaveCredentials(); connected_ = true; takeover_active_ = true; return true; } - login_state_ = LoginState::kQrError; - qr_seconds_left_ = 0; - ui_.ShowError("二维码已过期", "正在请求新的微信登录二维码"); - vTaskDelay(pdMS_TO_TICKS(1200)); + if (!relogin_requested_) { + login_state_ = LoginState::kFetchingQr; + qr_seconds_left_ = 0; + ESP_LOGI(kTag, "Refreshing expired QR in place"); + } } return false; } diff --git a/main/wechat_bot.h b/main/wechat_bot.h index f0e9af9..51d91cc 100644 --- a/main/wechat_bot.h +++ b/main/wechat_bot.h @@ -23,7 +23,7 @@ class WechatBot { void Start(); void RequestRelogin(); void ClearSavedCredentials(); - void ClearAgentCredentials(); + void ClearAgentCredentials(bool disconnect_mqtt = true); bool Connected() const { return connected_; } const char* LoginStateText() const; int QrSecondsLeft() const { return qr_seconds_left_; } @@ -237,6 +237,7 @@ class WechatBot { int64_t agent_last_reject_at_ = 0; int64_t weather_last_request_mono_ = 0; int64_t weather_last_update_mono_ = 0; + int agent_pairing_ui_bucket_ = -1; int weather_failure_count_ = 0; bool cloud_weather_valid_ = false; std::atomic wechat_send_priority_until_ms_{0}; diff --git a/main/zectrix_epd_display.cc b/main/zectrix_epd_display.cc index a7812f6..d5bf3d2 100644 --- a/main/zectrix_epd_display.cc +++ b/main/zectrix_epd_display.cc @@ -24,6 +24,7 @@ constexpr int kDtm1BytesPerRow = kBytesPerRow * 2; constexpr int kFrameBytes = kBytesPerRow * WEC_RLCD_HEIGHT; constexpr int kRefreshPollMs = 50; constexpr int kRefreshDebounceMs = 250; +constexpr int kRefreshFailureRetryDelayMs = 15000; constexpr int kSampleIntervalMs = 300; constexpr float kMinDiffBitRatio = 0.0f; constexpr int kTinyMaxStreak = 4; @@ -823,12 +824,36 @@ void ZectrixEpdDisplay::RefreshTask() { refresh_active_ = false; xSemaphoreGive(mutex_); }; + auto schedule_full_recovery_refresh = [this](uint32_t trace_id, + const char* reason) { + xSemaphoreTake(mutex_, portMAX_DELAY); + refresh_active_ = false; + pending_ = true; + force_refresh_ = false; + force_full_refresh_ = true; + transition_pending_ = false; + page_transition_pending_ = false; + dirty_ = {0, 0, WEC_RLCD_WIDTH, WEC_RLCD_HEIGHT}; + prev_synced_ = false; + xSemaphoreGive(mutex_); + SerialTrace( + "WEC:{\"scope\":\"epd\",\"stage\":\"refresh_recovery_scheduled\"," + "\"trace_id\":%u,\"ms\":%u,\"reason\":\"%s\"," + "\"retry_delay_ms\":%d,\"type\":\"event\"}\n", + static_cast(trace_id), + static_cast(TraceNowMs()), + reason ? reason : "", + kRefreshFailureRetryDelayMs); + }; if (run_transition) { if (!RunInkDissolveTransition(dirty)) { ESP_LOGW(kTag, "ink dissolve transition failed"); PowerOff(); - vTaskDelay(pdMS_TO_TICKS(500)); + vTaskDelay(pdMS_TO_TICKS(kRefreshFailureRetryDelayMs)); + schedule_full_recovery_refresh(refresh_trace_seq_, + "transition_display_frame_failed"); + continue; } finish_refresh(); continue; @@ -1011,8 +1036,8 @@ void ZectrixEpdDisplay::RefreshTask() { static_cast(trace_id), static_cast(TraceNowMs())); PowerOff(); - vTaskDelay(pdMS_TO_TICKS(500)); - finish_refresh(); + vTaskDelay(pdMS_TO_TICKS(kRefreshFailureRetryDelayMs)); + schedule_full_recovery_refresh(trace_id, "display_frame_failed"); continue; } std::memcpy(prev_buffer_, tx_buffer_, kFrameBytes); diff --git a/partitions.csv b/partitions.csv index cf547cb..9694efa 100644 --- a/partitions.csv +++ b/partitions.csv @@ -4,4 +4,3 @@ otadata, data, ota, 0xf000, 0x2000, phy_init, data, phy, 0x11000, 0x1000, factory, app, factory, 0x20000, 0x800000, storage, data, spiffs, , 0x100000, -usbdrive, data, fat, , 0x180000, diff --git a/scripts/check_firmware_regressions.py b/scripts/check_firmware_regressions.py index b0816cb..1a38d18 100755 --- a/scripts/check_firmware_regressions.py +++ b/scripts/check_firmware_regressions.py @@ -239,12 +239,13 @@ def check_cloud_weather_polling() -> None: bot_source = read("main/wechat_bot.cc") body = find_function_body(source, "void EnvironmentTask(void*)") process_at = body.find("ProcessDashboardTimers();") + runtime_tick_at = body.find("TickRuntimeUi();") slow_tick_at = body.find("if (tick % 60 == 0)") poll_at = body.find("bot.PollCloudWeather();") - if process_at < 0 or slow_tick_at < 0 or poll_at < 0: - fail("EnvironmentTask must call ProcessDashboardTimers, PollCloudWeather, and the 60s block") - if not (process_at < poll_at < slow_tick_at): - fail("cloud weather must be polled before the 60s sensor/battery block") + if process_at < 0 or runtime_tick_at < 0 or slow_tick_at < 0 or poll_at < 0: + fail("EnvironmentTask must call ProcessDashboardTimers, TickRuntimeUi, PollCloudWeather, and the 60s block") + if not (process_at < runtime_tick_at < slow_tick_at < poll_at): + fail("runtime UI tick must run before the 60s block and cloud weather polling") guard = body[max(0, poll_at - 80):poll_at] if "!WEC_BOARD_HAS_SHTC3" not in guard: fail("cloud weather polling must remain guarded to non-SHTC3 boards") @@ -326,6 +327,9 @@ def check_epd_carousel_contract() -> None: if forbidden in tick_body: fail(f"EPD runtime tick must not suppress the calendar clock via {forbidden}") ui_source = read("main/ui.cc") + ui_tick_body = find_function_body(ui_source, "void Ui::Tick()") + if "date_changed" not in ui_tick_body or "calendar_date_key_" not in ui_tick_body: + fail("calendar tick must redraw the home calendar when the local date rolls over") status_body = find_function_body(ui_source, "void Ui::UpdateStatusLabelsLocked()") if "PassiveEpdCalendarLocked()" not in status_body: fail("EPD calendar status updates must still use the passive-calendar guard") @@ -553,6 +557,17 @@ def check_epd_carousel_contract() -> None: fail(f"EPD explicit full-refresh contract is missing {required}") if "ui_.RequestFullRefresh();" not in clear_all_body: fail("full-clear button path must request an EPD full refresh") + for required in [ + "bot_.ClearAgentCredentials(false);", + "ui_.CommitPageTransition();", + "ui_.WaitForDisplayIdle(kFullClearRestartPreviewTimeoutMs)", + r'\"full_clear_restart\"', + "esp_restart();", + ]: + if required not in clear_all_body: + fail(f"full-clear button path must restart from a rendered reset page: missing {required}") + if "void ClearAgentCredentials(bool disconnect_mqtt = true);" not in read("main/wechat_bot.h"): + fail("button reset must be able to skip blocking MQTT disconnect before restart") serial_source = read("main/serial_config.cc") for required in [ "EPD_PAGE_TSSET", @@ -639,11 +654,27 @@ def check_weather_ui_contract() -> None: for required in ["CloudWeatherLabel", "天气 --℃", "湿度 --%%"]: if required not in source: fail(f"weather UI contract is missing {required}") - for required in ["kTakeoverSwitchHint", '"上下循环"', '"左右循环"']: + for required in [ + "kTakeoverSwitchHint", + '"按上/下键切换智能体"', + '"按左/右键切换智能体"', + '"按上/下键切换屏幕"', + '"按左/右键切换屏幕"', + '"微信扫码接入微笺智能体 %s"', + '"有效期 %02d:%02d\\n%s"', + '"绑定码刷新中\\n%s"', + '"请为智能体安装 weclawbotctl 插件,输入上方绑定码"', + "calendar_overlay_value_label_", + "qr_image_", + "#include ", + ]: if required not in source: fail(f"takeover switch hint contract is missing {required}") - if '"正在刷新绑定码 %s"' not in source: - fail("BYOA pairing refresh UI must still advertise takeover switching") + bot = read("main/wechat_bot.cc") + if 'ShowError("二维码已过期"' in bot: + fail("expired WeChat QR must refresh in place instead of showing an error page") + if '"Refreshing expired QR in place"' not in bot: + fail("expired WeChat QR refresh path is missing") def check_zectrix_power_contract() -> None: @@ -670,6 +701,72 @@ def check_development_build_contract() -> None: fail("public release builds must hide the development label") +def check_no_usb_mass_storage_contract() -> None: + for removed_path in [ + "main/usb_product_disk.cc", + "main/usb_product_disk.h", + "docs/usb-msc-product-disk.md", + ]: + if (ROOT / removed_path).exists(): + fail(f"USB mass-storage product disk file must remain removed: {removed_path}") + + public_surface = "\n".join( + read(path) + for path in [ + "main/CMakeLists.txt", + "main/Kconfig.projbuild", + "main/app_main.cc", + "main/idf_component.yml", + "partitions.csv", + "sdkconfig.defaults", + "sdkconfig.release.defaults", + "main/ui.cc", + "main/wechat_bot.cc", + ] + ) + for forbidden in [ + "WEC_PRODUCT_DISK", + "usb_product_disk", + "usbdrive", + "TINYUSB_MSC", + "U 盘", + "U盘", + ]: + if forbidden in public_surface: + fail(f"USB mass-storage product disk contract reintroduced {forbidden}") + + development_defaults = read("sdkconfig.defaults") + if "CONFIG_USJ_ENABLE_USB_SERIAL_JTAG=y" not in development_defaults: + fail("USB Serial/JTAG must remain enabled after removing USB mass storage") + + +def check_serial_bootloader_contract() -> None: + serial_source = read("main/serial_config.cc") + for required in [ + '"BOOTLOADER"', + '"DOWNLOAD_MODE"', + "RTC_CNTL_FORCE_DOWNLOAD_BOOT", + "RTC_CNTL_OPTION1_REG", + "EnterRomDownloadMode", + ]: + if required not in serial_source: + fail(f"serial bootloader contract is missing {required}") + script = read("scripts/flash_via_serial.py") + for required in [ + "WEC:BOOTLOADER", + "WEC:GET", + "Waveshare ESP32-S3-RLCD-4.2", + "Zectrix Note 4", + "@flash_args", + "ser.dtr = True", + "default-reset", + "next_send", + "Flash failures:", + ]: + if required not in script: + fail(f"host serial flashing helper is missing {required}") + + def main() -> None: check_button_short_press_paths() check_cloud_weather_polling() @@ -679,6 +776,8 @@ def main() -> None: check_weather_ui_contract() check_zectrix_power_contract() check_development_build_contract() + check_no_usb_mass_storage_contract() + check_serial_bootloader_contract() print("firmware regression checks passed") diff --git a/scripts/flash_via_serial.py b/scripts/flash_via_serial.py new file mode 100755 index 0000000..155e323 --- /dev/null +++ b/scripts/flash_via_serial.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Flash WeClawBot boards through the firmware serial protocol. + +The script is intentionally board-aware: it asks each connected device for +WEC:GET, maps the reported board name to the matching build directory, asks the +running firmware to enter ROM download mode, then invokes esptool with the +build directory's flash_args. It does not erase NVS. +""" + +from __future__ import annotations + +import argparse +import glob +import json +import os +from pathlib import Path +import subprocess +import sys +import time +from typing import Any + +try: + import serial +except ImportError as exc: # pragma: no cover - host environment issue + raise SystemExit(f"pyserial is required: {exc}") from exc + + +PROJECT_DIR = Path(__file__).resolve().parents[1] +PIO_PYTHON = Path.home() / ".platformio/penv/bin/python3" +PIO_ESPTOOL = ( + Path.home() + / ".platformio/packages/framework-espidf/components/esptool_py/esptool/esptool.py" +) + +BOARD_BUILD_KEYS = { + "Waveshare ESP32-S3-RLCD-4.2": "waveshare", + "Zectrix Note 4": "note4", +} + + +def serial_ports() -> list[str]: + return sorted(glob.glob("/dev/cu.usbmodem*")) + + +def read_device_status(port: str, timeout: float = 8.0) -> dict[str, Any] | None: + try: + with serial.Serial(port, 115200, timeout=0.2) as ser: + # The product firmware uses a USB serial channel for its WEC:* + # protocol. Keep DTR asserted while talking to the app; older + # boards can reboot when a host opens the port with DTR + # deasserted. + ser.dtr = True + ser.rts = False + time.sleep(0.25) + deadline = time.time() + timeout + next_send = 0.0 + while time.time() < deadline: + now = time.time() + if now >= next_send: + ser.write(b"WEC:GET\n") + ser.flush() + next_send = now + 1.0 + raw = ser.readline() + if not raw: + continue + line = raw.decode("utf-8", "replace").strip() + if not line.startswith("WEC:"): + continue + try: + payload = json.loads(line[4:]) + except json.JSONDecodeError: + continue + if payload.get("type") == "config" and payload.get("board"): + return payload + except OSError: + return None + return None + + +def send_bootloader_command(port: str, timeout: float = 2.0) -> bool: + try: + with serial.Serial(port, 115200, timeout=0.2) as ser: + ser.dtr = True + ser.rts = False + time.sleep(0.2) + ser.write(b"WEC:BOOTLOADER\n") + deadline = time.time() + timeout + while time.time() < deadline: + raw = ser.readline() + if not raw: + continue + line = raw.decode("utf-8", "replace").strip() + if not line.startswith("WEC:"): + continue + try: + payload = json.loads(line[4:]) + except json.JSONDecodeError: + continue + if payload.get("ok") is True and payload.get("type") == "bootloader": + return True + if payload.get("ok") is False: + return False + except OSError: + return False + return False + + +def try_cdc_line_toggle(port: str) -> None: + """Best-effort compatibility with USB CDC bootloader gestures.""" + try: + ser = serial.Serial(port, 1200, timeout=0.1) + ser.dtr = True + ser.rts = True + time.sleep(0.1) + ser.rts = False + time.sleep(0.2) + ser.close() + except OSError: + return + + +def wait_for_download_port(preferred: str, timeout: float = 15.0) -> str: + deadline = time.time() + timeout + seen = set(serial_ports()) + saw_preferred_absent = preferred not in seen + preferred_stable_since: float | None = None + while time.time() < deadline: + ports = serial_ports() + now = time.time() + if preferred not in ports: + saw_preferred_absent = True + preferred_stable_since = None + elif saw_preferred_absent: + return preferred + elif preferred_stable_since is None: + preferred_stable_since = now + elif now - preferred_stable_since >= 2.0: + return preferred + new_ports = [port for port in ports if port not in seen] + if new_ports: + return new_ports[0] + time.sleep(0.25) + raise TimeoutError("USB serial port did not reappear in download mode") + + +def esptool_python() -> Path: + if PIO_PYTHON.exists() and PIO_ESPTOOL.exists(): + return PIO_PYTHON + return Path(sys.executable) + + +def esptool_script() -> Path: + if PIO_ESPTOOL.exists(): + return PIO_ESPTOOL + idf_path = os.environ.get("IDF_PATH") + if idf_path: + candidate = Path(idf_path) / "components/esptool_py/esptool/esptool.py" + if candidate.exists(): + return candidate + raise FileNotFoundError("could not find esptool.py") + + +def flash_build(port: str, build_dir: Path, baud: int, before: str) -> None: + flash_args = build_dir / "flash_args" + if not flash_args.exists(): + raise FileNotFoundError(f"missing flash_args in {build_dir}") + cmd = [ + str(esptool_python()), + str(esptool_script()), + "--chip", + "esp32s3", + "-p", + port, + "-b", + str(baud), + "--before", + before, + "--after", + "hard-reset", + "write_flash", + "@flash_args", + ] + subprocess.run(cmd, cwd=build_dir, check=True) + + +def flash_build_with_fallback(port: str, build_dir: Path, baud: int, before: str) -> None: + try: + flash_build(port, build_dir, baud, before) + except subprocess.CalledProcessError: + if before == "default-reset": + raise + print(f"{port}: no-reset flash failed; retrying with esptool default-reset", file=sys.stderr) + flash_build(port, build_dir, baud, "default-reset") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", action="append", help="USB serial port to consider; default scans /dev/cu.usbmodem*") + parser.add_argument("--waveshare-build", required=True, help="Build directory for Waveshare RLCD firmware") + parser.add_argument("--note4-build", required=True, help="Build directory for Zectrix Note 4 firmware") + parser.add_argument("--baud", type=int, default=460800) + parser.add_argument("--boot-timeout", type=float, default=15.0) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + build_by_key = { + "waveshare": Path(args.waveshare_build).resolve(), + "note4": Path(args.note4_build).resolve(), + } + ports = args.port or serial_ports() + if not ports: + print("No USB serial ports found.", file=sys.stderr) + return 2 + + targets: list[tuple[str, str, dict[str, Any]]] = [] + for port in ports: + status = read_device_status(port) + if not status: + print(f"{port}: no WEC:GET response; skipping", file=sys.stderr) + continue + board = str(status.get("board") or "") + key = BOARD_BUILD_KEYS.get(board) + if not key: + print(f"{port}: unsupported board {board!r}; skipping", file=sys.stderr) + continue + targets.append((port, key, status)) + + if not targets: + print("No flashable WeClawBot devices responded.", file=sys.stderr) + return 3 + + failures: list[tuple[str, str]] = [] + for port, key, status in targets: + build_dir = build_by_key[key] + print( + f"{port}: {status.get('board')} version={status.get('version')} " + f"-> {build_dir.name}", + flush=True, + ) + before = "no-reset" + if not send_bootloader_command(port): + print(f"{port}: WEC:BOOTLOADER not accepted; trying CDC line toggle", file=sys.stderr) + try_cdc_line_toggle(port) + before = "default-reset" + download_port = port + else: + download_port = wait_for_download_port(port, args.boot_timeout) + print(f"{port}: flashing via {download_port}", flush=True) + try: + flash_build_with_fallback(download_port, build_dir, args.baud, before) + except (OSError, subprocess.CalledProcessError, TimeoutError) as exc: + failures.append((port, str(exc))) + print(f"{port}: flash failed: {exc}", file=sys.stderr) + + if failures: + print("Flash failures:", file=sys.stderr) + for port, detail in failures: + print(f"- {port}: {detail}", file=sys.stderr) + return 4 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 872a04c..e12ec25 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -31,7 +31,6 @@ CONFIG_LV_USE_IMGFONT=y CONFIG_USJ_ENABLE_USB_SERIAL_JTAG=y # CONFIG_ESP_CONSOLE_SECONDARY_NONE is not set CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y -# CONFIG_WEC_PRODUCT_DISK is not set CONFIG_WEC_CURATOR_URL="https://weclawbot.link/gateway" CONFIG_MQTT_PROTOCOL_311=y CONFIG_MQTT_TRANSPORT_SSL=y diff --git a/sdkconfig.release.defaults b/sdkconfig.release.defaults index a4139f4..1e77cdf 100644 --- a/sdkconfig.release.defaults +++ b/sdkconfig.release.defaults @@ -1,16 +1 @@ -CONFIG_WEC_PRODUCT_DISK=y # CONFIG_WEC_DEVELOPMENT_BUILD is not set -CONFIG_WL_SECTOR_SIZE_512=y -CONFIG_WL_SECTOR_SIZE=512 -CONFIG_TINYUSB_MSC_ENABLED=y -CONFIG_TINYUSB_MSC_BUFSIZE=512 -CONFIG_TINYUSB_CDC_ENABLED=y -CONFIG_TINYUSB_CDC_COUNT=1 -# CONFIG_TINYUSB_DESC_USE_DEFAULT_PID is not set -CONFIG_TINYUSB_DESC_CUSTOM_PID=0x4010 -CONFIG_TINYUSB_DESC_PRODUCT_STRING="WeClawBot" -CONFIG_TINYUSB_DESC_MSC_STRING="WeClawBot" -# CONFIG_FATFS_LFN_NONE is not set -CONFIG_FATFS_LFN_HEAP=y -CONFIG_FATFS_MAX_LFN=255 -CONFIG_FATFS_USE_LABEL=y diff --git a/services/weclawbot-agent/app/config.py b/services/weclawbot-agent/app/config.py index eed8fc0..c5d0b02 100644 --- a/services/weclawbot-agent/app/config.py +++ b/services/weclawbot-agent/app/config.py @@ -6,10 +6,11 @@ import os from pathlib import Path -# ---- screen geometry ---- -# The panel is 400x300, but the device draws its own chrome (status/title bar) and -# only accepts note content in a 368x206 window at (x=16, y=42), stride 46. This was -# reverse-engineered from the working SCF screen_document. Render to CONTENT_*, not full. +# ---- fallback screen geometry ---- +# These values describe the original 4.2-inch content viewport. Runtime rendering +# must prefer event.device_context.content_viewport/display_capabilities whenever +# a device reports them, so future ESP32 screen products are not forced into this +# geometry. SCREEN_W = 400 SCREEN_H = 300 CONTENT_W = int(os.environ.get("WCB_CONTENT_W", "368")) @@ -18,7 +19,7 @@ CONTENT_X = int(os.environ.get("WCB_CONTENT_X", "16")) CONTENT_Y = int(os.environ.get("WCB_CONTENT_Y", "42")) STRIDE = CONTENT_STRIDE -# device polarity: bit1=black. SCF pages have first bytes 0x00 (white bg) — matches ours. +# Fallback device polarity for old contexts without display_capabilities.pixel.black_bit. INVERT_BITS = os.environ.get("WCB_INVERT_BITS", "1") == "1" # ---- fonts (Noto Sans CJK is present on the weclawbot host) ---- @@ -26,10 +27,42 @@ "WCB_FONT", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" ) FONT_INDEX = int(os.environ.get("WCB_FONT_INDEX", "0")) -# 霞鹜文楷 (LXGW WenKai) = the "wenkai" calligraphic style SCF used; for 书法体/楷体 requests + +FONT_DIR = Path(os.environ.get("WCB_FONT_DIR", "/opt/weclawbot-agent/fonts")) + +# macOS-style Chinese display fonts installed on the cloud host. Keep every path +# env-overridable so deployments without these files gracefully fall back. +FONT_HANDWRITING = os.environ.get("WCB_FONT_HANDWRITING", str(FONT_DIR / "Hanzipen.ttc")) +FONT_HANDWRITING_INDEX = int(os.environ.get("WCB_FONT_HANDWRITING_INDEX", "0")) +FONT_HANNOTATE = os.environ.get("WCB_FONT_HANNOTATE", str(FONT_DIR / "Hannotate.ttc")) +FONT_HANNOTATE_INDEX = int(os.environ.get("WCB_FONT_HANNOTATE_INDEX", "0")) +FONT_KAITI = os.environ.get("WCB_FONT_KAITI", str(FONT_DIR / "Kaiti.ttc")) +FONT_KAITI_INDEX = int(os.environ.get("WCB_FONT_KAITI_INDEX", "0")) +FONT_BIAUKAI = os.environ.get("WCB_FONT_BIAUKAI", str(FONT_DIR / "BiauKai.ttc")) +FONT_BIAUKAI_INDEX = int(os.environ.get("WCB_FONT_BIAUKAI_INDEX", "0")) +FONT_XINGKAI = os.environ.get("WCB_FONT_XINGKAI", str(FONT_DIR / "Xingkai.ttc")) +FONT_XINGKAI_INDEX = int(os.environ.get("WCB_FONT_XINGKAI_INDEX", "0")) +FONT_WAWA = os.environ.get("WCB_FONT_WAWA", str(FONT_DIR / "WawaSC-Regular.otf")) +FONT_WAWA_INDEX = int(os.environ.get("WCB_FONT_WAWA_INDEX", "0")) +FONT_YUPPY = os.environ.get("WCB_FONT_YUPPY", str(FONT_DIR / "YuppySC-Regular.otf")) +FONT_YUPPY_INDEX = int(os.environ.get("WCB_FONT_YUPPY_INDEX", "0")) +FONT_LINGWAI = os.environ.get("WCB_FONT_LINGWAI", str(FONT_DIR / "LingWaiSC-Medium.otf")) +FONT_LINGWAI_INDEX = int(os.environ.get("WCB_FONT_LINGWAI_INDEX", "0")) +FONT_LIBIAN = os.environ.get("WCB_FONT_LIBIAN", str(FONT_DIR / "Libian.ttc")) +FONT_LIBIAN_INDEX = int(os.environ.get("WCB_FONT_LIBIAN_INDEX", "0")) +FONT_BAOLI = os.environ.get("WCB_FONT_BAOLI", str(FONT_DIR / "Baoli.ttc")) +FONT_BAOLI_INDEX = int(os.environ.get("WCB_FONT_BAOLI_INDEX", "0")) + +# Legacy brush font fallback for deployments that have not installed Xingkai. +FONT_CALLIGRAPHY = os.environ.get( + "WCB_FONT_CALLIGRAPHY", "/usr/local/share/fonts/weclawbot/MaShanZheng-Regular.ttf" +) +FONT_CALLIGRAPHY_INDEX = int(os.environ.get("WCB_FONT_CALLIGRAPHY_INDEX", "0")) +# 霞鹜文楷 (LXGW WenKai) = softer kai/handwriting style for 楷体/文楷 requests. FONT_WENKAI = os.environ.get( "WCB_FONT_WENKAI", "/usr/local/share/fonts/weclawbot/LXGWWenKai-Regular.ttf" ) +FONT_WENKAI_INDEX = int(os.environ.get("WCB_FONT_WENKAI_INDEX", "0")) # ---- render/content self-review loop ---- MAX_RENDER_ATTEMPTS = int(os.environ.get("WCB_MAX_RENDER_ATTEMPTS", "2")) diff --git a/services/weclawbot-agent/app/critic.py b/services/weclawbot-agent/app/critic.py index 6da3b32..b872c95 100644 --- a/services/weclawbot-agent/app/critic.py +++ b/services/weclawbot-agent/app/critic.py @@ -36,10 +36,11 @@ def critic_geometric(canonical_text: str, key_fields: list[str], metrics: dict) Whitespace is fine on an e-ink note: density only fails when *overcrowded*, never for being sparse (a schedule that fits with room to spare is good). """ - density_ok = metrics["density"] <= config.DENSITY_HI + density_hi = float(metrics.get("density_hi") or config.DENSITY_HI) + density_ok = metrics["density"] <= density_hi checks = { "no_truncation": {"pass": not metrics["overflow"], "note": ""}, - "density": {"pass": density_ok, "note": f"density={metrics['density']}"}, + "density": {"pass": density_ok, "note": f"density={metrics['density']} limit={density_hi}"}, "alignment": {"pass": True, "note": "deterministic renderer"}, "readability": {"pass": metrics["min_font"] >= config.MIN_FONT_PX, "note": ""}, } @@ -67,7 +68,7 @@ def critic_geometric(canonical_text: str, key_fields: list[str], metrics: dict) _SCREEN_COMMAND = re.compile(r"上屏|显示到?屏|放到?屏|发到?屏|贴到?屏|写到?屏|screen", re.I) -_STYLE_TOKEN = re.compile(r"书法体|楷体|文楷|毛笔|手写") +_STYLE_TOKEN = re.compile(r"书法体|楷体|文楷|毛笔|手写|竖排|纵排|从右到左|右到左|vertical", re.I) def _plan_text(plan: dict) -> str: @@ -151,6 +152,7 @@ def content_critic_llm(state: dict, plan: dict) -> dict: "items": plan.get("items"), "font_style": plan.get("font_style"), "item_prefix": plan.get("item_prefix"), + "writing_mode": plan.get("writing_mode"), }, }, ensure_ascii=False, diff --git a/services/weclawbot-agent/app/graph.py b/services/weclawbot-agent/app/graph.py index 4fdbe29..cca74e1 100644 --- a/services/weclawbot-agent/app/graph.py +++ b/services/weclawbot-agent/app/graph.py @@ -28,16 +28,31 @@ DISPLAY_ACTIONS = {"create_note", "update_note", "replace_note", "merge_note", "draft_note"} SKILL = {"id": "official-langgraph-agent", "version": "0.1.0"} STICKY_SKILL = {"id": "sticky-core-parity", "version": "0.1.8-py"} +FONT_STYLES = { + "sans", + "wenkai", + "kaiti", + "handwriting", + "hannotate", + "calligraphy", + "wawa", + "yuppy", + "lingwai", + "libian", + "baoli", + "biaukai", +} _SCREEN_INTENT = re.compile(r"上屏|放到?屏|发到?屏|贴到?屏|显示到?屏|记到?屏|写到?屏|弄上去|放上去") _MODIFY_INTENT = re.compile(r"改|简洁|精简|换成|重排|调整|清掉|去掉|太挤|太空|大一点|小一点|哪里.*书法|不像.*书法|不是.*书法|不够.*书法|这也叫.*书法|书法.*不") _FIT_INTENT = re.compile(r"一屏|一页|显示完|放得下|放不下|压缩|太长|翻页") _FOLLOWUP = re.compile(r"你自己|自己去|去搜|就这样|继续|接着|还是|那就") _GREETING = re.compile(r"^(?:在吗|你好|您好|hi|hello|hey|早|晚安|测试|ping|test)$", re.I) -_COMMAND_ONLY = re.compile(r"^(?:[++]|上屏|贴上|显示到屏上|放到屏上|发到屏上|用?书法体|用?楷体|用?文楷|手写|毛笔|上屏[,, ]*(?:书法体|楷体|文楷|手写|毛笔))$", re.I) +_COMMAND_ONLY = re.compile(r"^(?:[++]|上屏|贴上|显示到屏上|放到屏上|发到屏上|用?书法体|用?楷体|用?文楷|用?手写体|手写|毛笔|行楷|上屏[,, ]*(?:书法体|楷体|文楷|手写体|手写|毛笔|行楷))$", re.I) _GENERATION_INTENT = re.compile(r"(?:写|编|生成|创作|来|讲|出|做).{0,14}(?:笑话|段子|故事|小诗|诗|祝福|文案|标语|金句|名言|句子)", re.I) _GENERIC_REPLY = re.compile(r"我收到了|收到。我先不更新屏幕|这条我先按普通对话处理|微信通道和 WeClawBot 云端都在线") _REASONING_LEAK = re.compile(r"thinking process|analyze the request|current context|final answer|思考过程|推理过程", re.I) +_VERTICAL_LAYOUT_INTENT = re.compile(r"竖排|纵排|竖着|纵向|从右到左|右到左|vertical(?:-rl)?", re.I) def _plan_font_source(state: OfficialAgentState, plan: dict) -> str: @@ -92,6 +107,10 @@ def _valid_plan_json(value: object) -> bool: return isinstance(value, dict) and isinstance(value.get("items"), list) and bool(value.get("items")) +def _preferred_writing_mode_from_text(text: str) -> str: + return "vertical-rl" if _VERTICAL_LAYOUT_INTENT.search(str(text or "")) else "" + + def _apply_personal_layout_prefs(plan: dict, prefs: dict) -> dict: plan = dict(plan) font_scale = str((prefs or {}).get("font_scale") or "") @@ -119,10 +138,10 @@ def _normalize_model_plan(state: OfficialAgentState, plan: dict) -> dict: if explicit_font: plan["font_style"] = explicit_font plan["_font_source"] = "explicit_user_text" - elif model_font in {"sans", "wenkai"}: + elif model_font in FONT_STYLES: plan["font_style"] = model_font plan["_font_source"] = "model" - elif pref_font in {"sans", "wenkai"}: + elif pref_font in FONT_STYLES: plan["font_style"] = pref_font plan["_font_source"] = "memory" else: @@ -131,11 +150,30 @@ def _normalize_model_plan(state: OfficialAgentState, plan: dict) -> dict: if str(plan.get("item_prefix") or "") not in {"bullet", "none", "checkbox"}: plan.pop("item_prefix", None) + + explicit_mode = _preferred_writing_mode_from_text(state.get("text", "")) + model_mode = str(plan.get("writing_mode") or "").lower().replace("_", "-") + if explicit_mode: + plan["writing_mode"] = explicit_mode + plan["_writing_mode_source"] = "explicit_user_text" + elif model_mode in {"horizontal", "vertical-rl"}: + plan["writing_mode"] = model_mode + plan["_writing_mode_source"] = "model" + else: + plan["writing_mode"] = "horizontal" + plan["_writing_mode_source"] = "renderer_default" + if plan.get("writing_mode") == "vertical-rl": + plan["item_prefix"] = "none" + artistic_fonts = {"calligraphy", "handwriting", "hannotate", "wenkai", "kaiti", "biaukai"} + if not bool((state.get("metrics") or {}).get("overflow")) and str(plan.get("font_style") or "") in artistic_fonts: + min_body = 23 if str(plan.get("font_style")) in {"calligraphy", "handwriting", "hannotate"} else 22 + plan["item_size"] = max(int(plan.get("item_size") or 20), min_body) + plan["title_size"] = max(int(plan.get("title_size") or 26), 30) return plan def _is_generation_request(text: str) -> bool: - t = str(text or "") + t = re.sub(sticky_rules.STYLE_WORD_PATTERN, "", str(text or ""), flags=re.I) return bool(_SCREEN_INTENT.search(t) and _GENERATION_INTENT.search(t)) @@ -237,6 +275,8 @@ def _sticky_should_finish(sticky: dict) -> bool: return True if action in DISPLAY_ACTIONS and "current_screen_delete" in trace: return True + if action in DISPLAY_ACTIONS and trace.intersection({"poem_card_durable_screen_value", "explicit_screen_intent_poem_card"}): + return True if action in DISPLAY_ACTIONS and trace.intersection({"single_reminder_note", "preserve_current_reminder_append"}): return True return action == "reply_only" and "reply_conversation_probe" in trace @@ -545,6 +585,8 @@ def _note_to_plan(note: dict) -> dict: "item_size": 22 if poem_like else 20, "key_fields": [], **({"item_prefix": item_prefix} if item_prefix else {}), + **({"font_style": "kaiti"} if poem_like else {}), + **({"layout_mode": "poem-card"} if poem_like else {}), } @@ -556,6 +598,8 @@ def _is_structured_note_line(line: str) -> bool: def _looks_like_poem_note(title: str, lines: list[str]) -> bool: if title in {"唐诗", "诗句", "小诗", "诗词"}: return True + if title and sticky_rules.looks_like_poem_card(f"《{title}》\n" + "\n".join(lines)): + return True sample = "".join(lines[:4]) if not sample or len(sample) > 80: return False @@ -611,7 +655,7 @@ def sticky_planner(state: OfficialAgentState) -> dict: plan["item_size"] = max(config.MIN_FONT_PX, min(22, plan["item_size"] - attempt * 2)) plan = _apply_personal_layout_prefs(plan, state.get("prefs") or {}) plan = _normalize_model_plan(state, plan) - trace = state["trace"] + [{"stage": "sticky_planner", "attempt": attempt, "action": sticky.get("action"), "items": len(plan["items"]), "font": plan.get("font_style"), "font_source": _plan_font_source(state, plan), "prefix": plan.get("item_prefix"), "model_calls": state.get("model_calls", 0)}] + trace = state["trace"] + [{"stage": "sticky_planner", "attempt": attempt, "action": sticky.get("action"), "items": len(plan["items"]), "font": plan.get("font_style"), "font_source": _plan_font_source(state, plan), "prefix": plan.get("item_prefix"), "writing_mode": plan.get("writing_mode"), "writing_mode_source": plan.get("_writing_mode_source"), "model_calls": state.get("model_calls", 0)}] return { "plan": plan, "curator_action": str(sticky.get("action") or DISPLAY_ACTION), @@ -633,7 +677,7 @@ def planner(state: OfficialAgentState) -> dict: used_model = False if _can_call_model(state): sys = ( - "你把任务整理成墨水屏内容区(约 368x206 像素, 设备另画标题栏)能放下的结构化内容。中文。" + "你把任务整理成当前设备内容区能放下的结构化内容。中文。" "内容区不大, 标题短, 条目尽量短(单行为宜), 优先保留关键信息(时间/取件码/标题)。" "用户本轮新增、要求修改、要求补充的信息优先级最高;压缩时不能丢失本轮新增事项。" "如果用户要求你写/编/生成笑话、诗、祝福、文案等,要先创作适合上屏的正文,不能把用户命令原句当正文。" @@ -643,7 +687,7 @@ def planner(state: OfficialAgentState) -> dict: "你要自己根据内容、当前屏幕、用户反馈和长期偏好选择标题、正文拆分、字号、字体和项目符号;" "共享版式提示只用于提升可读性和美观,不得扩展成新工具或额外任务;" "不要依赖固定分类规则。用户未指定时,选择你认为在真机上最易读、最贴合内容的一种。" - '只输出JSON: {"title":str,"items":[str],"title_size":int(22-30),"item_size":int(16-22),"key_fields":[str],"font_style":"sans|wenkai","item_prefix":"bullet|none|checkbox"}' + '只输出JSON: {"title":str,"items":[str],"title_size":int(22-30),"item_size":int(16-23),"key_fields":[str],"font_style":"sans|wenkai|kaiti|handwriting|hannotate|calligraphy|wawa|yuppy|lingwai","item_prefix":"bullet|none|checkbox","writing_mode":"horizontal|vertical-rl"}' ) usr = ( f"用户: {state.get('text')}\n" @@ -671,6 +715,7 @@ def planner(state: OfficialAgentState) -> dict: "key_fields": [str(x) for x in (j.get("key_fields") or plan["key_fields"])], "font_style": str(j.get("font_style") or ""), "item_prefix": str(j.get("item_prefix") or ""), + "writing_mode": str(j.get("writing_mode") or ""), } break if sticky_note and str(sticky.get("action") or "") in {"update_note", "merge_note"}: @@ -690,7 +735,7 @@ def planner(state: OfficialAgentState) -> dict: plan["title_size"] = max(config.MIN_FONT_PX, min(30, plan["title_size"])) plan = _apply_personal_layout_prefs(plan, state.get("prefs") or {}) plan = _normalize_model_plan(state, plan) - trace = state["trace"] + [{"stage": "planner", "attempt": attempt, "items": len(plan["items"]), "font": plan.get("font_style"), "font_source": _plan_font_source(state, plan), "prefix": plan.get("item_prefix"), "model_used": used_model, "model_tiers": used_tiers if used_model else [], "model_calls": model_calls}] + trace = state["trace"] + [{"stage": "planner", "attempt": attempt, "items": len(plan["items"]), "font": plan.get("font_style"), "font_source": _plan_font_source(state, plan), "prefix": plan.get("item_prefix"), "writing_mode": plan.get("writing_mode"), "writing_mode_source": plan.get("_writing_mode_source"), "model_used": used_model, "model_tiers": used_tiers if used_model else [], "model_calls": model_calls}] return {"plan": plan, "trace": trace, "model_calls": model_calls} @@ -722,7 +767,7 @@ def _after_content_critic(state: OfficialAgentState) -> str: def raster_preview(state: OfficialAgentState) -> dict: - plan = state["plan"] + plan = {**state["plan"], "device_context": (state.get("raw_event") or {}).get("device_context") or {}} pages, png, metrics = render.render_layout(plan) name = f"{state.get('event_id','ev')}_{state.get('render_attempts',0)}.png" path = render.save_preview(png, name) @@ -769,7 +814,8 @@ def finalize(state: OfficialAgentState) -> dict: ) forced = v.get("verdict") != "pass" or cv.get("verdict") != "pass" seed_reply = str(((state.get("sticky_decision") or {}).get("user_reply") or "")).strip() - reply = state.get("curator_reply") or seed_reply or f"已{'更新' if action != DISPLAY_ACTION else '整理并上屏'}:{plan['title']}({len(plan['items'])}条)。" + reply_title = str((note.get("title") or plan["title"] or "微笺")).strip("《》") or "微笺" + reply = state.get("curator_reply") or seed_reply or f"已{'更新' if action != DISPLAY_ACTION else '整理并上屏'}:{reply_title}。" if forced: reply += " 版面较满,如需更简洁可以说“再精简点”。" render_id = _rid() @@ -790,6 +836,17 @@ def finalize(state: OfficialAgentState) -> dict: "priority": note.get("priority") or "normal", }, "screen_pages": state["pages"], + "screen_render": { + "format": "mono1", + "source": "official_agent_raster", + "viewport": "content", + "font_style": (state.get("metrics") or {}).get("font_style"), + "font_resolved": (state.get("metrics") or {}).get("font_resolved"), + "writing_mode": (state.get("metrics") or {}).get("writing_mode"), + "layout_mode": (state.get("metrics") or {}).get("layout_mode"), + "page_count": len(state.get("pages") or []), + "render_id": render_id, + }, "key_fields": plan.get("key_fields", []), "preview_path": state.get("preview_path"), **({"screen_preview_url": preview_url, "screen_preview_delivery": "device_ilink_image"} if preview_url else {}), diff --git a/services/weclawbot-agent/app/memory.py b/services/weclawbot-agent/app/memory.py index a0271e9..e102c29 100644 --- a/services/weclawbot-agent/app/memory.py +++ b/services/weclawbot-agent/app/memory.py @@ -32,8 +32,15 @@ LANGMEM_AVAILABLE = False -_STYLE_WENKAI = re.compile(r"书法|楷体|文楷|手写|毛笔|calligraph", re.I) -_STYLE_SANS = re.compile(r"不用.*(书法|楷体|文楷|手写|毛笔)|普通字体|默认字体|黑体|sans", re.I) +_STYLE_HANDWRITING = re.compile(r"手写体|手写|翩翩体|翩翩|hanzipen|handwriting|handwrite", re.I) +_STYLE_HANNOTATE = re.compile(r"手札体|手札|hannotate|annotation", re.I) +_STYLE_CALLIGRAPHY = re.compile(r"书法体|书法|毛笔字|毛笔|行楷|xingkai|calligraph|brush", re.I) +_STYLE_KAITI = re.compile(r"楷体|楷书|楷書|标楷体|標楷體|kaiti|biaukai|\bkai\b", re.I) +_STYLE_WENKAI = re.compile(r"文楷|霞鹜文楷|霞鶩文楷|wenkai", re.I) +_STYLE_WAWA = re.compile(r"娃娃体|娃娃體|wawa|wawati", re.I) +_STYLE_YUPPY = re.compile(r"雅痞|yuppy", re.I) +_STYLE_LINGWAI = re.compile(r"凌慧体|凌慧體|lingwai", re.I) +_STYLE_SANS = re.compile(r"不用.*(书法|楷体|文楷|手写|毛笔|行楷|翩翩|手札)|普通字体|默认字体|黑体|sans", re.I) _PERSISTENT_PREF = re.compile(r"以后|默认|下次|每次|都用|一直|记住|我喜欢|更喜欢|保持|固定") _CORRECTION = re.compile(r"不对|不是|别把|不要把|刚才|上一条|上一句|应该|改成|你弄错|打到屏上") _DISPLAY_ACTIONS = {"create_note", "update_note", "replace_note", "merge_note", "draft_note"} @@ -45,7 +52,7 @@ ("too_verbose", re.compile(r"太多|啰嗦|简洁|精简|短一点|少一点")), ("weak_emphasis", re.compile(r"重点|突出|醒目|标题|层次")), ("make_prettier", re.compile(r"不好看|难看|丑|美化|好看点|弄好看|排版|版式")), - ("style_not_calligraphic", re.compile(r"(哪里|哪有|哪像|这也叫|不像|不是|不够|没有|没).*?(书法体|书法|楷体|文楷|手写|毛笔|字体)|(书法感|书法味|毛笔味|字体).*?(没有|不够|不足|太弱|太普通|不对|不行)")), + ("style_not_calligraphic", re.compile(r"(哪里|哪有|哪像|这也叫|不像|不是|不够|没有|没).*?(书法体|书法|行楷|楷体|文楷|手写|翩翩|手札|毛笔|字体)|(书法感|书法味|毛笔味|手写感|字体).*?(没有|不够|不足|太弱|太普通|不对|不行)")), ("positive_layout", re.compile(r"很好看|挺好看|真好看|舒服|清楚|不错|可以|就这样")), ] @@ -57,7 +64,7 @@ "too_verbose": "When users ask for cleaner screens, keep the newest key facts and remove filler words.", "weak_emphasis": "Important fields such as time, code, title, and action should receive clearer hierarchy.", "make_prettier": "Aesthetic feedback should improve hierarchy, line breaks, and density; do not invent unrelated content.", - "style_not_calligraphic": "When users reject a calligraphy/kai style screen, preserve the content but make the typography more visibly calligraphic: use the WenKai font, avoid bullets for poems/quotes, and give the text more breathing room.", + "style_not_calligraphic": "When users reject an artistic Chinese font, preserve the content but switch to the matching macOS-style family: HanziPen for 手写体, Xingkai for 书法体/行楷/毛笔, Kaiti for 楷体, and WenKai for 文楷.", "positive_layout": "Positive feedback reinforces the current balance of readability, density, and hierarchy.", } @@ -297,8 +304,22 @@ def preferred_font_from_text(text: str) -> str: text = str(text or "") if _STYLE_SANS.search(text): return "sans" + if _STYLE_HANDWRITING.search(text): + return "handwriting" + if _STYLE_HANNOTATE.search(text): + return "hannotate" + if _STYLE_CALLIGRAPHY.search(text): + return "calligraphy" if _STYLE_WENKAI.search(text): return "wenkai" + if _STYLE_KAITI.search(text): + return "kaiti" + if _STYLE_WAWA.search(text): + return "wawa" + if _STYLE_YUPPY.search(text): + return "yuppy" + if _STYLE_LINGWAI.search(text): + return "lingwai" return "" diff --git a/services/weclawbot-agent/app/render.py b/services/weclawbot-agent/app/render.py index 4bf4f56..3d73029 100644 --- a/services/weclawbot-agent/app/render.py +++ b/services/weclawbot-agent/app/render.py @@ -1,4 +1,4 @@ -"""Deterministic renderer: structured layout -> 400x300 mono1 page + PNG preview. +"""Deterministic renderer: structured layout -> mono1 page + PNG preview. Design doc §5 option A: the agent decides *what* and *how to arrange*; this renderer does the pixel-level rasterization and reports geometric metrics so the critic @@ -10,6 +10,7 @@ import base64 import io import os +import re from typing import Any from PIL import Image, ImageDraw, ImageFont @@ -18,19 +19,179 @@ _FONT_CACHE: dict[tuple, ImageFont.FreeTypeFont] = {} MARGIN = 10 -_WENKAI_STYLES = {"wenkai", "kai", "楷体", "书法", "calligraphy", "handwriting"} +_HANDWRITING_STYLES = {"handwriting", "handwrite", "hanzipen", "手写体", "手写", "翩翩体", "翩翩"} +_HANNOTATE_STYLES = {"hannotate", "annotation", "手札体", "手札"} +_CALLIGRAPHY_STYLES = {"calligraphy", "brush", "xingkai", "书法", "书法体", "毛笔", "毛笔字", "行楷"} +_KAITI_STYLES = {"kaiti", "kai", "楷体", "楷书", "楷書"} +_WENKAI_STYLES = {"wenkai", "文楷", "霞鹜文楷", "霞鶩文楷"} +_WAWA_STYLES = {"wawa", "wawati", "娃娃体", "娃娃體"} +_YUPPY_STYLES = {"yuppy", "雅痞"} +_LINGWAI_STYLES = {"lingwai", "凌慧体", "凌慧體"} +_LIBIAN_STYLES = {"libian", "隶变", "隸變", "隶书", "隸書"} +_BAOLI_STYLES = {"baoli", "报隶", "報隸"} +_BIAUKAI_STYLES = {"biaukai", "标楷体", "標楷體"} + + +def _as_bool(value: object) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + return str(value or "").lower() in {"1", "true", "yes", "y", "on"} + + +def _content_geometry(layout: dict) -> dict[str, Any]: + ctx = layout.get("device_context") if isinstance(layout.get("device_context"), dict) else {} + viewport = ctx.get("content_viewport") if isinstance(ctx.get("content_viewport"), dict) else {} + canvas = ctx.get("canvas") if isinstance(ctx.get("canvas"), dict) else {} + caps = ctx.get("display_capabilities") if isinstance(ctx.get("display_capabilities"), dict) else {} + pixel = caps.get("pixel") if isinstance(caps.get("pixel"), dict) else {} + refresh = caps.get("refresh") if isinstance(caps.get("refresh"), dict) else {} + + width = int(viewport.get("width") or pixel.get("width") or config.CONTENT_W) + height = int(viewport.get("height") or pixel.get("height") or config.CONTENT_H) + width = max(64, min(width, 800)) + height = max(64, min(height, 800)) + display_kind = str(caps.get("kind") or "").lower() + technology = str(caps.get("technology") or "").lower() + refresh_class = str(refresh.get("class") or canvas.get("refresh") or "unknown") + is_epaper = display_kind in {"epaper", "eink", "e-paper"} or technology in { + "electrophoretic", + "eink", + "epaper", + } + grayscale_levels = int(pixel.get("grayscale_levels") or 2) + density_hi = 0.42 if is_epaper else 0.62 + if is_epaper and width * height <= 240 * 240: + density_hi = 0.36 + return { + "x": int(viewport.get("x") or config.CONTENT_X), + "y": int(viewport.get("y") or config.CONTENT_Y), + "width": width, + "height": height, + "stride": int(viewport.get("stride") or ((width + 7) // 8)), + "format": str(viewport.get("format") or pixel.get("native_format") or "mono1"), + "max_pages": int(viewport.get("max_pages") or 1), + "canvas_width": int(canvas.get("width") or pixel.get("width") or config.SCREEN_W), + "canvas_height": int(canvas.get("height") or pixel.get("height") or config.SCREEN_H), + "canvas_color": str(canvas.get("color") or pixel.get("color_mode") or "mono1"), + "refresh": refresh_class, + "display_product_id": str(caps.get("product_id") or ""), + "display_board_name": str(caps.get("board_name") or ""), + "display_kind": display_kind or ("epaper" if is_epaper else "unknown"), + "display_technology": technology or "", + "display_is_epaper": is_epaper, + "display_partial_refresh": _as_bool(refresh.get("partial")), + "display_ghosting_risk": str(refresh.get("ghosting_risk") or "unknown"), + "display_full_refresh_timeout_ms": int(refresh.get("full_refresh_timeout_ms") or 0), + "display_min_update_interval_ms": int(refresh.get("recommended_min_update_interval_ms") or 0), + "display_grayscale_levels": grayscale_levels, + "display_bit_order": str(pixel.get("bit_order") or "msb_first"), + "display_black_bit": int(pixel.get("black_bit") if pixel.get("black_bit") is not None else (1 if config.INVERT_BITS else 0)), + "density_hi": density_hi, + } + + +def _existing(path: str, index: int, resolved: str) -> tuple[str, int, str] | None: + return (path, index, resolved) if path and os.path.exists(path) else None + + +def _first_font(*candidates: tuple[str, int, str] | None) -> tuple[str, int, str]: + for candidate in candidates: + if candidate: + return candidate + return config.FONT_PATH, config.FONT_INDEX, "sans" + + +def _font_choice(style: str) -> tuple[str, int, str]: + style = str(style or "sans") + if style in _HANDWRITING_STYLES: + return _first_font( + _existing(config.FONT_HANDWRITING, config.FONT_HANDWRITING_INDEX, "handwriting"), + _existing(config.FONT_HANNOTATE, config.FONT_HANNOTATE_INDEX, "hannotate"), + _existing(config.FONT_WENKAI, config.FONT_WENKAI_INDEX, "wenkai"), + ) + if style in _HANNOTATE_STYLES: + return _first_font( + _existing(config.FONT_HANNOTATE, config.FONT_HANNOTATE_INDEX, "hannotate"), + _existing(config.FONT_HANDWRITING, config.FONT_HANDWRITING_INDEX, "handwriting"), + _existing(config.FONT_WENKAI, config.FONT_WENKAI_INDEX, "wenkai"), + ) + if style in _CALLIGRAPHY_STYLES: + return _first_font( + _existing(config.FONT_XINGKAI, config.FONT_XINGKAI_INDEX, "calligraphy"), + _existing(config.FONT_CALLIGRAPHY, config.FONT_CALLIGRAPHY_INDEX, "calligraphy"), + _existing(config.FONT_KAITI, config.FONT_KAITI_INDEX, "kaiti"), + ) + if style in _KAITI_STYLES: + return _first_font( + _existing(config.FONT_KAITI, config.FONT_KAITI_INDEX, "kaiti"), + _existing(config.FONT_BIAUKAI, config.FONT_BIAUKAI_INDEX, "biaukai"), + _existing(config.FONT_WENKAI, config.FONT_WENKAI_INDEX, "wenkai"), + ) + if style in _WENKAI_STYLES: + return _first_font(_existing(config.FONT_WENKAI, config.FONT_WENKAI_INDEX, "wenkai")) + if style in _WAWA_STYLES: + return _first_font(_existing(config.FONT_WAWA, config.FONT_WAWA_INDEX, "wawa")) + if style in _YUPPY_STYLES: + return _first_font(_existing(config.FONT_YUPPY, config.FONT_YUPPY_INDEX, "yuppy")) + if style in _LINGWAI_STYLES: + return _first_font(_existing(config.FONT_LINGWAI, config.FONT_LINGWAI_INDEX, "lingwai")) + if style in _LIBIAN_STYLES: + return _first_font(_existing(config.FONT_LIBIAN, config.FONT_LIBIAN_INDEX, "libian")) + if style in _BAOLI_STYLES: + return _first_font(_existing(config.FONT_BAOLI, config.FONT_BAOLI_INDEX, "baoli")) + if style in _BIAUKAI_STYLES: + return _first_font(_existing(config.FONT_BIAUKAI, config.FONT_BIAUKAI_INDEX, "biaukai")) + return config.FONT_PATH, config.FONT_INDEX, "sans" + + +def _resolved_font_style(style: str) -> str: + return _font_choice(style)[2] def _font(size: int, style: str = "sans") -> ImageFont.FreeTypeFont: - key = (style, size) + path, index, resolved = _font_choice(style) + key = (path, index, resolved, size) if key not in _FONT_CACHE: - if style in _WENKAI_STYLES and os.path.exists(config.FONT_WENKAI): - _FONT_CACHE[key] = ImageFont.truetype(config.FONT_WENKAI, size, index=0) - else: - _FONT_CACHE[key] = ImageFont.truetype(config.FONT_PATH, size, index=config.FONT_INDEX) + _FONT_CACHE[key] = ImageFont.truetype(path, size, index=index) return _FONT_CACHE[key] +def _ink_threshold(style: str) -> int: + resolved = _resolved_font_style(style) + if resolved == "calligraphy": + return 210 + if resolved in {"handwriting", "hannotate"}: + return 212 + if resolved in {"wenkai", "kaiti", "biaukai"}: + return 205 + if resolved in {"wawa", "yuppy", "lingwai", "libian", "baoli"}: + return 207 + return 190 + + +def _stroke_width(font, style: str) -> int: + return 0 + + +def _draw_text(draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str, font, style: str) -> None: + stroke = _stroke_width(font, style) + draw.text(xy, text, font=font, fill=0, stroke_width=stroke, stroke_fill=0) + + +def _to_mono(img: Image.Image, style: str) -> Image.Image: + threshold = _ink_threshold(style) + return img.point(lambda px: 0 if px < threshold else 255, "1") + + +def _mono_density(img: Image.Image) -> float: + W, H = img.size + px = img.load() + black = sum(1 for j in range(H) for i in range(W) if px[i, j] == 0) + return black / float(W * H) + + def _wrap(draw: ImageDraw.ImageDraw, text: str, font, max_w: int) -> list[str]: """Character-wrap (works for CJK which has no spaces).""" lines: list[str] = [] @@ -51,8 +212,249 @@ def _wrap(draw: ImageDraw.ImageDraw, text: str, font, max_w: int) -> list[str]: return lines +def _column_chunks(text: str, max_chars: int) -> list[str]: + value = re.sub(r"\s+", "", str(text or "")) + if not value: + return [] + max_chars = max(1, max_chars) + return [value[i : i + max_chars] for i in range(0, len(value), max_chars)] + + +def _draw_vertical_text(draw: ImageDraw.ImageDraw, text: str, font, style: str, x: int, y: int, col_w: int, step: int) -> None: + top = y + for ch in str(text or ""): + bbox = font.getbbox(ch) + w = (bbox[2] - bbox[0]) or font.size + px = x + max(0, (col_w - w) // 2) - bbox[0] + py = top - bbox[1] + _draw_text(draw, (px, py), ch, font, style) + top += step + + +def _render_vertical_rl(layout: dict) -> tuple[Image.Image, dict]: + title = str(layout.get("title") or "") + title_size = int(layout.get("title_size") or 26) + item_size = int(layout.get("item_size") or 20) + items = [str(x) for x in (layout.get("items") or [])] + style = str(layout.get("font_style") or "sans") + + geom = _content_geometry(layout) + W, H = geom["width"], geom["height"] + img = Image.new("L", (W, H), 255) + draw = ImageDraw.Draw(img) + + title_font = _font(title_size, style) + item_font = _font(item_size, style) + top = MARGIN + available_h = H - 2 * MARGIN + col_gap = 6 + overflow = False + shown_items = 0 + columns_total = 0 + columns_shown = 0 + x = W - MARGIN + + def blit_columns(text: str, font, source_item: bool) -> bool: + nonlocal x, overflow, columns_total, columns_shown, shown_items + step = max(font.size + 2, int(font.size * 1.12)) + col_w = max(font.size + 6, int(font.size * 1.22)) + max_chars = max(1, available_h // step) + chunks = _column_chunks(text, max_chars) + columns_total += len(chunks) + item_complete = True + for chunk in chunks: + next_x = x - col_w + if next_x < MARGIN: + overflow = True + item_complete = False + break + _draw_vertical_text(draw, chunk, font, style, next_x, top, col_w, step) + columns_shown += 1 + x = next_x - col_gap + if source_item and item_complete: + shown_items += 1 + return item_complete + + if title: + blit_columns(title, title_font, False) + if x > MARGIN: + draw.line([(x + col_gap // 2, MARGIN), (x + col_gap // 2, H - MARGIN)], fill=0, width=1) + x -= col_gap + 2 + + for item in items: + if not blit_columns(item, item_font, True): + break + + mono = _to_mono(img, style) + density = _mono_density(mono) + metrics = { + "overflow": overflow, + "items_total": len(items), + "items_shown": shown_items, + "used_height": H - MARGIN, + "used_width": W - max(MARGIN, x), + "min_font": min(title_size, item_size) if title else item_size, + "density": round(density, 4), + "viewport_width": W, + "viewport_height": H, + "viewport_x": geom["x"], + "viewport_y": geom["y"], + "viewport_stride": geom["stride"], + "screen_refresh": geom["refresh"], + "canvas_color": geom["canvas_color"], + "display_product_id": geom["display_product_id"], + "display_kind": geom["display_kind"], + "display_technology": geom["display_technology"], + "display_is_epaper": geom["display_is_epaper"], + "display_partial_refresh": geom["display_partial_refresh"], + "display_ghosting_risk": geom["display_ghosting_risk"], + "display_full_refresh_timeout_ms": geom["display_full_refresh_timeout_ms"], + "display_min_update_interval_ms": geom["display_min_update_interval_ms"], + "display_grayscale_levels": geom["display_grayscale_levels"], + "display_bit_order": geom["display_bit_order"], + "display_black_bit": geom["display_black_bit"], + "density_hi": geom["density_hi"], + "font_style": style, + "font_resolved": _resolved_font_style(style), + "ink_threshold": _ink_threshold(style), + "stroke_width": _stroke_width(item_font, style), + "writing_mode": "vertical-rl", + "columns_total": columns_total, + "columns_shown": columns_shown, + } + return mono, metrics + + +def _poem_card_lines(items: list[str]) -> tuple[list[str], str]: + body: list[str] = [] + signature = "" + for item in items: + line = str(item or "").strip() + if not line: + continue + if re.match(r"^\s*[—-]{1,2}\s*", line): + signature = line + continue + parts = [ + part.strip() + for part in re.split(r"[,,。.!!??;;]+", line) + if part.strip() + ] + if len(parts) > 1: + body.extend(parts) + else: + body.append(line) + return body or [str(x).strip() for x in items if str(x).strip()], signature + + +def _render_poem_card(layout: dict) -> tuple[Image.Image, dict]: + title = str(layout.get("title") or "") + title_size = int(layout.get("title_size") or 28) + item_size = int(layout.get("item_size") or 23) + items = [str(x) for x in (layout.get("items") or [])] + style = str(layout.get("font_style") or "calligraphy") + + geom = _content_geometry(layout) + W, H = geom["width"], geom["height"] + img = Image.new("L", (W, H), 255) + draw = ImageDraw.Draw(img) + + title_font = _font(title_size, style) + item_font = _font(item_size, style) + sig_font = _font(max(15, item_size - 5), style) + body_lines, signature = _poem_card_lines(items) + max_w = W - 2 * MARGIN - 8 + visual_lines: list[str] = [] + for line in body_lines: + visual_lines.extend(_wrap(draw, line, item_font, max_w)) + + y = MARGIN - 2 + if title: + title_text = title if title.startswith("《") else f"《{title}》" + _draw_text(draw, (MARGIN, y), title_text, title_font, style) + title_w = draw.textlength(title_text, font=title_font) + line_y = y + max(18, title_size - 2) + if title_w + MARGIN + 10 < W - MARGIN: + draw.line([(MARGIN + int(title_w) + 8, line_y), (W - MARGIN, line_y)], fill=0, width=1) + y += max(title_size + 12, 38) + + line_h = max(item_size + 9, int(item_size * 1.38)) + sig_h = max(sig_font.size + 6, 22) if signature else 0 + block_h = len(visual_lines) * line_h + sig_h + start_y = max(y, y + (H - y - MARGIN - block_h) // 2) + overflow = False + shown = 0 + for line in visual_lines: + if start_y + item_size > H - MARGIN - sig_h: + overflow = True + break + line_w = draw.textlength(line, font=item_font) + x = max(MARGIN, int((W - line_w) // 2)) + _draw_text(draw, (x, start_y), line, item_font, style) + start_y += line_h + shown += 1 + + if signature and start_y + sig_font.size <= H - MARGIN: + sig_w = draw.textlength(signature, font=sig_font) + _draw_text(draw, (max(MARGIN, W - MARGIN - int(sig_w)), start_y + 2), signature, sig_font, style) + elif signature: + overflow = True + + seal_size = 18 + seal_x = W - MARGIN - seal_size + seal_y = H - MARGIN - seal_size + if seal_y > start_y: + draw.rectangle((seal_x, seal_y, seal_x + seal_size, seal_y + seal_size), fill=0) + seal_font = _font(11, "sans") + draw.text((seal_x + 3, seal_y + 2), "微", font=seal_font, fill=255) + + mono = _to_mono(img, style) + density = _mono_density(mono) + metrics = { + "overflow": overflow, + "items_total": len(items), + "items_shown": shown + (1 if signature and not overflow else 0), + "used_height": min(H, start_y + sig_h), + "min_font": min(title_size, item_size), + "density": round(density, 4), + "viewport_width": W, + "viewport_height": H, + "viewport_x": geom["x"], + "viewport_y": geom["y"], + "viewport_stride": geom["stride"], + "screen_refresh": geom["refresh"], + "canvas_color": geom["canvas_color"], + "display_product_id": geom["display_product_id"], + "display_kind": geom["display_kind"], + "display_technology": geom["display_technology"], + "display_is_epaper": geom["display_is_epaper"], + "display_partial_refresh": geom["display_partial_refresh"], + "display_ghosting_risk": geom["display_ghosting_risk"], + "display_full_refresh_timeout_ms": geom["display_full_refresh_timeout_ms"], + "display_min_update_interval_ms": geom["display_min_update_interval_ms"], + "display_grayscale_levels": geom["display_grayscale_levels"], + "display_bit_order": geom["display_bit_order"], + "display_black_bit": geom["display_black_bit"], + "density_hi": geom["density_hi"], + "font_style": style, + "font_resolved": _resolved_font_style(style), + "ink_threshold": _ink_threshold(style), + "stroke_width": _stroke_width(item_font, style), + "writing_mode": "horizontal", + "layout_mode": "poem-card", + } + return mono, metrics + + def render_layout(layout: dict) -> tuple[list[dict], bytes, dict]: """Return (pages, preview_png_bytes, metrics).""" + if str(layout.get("writing_mode") or "").lower() in {"vertical", "vertical-rl", "vertical_rl"}: + img, metrics = _render_vertical_rl({**layout, "writing_mode": "vertical-rl"}) + return _pack_page_and_preview(img, metrics, _content_geometry(layout)) + if str(layout.get("layout_mode") or "").lower().replace("_", "-") in {"poem-card", "poem"}: + img, metrics = _render_poem_card(layout) + return _pack_page_and_preview(img, metrics, _content_geometry(layout)) + title = str(layout.get("title") or "") title_size = int(layout.get("title_size") or 26) item_size = int(layout.get("item_size") or 20) @@ -61,8 +463,9 @@ def render_layout(layout: dict) -> tuple[list[dict], bytes, dict]: item_prefix = str(layout.get("item_prefix") or "bullet") line_gap = 6 - W, H = config.CONTENT_W, config.CONTENT_H - img = Image.new("1", (W, H), 1) # 1 = white + geom = _content_geometry(layout) + W, H = geom["width"], geom["height"] + img = Image.new("L", (W, H), 255) # white draw = ImageDraw.Draw(img) max_w = W - 2 * MARGIN @@ -77,7 +480,7 @@ def blit(text: str, font, gap_after: int) -> bool: if y + h > H - MARGIN: overflow = True return False - draw.text((MARGIN, y), ln, font=font, fill=0) # 0 = black + _draw_text(draw, (MARGIN, y), ln, font, style) y += h + line_gap y += gap_after return True @@ -98,9 +501,8 @@ def blit(text: str, font, gap_after: int) -> bool: shown += 1 # ---- metrics ---- - px = img.load() - black = sum(1 for j in range(H) for i in range(W) if px[i, j] == 0) - density = black / float(W * H) + mono = _to_mono(img, style) + density = _mono_density(mono) metrics = { "overflow": overflow, "items_total": len(items), @@ -108,20 +510,54 @@ def blit(text: str, font, gap_after: int) -> bool: "used_height": min(y, H), "min_font": min(title_size, item_size) if title else item_size, "density": round(density, 4), + "viewport_width": W, + "viewport_height": H, + "viewport_x": geom["x"], + "viewport_y": geom["y"], + "viewport_stride": geom["stride"], + "screen_refresh": geom["refresh"], + "canvas_color": geom["canvas_color"], + "display_product_id": geom["display_product_id"], + "display_kind": geom["display_kind"], + "display_technology": geom["display_technology"], + "display_is_epaper": geom["display_is_epaper"], + "display_partial_refresh": geom["display_partial_refresh"], + "display_ghosting_risk": geom["display_ghosting_risk"], + "display_full_refresh_timeout_ms": geom["display_full_refresh_timeout_ms"], + "display_min_update_interval_ms": geom["display_min_update_interval_ms"], + "display_grayscale_levels": geom["display_grayscale_levels"], + "display_bit_order": geom["display_bit_order"], + "display_black_bit": geom["display_black_bit"], + "density_hi": geom["density_hi"], + "font_style": style, + "font_resolved": _resolved_font_style(style), + "ink_threshold": _ink_threshold(style), + "stroke_width": _stroke_width(_font(item_size, style), style), } - # ---- mono1 page (368x206 @ 16,42 content window; must match device slot) ---- + metrics["writing_mode"] = "horizontal" + return _pack_page_and_preview(mono, metrics, geom) + + +def _pack_page_and_preview(img: Image.Image, metrics: dict, geom: dict[str, Any] | None = None) -> tuple[list[dict], bytes, dict]: + geom = geom or _content_geometry({}) + W, H = img.size + row_stride = (W + 7) // 8 + stride = max(int(geom.get("stride") or row_stride), row_stride) + # ---- mono1 page for the current device content viewport ---- raw = img.tobytes() # mode "1": MSB-first, CONTENT_STRIDE bytes/row, bit1 = white - if config.INVERT_BITS: + if stride != row_stride: + raw = b"".join(raw[y * row_stride : (y + 1) * row_stride] + bytes(stride - row_stride) for y in range(H)) + if int(geom.get("display_black_bit") or 0) == 1: raw = bytes(b ^ 0xFF for b in raw) # device convention: bit1 = black page = { "format": "mono1", "viewport": "content", - "x": config.CONTENT_X, - "y": config.CONTENT_Y, + "x": int(geom.get("x") or config.CONTENT_X), + "y": int(geom.get("y") or config.CONTENT_Y), "width": W, "height": H, - "stride": config.CONTENT_STRIDE, + "stride": stride, "page": 1, "page_count": 1, "data_b64": base64.b64encode(raw).decode("ascii"), diff --git a/services/weclawbot-agent/app/server.py b/services/weclawbot-agent/app/server.py index 409e946..984c5e6 100644 --- a/services/weclawbot-agent/app/server.py +++ b/services/weclawbot-agent/app/server.py @@ -27,6 +27,27 @@ class Event(BaseModel): device_context: dict | None = None +class EvolutionGap(BaseModel): + model_config = ConfigDict(extra="allow") + + kind: str | None = None + event_id: str | None = None + id: str | None = None + device_id: str = "" + sender_ref: str = "" + text: str | None = None + transcript: str | None = None + screen: dict | None = None + device_context: dict | None = None + source: str = "" + observed_action: str = "" + expected_action_family: str = "" + categories: list[str] | None = None + gateway_stage: str = "" + gateway_reason: str = "" + trace: list[dict] | None = None + + @app.get("/healthz") def healthz() -> dict: return { @@ -53,6 +74,29 @@ def skillrepo_status() -> dict: return {"ok": True, "skillrepo": skillrepo.status()} +@app.get("/evolution/status") +def evolution_status() -> dict: + return {"ok": True, "skillrepo": skillrepo.status()} + + +@app.post("/evolution/gap") +def evolution_gap(gap: EvolutionGap) -> dict: + payload = gap.model_dump() + payload.update(gap.model_extra or {}) + observed = str(payload.get("observed_action") or (payload.get("decision") or {}).get("action") or "") + updates = skillrepo.record_capability_gap( + event=payload, + observed_action=observed, + categories=payload.get("categories") or [], + source=str(payload.get("source") or "evolution_gap"), + gateway_stage=str(payload.get("gateway_stage") or ""), + gateway_reason=str(payload.get("gateway_reason") or ""), + expected_action_family=str(payload.get("expected_action_family") or ""), + trace=payload.get("trace") or [], + ) + return {"ok": True, "updates": updates, "skillrepo": skillrepo.status()} + + @app.post("/decide") def decide(event: Event) -> dict: payload = event.model_dump() diff --git a/services/weclawbot-agent/app/skillrepo.py b/services/weclawbot-agent/app/skillrepo.py index 26f9400..edf3c8d 100644 --- a/services/weclawbot-agent/app/skillrepo.py +++ b/services/weclawbot-agent/app/skillrepo.py @@ -29,6 +29,11 @@ "make_prettier": "screen-note", "positive_layout": "screen-note", "style_not_calligraphic": "typography-style", + "capability_gap": "intent-gate", + "intent_gate_false_negative": "intent-gate", + "context_followup_missed": "intent-gate", + "unsupported_display_capability": "display-capability", + "layout_capability_gap": "display-capability", } _PROPOSED_MARKDOWN = { @@ -55,6 +60,22 @@ # When NOT to use - Do not claim a font is available unless the renderer can actually load it. - Do not store the user's poem or private note as a shared skill.""", + "intent-gate": """# Workflow +- Treat local-gate false negatives as routing capability gaps, not hot-path patches. +- When a short follow-up arrives while a screen is active, collect redacted evidence first and require replay coverage before widening the gate. +- Promote only when the proof shows fewer missed screen tasks without increasing ordinary-chat false positives. + +# When NOT to use +- Do not add keyword-only shortcuts from one failure. +- Do not route greetings, bot probes, or unrelated questions to the screen pipeline only because a screen exists.""", + "display-capability": """# Workflow +- Treat unsupported layout requests as renderer capability gaps until the renderer, planner schema, and geometric proof all support them. +- The planner may ask for a display mode only after the renderer declares that capability and the proof suite covers it. +- Preserve the user's current facts while evaluating display-mode requests; the capability candidate must not store private screen text. + +# When NOT to use +- Do not claim a display mode exists before render output is validated. +- Do not encode a single user's private text as a shared capability.""", } _FORBIDDEN_CANDIDATE_KEYS = { @@ -226,6 +247,79 @@ def record_after_turn( return updates +def record_capability_gap( + *, + event: dict[str, Any], + observed_action: str = "", + categories: list[str] | None = None, + source: str = "", + gateway_stage: str = "", + gateway_reason: str = "", + expected_action_family: str = "", + trace: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """Write a redacted candidate for a capability/routing gap. + + This is the SkillOS-style "trace -> lesson -> candidate" entry point for + failures observed outside the agent graph, such as a gateway-local + reply_only that never reached /decide. It deliberately does not change the + published skills or hot path. + """ + if not config.SKILL_REPO_ENABLED: + return [] + + cats = [str(c) for c in categories or [] if str(c) in _CATEGORY_TO_SKILL] + if not cats: + cats = ["capability_gap"] + + grouped: dict[str, list[str]] = {} + for category in cats: + grouped.setdefault(_CATEGORY_TO_SKILL[category], []).append(category) + + rows: list[dict[str, Any]] = [] + updates: list[dict[str, Any]] = [] + for target_skill, target_categories in sorted(grouped.items()): + candidate_id = _capability_candidate_id( + event, + target_skill, + target_categories, + observed_action=observed_action, + source=source, + ) + row = { + "id": candidate_id, + "created_at": _now(), + "status": "candidate", + "operation": "propose_capability", + "target_skill": target_skill, + "categories": target_categories, + "proposed_markdown": _PROPOSED_MARKDOWN[target_skill], + "evidence": _redacted_gap_evidence( + event=event, + observed_action=observed_action, + source=source, + gateway_stage=gateway_stage, + gateway_reason=gateway_reason, + expected_action_family=expected_action_family, + trace=trace or [], + ), + } + rows.append(row) + updates.append( + { + "scope": "skillrepo", + "type": "candidate_capability", + "key": target_skill, + "candidate_id": candidate_id, + "categories": target_categories, + "status": "candidate", + } + ) + + _append_jsonl(Path(config.SKILL_CANDIDATE_LEDGER), rows) + return updates + + def load_candidates(limit: int = 200) -> list[dict[str, Any]]: path = Path(config.SKILL_CANDIDATE_LEDGER) try: @@ -311,6 +405,7 @@ def status() -> dict[str, Any]: "enabled": config.SKILL_REPO_ENABLED, "published_skill_count": len(iter_published_skills()), "candidate_count": len(load_candidates(limit=10_000)), + "candidate_summary": candidate_summary(), "candidate_ledger": config.SKILL_CANDIDATE_LEDGER, "published_dir": config.SKILL_PUBLISHED_DIR, "shared_layout_direct_learning": config.SHARED_LAYOUT_DIRECT_LEARNING, @@ -318,6 +413,26 @@ def status() -> dict[str, Any]: } +def candidate_summary(limit: int = 10_000) -> dict[str, Any]: + rows = load_candidates(limit=limit) + by_skill: dict[str, int] = {} + by_operation: dict[str, int] = {} + by_category: dict[str, int] = {} + for row in rows: + skill = str(row.get("target_skill") or "unknown") + operation = str(row.get("operation") or "unknown") + by_skill[skill] = by_skill.get(skill, 0) + 1 + by_operation[operation] = by_operation.get(operation, 0) + 1 + for category in row.get("categories") or []: + key = str(category or "unknown") + by_category[key] = by_category.get(key, 0) + 1 + return { + "by_skill": by_skill, + "by_operation": by_operation, + "by_category": by_category, + } + + def _candidate_id(event: dict[str, Any], target_skill: str, categories: list[str]) -> str: raw = "|".join( [ @@ -330,6 +445,29 @@ def _candidate_id(event: dict[str, Any], target_skill: str, categories: list[str return "cand_" + _hash(raw, 20) +def _capability_candidate_id( + event: dict[str, Any], + target_skill: str, + categories: list[str], + *, + observed_action: str, + source: str, +) -> str: + text = str(event.get("text") or event.get("transcript") or "") + raw = "|".join( + [ + str(event.get("event_id") or event.get("id") or ""), + target_skill, + ",".join(sorted(categories)), + str(observed_action or ""), + str(source or ""), + _hash(text), + str(_now() // 60), + ] + ) + return "cap_" + _hash(raw, 20) + + def _redacted_evidence( *, device_id: str, @@ -368,6 +506,42 @@ def _redacted_evidence( } +def _redacted_gap_evidence( + *, + event: dict[str, Any], + observed_action: str, + source: str, + gateway_stage: str, + gateway_reason: str, + expected_action_family: str, + trace: list[dict[str, Any]], +) -> dict[str, Any]: + text = str(event.get("text") or event.get("transcript") or "") + screen = event.get("screen") if isinstance(event.get("screen"), dict) else {} + current = screen.get("current_note") if isinstance(screen.get("current_note"), dict) else {} + device_context = event.get("device_context") if isinstance(event.get("device_context"), dict) else {} + state = device_context.get("state") if isinstance(device_context.get("state"), dict) else {} + source_obj = event.get("source") if isinstance(event.get("source"), dict) else {} + screen_fingerprint = current.get("canonical_text") or current.get("text") or state.get("screen_revision") or "" + return { + "event_id": str(event.get("event_id") or event.get("id") or ""), + "event_kind": str(event.get("kind") or event.get("type") or ""), + "device_hash": _hash(event.get("device_id") or device_context.get("device_id") or ""), + "sender_hash": _hash(event.get("sender_ref") or source_obj.get("sender_ref") or ""), + "input_hash": _hash(text), + "input_len": len(text), + "screen_present": bool(screen_fingerprint), + "screen_hash": _hash(screen_fingerprint), + "observed_action": str(observed_action or ""), + "expected_action_family": str(expected_action_family or ""), + "source": str(source or ""), + "gateway_stage": str(gateway_stage or ""), + "gateway_reason_hash": _hash(gateway_reason), + "gateway_reason_len": len(str(gateway_reason or "")), + "trace_stages": [str(t.get("stage") or "") for t in trace[-12:] if isinstance(t, dict)], + } + + def _append_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: if not rows: return diff --git a/services/weclawbot-agent/app/sticky_rules.py b/services/weclawbot-agent/app/sticky_rules.py index 4091ec3..63a95b0 100644 --- a/services/weclawbot-agent/app/sticky_rules.py +++ b/services/weclawbot-agent/app/sticky_rules.py @@ -52,12 +52,13 @@ ] DISPLAY_STYLE_FEEDBACK_PATTERNS = [ - re.compile(r"(哪里|哪有|哪像|这也叫|不像|不是|不够|没有|没).*?(书法体|书法|楷体|文楷|手写|毛笔|字体|版式|排版)"), - re.compile(r"(书法体|书法|楷体|文楷|手写|毛笔).*?(呢|在哪|哪里|哪儿|不像|不对|没有|没出来|不明显|不够)"), - re.compile(r"(书法感|书法味|毛笔味|字体).*?(没有|不够|不足|太弱|太普通|不对|不行)"), + re.compile(r"(哪里|哪有|哪像|这也叫|不像|不是|不够|没有|没).*?(书法体|书法|行楷|楷体|文楷|手写|翩翩|手札|毛笔|字体|版式|排版)"), + re.compile(r"(书法体|书法|行楷|楷体|文楷|手写|翩翩|手札|毛笔).*?(呢|在哪|哪里|哪儿|不像|不对|没有|没出来|不明显|不够)"), + re.compile(r"(书法感|书法味|毛笔味|手写感|字体).*?(没有|不够|不足|太弱|太普通|不对|不行)"), ] -STYLE_WORD_PATTERN = r"(?:书法体|书法|楷体|文楷|手写|毛笔字|毛笔|calligraphy|handwriting)" +STYLE_WORD_PATTERN = r"(?:霞鹜文楷|霞鶩文楷|书法体|书法|毛笔字|毛笔|行楷|标楷体|標楷體|楷体|楷书|楷書|文楷|手写体|手写|翩翩体|翩翩|手札体|手札|娃娃体|娃娃體|雅痞|凌慧体|凌慧體|隶变|隸變|报隶|報隸|calligraphy|handwriting|handwrite|hanzipen|hannotate|annotation|xingkai|kaiti|biaukai|wenkai|wawa|wawati|yuppy|lingwai|libian|baoli)" +LAYOUT_WORD_PATTERN = r"(?:竖排|纵排|竖着|纵向|从右到左|右到左|vertical(?:-rl)?)" CURRENT_SCREEN_SECTION_HEADERS = { "记事", @@ -212,7 +213,7 @@ def curate( } else: note_text = strip_style_directives(explicit) or explicit - trace.append("explicit_screen_intent") + trace.append("explicit_screen_intent_poem_card" if looks_like_poem_card(note_text) else "explicit_screen_intent") note = create_note(bundle, note_text) return { "action": "replace_note" if current_screen_text(bundle) else "create_note", @@ -246,6 +247,17 @@ def curate( "trace": trace, } + if looks_like_poem_card(compact): + trace.append("poem_card_durable_screen_value") + note = create_note(bundle, strip_style_directives(compact) or compact) + return { + "action": "replace_note" if current_text else "create_note", + "note": note, + "user_reply": create_note_reply(note), + "confidence": 0.84, + "trace": trace, + } + if looks_like_conversation(compact) and not has_screen_intent(compact): trace.append("conversation_not_screen_task") return reply_only( @@ -412,19 +424,39 @@ def is_ignorable(text: str) -> bool: def has_screen_intent(text: str) -> bool: - return any(pattern.search(text) for pattern in SCREEN_INTENT_PATTERNS) or bool(style_display_text_from(text)) + return ( + any(pattern.search(text) for pattern in SCREEN_INTENT_PATTERNS) + or bool(directive_prefix_display_text_from(text)) + or bool(style_display_text_from(text)) + ) def explicit_screen_text_from(text: str) -> str: command = r"(?:上屏|放到?屏(?:幕|上)?|发到?屏(?:幕|上)?|贴到?屏(?:幕|上)?|显示到?屏(?:幕|上)?|显示在屏(?:幕|上)?|记到?微笺|记在屏(?:幕|上)?|写到?屏(?:幕|上)?)" if any(pattern.search(text) for pattern in SCREEN_INTENT_PATTERNS): + labelled = re.search( + command + + r"(?:下面|以下|下列)?(?:这首|这段|这篇)?(?:诗歌|古诗|诗词|内容|文字|文本)?[::]\s*(.{1,420})$", + text, + flags=re.S, + ) + if labelled: + return labelled.group(1).strip() after = re.search(command + r"[::,, ]+(.{1,420})$", text) if after: return after.group(1).strip() before = re.search(r"^(?:请|帮我|把|将)?(.{1,420}?)" + command + r"(?:吧|一下)?$", text) if before: return before.group(1).strip() - return style_display_text_from(text) + return directive_prefix_display_text_from(text) or style_display_text_from(text) + + +def directive_prefix_display_text_from(text: str) -> str: + body, stripped = strip_leading_style_directives(str(text or "")) + if not stripped: + return "" + body = re.sub(r"^[\s,,。.!!??、;;::]+", "", body).strip() + return body if body and not is_style_only_payload(body) else "" def style_display_text_from(text: str) -> str: @@ -447,10 +479,55 @@ def style_display_text_from(text: str) -> str: def is_style_only_payload(text: str) -> bool: - value = re.sub(r"[\s,,。.!!??、]+", "", str(text or "")).lower() + value = re.sub(r"[\s,,。.!!??、]+", "", str(text or "")).lower().replace("-", "") value = re.sub(r"^(?:请|帮我|帮忙)?用", "", value) value = re.sub(r"(?:字体|风格)$", "", value) - return value in {"书法体", "楷体", "文楷", "手写", "毛笔字", "毛笔", "calligraphy", "handwriting"} + return value in { + "书法体", + "楷体", + "楷书", + "文楷", + "霞鹜文楷", + "手写体", + "手写", + "翩翩体", + "翩翩", + "手札体", + "手札", + "毛笔字", + "毛笔", + "行楷", + "标楷体", + "娃娃体", + "雅痞", + "凌慧体", + "隶变", + "报隶", + "calligraphy", + "handwriting", + "handwrite", + "hanzipen", + "hannotate", + "annotation", + "xingkai", + "kaiti", + "biaukai", + "wenkai", + "wawa", + "wawati", + "yuppy", + "lingwai", + "libian", + "baoli", + "竖排", + "纵排", + "竖着", + "纵向", + "从右到左", + "右到左", + "vertical", + "verticalrl", + } def is_screen_followup_command(text: str) -> bool: @@ -467,26 +544,45 @@ def is_screen_followup_command(text: str) -> bool: "书法体", "楷体", "文楷", + "手写体", "手写", + "翩翩体", + "手札体", "毛笔", + "行楷", + "娃娃体", "上屏书法体", "书法体上屏", "上屏楷体", "楷体上屏", "上屏文楷", "文楷上屏", + "上屏手写体", + "手写体上屏", + "上屏行楷", + "行楷上屏", "换书法体", "换成书法体", "换楷体", "换成楷体", "换文楷", "换成文楷", + "换手写体", + "换成手写体", "换手写", "换成手写", + "换翩翩体", + "换成翩翩体", + "换手札体", + "换成手札体", + "换行楷", + "换成行楷", + "换娃娃体", + "换成娃娃体", "书法体显示", }: return True - return bool(re.search(r"^(上屏|贴上|显示到屏上|放到屏上|发到屏上)(书法体|楷体|文楷|手写|毛笔)$", value)) + return bool(re.search(r"^(上屏|贴上|显示到屏上|放到屏上|发到屏上)(书法体|楷体|文楷|手写体|手写|翩翩体|手札体|毛笔|行楷|娃娃体)$", value)) def is_display_style_feedback(text: str) -> bool: @@ -496,7 +592,7 @@ def is_display_style_feedback(text: str) -> bool: def is_style_change_current_screen_command(text: str) -> bool: value = re.sub(r"[\s,,。.!!??、;;::]+", "", str(text or "")).lower() - return bool(re.fullmatch(r"(?:换成?|改成?|用)?(?:书法体|书法|楷体|文楷|手写|毛笔字|毛笔)(?:显示|排版|上屏)?", value, flags=re.I)) + return bool(re.fullmatch(rf"(?:换成?|改成?|用)?(?:{STYLE_WORD_PATTERN}|竖排|纵排|竖着|纵向|从右到左|右到左|vertical(?:rl)?)(?:显示|排版|上屏)?", value, flags=re.I)) def is_current_screen_deletion_command(text: str) -> bool: @@ -631,9 +727,11 @@ def looks_like_current_screen_title(line: str, rest: list[str]) -> bool: def strip_style_directives(text: str) -> str: - value = str(text or "").strip() + value, _ = strip_leading_style_directives(str(text or "")) + value = value.strip() + directive = rf"(?:{STYLE_WORD_PATTERN}|{LAYOUT_WORD_PATTERN})" value = re.sub( - r"(?:(?:[,,。;;、\s]+(?:用)?|用)(?:书法体|楷体|文楷|手写|毛笔|calligraphy|handwriting)(?:字体|风格)?)+$", + rf"(?:(?:[,,。;;、\s]+(?:用|按)?|(?:用|按)){directive}(?:字体|风格|排版|显示)?)+$", "", value, flags=re.I, @@ -641,6 +739,18 @@ def strip_style_directives(text: str) -> str: return value +def strip_leading_style_directives(text: str) -> tuple[str, bool]: + value = str(text or "").strip() + directive = rf"(?:{STYLE_WORD_PATTERN}|{LAYOUT_WORD_PATTERN})" + stripped = re.sub( + rf"^(?:(?:请|帮我|帮忙|麻烦)?(?:用|按)?{directive}(?:字体|风格|排版|显示|展示|呈现)?[\s,,。;;、::]*)+", + "", + value, + flags=re.I, + ).strip() + return stripped, stripped != value + + def looks_like_conversation(text: str) -> bool: raw = text.strip() if re.search(r"[??]$", raw): @@ -652,6 +762,34 @@ def looks_like_conversation(text: str) -> bool: return False +def looks_like_poem_card(text: str) -> bool: + value = compact_text(strip_style_directives(text) or text) + quoted = re.match(r"^\s*《([^》\n]{1,14})》\s*", value) + if not quoted: + return False + title = quoted.group(1).strip() + if re.search(r"(通知|清单|待办|会议|取件|提醒|验证码|密码|账单|赛程|地址)", title): + return False + body = value[quoted.end() :].strip() + body = re.sub(r"^[((][^))\n]{1,24}[))]\s*", "", body).strip() + body_lines = [ + line + for line in body.replace("\r", "\n").split("\n") + if line.strip() and not re.match(r"^\s*[—-]{1,2}\s*[^,。!?;\n]{1,36}\s*$", line.strip()) + ] + body = "\n".join(body_lines) + han_count = len(re.findall(r"[\u4e00-\u9fff]", body)) + if han_count < 12 or han_count > 140: + return False + clauses = [ + part.strip() + for part in re.split(r"[,,。.!!??;;\n]+", body) + if len(re.findall(r"[\u4e00-\u9fff]", part)) >= 3 + ] + short_clauses = [part for part in clauses if 4 <= len(re.findall(r"[\u4e00-\u9fff]", part)) <= 10] + return len(clauses) >= 3 and len(short_clauses) >= 3 + + def current_screen_text(bundle: dict[str, Any]) -> str: current = (bundle.get("screen") or {}).get("current_note") or {} return str(current.get("canonical_text") or current.get("text") or "") @@ -680,18 +818,37 @@ def depends_on_current_screen(text: str, bundle: dict[str, Any]) -> bool: def create_note(bundle: dict[str, Any], text: str) -> dict[str, str]: title = infer_title(bundle, text) - checklist = structure_checklist_body(text, title) + body_source = note_body_source(text, title) + checklist = structure_checklist_body(body_source, title) return { "template": "sticky.v1", "title": title, - "body": checklist or fit_body(text), + "body": checklist or fit_body(body_source), "footer": "", "priority": infer_priority(text), } +def note_body_source(text: str, title: str) -> str: + source = compact_text(text) + quoted = str(title or "").strip() + if quoted: + without_title = re.sub(rf"^\s*《{re.escape(quoted)}》\s*", "", source, count=1) + if without_title != source: + author = re.match(r"^[((]([^))\n]{1,24})[))]\s*", without_title) + if author: + without_title = f"{author.group(1).strip()}\n{without_title[author.end():].lstrip()}" + source = without_title.strip() + return source or compact_text(text) + + def create_note_reply(note: dict[str, str]) -> str: - return f"已覆盖到屏幕:{note['body']}。不合适可直接发“修改为…”或“清屏”。" + title = str(note.get("title") or "").strip() + body = str(note.get("body") or "").rstrip() + if title and looks_like_poem_card(f"《{title}》\n{body}"): + return f"已按诗笺排版上屏:{title}。不合适可直接发“修改为…”或“清屏”。" + suffix = "" if body.endswith(("。", "!", "?", ".", "!", "?")) else "。" + return f"已覆盖到屏幕:{body}{suffix}不合适可直接发“修改为…”或“清屏”。" def should_preserve_current_for_independent_note(current_text: str, new_text: str) -> bool: @@ -910,6 +1067,9 @@ def push_unique_line(lines: list[str], line: str) -> None: def infer_title(bundle: dict[str, Any], text: str) -> str: filename = clean_filename_title(bundle["source"].get("filename", "")) + quoted = re.search(r"^\s*《([^》\n]{1,18})》", compact_text(text)) + if quoted: + return quoted.group(1).strip() if is_relationship_message(text): return "记事" if looks_like_checklist(text): diff --git a/services/weclawbot-agent/proofs/display-eval.jsonl b/services/weclawbot-agent/proofs/display-eval.jsonl index d9722dd..6db18b5 100644 --- a/services/weclawbot-agent/proofs/display-eval.jsonl +++ b/services/weclawbot-agent/proofs/display-eval.jsonl @@ -2,3 +2,6 @@ {"name":"provided reminder becomes screen note","event":{"kind":"wechat_text","event_id":"proof_reminder","device_id":"proof-device","sender_ref":"proof-user-reminder","text":"明天早上九点去牙医诊所复诊,带上检查报告。"},"expect":{"action_any":["create_note","replace_note"],"contains":"牙医诊所","page_ok":true}} {"name":"official realtime search boundary","event":{"kind":"wechat_text","event_id":"proof_boundary","device_id":"proof-device","sender_ref":"proof-user-boundary","text":"帮我把今天热榜整理后上屏"},"expect":{"action":"service_required","no_screen":true,"reply_contains":"只负责把你已经提供的信息整理"}} {"name":"style complaint preserves current poem","event":{"kind":"wechat_text","event_id":"proof_style","device_id":"proof-device","sender_ref":"proof-user-style","text":"哪里是书法体","screen":{"current_note":{"canonical_text":"唐诗\n飞流直下三千尺,疑是银河落九天。","revision":"proof-base"}}},"expect":{"action_any":["replace_note","update_note"],"contains":"飞流直下三千尺","not_body_contains":"哪里是书法体","page_ok":true}} +{"name":"device context viewport drives page geometry","event":{"kind":"wechat_text","event_id":"proof_viewport","device_id":"proof-device","sender_ref":"proof-user-viewport","text":"上屏:设备上下文几何测试","device_context":{"schema":"weclawbot.device_context.v1","canvas":{"width":320,"height":240,"color":"mono1","refresh":"reflective_slow"},"content_viewport":{"id":"content","x":8,"y":32,"width":296,"height":128,"format":"mono1","max_pages":2}}},"expect":{"action_any":["create_note","replace_note"],"contains":"设备上下文几何测试","page_ok":true,"page_width":296,"page_height":128,"page_stride":37}} +{"name":"display capabilities drive small epaper profile","event":{"kind":"wechat_text","event_id":"proof_small_epaper","device_id":"proof-device","sender_ref":"proof-user-small-epaper","text":"上屏:小屏墨水屏能力测试","device_context":{"schema":"weclawbot.device_context.v1","canvas":{"width":200,"height":200,"color":"mono1","refresh":"reflective_slow"},"content_viewport":{"id":"content","x":8,"y":28,"width":184,"height":136,"format":"mono1","max_pages":2},"display_capabilities":{"schema":"weclawbot.display_capabilities.v1","product_id":"waveshare-s3-epaper-1.54","board_name":"Waveshare ESP32-S3 ePaper 1.54","kind":"epaper","technology":"electrophoretic","pixel":{"width":200,"height":200,"color_mode":"mono1","native_format":"mono1","bit_order":"msb_first","black_bit":1,"grayscale_levels":2},"refresh":{"class":"reflective_slow","partial":true,"full_refresh_timeout_ms":15000,"recommended_min_update_interval_ms":60000,"ghosting_risk":"medium"},"supported_page_formats":["mono1"]}}},"expect":{"action_any":["create_note","replace_note"],"contains":"小屏墨水屏能力测试","page_ok":true,"page_width":184,"page_height":136,"page_stride":23,"metrics_equal":{"display_product_id":"waveshare-s3-epaper-1.54","display_kind":"epaper","display_is_epaper":true,"display_partial_refresh":true,"display_ghosting_risk":"medium","screen_refresh":"reflective_slow"},"metrics_lte":{"density_hi":0.36}}} +{"name":"vertical right-to-left poem layout","event":{"kind":"wechat_text","event_id":"proof_vertical_rl","device_id":"proof-device","sender_ref":"proof-user-vertical","text":"书法体显示,竖排,从右到左:《渔歌子》(张志和)\n西塞山前白鹭飞,桃花流水鳜鱼肥。\n青箬笠,绿蓑衣,斜风细雨不须归。"},"expect":{"action_any":["create_note","replace_note"],"contains":"西塞山前白鹭飞","not_body_contains":"竖排","page_ok":true,"writing_mode":"vertical-rl","font_resolved":"calligraphy","min_font_at_least":23}} diff --git a/services/weclawbot-agent/proofs/run_display_shadow_proof.py b/services/weclawbot-agent/proofs/run_display_shadow_proof.py index e582320..4453ad9 100644 --- a/services/weclawbot-agent/proofs/run_display_shadow_proof.py +++ b/services/weclawbot-agent/proofs/run_display_shadow_proof.py @@ -40,6 +40,8 @@ def _mac_font_fallback() -> None: config.FONT_PATH = candidate if not Path(config.FONT_WENKAI).exists(): config.FONT_WENKAI = candidate + if not Path(config.FONT_CALLIGRAPHY).exists(): + config.FONT_CALLIGRAPHY = candidate return @@ -57,7 +59,8 @@ def _decision_text(decision: dict[str, Any]) -> str: ) -def _page_ok(decision: dict[str, Any]) -> tuple[bool, str]: +def _page_ok(decision: dict[str, Any], expect: dict[str, Any] | None = None) -> tuple[bool, str]: + expect = expect or {} pages = decision.get("screen_pages") or [] if not pages: return False, "no_pages" @@ -66,13 +69,16 @@ def _page_ok(decision: dict[str, Any]) -> tuple[bool, str]: raw = base64.b64decode(page["data_b64"]) except Exception as exc: # noqa: BLE001 return False, f"bad_b64:{exc}" + expected_w = int(expect.get("page_width") or config.CONTENT_W) + expected_h = int(expect.get("page_height") or config.CONTENT_H) + expected_stride = int(expect.get("page_stride") or ((expected_w + 7) // 8)) ok = ( page.get("format") == "mono1" and page.get("viewport") == "content" - and int(page.get("width") or 0) == config.CONTENT_W - and int(page.get("height") or 0) == config.CONTENT_H - and int(page.get("stride") or 0) == config.CONTENT_STRIDE - and len(raw) == config.CONTENT_STRIDE * config.CONTENT_H + and int(page.get("width") or 0) == expected_w + and int(page.get("height") or 0) == expected_h + and int(page.get("stride") or 0) == expected_stride + and len(raw) == expected_stride * expected_h ) return ok, f"stride={page.get('stride')} bytes={len(raw)}" @@ -118,10 +124,22 @@ def _check_case(case: dict[str, Any], run_id: str) -> dict[str, Any]: issues.append("unexpected_screen_pages") page_detail = "" if expect.get("page_ok"): - ok, page_detail = _page_ok(decision) + ok, page_detail = _page_ok(decision, expect) if not ok: issues.append(f"page_not_ok:{page_detail}") metrics = out.get("metrics") or {} + if expect.get("writing_mode") and metrics.get("writing_mode") != expect["writing_mode"]: + issues.append(f"writing_mode:{metrics.get('writing_mode')}!={expect['writing_mode']}") + if expect.get("font_resolved") and metrics.get("font_resolved") != expect["font_resolved"]: + issues.append(f"font_resolved:{metrics.get('font_resolved')}!={expect['font_resolved']}") + if expect.get("min_font_at_least") and int(metrics.get("min_font") or 0) < int(expect["min_font_at_least"]): + issues.append(f"min_font:{metrics.get('min_font')}<{expect['min_font_at_least']}") + for key, expected in (expect.get("metrics_equal") or {}).items(): + if metrics.get(key) != expected: + issues.append(f"metric:{key}:{metrics.get(key)}!={expected}") + for key, expected_max in (expect.get("metrics_lte") or {}).items(): + if float(metrics.get(key) or 0) > float(expected_max): + issues.append(f"metric_lte:{key}:{metrics.get(key)}>{expected_max}") if decision.get("screen_pages") and metrics.get("overflow") and float(decision.get("confidence") or 0) > 0.7: issues.append("overflow_without_low_confidence") return { diff --git a/services/weclawbot-agent/tests/e2e.py b/services/weclawbot-agent/tests/e2e.py index 8b96f1e..cc895b4 100644 --- a/services/weclawbot-agent/tests/e2e.py +++ b/services/weclawbot-agent/tests/e2e.py @@ -16,7 +16,7 @@ import time from pathlib import Path -from app import config, graph, models, server +from app import config, graph, models, server, skillrepo RUN_ID = os.environ.get("WCB_E2E_RUN_ID") or f"{os.getpid()}-{int(time.time())}" DEV = f"e2e-device-001-{RUN_ID}" @@ -25,6 +25,21 @@ _fails = 0 +def local_font_fallback() -> None: + if Path(config.FONT_PATH).exists(): + return + for candidate in ( + "/System/Library/Fonts/STHeiti Medium.ttc", + "/System/Library/Fonts/PingFang.ttc", + "/Library/Fonts/Arial Unicode.ttf", + ): + if Path(candidate).exists(): + config.FONT_PATH = candidate + config.FONT_WENKAI = candidate + config.FONT_CALLIGRAPHY = candidate + return + + def check(name: str, cond: bool, detail: str = "") -> None: global _fails print(f" [{PASS if cond else FAIL}] {name}" + (f" ({detail})" if detail else "")) @@ -33,12 +48,22 @@ def check(name: str, cond: bool, detail: str = "") -> None: def page_ok(d: dict) -> tuple[bool, str]: + return page_ok_expected(d, 368, 206, 46) + + +def page_ok_expected(d: dict, width: int, height: int, stride: int) -> tuple[bool, str]: pages = d.get("screen_pages") or [] if not pages: return False, "no pages" p = pages[0] raw = base64.b64decode(p["data_b64"]) - ok = p["format"] == "mono1" and p["stride"] == 46 and p["width"] == 368 and p["height"] == 206 and len(raw) == 9476 + ok = ( + p["format"] == "mono1" + and p["stride"] == stride + and p["width"] == width + and p["height"] == height + and len(raw) == stride * height + ) return ok, f"stride={p['stride']} bytes={len(raw)}" @@ -213,10 +238,10 @@ def scenario_g_langmem_font_preference() -> None: third_planner = [t for t in third["trace"] if t.get("stage") in ("planner", "sticky_planner")] check("第一次显式样式上屏", d1["action"] in ("create_note", "replace_note"), d1["action"]) check("样式词不进正文", "书法体" not in first_body, first_body) - check("本次样式不自动变长期偏好", all(p.get("font") != "wenkai" for p in planner), str(planner)) + check("本次样式不自动变长期偏好", all(p.get("font") != "calligraphy" for p in planner), str(planner)) check("第二次产出合规页", page_ok(d2)[0], page_ok(d2)[1]) - check("明确长期反馈写入字体偏好", any(u.get("key") == "prefs.font_style" and u.get("value") == "wenkai" for u in pref_updates), str(pref_updates)) - check("长期偏好后续复用", any(p.get("font") == "wenkai" for p in third_planner), str(third_planner)) + check("明确长期反馈写入书法字体偏好", any(u.get("key") == "prefs.font_style" and u.get("value") == "calligraphy" for u in pref_updates), str(pref_updates)) + check("长期偏好后续复用", any(p.get("font") == "calligraphy" for p in third_planner), str(third_planner)) def scenario_h_weclawbotctl_official_contract() -> None: @@ -561,7 +586,7 @@ def scenario_u_style_complaint_relayouts_current_screen() -> None: check("抱怨不是普通聊天", d["action"] in ("replace_note", "update_note"), d["action"]) check("保留当前屏诗句", "飞流直下三千尺" in text and "疑是银河落九天" in text, text) check("不把抱怨句写进正文", "哪里是书法体" not in body, body) - check("显式切到书法字体", any(p.get("font") == "wenkai" for p in planners), str(planners)) + check("显式切到书法字体", any(p.get("font") == "calligraphy" for p in planners), str(planners)) check("诗词版式不用项目符号", any(p.get("prefix") == "none" for p in planners), str(planners)) check("产出合规页", page_ok(d)[0], page_ok(d)[1]) @@ -601,14 +626,14 @@ def scenario_v_inline_style_display_is_screen_task() -> None: check("正文+样式显示进入上屏", d1["action"] in ("create_note", "replace_note"), d1["action"]) check("正文保留诗句", "飞流直下三千尺" in text1 and "疑是银河落九天" in text1, text1) check("样式命令不进正文", "书法体显示" not in body1 and "书法体" not in body1, body1) - check("正文+样式显示使用书法字体", any(p.get("font") == "wenkai" for p in planners1), str(planners1)) + check("正文+样式显示使用书法字体", any(p.get("font") == "calligraphy" for p in planners1), str(planners1)) d2 = change["decision"] text2 = decision_text(d2) planners2 = [t for t in change.get("trace") or [] if t.get("stage") in ("planner", "sticky_planner")] check("换书法体更新当前屏", d2["action"] in ("replace_note", "update_note"), d2["action"]) check("换书法体保留当前诗句", "飞流直下三千尺" in text2 and "疑是银河落九天" in text2, text2) - check("换书法体使用书法字体", any(p.get("font") == "wenkai" for p in planners2), str(planners2)) + check("换书法体使用书法字体", any(p.get("font") == "calligraphy" for p in planners2), str(planners2)) check("换书法体产出合规页", page_ok(d2)[0], page_ok(d2)[1]) @@ -707,7 +732,130 @@ def scenario_x_single_reminder_preserves_time_and_category() -> None: check("产出合规页", page_ok(d)[0], page_ok(d)[1]) +def scenario_y_evolution_gap_candidate_is_redacted() -> None: + print("\n== Y: 云端进化缺口 -> 只进脱敏候选,不改热路径 ==") + saved_ledger = config.SKILL_CANDIDATE_LEDGER + ledger = Path(config.OUT_DIR) / f"e2e-evolution-candidates-{RUN_ID}.jsonl" + config.SKILL_CANDIDATE_LEDGER = str(ledger) + try: + if ledger.exists(): + ledger.unlink() + raw_followup = "按新的阅读方向整理上面那段" + raw_screen = "私人标题\n这是一段不应进入共享候选的屏幕原文" + payload = server.EvolutionGap( + kind="wechat_text", + event_id="Y1", + device_id=DEV, + sender_ref=f"e2e-evolution-{os.getpid()}", + text=raw_followup, + screen={"current_note": {"canonical_text": raw_screen, "revision": "screen-y"}}, + source="gateway_mqtt_local_gate", + observed_action="reply_only", + expected_action_family="screen_pipeline_review", + categories=["intent_gate_false_negative", "context_followup_missed"], + gateway_stage="localOfficialMqttDecision", + gateway_reason="no_screen_signal", + trace=[{"stage": "gateway", "result": "reply_only", "reason": "no_screen_signal"}], + ) + result = server.evolution_gap(payload) + rows = skillrepo.load_candidates(limit=10) + row = rows[-1] if rows else {} + serialized = json.dumps(row, ensure_ascii=False) + privacy = skillrepo.validate_candidate_privacy(row) + check("接口返回 ok", result.get("ok") is True, str(result)) + check("生成 intent-gate 候选", row.get("target_skill") == "intent-gate", str(row)) + check("候选类别正确", "intent_gate_false_negative" in (row.get("categories") or []), str(row.get("categories"))) + check("privacy gate 通过", privacy.get("ok") is True, str(privacy)) + check("不保存原始输入", raw_followup not in serialized, serialized) + check("不保存屏幕原文", raw_screen not in serialized, serialized) + finally: + config.SKILL_CANDIDATE_LEDGER = saved_ledger + + +def scenario_z_vertical_rl_poem_layout() -> None: + print("\n== Z: 竖排从右到左 -> renderer 能力生效,不把版式词进正文 ==") + saved = config.MODEL_STUB + config.MODEL_STUB = True + try: + out = graph.run({ + "kind": "wechat_text", + "event_id": "Z1", + "device_id": DEV, + "sender_ref": f"e2e-vertical-{os.getpid()}", + "text": "书法体显示,竖排,从右到左:《渔歌子》(张志和)\n西塞山前白鹭飞,桃花流水鳜鱼肥。\n青箬笠,绿蓑衣,斜风细雨不须归。", + }) + finally: + config.MODEL_STUB = saved + d = out["decision"] + body = str((d.get("note") or {}).get("body") or "") + metrics = out.get("metrics") or {} + planners = [t for t in out.get("trace") or [] if t.get("stage") in ("planner", "sticky_planner")] + check("动作是上屏", d["action"] in ("create_note", "replace_note"), d["action"]) + check("正文保留诗词", "西塞山前白鹭飞" in decision_text(d) and "斜风细雨不须归" in decision_text(d), decision_text(d)) + check("版式词不进正文", "竖排" not in body and "从右到左" not in body and "书法体显示" not in body, body) + check("planner 写入 vertical-rl", any(p.get("writing_mode") == "vertical-rl" for p in planners), str(planners)) + check("planner 使用书法字体", any(p.get("font") == "calligraphy" for p in planners), str(planners)) + check("renderer 使用 vertical-rl", metrics.get("writing_mode") == "vertical-rl", str(metrics)) + check("renderer 解析到书法字体", metrics.get("font_resolved") == "calligraphy", str(metrics)) + check("墨水屏字号足够", int(metrics.get("min_font") or 0) >= 23, str(metrics)) + check("竖排产生多列", int(metrics.get("columns_shown") or 0) >= 4, str(metrics)) + check("产出合规页", page_ok(d)[0], page_ok(d)[1]) + + +def scenario_aa_display_capabilities_product_profile() -> None: + print("\n== AA: display_capabilities -> 多屏产品能力驱动几何和自检 ==") + saved = config.MODEL_STUB + config.MODEL_STUB = True + try: + out = graph.run({ + "kind": "wechat_text", + "event_id": "AA1", + "device_id": DEV, + "sender_ref": f"e2e-display-caps-{os.getpid()}", + "text": "上屏:小屏墨水屏能力测试", + "device_context": { + "schema": "weclawbot.device_context.v1", + "canvas": {"width": 200, "height": 200, "color": "mono1", "refresh": "reflective_slow"}, + "content_viewport": {"id": "content", "x": 8, "y": 28, "width": 184, "height": 136, "format": "mono1", "max_pages": 2}, + "display_capabilities": { + "schema": "weclawbot.display_capabilities.v1", + "product_id": "waveshare-s3-epaper-1.54", + "board_name": "Waveshare ESP32-S3 ePaper 1.54", + "kind": "epaper", + "technology": "electrophoretic", + "pixel": { + "width": 200, + "height": 200, + "color_mode": "mono1", + "native_format": "mono1", + "bit_order": "msb_first", + "black_bit": 1, + "grayscale_levels": 2, + }, + "refresh": { + "class": "reflective_slow", + "partial": True, + "full_refresh_timeout_ms": 15000, + "recommended_min_update_interval_ms": 60000, + "ghosting_risk": "medium", + }, + "supported_page_formats": ["mono1"], + }, + }, + }) + finally: + config.MODEL_STUB = saved + d = out["decision"] + metrics = out.get("metrics") or {} + check("动作是上屏", d["action"] in ("create_note", "replace_note"), d["action"]) + check("按小屏 viewport 出页", page_ok_expected(d, 184, 136, 23)[0], page_ok_expected(d, 184, 136, 23)[1]) + check("识别产品 id", metrics.get("display_product_id") == "waveshare-s3-epaper-1.54", str(metrics)) + check("识别墨水屏能力", metrics.get("display_is_epaper") is True and metrics.get("display_partial_refresh") is True, str(metrics)) + check("小墨水屏使用更保守密度阈值", float(metrics.get("density_hi") or 1) <= 0.36, str(metrics)) + + def main() -> int: + local_font_fallback() print(f"model_stub={config.MODEL_STUB} deepseek_key={'yes' if config.DEEPSEEK_API_KEY else 'no'} content_critic={config.CONTENT_CRITIC}") scenario_a() scenario_b() @@ -733,6 +881,9 @@ def main() -> int: scenario_v_inline_style_display_is_screen_task() scenario_w_remove_current_screen_section() scenario_x_single_reminder_preserves_time_and_category() + scenario_y_evolution_gap_candidate_is_redacted() + scenario_z_vertical_rl_poem_layout() + scenario_aa_display_capabilities_product_profile() print(f"\n==== {'ALL PASS' if _fails == 0 else str(_fails) + ' FAILED'} ====") return 1 if _fails else 0 diff --git a/services/weclawbot-agent/tests/fixtures/sticky-core.jsonl b/services/weclawbot-agent/tests/fixtures/sticky-core.jsonl index 21ccf5d..558acfe 100644 --- a/services/weclawbot-agent/tests/fixtures/sticky-core.jsonl +++ b/services/weclawbot-agent/tests/fixtures/sticky-core.jsonl @@ -5,6 +5,8 @@ {"name":"long greeting stays off screen","text":"hello there nice to meet you","expect":{"action":"reply_only","no_note":true}} {"name":"conversation question stays off screen","text":"你今天好吗?","expect":{"action":"reply_only","no_note":true}} {"name":"plain quote stays off screen without display intent","text":"床前明月光,疑似地上霜。","expect":{"action":"reply_only","no_note":true}} +{"name":"standalone poem card becomes screen content","text":"《微笺秋夕》\n半窗冷月明如练,一纸微笺透墨痕。\n不怨秋风惊落叶,且邀明月共黄昏。\n—— WeClawBot 题","expect":{"action":"create_note","title":"微笺秋夕","contains":"半窗冷月明如练"}} +{"name":"explicit handwriting poem card uses poem reply","text":"用手写体上屏下面诗歌:\n《微笺秋夕》\n\n半窗冷月明如练,一纸微笺透墨痕。\n不怨秋风惊落叶,且邀明月共黄昏。\n\n—— WeClawBot 题","expect":{"action":"create_note","title":"微笺秋夕","contains":"半窗冷月明如练","reply_contains":"已按诗笺排版上屏:微笺秋夕"}} {"name":"explicit display intent creates note","text":"把这句话上屏:床前明月光","expect":{"action":"create_note","contains":"床前明月光"}} {"name":"ignore ack","text":"收到","expect":{"action":"ignore","no_note":true}} {"name":"ignore emoji","text":"👍","expect":{"action":"ignore","no_note":true}} diff --git a/web/style.css b/web/style.css index fa9ba00..e9ce07d 100644 --- a/web/style.css +++ b/web/style.css @@ -269,12 +269,13 @@ strong { .segmented-control { display: inline-grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - overflow: hidden; + overflow: visible; border: 1px solid var(--line); border-radius: 8px; } .segment { + position: relative; min-height: 40px; border: 0; border-right: 1px solid var(--line); @@ -289,6 +290,14 @@ strong { border-right: 0; } +.segment:first-child { + border-radius: 7px 0 0 7px; +} + +.segment:last-child { + border-radius: 0 7px 7px 0; +} + .segment.is-selected { background: var(--primary); color: var(--primary-ink); @@ -301,6 +310,42 @@ strong { line-height: 1.5; } +.assistant-tip { + position: relative; + display: inline-flex; + align-items: baseline; + border-bottom: 1px dotted currentColor; + cursor: help; +} + +.assistant-tip-card { + position: absolute; + left: 50%; + bottom: calc(100% + 10px); + z-index: 4; + width: min(280px, calc(100vw - 48px)); + padding: 10px 12px; + border: 1px solid rgba(18, 102, 93, 0.22); + border-radius: 8px; + background: #fff; + box-shadow: 0 12px 28px rgba(23, 32, 29, 0.14); + color: var(--ink); + font-size: 13px; + font-weight: 500; + line-height: 1.5; + text-align: left; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 4px); + transition: opacity 120ms ease, transform 120ms ease; +} + +.assistant-tip:hover .assistant-tip-card, +.segment:focus-visible .assistant-tip-card { + opacity: 1; + transform: translate(-50%, 0); +} + .wifi-form label { display: grid; gap: 7px;