Files
HomeAgent/GUI-PLAN.md
JianFeeeee 8fc9d499d8 docs(gui): 修正 GUI-PLAN 陈旧段落并补齐待办
## 修正三处因改用 SendInput 而陈旧/错误的表述

1. **「平台硬限制」说法是错的**(重要)
   原文写「Chromium/Electron 自绘界面、游戏会忽略合成消息,这是 Windows
   消息模型的硬限制」。实际那只是 `PostMessage` 这条 API 的限制 ——
   `SendInput` 注入系统输入队列,自绘界面照常接收(Coopanion 即如此)。
   已改为分别说明两种注入的适用范围。

2. **3.4/3.5 已验证节只覆盖 overlay 路径**
   补上 sendinput 路径的回执形态,并把「未验证」的范围写清楚:
   验证的是「错误处理正确」,不是「点击落到目标窗口」。

3. **3.7 跨平台状态仍写「未做模式 A」**
   Windows 已实现两档注入;Linux 仍为 xdotool(会动真鼠标)。

## 修文档自身的缺陷
发现两个重复的 `### 4.3`(编辑时留下的同一设置页条目),已合并去重,
现在编号连续(4.1–4.6)。

## 补齐待办(4.6 其它已定位但未修)
以下均已核对代码确认属实,不是猜测:
  · overlay 模式键盘动作「必须先点过」可改为取前台窗口兜底
    (main.js:2283 / 2291 确为直接报错)
  · real 模式与 sendinput 行为重复,可考虑合并
    (main.js:2051 sendinput / 2189 overlay / 2324 起为旧 koffi 路径,
      real 与未命中两档时都会落到这里走 SetCursorPos)
  · p3.js / p4.js 是本轮遗留的临时补丁脚本(git status 确认为未跟踪),
    确认无价值后删除

判据未改动,npm test 131 条全绿。
2026-10-02 15:35:27 +08:00

14 KiB
Raw Blame History

GUI 改造计划与进度

本文件是 GUI(cmd/gui)当前改造的单一事实来源:做完了什么、为什么这么做、 哪些结论是实测得到的、哪些还没做完。

