## 拆解 Coopanion(用户指出的参考项目)
新增 DESKTOP-PET-NOTES.md,记录 Pal-AI-Lab/Coopanion 的桌宠动作系统
(packages/cortico-world-desktop-pet):
· 4 个工具:pet_say(气泡说话,可夹带动作标记) / pet_ask(提问,异步回答) /
pet_walk_to(走到 0-1 比例或 left/center/right/cursor,含走到鼠标位置) /
pet_act(只做动作; sit/sleep 是持续态)
· 动作词表 VOCAB:10 个表情 + 13 个动作,每个词多个中文别名,
英文 id 与中文别名进同一张 Map;未知词丢弃而非报错
· 脚本标记:【词,词】= 先做动作再换新气泡;<词> = 打字到那里时做、不换气泡。
让文字与动作在时间轴上对齐 —— 实现成本低但表现力强
· 设计要点:身体是 agent 唯一可见输出通道(用户看不见消息正文);
互动事件不单独唤醒模型,跟着下批事件到
可借鉴(已写入文档待办):姿态表达状态、动作与文字对齐、持续态、
动作解析容错、待机自主行为。不建议照搬:桌宠承载全部对话输出、ASR、戳摸甩。
## 注入层改 SendInput(参考它的实现)
原 PostMessage 路径只能操作原生 Win32 程序 —— Chromium/Electron 自绘界面
与游戏会忽略合成消息。这不是「平台硬限制」,是所选 API 的限制。
Coopanion 用 SendInput 注入系统输入队列,可靠性高得多。
新增三档模式 gui-prefs.deviceBridge.agentCursor:
sendinput(默认) SendInput,可靠;会动真光标,故先等用户停手
overlay PostMessage,不动用户鼠标;只能操作原生程序
real 保留旧代码路径
另有 deferMs:用户活动时最多等多久(默认 3000,上限 10000)
★ SendInput 绝对坐标是 0–65535 归一化值,不是像素。按虚拟桌面归一化
并带 MOUSEEVENTV_VIRTUALDESK,多屏下也正确(Coopanion 只按主屏归一化)。
★ 文本用 KEYEVENTF.UNICODE 逐字符发,不受键盘布局与输入法影响(比 WM_CHAR 可靠)。
## 修「用户活动感知」把自己也算进去的 bug
GetLastInputInfo 是**全局**的 —— agent 自己的 SendInput 也被算作「用户输入」。
实测:deferMs 设 0 仍被暂缓,回执写「开始时空闲仅 0ms」,即上一条命令的
注入刚把 idleMs 清零。若不排除自身注入,会永远处于「用户正在操作」状态。
按 Coopanion 的三重判定修正:
1. ownTick 自己最后一次注入时刻,之后 40ms 内的输入忽略
2. ownCursor 自己放下的光标位置,偏离 >2px 说明是用户动的
3. 无符号差值 GetTickCount 每 2^32ms 回绕,必须按无符号比较
## 修同一条命令被执行两遍
sendinput 分支把逻辑包进 runAction 箭头函数,里面的 return 只退出它自己,
于是执行完继续 fall through 到旧 real 路径。实测 8 条命令回了 16 个
cmd_result,且真光标被额外抢一次。补上外层 return。
## 判据
computeruse.test.mjs 47 条(npm test 共 131 条全绿),新增 4 条钉住本轮
踩过的坑,均做变异验证:
· 三档模式存在(overlay/real 只在条件里,不能要求赋值形式)
· 用户活动排除自身注入;且盯 send() 里的**调用点** —— 只查函数名时
删掉调用仍会误报绿(已实测并改正)
· SendInput 坐标归一化 + VIRTUALDESK
· sendinput 分支必须 return(防双执行)
## 待办(已写入 GUI-PLAN.md)
· loadGuiPrefs 不透传 agentCursor,导致 deferMs 配置读取不生效(实测)
· 显式调 SetProcessDpiAwarenessContext(-4),不依赖 Electron 默认值
· mascot 姿态:思考/等待/出错
· 真实点击效果仍未验证(当前环境无可注入的可见窗口)
14 KiB
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 已验证(端到端,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 优先:修 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.3 设置页
agentCursor.mode的 UI(目前只能手改gui-prefs.json)
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 合成的是真实设备事件)
五、坐标空间:实测结论与踩坑记录 ⚠️
结论:截图给模型的像素、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 加过:
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:
- 起一个最小 WS 服务端(自实现 HTTP
Upgrade握手 + 帧编解码) - 改写
gui-prefs.json指向它,并置deviceBridge.authorized = true - 启动 GUI,等
hello/bind完成 - 下发
{op:"cmd", cmd_type:"homeagent", command:"computeruse {...}"} - 读取 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/ 真实光标相关行为 无法在此验证,需在有桌面的会话实测。