mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 模式 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 方向语义、
写入坐标约定防重蹈
295 lines
11 KiB
Markdown
295 lines
11 KiB
Markdown
# 服务端交接:computeruse 协议对齐
|
||
|
||
> 面向服务端接手人。本文档描述 GUI 侧已完成的改动,以及**服务端必须同步修改**的部分。
|
||
> 修改范围集中在 `internal/plugins/remotedevice/`(`device.go`)。
|
||
> GUI 侧代码与判据见 `cmd/gui/`。
|
||
|
||
---
|
||
|
||
## 一、为什么需要这份文档
|
||
|
||
GUI 侧把 `computeruse` 从 9 个基础动作扩到 **18 个**,并新增了**模式 A(agent 独立光标 + 后台注入)**。
|
||
但服务端 `device.go` 目前对 `action` 做了**两重硬限制**,导致新增能力根本下发不到设备:
|
||
|
||
1. **JSON Schema 的 `enum`**(`device.go:129`)—— 7 个值之外直接被 schema 校验拒绝
|
||
2. **`switch` 的 `default` 分支**(`device.go:471`)—— 返回「不支持的操作类型」
|
||
|
||
所以:**即使 GUI 已实现,服务端不改则新功能等于不存在。**
|
||
|
||
---
|
||
|
||
## 二、必做项 1:放开 action 白名单
|
||
|
||
### 现状
|
||
|
||
```go
|
||
// device.go:129 —— schema enum,只有 7 个
|
||
"enum": []interface{}{"click", "doubleclick", "rightclick", "move", "scroll", "keypress", "type"},
|
||
```
|
||
|
||
```go
|
||
// device.go:471 —— default 直接报错
|
||
default:
|
||
return nil, fmt.Errorf("不支持的操作类型 %s(可选 click/doubleclick/rightclick/move/scroll/keypress/type)", action)
|
||
```
|
||
|
||
### 需要改成
|
||
|
||
GUI 侧实际支持的 18 个 action(判据同源,`cmd/gui/computeruse.test.mjs` 会校验两端一致):
|
||
|
||
```
|
||
click doubleclick rightclick middleclick tripleclick
|
||
move hover drag mousedown mouseup
|
||
scroll keypress hotkey combo
|
||
type wait sleep display
|
||
```
|
||
|
||
建议在 `device.go` 里提取成单一常量,schema 与 switch 共用,避免两处漂移:
|
||
|
||
```go
|
||
// computeruseActions 是设备端支持的完整动作集合。
|
||
// ★ 必须与 cmd/gui/main.js 的 computeruse switch 保持一致;
|
||
// cmd/gui/computeruse.test.mjs 会校验 GUI 侧包含这些动作。
|
||
var computeruseActions = []string{
|
||
"click", "doubleclick", "rightclick", "middleclick", "tripleclick",
|
||
"move", "hover", "drag", "mousedown", "mouseup",
|
||
"scroll", "keypress", "hotkey", "combo", "type",
|
||
"wait", "sleep", "display",
|
||
}
|
||
```
|
||
|
||
然后:
|
||
- schema 的 `enum` 改为由 `computeruseActions` 生成
|
||
- `switch` 的 `default` 分支的错误信息里列出这份清单
|
||
|
||
### 各动作的语义(供写 tool description 时参考)
|
||
|
||
| action | 必需参数 | 说明 |
|
||
|---|---|---|
|
||
| `click` | `x`,`y` | 可选 `button`(left/right/middle) |
|
||
| `doubleclick` | `x`,`y` | |
|
||
| `rightclick` | `x`,`y` | 等价 `click` + `button:right` |
|
||
| `middleclick` | `x`,`y` | 中键 |
|
||
| `tripleclick` | `x`,`y` | 三击选中整行 |
|
||
| `move` / `hover` | `x`,`y` | 只移动不点击,用于触发 tooltip |
|
||
| `mousedown` / `mouseup` | `x`,`y` | 按下/释放分离,配对可做「按住」 |
|
||
| `drag` | `x`,`y`,`tox`,`toy` | 可选 `steps`(默认 12,上限 60) |
|
||
| `scroll` | `dy` | 可选 `dx`(横向) |
|
||
| `keypress` | `key` | 如 `ctrl+c`、`alt+f4` |
|
||
| `hotkey` / `combo` | `key` | 与 keypress 同义 |
|
||
| `type` | `text` | |
|
||
| `wait` / `sleep` | `ms` | 显式等待,等 UI/动画完成 |
|
||
| `display` | — | 返回各显示器 bounds + scaleFactor |
|
||
|
||
> ⚠ **`drag` 的分步移动不是可有可无的**:canvas 拖拽、列表排序、滑动条
|
||
> 这类应用依赖中间 `mousemove` 事件,一步到位会失效。所以 `steps` 建议透传。
|
||
|
||
---
|
||
|
||
## 三、必做项 2:透传新参数
|
||
|
||
### 现状
|
||
|
||
`device.go` 的 `switch action`(第 439 行起)只转发这些:
|
||
|
||
```go
|
||
params["x"], params["y"], params["button"]
|
||
params["dy"]
|
||
params["key"]
|
||
params["text"]
|
||
```
|
||
|
||
其余参数**被静默丢弃**,即使 LLM 传了也到不了设备。
|
||
|
||
### 需要补上
|
||
|
||
| 参数 | 哪些动作需要 | 缺失后果 |
|
||
|---|---|---|
|
||
| `tox` / `toy` | `drag` | 拖拽终点丢失,拖不动 |
|
||
| `steps` | `drag` | 退化成一步到位,拖拽排序类操作失效 |
|
||
| `dx` | `scroll` | 横向滚动不可用 |
|
||
| `ms` / `duration` | `wait` / `sleep` | 等待时长不可控 |
|
||
| `button` | `hover`/`mousedown`/`mouseup`/`tripleclick` | 中键/右键按不下 |
|
||
| `physical` | 全部坐标类 | 无法声明坐标系(见第五节) |
|
||
|
||
建议实现(保持与现有风格一致):
|
||
|
||
```go
|
||
// 透传辅助:从 args 取整数键,缺省则不写入 params。
|
||
func putInt(params map[string]interface{}, args map[string]interface{}, key string) {
|
||
if v, ok := args[key].(float64); ok {
|
||
params[key] = int(v)
|
||
}
|
||
}
|
||
```
|
||
|
||
在 `switch` 之后统一补一遍(避免每个 case 重复):
|
||
|
||
```go
|
||
for _, k := range []string{"tox", "toy", "steps", "dx", "ms", "duration", "physical"} {
|
||
putInt(params, args, k)
|
||
}
|
||
```
|
||
|
||
> ⚠ `physical` 的类型是布尔或字符串都可能,取决于 LLM 怎么填。
|
||
> 建议 schema 里显式声明 `"type": "boolean"`,并在 `putInt` 之外单独处理。
|
||
|
||
---
|
||
|
||
## 四、必做项 3:统一 scroll 方向语义 ⚠️
|
||
|
||
### 现状:两侧约定相反
|
||
|
||
**服务端**(`device.go:130`):
|
||
|
||
```go
|
||
"dy": map[string]interface{}{"type": "integer", "description": "scroll 滚动量:正=向下,负=向上"},
|
||
```
|
||
|
||
**GUI / Windows 惯例**:正 = 向上。
|
||
|
||
这不是笔误级的小问题——**方向反了会让 agent 在页面上滚到完全相反的位置**,
|
||
且因为「能滚」,不会报错,只会静默地做错事,比崩溃更难发现。
|
||
|
||
### 建议
|
||
|
||
统一为 **Windows 惯例:正 = 向上,负 = 向下**(与鼠标滚轮一致,也与 GUI 实现一致)。
|
||
改 schema description:
|
||
|
||
```go
|
||
"dy": map[string]interface{}{
|
||
"type": "integer",
|
||
"description": "scroll 垂直滚动量:正=向上滚,负=向下滚(与鼠标滚轮方向一致)。可选 dx 做横向滚动,正=向右",
|
||
},
|
||
```
|
||
|
||
GUI 侧对应实现(`cmd/gui/main.js`)已按此约定,无需改动。
|
||
|
||
> 如果服务端坚持保留「正=向下」,则必须**同步改 GUI**,否则两侧永远对不上。
|
||
> 二选一,但必须写进注释并进判据。
|
||
|
||
---
|
||
|
||
## 五、建议项:把坐标约定写进注释(防重蹈)
|
||
|
||
这条不是功能需求,而是**防止后来人再犯同一个错**——我本人就在这一轮犯过。
|
||
|
||
### 事实
|
||
|
||
**截图给模型的像素、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 坐标不做虚拟化。
|
||
|
||
### 曾经的坑
|
||
|
||
本仓曾在 `cmd/gui/main.js` 加过:
|
||
|
||
```go
|
||
absX = ox + rawX / scaleFactor // ← 错的
|
||
```
|
||
|
||
理由是「SetCursorPos 期望 DIP」——**那个前提不成立**,DIP 行为只出现在
|
||
DPI-*unaware* 进程里。加了之后反而把原本正确的点击改坏:200% 缩放屏上
|
||
点 (600,400) 会被送到 (400,267),**越靠右下偏得越远**。
|
||
|
||
(易误判之处:在普通 node 进程里测同一个函数,读到的是 1260×840,
|
||
那是虚拟化后的值,会把人带偏。必须在 Electron 内测。)
|
||
|
||
### 建议
|
||
|
||
在两处加注释:
|
||
|
||
1. `device.go` 的 `computeruse` 函数上方
|
||
2. tool schema 里 `x`/`y` 的 description
|
||
|
||
```go
|
||
// 坐标约定:x/y 为**物理像素**,与 screensee 截图坐标系一致,
|
||
// 也与设备端 Win32 坐标一致,**不需要按 scaleFactor 换算**。
|
||
// (设备端 GUI 是 per-monitor DPI-aware,Win32 坐标不做虚拟化。)
|
||
// 仅当调用方显式传 physical=false 时,设备端才按 DIP 再乘回缩放比。
|
||
```
|
||
|
||
`cmd/gui/computeruse.test.mjs` 已加判据防止 GUI 侧回退(会检测 `/scale` 是否回来了)。
|
||
**服务端侧目前无对等判据**,建议补一个,锁定 schema 里的 description 不含
|
||
「需换算」之类的误导表述。
|
||
|
||
---
|
||
|
||
## 六、模式 A(agent 独立光标 + 后台注入)需要服务端知道的事
|
||
|
||
### 行为变化
|
||
|
||
GUI 默认启用 `agentCursor.mode = "overlay"`:
|
||
|
||
- agent 操作时**不再移动用户的真实鼠标**,用户可同时正常使用电脑
|
||
- GUI 会在目标位置绘制一个 mascot 形象作为 agent 的虚拟光标,并有移动/点击脉冲动画
|
||
- 实际输入通过 `PostMessage` 直接投递到目标窗口句柄
|
||
|
||
配置项(`gui-prefs.json` → `deviceBridge.agentCursor.mode`):
|
||
|
||
| 值 | 行为 |
|
||
|---|---|
|
||
| `"overlay"` | **默认**。不动用户鼠标,后台注入 |
|
||
| `"real"` | 旧行为。操作真实鼠标(`SetCursorPos` + `mouse_event`) |
|
||
|
||
### 服务端无需改动,但要知道三条限制
|
||
|
||
1. **只对「接受窗口消息的程序」可靠。**
|
||
原生 Win32 程序(记事本、Excel、Office 等)可靠;Chromium/Electron 自绘界面、
|
||
多数游戏、DirectX 程序会忽略合成消息。这是 Windows 消息模型的硬限制,
|
||
不是实现缺陷。
|
||
|
||
2. **注入失败会明确报错,不静默回退。**
|
||
用户既然选择「不抢鼠标」,就不该偷偷改成操作真实鼠标。所以失败时
|
||
`status=error`,`error` 里说明原因(例如「该坐标下没有窗口」)。
|
||
|
||
3. **键盘类动作需要先有点击类动作。**
|
||
`keypress`/`hotkey`/`type` 不带坐标,且后台注入**不移动真实光标**,
|
||
所以 `GetCursorPos` 拿到的是**用户自己**的光标位置(通常不是 agent 要操作的地方)。
|
||
设备端记录「上一次鼠标操作命中的窗口」,键盘动作投递到那里。
|
||
**若从未点过,键盘动作会报错。** 服务端在 tool description 里说明这一依赖关系,
|
||
可以减少 LLM 调错。
|
||
|
||
---
|
||
|
||
## 七、验收方式
|
||
|
||
GUI 侧判据(服务端改完后跑):
|
||
|
||
```bash
|
||
cd cmd/gui && npm test
|
||
```
|
||
|
||
含 `computeruse.test.mjs`,会校验两端 action 覆盖面的一致性。
|
||
|
||
服务端侧建议补的判据(放在 `internal/plugins/remotedevice/`):
|
||
|
||
1. schema `enum` 覆盖全部 18 个 action
|
||
2. 每个 action 在 `switch` 里都有对应 case,不落 `default`
|
||
3. `tox`/`toy`/`steps`/`dx`/`ms` 能透传到下发 JSON(可用现有
|
||
`binary_test.go:471` 的 computeruse 下发测试作模板,参考它如何断言收到的 JSON)
|
||
|
||
---
|
||
|
||
## 八、附:本次 GUI 侧一并修掉的两个 bug(服务端可参考)
|
||
|
||
1. **GUI 误判「服务端未启动」**:原实现只探 `localhost:8080`,
|
||
服务端在 WSL/远端时探不通 → 每次启动误判服务没起 → 去拉起 GUI 旁不存在的
|
||
`homed.exe` → 干等 8s 超时。已改为按 `connections.json` 里已配置的连接探测。
|
||
(服务端侧无改动,但这条能解释历史上 GUI 日志里刷屏的
|
||
`homed failed to start within timeout`。)
|
||
|
||
2. **koffi 不认 Win32 的 `byte` 类型名**:
|
||
`keybd_event(byte bVk, ...)` 会抛
|
||
`Unknown or invalid type name 'byte'`,必须写 `uint8`。
|
||
曾导致组合键整体返回 `keybd_event unavailable`,而单键 click 不受影响、极易漏掉。
|
||
与服务端无直接关系,但若服务端将来也走 FFI 调 Win32,注意这一点。
|