mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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 方向语义、
写入坐标约定防重蹈
This commit is contained in:
291
GUI-PLAN.md
Normal file
291
GUI-PLAN.md
Normal file
@ -0,0 +1,291 @@
|
||||
# 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` / 真实光标相关行为
|
||||
**无法在此验证**,需在有桌面的会话实测。
|
||||
Reference in New Issue
Block a user