## 模式 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 方向语义、
写入坐标约定防重蹈
11 KiB
服务端交接:computeruse 协议对齐
面向服务端接手人。本文档描述 GUI 侧已完成的改动,以及服务端必须同步修改的部分。 修改范围集中在
internal/plugins/remotedevice/(device.go)。 GUI 侧代码与判据见cmd/gui/。
一、为什么需要这份文档
GUI 侧把 computeruse 从 9 个基础动作扩到 18 个,并新增了模式 A(agent 独立光标 + 后台注入)。
但服务端 device.go 目前对 action 做了两重硬限制,导致新增能力根本下发不到设备:
- JSON Schema 的
enum(device.go:129)—— 7 个值之外直接被 schema 校验拒绝 switch的default分支(device.go:471)—— 返回「不支持的操作类型」
所以:即使 GUI 已实现,服务端不改则新功能等于不存在。
二、必做项 1:放开 action 白名单
现状
// device.go:129 —— schema enum,只有 7 个
"enum": []interface{}{"click", "doubleclick", "rightclick", "move", "scroll", "keypress", "type"},
// 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 共用,避免两处漂移:
// 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 行起)只转发这些:
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 |
全部坐标类 | 无法声明坐标系(见第五节) |
建议实现(保持与现有风格一致):
// 透传辅助:从 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 重复):
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):
"dy": map[string]interface{}{"type": "integer", "description": "scroll 滚动量:正=向下,负=向上"},
GUI / Windows 惯例:正 = 向上。
这不是笔误级的小问题——方向反了会让 agent 在页面上滚到完全相反的位置, 且因为「能滚」,不会报错,只会静默地做错事,比崩溃更难发现。
建议
统一为 Windows 惯例:正 = 向上,负 = 向下(与鼠标滚轮一致,也与 GUI 实现一致)。 改 schema description:
"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 加过:
absX = ox + rawX / scaleFactor // ← 错的
理由是「SetCursorPos 期望 DIP」——那个前提不成立,DIP 行为只出现在 DPI-unaware 进程里。加了之后反而把原本正确的点击改坏:200% 缩放屏上 点 (600,400) 会被送到 (400,267),越靠右下偏得越远。
(易误判之处:在普通 node 进程里测同一个函数,读到的是 1260×840, 那是虚拟化后的值,会把人带偏。必须在 Electron 内测。)
建议
在两处加注释:
device.go的computeruse函数上方- tool schema 里
x/y的 description
// 坐标约定: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) |
服务端无需改动,但要知道三条限制
-
只对「接受窗口消息的程序」可靠。 原生 Win32 程序(记事本、Excel、Office 等)可靠;Chromium/Electron 自绘界面、 多数游戏、DirectX 程序会忽略合成消息。这是 Windows 消息模型的硬限制, 不是实现缺陷。
-
注入失败会明确报错,不静默回退。 用户既然选择「不抢鼠标」,就不该偷偷改成操作真实鼠标。所以失败时
status=error,error里说明原因(例如「该坐标下没有窗口」)。 -
键盘类动作需要先有点击类动作。
keypress/hotkey/type不带坐标,且后台注入不移动真实光标, 所以GetCursorPos拿到的是用户自己的光标位置(通常不是 agent 要操作的地方)。 设备端记录「上一次鼠标操作命中的窗口」,键盘动作投递到那里。 若从未点过,键盘动作会报错。 服务端在 tool description 里说明这一依赖关系, 可以减少 LLM 调错。
七、验收方式
GUI 侧判据(服务端改完后跑):
cd cmd/gui && npm test
含 computeruse.test.mjs,会校验两端 action 覆盖面的一致性。
服务端侧建议补的判据(放在 internal/plugins/remotedevice/):
- schema
enum覆盖全部 18 个 action - 每个 action 在
switch里都有对应 case,不落default tox/toy/steps/dx/ms能透传到下发 JSON(可用现有binary_test.go:471的 computeruse 下发测试作模板,参考它如何断言收到的 JSON)
八、附:本次 GUI 侧一并修掉的两个 bug(服务端可参考)
-
GUI 误判「服务端未启动」:原实现只探
localhost:8080, 服务端在 WSL/远端时探不通 → 每次启动误判服务没起 → 去拉起 GUI 旁不存在的homed.exe→ 干等 8s 超时。已改为按connections.json里已配置的连接探测。 (服务端侧无改动,但这条能解释历史上 GUI 日志里刷屏的homed failed to start within timeout。) -
koffi 不认 Win32 的
byte类型名:keybd_event(byte bVk, ...)会抛Unknown or invalid type name 'byte',必须写uint8。 曾导致组合键整体返回keybd_event unavailable,而单键 click 不受影响、极易漏掉。 与服务端无直接关系,但若服务端将来也走 FFI 调 Win32,注意这一点。