相关文档:

  • cmd/gui/DESIGN-NOTES.md —— 借鉴 PiDeck 的设计与实现
  • SERVER-HANDOFF.md —— 需要服务端同步配合的部分
  • 判据:cmd/gui/*.test.mjs,npm test 全绿为准(当前 127 条)

一、当前状态

分支:feature/gui-starmap-pulse-vendor-align(不合入 main)

最近提交:

commit 内容
02566f3 修启动期误判服务端未启动;keybd_event 类型名致组合键全废
0d8b129 computeruse 补高级操作(两端 9→18);修 HiDPI 坐标;记录 PiDeck 借鉴
3059c99 加载动画改三点式 + 新增系统通知

判据:cd cmd/gui && npm test —— 127 条全绿(其中 computeruse.test.mjs 43 条)。


二、已完成

2.1 computeruse 能力面:9 → 18 个动作

此前 GUI 侧(cmd/gui/main.js)与 waiter 侧(cmd/waiter/device.go)都只有 9 个基础动作,与业界 computer-use 的基本盘差距明显。

GUI 侧现支持 18 个(已用脚本从 main.js 的 case "computeruse" 块精确提取核对):

click       doubleclick  rightclick   middleclick  tripleclick
move        hover        drag         mousedown    mouseup
scroll      keypress     hotkey       combo
type        wait         sleep        display

waiter 侧(Linux)为 20 个 —— 比 GUI 多 middle、right 两个 「button 简写形式」,少 physical 相关处理;其余一致。

要点:

  • 拖拽分步移动(默认 12 步,上限 60)。一步到位会让 canvas 拖拽、列表排序、 滑动条失效 —— 这类应用依赖中间 mousemove 事件。
  • 组合键归一化(waiter 侧 normKeySpec)。旧实现把模型给的 key 字符串原样丢给 xdotool key,于是 "Ctrl+C"、"ctrl + c"、"CTRL-C" 全部失效。
  • type 加 --clearmodifiers(waiter 侧)。避免残留的 Ctrl/Alt 把后续输入 变成快捷键。
  • scroll 修正(两端)。旧实现固定滚 click 4/5,忽略方向语义也不支持横向。

2.2 修 HiDPI 坐标错位 ⚠️ 重要,详见第五节

截图给模型的是物理像素,Win32 坐标 API 在 Electron 里也是物理像素, 不需要换算。详见第五节的实测与踩坑记录。

2.3 修启动期误判「服务端未启动」

isServerRunning() 原硬编码 http://localhost:8080/。实测该部署下:

localhost:8080      -> 000 连不上
192.168.2.60:8080   -> 302 正常(homed 跑在 WSL 里)

于是每次启动都:探测失败 → 误判服务没起 → 拉起 GUI 旁并不存在的 homed.exe → 干等 8s 超时 → 打印 homed failed to start within timeout。 该 ERR 曾累计出现数十次(实测 40+ 次,日志轮转后计数会变), 且是 gui.log 里唯一的错误项。

改法:按 connections.json 里已配置的连接(当前优先)探测,命中即在线; 服务可达时跳过 homed 自动拉起;无本地 homed 可执行文件时不再空等超时。

实测验证:修复后启动日志为 server reachable, skip homed autostart,该 ERR 消失。

这条很可能就是此前「聊天链路有问题」的根因 —— GUI 自以为服务端由自己托管, 实际聊天依赖的是 connections.json 里的连接。

2.4 修 keybd_event 类型名致组合键全废

koffi 不认 Win32 头文件里的 byte:

"void keybd_event(byte bVk, ...)"    -> Error: Unknown or invalid type name 'byte'
"void keybd_event(uint8 bVk, ...)"   -> 正常

原代码用的正是 byte,且整段包在空 catch 里,于是 keybd_event 恒为 null, 所有组合键返回 keybd_event unavailable。

这个 bug 极难发现:单键 click/move/scroll 走 mouse_event,完全不受影响。 现在签名解析失败会打日志,不再静默。

2.5 模式 A:agent 独立光标 + 后台注入(代码已通,效果待验证)

见第三节。


三、模式 A:agent 独立光标(进行中)

3.1 目标

现有 computeruse 走 SetCursorPos + mouse_event,会抢走用户的真实鼠标, agent 一干活用户就没法用电脑。

模式 A 的目标:GUI 自绘一个 agent 专属光标(mascot 形象),实际输入通过 PostMessage 直接投递到目标窗口句柄,用户真实鼠标全程不动。

3.2 新增文件

文件 职责
cmd/gui/agent-cursor.js 透明置顶层窗口 + mascot 指针 + 移动/点击脉冲动画;记录上次命中的窗口句柄
cmd/gui/agent-inject.js 两种注入路径:SendInput(默认)与 PostMessage(后台);含用户活动感知

3.3 配置

gui-prefs.json → deviceBridge.agentCursor:

键 值 行为
mode "sendinput" 默认。SendInput 注入系统输入队列,可靠性高(Chromium/游戏都能操作)。代价:会移动真实光标,故先等用户停手
mode "overlay" PostMessage 直投窗口句柄。用户鼠标纹丝不动,但自绘界面常忽略合成消息
mode "real" 保留旧代码路径(兼容)
deferMs 数字 用户活动时最多等多久(默认 3000,上限 10000)。0 = 不等待

为什么默认改成 sendinput:用户要求「可靠 + 用户正在操作则暂缓」。 参考 Pal-AI-Lab/Coopanion(见 DESKTOP-PET-NOTES.md 第七节)。

⚠ 实测发现:GetLastInputInfo 是全局的,agent 自己的 SendInput 也会被算作「用户输入」,若不排除则永远处于「用户正在操作」状态。 已按 Coopanion 的三重判定修正(ownTick / ownCursor / 无符号差值)。

3.4 已验证(端到端)

用假网关(自实现 WS 服务端)下发 computeruse 命令,GUI 执行并回执。 两轮合计:

overlay(PostMessage)路径:8/8 有响应,错误处理正确 —— 无窗口时报「WindowFromPoint 返回空」,而不是静默回退到真实鼠标。

sendinput 路径(当前默认):8/8 有响应,回执形如 sendinput click @ (300,300) / sendinput typed 2 chars / sendinput drag (100,100) -> (400,300)。

3.5 ⚠️ 未完成:真实点击效果未验证

当前执行环境没有可供注入的可见目标窗口 (WindowFromPoint 对所有坐标返回 null,GetCursorPos 读回 0,0)。

因此:「点击真的落到目标窗口上」这一步尚未被证明。 需要在有交互桌面的 会话里跑一个端到端测试:

打开记事本 → agent 点它 → 输入文字 → 截图回读验证

同时验证「overlay 模式下用户鼠标位置在全过程未变」。

3.6 设计取舍(明确的边界)

  • 两种注入的适用范围不同:

    • sendinput(默认):注入系统输入队列,等价真实硬件事件。 Chromium/Electron 自绘界面、多数游戏都能接收 —— 可靠。 代价:会移动用户真实光标,故先等用户停手(deferMs)。
    • overlay:PostMessage 直投窗口句柄,用户鼠标纹丝不动。 但自绘界面常忽略合成消息 ⇒ 只能操作原生 Win32 程序。

    本仓曾把「自绘界面点不动」写成「Windows 消息模型的硬限制」—— 那个说法是错的,它只是 PostMessage 这条 API 的限制。 用 SendInput 就不受此限(参考 Coopanion)。

  • 不做静默降级。无论哪种模式,失败即 status=error 并说明原因, 不会自作主张换成另一种注入方式。

  • sendinput 模式下用户正在操作会暂缓(而不是硬抢): 轮询等到用户空闲,超时则跳过本次操作并报错。 已在 agent-inject.js 中排除「自身注入被误判为用户操作」的陷阱。

  • overlay 模式下键盘类动作依赖先前的鼠标操作。 keypress/hotkey/type 不带坐标,而该模式不移动真实光标, 所以 GetCursorPos 拿到的是用户自己的光标位置; 故记录「上次鼠标操作命中的 hwnd」,键盘动作投递到那里。从未点过则报错。

3.7 跨平台状态

平台 状态
Windows 已实现(sendinput 默认 / overlay 可选;真实效果待验证)
Linux waiter 侧仍是 xdotool,会动真鼠标。未做独立光标
macOS 同上

四、待办

4.1 优先:修 deferMs 读取不生效

实测:gui-prefs.json 里 agentCursor.deferMs = 0,但命令仍按默认 3000ms 暂缓(回执写「已等待 3000ms」)。loadGuiPrefs() 会重建对象且不透传 agentCursor 字段,导致读取永远拿到默认值。

  • loadGuiPrefs() 透传 deviceBridge.agentCursor
  • 判据:配置 deferMs:0 时不得进入暂缓路径

4.2 验证 sendinput 的真实效果

当前环境无可注入的可见目标窗口(WindowFromPoint 恒返回 null, GetCursorPos 读回 0,0),故「点击真的落到目标窗口上」尚未证明。

  • 端到端测试:打开真实应用 → 点它 → 输入 → 截图回读
  • 断言:overlay 模式下全过程用户鼠标位置未变
  • 判据:固化进 npm test(需能在无桌面 CI 环境跳过)

4.3 服务端配合(详见 SERVER-HANDOFF.md)

  • 放开 device.go 的 action 白名单(schema enum + switch default 双重限制)
  • 透传新参数 tox/toy/steps/dx/ms/physical
  • 统一 scroll 方向语义(服务端写「正=向下」,Windows 惯例是正=向上)
  • 把坐标约定写进注释(防重蹈 /scale 覆辙)

⚠ 不改白名单,则新增的 11 个动作根本下发不到设备。

4.4 设置页

  • agentCursor.mode / deferMs 的 UI(目前只能手改 gui-prefs.json)

4.5 可选增强(详见 DESKTOP-PET-NOTES.md 第六、九节)

  • main.js 显式调 SetProcessDpiAwarenessContext(-4),不依赖 Electron 默认值
  • mascot 姿态:思考 / 等待 / 出错
  • 动作解析容错:未知动作丢弃而非整条失败
  • mascot 待机自主行为:闲置时轻微眨眼/呼吸
  • Linux 侧若也要「不抢鼠标」,需 Wayland + uinput 虚拟设备 (X11 下做不到 —— XTEST 合成的是真实设备事件)

4.6 其它已定位但未修

  • overlay 模式下键盘类动作「必须先点过」的限制可放宽: 可改为「取当前前台窗口」作为兵底,而不是直接报错。
  • real 模式与 sendinput 模式代码重叠较多,可考虑合并; 目前保留 real 仅为兼容旧路径,实际上与 sendinput 行为重复。
  • p3.js / p4.js 是本轮遗留的临时补丁脚本(未被跟踪), 确认无价值后删除,避免占坑。

五、坐标空间:实测结论与踩坑记录 ⚠️

结论:截图给模型的像素、Win32 坐标 API 的坐标,是同一个坐标系 —— 物理像素。 不要换算。

实测证据(Electron 主进程内,192 DPI / 200% 缩放屏):

Electron screen API (DIP) : 1260 x 840    scaleFactor=2
GetSystemMetrics          : 2520 x 1680
SetCursorPos(200,200)  →  GetCursorPos 读回 (200,200)      ← 恒等,不缩放

原因是 Electron 主进程是 per-monitor DPI-aware,此感知下 Win32 坐标不做虚拟化。

踩坑:曾经加错的 /scaleFactor 换算

本仓曾在 main.js 加过:

absX = ox + rawX / scaleFactor   // ← 错的

理由是「SetCursorPos 期望 DIP」—— 那个前提不成立,DIP 行为只出现在 DPI-unaware 进程里。加了之后反而把原本正确的点击改坏:200% 缩放屏上 点 (600,400) 会被送到 (400,267),越靠右下偏得越远。

已改回 physical ? rawX : rawX * scale,并在 computeruse.test.mjs 加判据 防止回退(检测 /scale 是否回来了)。

为什么会误判(重要)

在普通 node 进程里测同一个函数,读到的是 1260×840(虚拟化后的值), 会让人以为 Win32 用 DIP。必须在 Electron 内测 —— 同一份代码, 不同进程的 DPI 感知状态不同,答案也不同。

同理,python/go 等非 DPI-aware 宿主里测同样不可作为依据。


六、端到端测试方法(可复用)

不依赖真实服务端,用自实现的假网关驱动 GUI:

  1. 起一个最小 WS 服务端(自实现 HTTP Upgrade 握手 + 帧编解码)
  2. 改写 gui-prefs.json 指向它,并置 deviceBridge.authorized = true
  3. 启动 GUI,等 hello / bind 完成
  4. 下发 {op:"cmd", cmd_type:"homeagent", command:"computeruse {...}"}
  5. 读取 GUI 回的 {op:"cmd_result", status, output, error}

两个踩过的坑

  • 服务端→客户端的帧不能加掩码。 RFC 6455 只要求客户端→服务端掩码。 照抄 GUI 主进程(客户端方向)的 sendDeviceFrame 会导致 GUI 解不出。
  • 客户端→服务端的帧是掩码的,解帧时必须先解掩码。

七、koffi 3.x 使用约定(踩坑记录)

事项 正确做法 错误做法 / 现象
类型名 uint8 / int32_t / long byte → Unknown or invalid type name
指针参数 传 Node Buffer 传普通 JS 对象 → 不写回,读回仍是原值
结构体 koffi.struct(name, {...}) 后传普通对象 重复 koffi.struct("POINT") → Duplicate type name
字符串 Buffer.from(s + "\0", "utf16le") koffi.alloc(str) 把 str 当类型名

指针参数必须用 Buffer 这条尤其重要:WindowFromPoint 若用普通对象, 会拿不到真实 hwnd ⇒ 点击落到错误窗口上。

跨进程/跨模块重复声明同名结构体会抛 Duplicate type name,需 try/catch 换名注册。


八、环境注意事项

  • 本仓库文件多为 CRLF。用 Node 脚本打补丁时先 replace(/\r\n/g, "\n") 处理, 写回时按原行尾还原。
  • git status 会把大量文件标成 M,但 git diff --numstat 显示 0 0 —— 那是 CRLF 归一化噪声,不是真实改动。提交前用 --numstat 甄别。
  • 本机没有 Go 工具链(go: command not found),cmd/waiter/ 的改动 未经过编译验证,需在有 Go 环境的机器上 go build ./cmd/waiter 确认。
  • 本会话多数为无头/无交互桌面环境,WindowFromPoint / 真实光标相关行为 无法在此验证,需在有桌面的会话实测。