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

335 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` 加过:
```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` / 真实光标相关行为
**无法在此验证**,需在有桌面的会话实测。