Files
HomeAgent/GUI-PLAN.md
JianFeeeee f0a7ce23b1 feat(gui): 模式 A agent 独立光标 + 后台注入;修坐标换算;固化计划与交接文档
## 模式 A:agent 独立光标(不抢用户鼠标)

现有 computeruse 走 SetCursorPos + mouse_event,agent 一干活就抢走用户
真实鼠标,用户无法同时使用电脑。现改为:GUI 自绘 agent 专属光标,实际
输入经 PostMessage 直接投递到目标窗口句柄,用户鼠标全程不动。

新增:
  cmd/gui/agent-cursor.js   透明置顶层窗口 + mascot 指针 + 移动/点击脉冲
                            动画;记录上次命中的 hwnd 供键盘动作使用
  cmd/gui/agent-inject.js   Windows 后台注入:WindowFromPoint 定位、
                            客户区坐标换算、PostMessage 投递鼠标/键盘/
                            滚轮/文本;拖拽分步移动

配置 gui-prefs.deviceBridge.agentCursor.mode = "overlay"(默认) | "real"。

明确的设计边界(不是实现缺陷,是平台限制):
  · 只对「接受窗口消息的程序」可靠。原生 Win32 程序可靠;Chromium/
    Electron 自绘界面、游戏、DirectX 会忽略合成消息。
  · 不做真实鼠标降级。用户既选了不抢鼠标,就不该偷偷改成操作真鼠标,
    失败即 status=error 并说明原因。
  · 键盘类动作依赖先前的鼠标操作(后台注入不动真光标,
    GetCursorPos 拿到的是用户自己的光标位置)。

端到端 8/8 正确响应;「真实点击是否落到目标窗口」因当前环境无可注入的
可见窗口而未验证,已在 GUI-PLAN.md 列为待办。

## 修坐标换算(我自己上一轮引入的 bug)

上一轮我加了 rawX / scaleFactor 的换算,理由是「SetCursorPos 期望 DIP」。
**那个前提是错的** —— Electron 主进程是 per-monitor DPI-aware,Win32 坐标
不做虚拟化。实测:

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

加 /scale 会把原本正确的点击改坏:200% 屏上点 (600,400) 被送到
(400,267),越靠右下偏得越远。已改回 physical ? rawX : rawX*scale,
drag 终点同步修正(原先两处换算不一致)。

易误判之处:在普通 node 进程里测同一函数读到的是 1260x840(虚拟化值),
会以为 Win32 用 DIP —— 必须在 Electron 内测。

## 判据
computeruse.test.mjs 43 条(npm test 共 127 条全绿),新增 5 条钉住
模式 A 的核心承诺:
  · agent-inject / agent-cursor 均无 SetCursorPos 调用(查前先剥注释,
    因为注释里会引用该名字解释「为什么不用它」)
  · koffi 指针参数用 Buffer(普通对象不写回,会拿错 hwnd)
  · overlay 默认开启且可切回 real
  · 自绘光标层 setIgnoreMouseEvents 穿透
核心不变量已做变异验证:注入 SetCursorPos 后判据立即转红。
另修正坐标判据方向(原判据断言 /scale 是对的,与实测相反)。

## 文档
GUI-PLAN.md          改造计划与进度的单一事实来源:已完成/待办、坐标
                     实测结论与踩坑、koffi 3.x 使用约定、端到端测试方法、
                     环境注意事项(含本机无 Go 工具链、waiter 改动未编译验证)
SERVER-HANDOFF.md    服务端需同步的部分:放开 action 白名单(schema enum
                     + switch default 双重限制,不改则 11 个新动作下发不到)、
                     透传 tox/toy/steps/dx/ms、统一 scroll 方向语义、
                     写入坐标约定防重蹈
2026-10-02 14:12:32 +08:00

292 lines
12 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` | Windows 后台注入:`WindowFromPoint` 定位、坐标换算、`PostMessage` 投递鼠标/键盘/滚轮/文本 |
### 3.3 配置
`gui-prefs.json` → `deviceBridge.agentCursor.mode`:
| 值 | 行为 |
|---|---|
| `"overlay"` | **默认**。不动用户鼠标,后台注入 |
| `"real"` | 旧行为,操作真实鼠标 |
### 3.4 已验证(端到端,8/8 有正确响应)
用假网关(自实现 WS 服务端)下发 computeruse 命令,GUI 执行并回执:
```
[error] agent-overlay: 该坐标下没有窗口(WindowFromPoint 返回空)...
[error] agent-overlay: type 需要先有点击类操作确定目标窗口
[ok] displays: [{...}] | coordinateSpace: physical-pixel (Win32 与截图像素一致, 无需换算)
[error] agent-overlay: unknown action __nope__
```
**结论:代码路径已接通,错误处理正确 —— 失败时明确报错,不静默回退到真实鼠标。**
### 3.5 ⚠️ 未完成:真实点击效果未验证
上面 7 个动作都报「该坐标下没有窗口」。原因是**当时的执行环境没有可供
注入的可见目标窗口**(`WindowFromPoint` 对所有坐标返回 null)。
**因此:「点击真的落到目标窗口上」这一步尚未被证明。** 需要在有交互桌面的
会话里跑一个端到端测试:
> 打开记事本 → agent 点它 → 输入文字 → 截图回读验证
同时验证「用户鼠标位置在全过程未变」。
### 3.6 设计取舍(明确的边界)
- **只对「接受窗口消息的程序」可靠。** 原生 Win32 程序(记事本、Excel、Office)
可靠;Chromium/Electron 自绘界面、多数游戏、DirectX 程序会忽略合成消息。
这是 Windows 消息模型的硬限制,**不是实现缺陷**。
- **不做真实鼠标降级。** 用户既然选择「不抢鼠标」,就不该偷偷改成操作真实鼠标。
失败即 `status=error` 并说明原因。
- **键盘类动作依赖先前的鼠标操作。** `keypress`/`hotkey`/`type` 不带坐标,
且后台注入不移动真实光标,所以 `GetCursorPos` 拿到的是**用户自己**的
光标位置。因此设备端记录「上次鼠标操作命中的 hwnd」,键盘动作投递到那里。
从未点过则报错。
### 3.7 跨平台状态
| 平台 | 状态 |
|---|---|
| Windows | 模式 A 已实现(效果待验证) |
| Linux | waiter 侧仍是 `xdotool`,**会动真鼠标**。未做模式 A |
| macOS | 同上 |
---
## 四、待办
### 4.1 优先:验证模式 A 的真实效果
- [ ] 端到端测试:打开真实应用 → 点它 → 输入 → 截图回读
- [ ] 断言全过程用户鼠标位置未变
- [ ] 判据:把该测试固化进 `npm test`(需能在无桌面 CI 环境跳过)
### 4.2 服务端配合(详见 `SERVER-HANDOFF.md`)
- [ ] 放开 `device.go` 的 action 白名单(schema enum + switch default 双重限制)
- [ ] 透传新参数 `tox`/`toy`/`steps`/`dx`/`ms`/`physical`
- [ ] 统一 scroll 方向语义(服务端写「正=向下」,Windows 惯例是正=向上)
- [ ] 把坐标约定写进注释(防重蹈 `/scale` 覆辙)
> ⚠ 不改白名单,则新增的 11 个动作**根本下发不到设备**。
### 4.3 设置页
- [ ] `agentCursor.mode` 的 UI(目前只能手改 `gui-prefs.json`)
### 4.4 可选增强
- [ ] mascot 待机动作、常驻屏幕边缘(借鉴桌宠的视觉层)
- [ ] Linux 侧若也要「不抢鼠标」,需 Wayland + uinput 虚拟设备
(X11 下做不到 —— XTEST 合成的是真实设备事件)
---
## 五、坐标空间:实测结论与踩坑记录 ⚠️
**结论:截图给模型的像素、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` / 真实光标相关行为
**无法在此验证**,需在有桌面的会话实测。