feat(gui): 注入改 SendInput(默认)+ 用户操作时暂缓;拆解 Coopanion 桌宠动作系统

## 拆解 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 姿态:思考/等待/出错
  · 真实点击效果仍未验证(当前环境无可注入的可见窗口)
This commit is contained in:
JianFeeeee
2026-10-02 15:27:33 +08:00
parent f0a7ce23b1
commit 57bf942474
5 changed files with 882 additions and 163 deletions

229
DESKTOP-PET-NOTES.md Normal file
View File

@ -0,0 +1,229 @@
# 桌宠动作系统:Coopanion 拆解与可借鉴点
> 来源:`Pal-AI-Lab/Coopanion`(本机 clone 于 `/tmp/coop`)
> 相关包:`packages/cortico-world-desktop-pet/`
>
> 本文只记录**桌宠的动作/表情/互动系统**,以及本仓(HomeAgent GUI)可借鉴什么。
> 输入注入层(SendInput)的对比见 `GUI-PLAN.md` 与下文第七节。
---
## 一、它是什么
一个 C 形小生物,站在屏幕底边(任务栏上沿)。**用户看得见它的身体,看不见 agent 的消息正文** ——
想让用户看到内容,只能通过它冒气泡。
这句话是整个设计的关键:
> 桌宠身体是 agent 的**唯一可见输出通道**,而不是装饰。
这和我们 GUI 的定位不同:我们的 mascot 只是「agent 光标」,不承载信息。这是一个可以借的点。
---
## 二、核心工具(4 个)
| 工具 | 作用 | 关键设计 |
|---|---|---|
| `pet_say` | 头顶冒气泡说话 | 脚本里可夹带**动作标记**,边说边做 |
| `pet_ask` | 冒提问气泡 + 最多 3 个选项 | **立即返回**,回答通过 `[回答]` 事件异步送达 |
| `pet_walk_to` | 沿屏幕底边走到某处 | 目标是 0–1 比例或 `left/center/right/cursor` |
| `pet_act` | 不说话,只做一串动作 | `sit`/`sleep` 会**持续**到下个动作 |
### 值得注意的三个设计
1. **`pet_ask` 是异步的**。agent 提完问不阻塞,用户答了再通过事件唤醒。
避免 agent 干等,也允许用户先不答。
实用细节:新问题会**替换**还没回答的旧问题;关掉不答也会回一个事件,
agent 不会永远等下去。
2. **`pet_walk_to` 支持 `cursor`** —— 走到鼠标当前横向位置。
这是个很自然的"到我这儿来"表达,不需要坐标。
3. **`sit`/`sleep` 是持续态**,其他动作是一次性。
这让 agent 能表达"待机"与"我在休息",而不只是瞬间动画。
---
## 三、动作词表(`src/script.ts` 的 `VOCAB`)
它把动作分成两类:**表情**(持续几秒后回到平常脸)与**动作**。
### 表情(expression,10 个)
| id | 中文 | 表现 |
|---|---|---|
| `neutral` | 平静 | 默认的脸 |
| `happy` | 开心 / 高兴 | 眼睛弯成 ^ ^ |
| `wink` | 眨眼 | 一只眼 ^ |
| `love` | 喜欢 / 爱心 | 眼睛变心形,冒小心心 |
| `shy` | 害羞 | 脸红,眼神躲开 |
| `surprised` | 惊讶 / 吃惊 | 眼睛放大,头顶感叹号 |
| `angry` | 生气 | 皱眉,头顶怒气符号,身体发抖 |
| `sad` | 难过 / 伤心 | 八字眉,掉眼泪 |
| `sleepy` | 犯困 / 困 | 眯眼打哈欠 |
| `thinking` | 思考 / 想想 | 眼睛往上看,头顶冒圈 |
### 动作(motion,13 个)
| id | 中文 | 表现 |
|---|---|---|
| `stand` | 站起 / 站 | 站起来(坐着、睡着时) |
| `jump` | 跳 / 跳起来 | 原地起跳 |
| `hop` | 小跳 / 蹦 | 小小蹦一下 |
| `look` | 张望 / 看看 | 左右张望 |
| `turn` | 转身 | 转向另一边 |
| `nod` | 点头 | 点两下头 |
| `shake` | 摇头 | 摇头 |
| `spin` | 转圈 | 原地转一圈 |
| `sit` | 坐下 / 坐 | **坐下,持续到下个动作** |
| `sleep` | 睡觉 / 睡 | **躺下睡觉,持续到下个动作** |
| `dizzy` | 晕 / 转晕 | 头晕眼花几秒 |
| `walk` | 走走 / 散步 | 随便走一段 |
| `run` | 跑 / 跑起来 | 跑到屏幕另一头 |
### 词表设计的可借鉴处
- **每个词有多个中文别名**(`开心`/`高兴` 同一个 id)。LLM 输出自然语言时容错率更高。
- **英文 id 与中文别名都进同一张 Map**(`BY_WORD`),不区分来源。
- **未知词被丢弃而非报错**(`dropped` 数组收集但不中断)。
对 LLM 输出的容错策略,值得学。
- 词表通过 `pet.vocab` 变量**注入到 prompt**,让模型知道能做什么 ——
而不是把 30 个动作硬编码进 tool schema 的 enum。
---
## 四、脚本标记(说明与动作混排)
`pet_say` 的 `script` 参数里可以夹带两种标记:
| 标记 | 作用 |
|---|---|
| `【词, 词】` | 先做这些动作,然后**换一个新气泡**继续说 |
| `<词>` | 打字打到这里时做,**不换气泡** |
示例(源码里的原例):
```
【开心】今天也辛苦啦!<眨眼>要不要歇一会儿?
```
这个设计解决了一个真实问题:**让文字与动作在时间轴上对齐**。
`<眨眼>` 落在句子中间,让"眨眼"发生在念到那个字的时候,而不是整句之前。
比"先做动作再说话"生动得多,实现成本却很低(解析时切分即可)。
相关实现:`parseScript` / `parseActions` / `estimateSeconds`(`src/script.ts`),
`estimateSeconds` 用于让回执报告"大约显示多久"。
---
## 五、自主行为与互动
### 待机(没有指令时)
> 没收到指令时身体会自己眨眼、张望、散步、坐下打盹;你的指令会先执行。
注意最后半句:**agent 指令优先于自主行为**。自主行为是填充,不是竞争。
### 用户互动(事件形式回给 agent)
| 事件 | 含义 |
|---|---|
| `[语音] 用户:…` | 麦克风听到并转写的话(可能同音错字) |
| `[打字] 用户:…` | 在桌宠上双击或点悬停按钮打的字 |
| `[回答] …` | 对 `pet_ask` 的回答 |
| `[互动] …` | 戳、摸、拎起来甩出去,以及落地摔晕 |
细节值得注意:
- 互动事件**不单独唤醒 agent**,"跟着下一批事件到,可能已经过去一会儿"
—— 避免用户随手一戳就把模型叫起来烧钱。
- `asleep` 状态下被摸,文案不同(`摸了摸睡着的你`)。
状态影响事件语义,而不只是影响动画。
---
## 六、对本仓(HomeAgent GUI)的可借鉴点
### 6.1 可以借鉴(成本低、收益明确)
| 点 | 现状 | 建议 |
|---|---|---|
| **动作与文字时间轴对齐**(`【】`/`<>`) | 我们的 mascot 只有移动/点击两种状态 | 若要表达"思考/等待/出错",可引入轻量标记 |
| **姿态表达状态** | 「忙碌呼吸」一种 | 可区分:思考中(转圈)、等待用户(张望)、出错(晕) |
| **持续态 vs 一次性**(`sit`/`sleep`) | 无持续态 | agent 空闲/被禁用时用持续态表达,而非静止 |
| **LLM 输出容错**(未知词丢弃不报错) | 未处理 | 动作解析应容错,不能因一个错词整条失败 |
| **待机自主行为** | 无 | 光标闲置时可轻微眨眼/呼吸,让"活着"可感知 |
### 6.2 不建议照搬
| 点 | 原因 |
|---|---|
| **桌宠承载全部对话输出** | 我们是桌面客户端,有完整聊天界面,不该退化到只能冒气泡 |
| **语音输入(ASR)** | 与本期目标无关,且引入麦克风权限问题 |
| **戳/摸/甩出去** | 需要模型 + 物理引擎,投入产出比低 |
| **`pet.vocab` 注入 prompt** | 我们没把 mascot 交给 LLM 控,动作由 computeruse 隐式驱动 |
### 6.3 一个更重要的判断
**Coopanion 的桌宠和我们的"独立光标"解决的是不同问题:**
- 它的桌宠 = **输出通道**(让用户看见 agent 想说什么)
- 我们的 mascot 光标 = **过程指示**(让用户看见 agent 在操作哪里)
两者不冲突,甚至可以合并 —— 让我们的 mascot 在 agent 思考时表现"思考"姿态,
在执行 computeruse 时表现"移动/点击"。这正是 6.1 表格里那几条的价值所在。
---
## 七、接口层对比(顺带记录,详见 GUI-PLAN.md)
它和我们在**输入注入**上的关键分歧:
| | Coopanion | 本仓(HomeAgent GUI) |
|---|---|---|
| 注入 API | `SendInput` | `SendInput`(默认)/ `PostMessage`(可选) |
| 能否操作 Chromium/游戏 | ✅ 能 | ✅ 能(sendinput 模式) |
| 是否移动真实光标 | ✅ 会移动 | ✅ 会移动(sendinput)/ ❌ 不动(overlay) |
| 用户操作时 | 等待空闲再注入 | 等待空闲再注入(**借鉴自它**) |
| 自绘光标 | ❌ 没有 | ✅ 有(mascot + 点击脉冲) |
### 从它学到的关键一条
`GetLastInputInfo` 是**全局**的 —— agent 自己的 `SendInput` 也会被算作"用户输入"。
若不排除自己的注入,`idleMs()` 每次注入后归零,于是**永远处于"用户正在操作"状态**,
所有命令都被暂缓。
它的做法(我们已照此实现)是三重判定:
1. `ownTick` —— 自己最后一次注入的时刻,之后 40ms 内的输入忽略
2. `ownCursor` —— 自己放下的光标位置,偏离 >2px 说明是用户动的
3. 无符号差值 —— `GetTickCount` 每 2^32 ms 回绕,必须按无符号比较
这个坑我们实测踩到了(`deferMs:0` 仍被暂缓),教训已写进 `agent-inject.js` 注释。
---
## 八、其他独立印证
Coopanion 独立印证了我们上一轮的两个判断:
1. **坐标就是物理像素,不要换算** —— 它注释明写:
> All coordinates are physical pixels of the primary display. The process declares
> itself per-monitor DPI aware (v2) before anything else, so no API here returns scaled values.
我们曾错误地加了 `/scaleFactor`,已改回并在判据中钉住。
2. **必须显式声明 DPI 感知** —— 它显式调用 `SetProcessDpiAwarenessContext(-4)`,
并注明 "before anything else"。我们是**依赖 Electron 默认值**,未显式设。
虽然 Electron 默认已是 per-monitor aware,但显式声明更稳。**建议补上**(见待办)。
---
## 九、待办(由本文档派生)
- [ ] `main.js` 显式调用 `SetProcessDpiAwarenessContext(-4)`,不依赖 Electron 默认值
- [ ] 修 `deferMs` 配置读取不生效的问题(实测 `deferMs:0` 仍走 3000ms 默认)
- [ ] mascot 增加姿态:思考 / 等待 / 出错(参考第三节词表)
- [ ] 动作解析容错:未知动作丢弃而非整条失败
- [ ] (可选)mascot 待机自主行为:闲置时轻微眨眼/呼吸

View File

@ -118,16 +118,25 @@ agent 一干活用户就没法用电脑。
| 文件 | 职责 |
|---|---|
| `cmd/gui/agent-cursor.js` | 透明置顶层窗口 + mascot 指针 + 移动/点击脉冲动画;记录上次命中的窗口句柄 |
| `cmd/gui/agent-inject.js` | Windows 后台注入:`WindowFromPoint` 定位、坐标换算、`PostMessage` 投递鼠标/键盘/滚轮/文本 |
| `cmd/gui/agent-inject.js` | 两种注入路径:`SendInput`(默认)与 `PostMessage`(后台);含用户活动感知 |
### 3.3 配置
`gui-prefs.json` → `deviceBridge.agentCursor.mode`:
`gui-prefs.json` → `deviceBridge.agentCursor`:
| 值 | 行为 |
|---|---|
| `"overlay"` | **默认**。不动用户鼠标,后台注入 |
| `"real"` | 旧行为,操作真实鼠标 |
| 键 | 值 | 行为 |
|---|---|---|
| `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 有正确响应)
@ -178,13 +187,25 @@ agent 一干活用户就没法用电脑。
## 四、待办
### 4.1 优先:验证模式 A 的真实效果
### 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),故「点击真的落到目标窗口上」尚未证明。
- [ ] 端到端测试:打开真实应用 → 点它 → 输入 → 截图回读
- [ ] 断言全过程用户鼠标位置未变
- [ ] 判据:把该测试固化进 `npm test`(需能在无桌面 CI 环境跳过)
- [ ] 断言:`overlay` 模式下全过程用户鼠标位置未变
- [ ] 判据:固化进 `npm test`(需能在无桌面 CI 环境跳过)
### 4.2 服务端配合(详见 `SERVER-HANDOFF.md`)
### 4.3 服务端配合(详见 `SERVER-HANDOFF.md`)
- [ ] 放开 `device.go` 的 action 白名单(schema enum + switch default 双重限制)
- [ ] 透传新参数 `tox`/`toy`/`steps`/`dx`/`ms`/`physical`
@ -197,9 +218,16 @@ agent 一干活用户就没法用电脑。
- [ ] `agentCursor.mode` 的 UI(目前只能手改 `gui-prefs.json`)
### 4.4 可选增强
### 4.4 设置页
- [ ] mascot 待机动作、常驻屏幕边缘(借鉴桌宠的视觉层)
- [ ] `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 合成的是真实设备事件)

View File

@ -1,45 +1,78 @@
// Windows 后台输入注入:不经过系统光标,直接把消息投递到目标窗口。
// Windows 输入注入:两种模式。
//
// 为什么不调 SetCursorPos:
// 那样会抢走用户的真实光标,用户在 agent 操作期间无法正常使用电脑。
// 本模块用 PostMessage 把输入直接送到目标窗口的 hwnd,系统光标纹丝不动。
// ┌─ SendInput(默认,可靠)────────────────────────────────────────┐
// │ 注入到**系统输入队列**,等价真实硬件事件。Chromium/Electron/ │
// │ 游戏等自绘界面都能接收。代价:会移动用户的真实光标。 │
// │ 参考实现:Pal-AI-Lab/Coopanion 的 cortico-world-cua/win32.ts │
// └────────────────────────────────────────────────────────────────┘
// ┌─ PostMessage(后台,不干扰用户)───────────────────────────────┐
// │ 直接投递给**某个窗口句柄**,用户鼠标纹丝不动。代价:自绘界面 │
// │ (Chromium/Electron/游戏)常忽略合成消息,点不动。 │
// └────────────────────────────────────────────────────────────────┘
//
// 已实测确认(本轮 probe-inject/electron-probe.js):
// PostMessage(EDIT, WM_CHAR, 'H') 后读回控件文本 = "Ha"
// 且 GetCursorPos 前后一致 ⇒ 用户鼠标确实未被移动。
// ★ 已实测确认(本轮):
// · SendInput 结构体尺寸 MOUSEINPUT=32 / KEYBDINPUT=24 / INPUT=40(x64),
// layout 正确,SendInput 返回 1(成功送出)。
// · GetLastInputInfo 可用,配合 GetTickCount 可算「用户空闲多久」。
// · koffi 指针参数必须传 **Buffer**;传普通 JS 对象不写回
// (GetCursorPos({x,y}) 读回仍是原值)。
//
// ★ koffi 3.x 的硬约定(踩过坑,务必遵守):
// 指针参数必须传 **Buffer**。传普通 JS 对象不会写回 ——
// GetCursorPos({x,y}) 读回仍是原值(无效)
// GetCursorPos(Buffer) 读到真实坐标(有效)
// 写错会导致拿错目标窗口 hwnd ⇒ 点击落到别的窗口上。
// ★ SendInput 绝对坐标是 **0–65535 归一化值**,不是像素 —— 最容易踩的坑。
// 本次实现按**虚拟桌面**归一化并带 MOUSEEVENTF_VIRTUALDESK,
// 因此多显示器下坐标也正确(Coopanion 只按主屏归一化)。
let koffi = null;
let u32 = null;
let k32 = null;
let cached = null;
// koffi 类型名是**全局注册**的:同进程重复注册同名 struct 会抛
// "Duplicate type name"。统一用 reg() 尝试原名、失败换 HA_ 前缀。
function reg(name, def) {
try {
return koffi.struct(name, def);
} catch (e) {
return koffi.struct("HA_" + name, def);
}
}
function lib() {
if (cached) return cached;
koffi = require("koffi");
u32 = koffi.load("user32.dll");
// ★ koffi 的类型名是**全局注册**的:同一个进程里重复 koffi.struct("POINT", ...)
// 会抛 "Duplicate type name 'POINT'"。main.js / 探针脚本可能已声明过同名结构体,
// 所以这里先试着复用,失败就换个名字注册。
let POINT;
try {
POINT = koffi.struct("POINT", { x: "long", y: "long" });
} catch (e) {
POINT = koffi.struct("HA_POINT", { x: "long", y: "long" });
}
k32 = koffi.load("kernel32.dll");
const MOUSEINPUT = reg("MOUSEINPUT", {
dx: "long", dy: "long", mouseData: "uint32_t",
dwFlags: "uint32_t", time: "uint32_t", dwExtraInfo: "uintptr_t",
});
const KEYBDINPUT = reg("KEYBDINPUT", {
wVk: "uint16_t", wScan: "uint16_t", dwFlags: "uint32_t",
time: "uint32_t", dwExtraInfo: "uintptr_t",
});
const HARDWAREINPUT = reg("HARDWAREINPUT", {
uMsg: "uint32_t", wParamL: "uint16_t", wParamH: "uint16_t",
});
const INPUT = koffi.struct(
MOUSEINPUT.name.startsWith("HA_") ? "HA_INPUT" : "INPUT",
{ type: "uint32_t", u: koffi.union({ mi: MOUSEINPUT, ki: KEYBDINPUT, hi: HARDWAREINPUT }) },
);
const LASTINPUTINFO = reg("LASTINPUTINFO", { cbSize: "uint32_t", dwTime: "uint32_t" });
const f = (sig) => u32.func(sig);
cached = {
POINT,
POINT: reg("POINT", { x: "long", y: "long" }),
INPUT,
LASTINPUTINFO,
INPUT_SIZE: koffi.sizeof(INPUT),
SendInput: f("uint32_t __stdcall SendInput(uint32_t count, " + INPUT.name + " *inputs, int size)"),
GetLastInputInfo: f("bool __stdcall GetLastInputInfo(_Inout_ " + LASTINPUTINFO.name + " *info)"),
GetTickCount: k32.func("uint32_t __stdcall GetTickCount()"),
GetSystemMetrics: f("int GetSystemMetrics(int i)"),
SetProcessDpiAwarenessContext: f("bool __stdcall SetProcessDpiAwarenessContext(intptr_t value)"),
// 指针参数一律用 void *,调用时传 Buffer
WindowFromPoint: f("void *WindowFromPoint(void *pt)"),
ChildWindowFromPointEx: f(
"void *ChildWindowFromPointEx(void *hwndParent, void *pt, uint flags)",
),
ChildWindowFromPointEx: f("void *ChildWindowFromPointEx(void *hwndParent, void *pt, uint flags)"),
GetAncestor: f("void *GetAncestor(void *hwnd, uint flags)"),
IsWindow: f("int IsWindow(void *hwnd)"),
IsWindowVisible: f("int IsWindowVisible(void *hwnd)"),
@ -49,49 +82,41 @@ function lib() {
GetCursorPos: f("bool GetCursorPos(void *pt)"),
PostMessageW: f("intptr_t PostMessageW(void *hwnd, uint msg, uintptr_t w, intptr_t l)"),
SendMessageW: f("intptr_t SendMessageW(void *hwnd, uint msg, uintptr_t w, intptr_t l)"),
// 这些用于"这类窗口吃不吃合成消息"的能力探测
GetWindowLongPtrW: f("intptr_t GetWindowLongPtrW(void *hwnd, int i)"),
};
return cached;
}
// Win32 常量
// ── Win32 常量 ─────────────────────────────────────────────────────
const GA_ROOT = 2;
const CWP_SKIPINVISIBLE = 0x0002;
const CWP_SKIPTRANSPARENT = 0x0004;
const GWL_STYLE = -16;
const GWL_EXSTYLE = -20;
const WS_EX_LAYERED = 0x00080000;
const WS_EX_TRANSPARENT = 0x00000020;
const WS_EX_NOACTIVATE = 0x08000000;
// SendInput
const INPUT_MOUSE = 0, INPUT_KEYBOARD = 1;
const ME = {
MOVE: 0x1, LEFTDOWN: 0x2, LEFTUP: 0x4, RIGHTDOWN: 0x8, RIGHTUP: 0x10,
MIDDLEDOWN: 0x20, MIDDLEUP: 0x40, WHEEL: 0x800, HWHEEL: 0x1000,
VIRTUALDESK: 0x4000, ABSOLUTE: 0x8000,
};
const KE = { EXTENDEDKEY: 0x1, KEYUP: 0x2, UNICODE: 0x4 };
const WHEEL_DELTA = 120;
// PostMessage
const WM_MOUSEMOVE = 0x0200;
const WM_LBUTTONDOWN = 0x0201;
const WM_LBUTTONUP = 0x0202;
const WM_LBUTTONDBLCLK = 0x0203;
const WM_RBUTTONDOWN = 0x0204;
const WM_RBUTTONUP = 0x0205;
const WM_MBUTTONDOWN = 0x0207;
const WM_MBUTTONUP = 0x0208;
const WM_MOUSEWHEEL = 0x020A;
const WM_MOUSEHWHEEL = 0x020E;
const WM_KEYDOWN = 0x0100;
const WM_KEYUP = 0x0101;
const WM_CHAR = 0x0102;
const WM_SYSKEYDOWN = 0x0104;
const WM_SYSKEYUP = 0x0105;
const MK_LBUTTON = 0x0001;
const MK_RBUTTON = 0x0002;
const MK_MBUTTON = 0x0010;
const MK_CONTROL = 0x0008;
const MK_SHIFT = 0x0004;
const WM_LBUTTONDOWN = 0x0201, WM_LBUTTONUP = 0x0202, WM_LBUTTONDBLCLK = 0x0203;
const WM_RBUTTONDOWN = 0x0204, WM_RBUTTONUP = 0x0205;
const WM_MBUTTONDOWN = 0x0207, WM_MBUTTONUP = 0x0208;
const WM_MOUSEWHEEL = 0x020A, WM_MOUSEHWHEEL = 0x020E;
const WM_KEYDOWN = 0x0100, WM_KEYUP = 0x0101, WM_CHAR = 0x0102;
const MK_LBUTTON = 0x0001, MK_RBUTTON = 0x0002, MK_MBUTTON = 0x0010;
const SM_XVIRTUALSCREEN = 76;
const SM_YVIRTUALSCREEN = 77;
const SM_CXVIRTUALSCREEN = 78;
const SM_CYVIRTUALSCREEN = 79;
const SM_XVIRTUALSCREEN = 76, SM_YVIRTUALSCREEN = 77;
const SM_CXVIRTUALSCREEN = 78, SM_CYVIRTUALSCREEN = 79;
// 虚拟键码(与 main.js 的 tapVk 共用同一张表)
const VK = {
ctrl: 0x11, control: 0x11, alt: 0x12, shift: 0x10, win: 0x5b, meta: 0x5b, super: 0x5b,
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b,
@ -102,13 +127,14 @@ const VK = {
const VK_EXTENDED = new Set([0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x2e, 0x5b, 0x5c, 0x6f, 0x74]);
const isExtVK = (vk) => VK_EXTENDED.has(vk);
function getSystemMetrics() {
const m = u32.func("int GetSystemMetrics(int i)");
// ── 公共小工具 ─────────────────────────────────────────────────────
function virtualScreen() {
const k = lib();
return {
x: m(SM_XVIRTUALSCREEN),
y: m(SM_YVIRTUALSCREEN),
w: m(SM_CXVIRTUALSCREEN),
h: m(SM_CYVIRTUALSCREEN),
x: k.GetSystemMetrics(SM_XVIRTUALSCREEN),
y: k.GetSystemMetrics(SM_YVIRTUALSCREEN),
w: k.GetSystemMetrics(SM_CXVIRTUALSCREEN),
h: k.GetSystemMetrics(SM_CYVIRTUALSCREEN),
};
}
@ -116,8 +142,257 @@ function hwndStr(h) {
return h ? "0x" + h.toString(16) : "(null)";
}
// 屏幕物理坐标 → 目标窗口 hwnd(含子窗口命中)
// ★ pt 必须是 Buffer
/** 屏幕坐标 → 0–65535 归一化(按虚拟桌面)。SendInput 绝对坐标用这个。 */
function toAbsolute(x, y) {
const v = virtualScreen();
const nx = Math.round(((x - v.x) * 65535) / Math.max(1, v.w - 1));
const ny = Math.round(((y - v.y) * 65535) / Math.max(1, v.h - 1));
return { nx: Math.max(0, Math.min(65535, nx)), ny: Math.max(0, Math.min(65535, ny)) };
}
// ── 用户活动感知(「用户在操作就暂缓」的核心)───────────────────────
//
// ★ 关键陷阱:GetLastInputInfo 是**全局**的 —— agent 自己的 SendInput
// 也会被算作「输入」。若不排除自己的注入,每次注入后 idleMs() 都归零,
// 于是默认永远处于「用户正在操作」状态,所有命令都被暂缓。
// (实测踩到:deferMs 设为 0 仍被暂缓,因为上一条命令刚注入过。)
//
// 参照 Pal-AI-Lab/Coopanion 的做法,用三重判定区分「自己注入」vs「用户操作」:
// 1. ownTick —— 自己最后一次注入的时刻,之后 40ms 内的输入忽略
// 2. ownCursor —— 自己放的光标位置;偏离 >2px 说明是用户动的
// 3. 无符号差值 —— GetTickCount 每 2^32 ms 回绕,必须按无符号比较
let ownTick = 0; // 自己最后一次 SendInput 的 tick
let ownCursor = null; // 自己放下的光标位置 {x,y}
let ownMovedAt = 0; // 检测到光标被外力移动的时刻
/** 无符号「a - b」,正确处理 2^32 回绕。 */
function since(a, b) {
return (a - b) >>> 0;
}
function cursorNow() {
const k = lib();
const pt = Buffer.alloc(8);
if (!k.GetCursorPos(pt)) return null;
return { x: pt.readInt32LE(0), y: pt.readInt32LE(4) };
}
/** 标记「刚刚由我们自己注入了输入」。 */
function markOwnInput() {
ownTick = lib().GetTickCount();
ownCursor = cursorNow();
}
/** 距上次**用户**输入过了多少毫秒(排除自己的注入)。无记录时返回 Infinity。 */
function idleMs() {
const k = lib();
const now = k.GetTickCount();
// 1) 若光标偏离我们自己放的位置 >2px,说明是用户移动的
const cur = cursorNow();
if (ownCursor && cur) {
if (Math.abs(cur.x - ownCursor.x) > 2 || Math.abs(cur.y - ownCursor.y) > 2) {
ownCursor = cur;
ownMovedAt = now;
}
}
// 2) 取全局最后输入时刻,并判断它是否发生在「我们自己注入」之后
const info = { cbSize: koffi.sizeof(k.LASTINPUTINFO), dwTime: 0 };
if (!k.GetLastInputInfo(info)) return Number.MAX_SAFE_INTEGER;
const last = info.dwTime;
// ownTick 之后 40ms 内的输入视为我们自己造成的(SendInput 生效有延迟)
const afterOwn =
ownTick === 0 || (since(last, ownTick) > 40 && since(last, ownTick) < 0x80000000);
let userAt = afterOwn ? last : 0;
// 3) 光标移动也算用户活动,取两者中更晚的
if (ownMovedAt && (!userAt || since(ownMovedAt, userAt) < 0x80000000)) userAt = ownMovedAt;
return userAt ? since(now, userAt) : Number.MAX_SAFE_INTEGER;
}
/** 用户是否在最近 thresholdMs 内操作过(已排除 agent 自身注入)。 */
function userIsActive(thresholdMs = 1200) {
return idleMs() < thresholdMs;
}
/**
* 等到用户停手再执行。
*
* 为什么需要:SendInput 会移动真实光标。若用户正在打字/拖拽,agent
* 插进来会抢走光标、打断输入。做法是轮询等待用户空闲,超过 maxWait
* 则放弃等待(返回 false)—— 宁可跳过本次操作,也不硬抢。
*/
async function waitForUserIdle(maxWaitMs = 3000, thresholdMs = 800) {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
if (!userIsActive(thresholdMs)) return true;
await new Promise((r) => setTimeout(r, 120));
}
return !userIsActive(thresholdMs);
}
// ── SendInput 实现 ─────────────────────────────────────────────────
const mouseEvent = (dwFlags, dx = 0, dy = 0, mouseData = 0) => ({
type: INPUT_MOUSE,
u: { mi: { dx, dy, mouseData, dwFlags, time: 0, dwExtraInfo: 0 } },
});
const keyEvent = (vk, up, extended, unicode) => ({
type: INPUT_KEYBOARD,
u: {
ki: {
wVk: unicode ? 0 : vk,
wScan: unicode ? vk : 0,
dwFlags: (up ? KE.KEYUP : 0) | (extended ? KE.EXTENDEDKEY : 0) | (unicode ? KE.UNICODE : 0),
time: 0,
dwExtraInfo: 0,
},
},
});
function send(events) {
const k = lib();
const sent = k.SendInput(events.length, events, k.INPUT_SIZE);
if (sent !== events.length) {
// 常见原因:目标窗口权限更高(UIPI 阻挡)。这必须报出来,
// 不能静默 —— 否则会表现为「命令成功但什么都没发生」。
throw new Error(
"SendInput 只送出 " + sent + "/" + events.length + " 个事件(可能被更高权限的窗口挡住)",
);
}
// 记录「这次是我们自己注入的」,避免被 idleMs() 误判为用户活动
markOwnInput();
return true;
}
const BUTTON_FLAGS = {
left: [ME.LEFTDOWN, ME.LEFTUP],
right: [ME.RIGHTDOWN, ME.RIGHTUP],
middle: [ME.MIDDLEDOWN, ME.MIDDLEUP],
};
/** 移动真实光标(SendInput)。坐标:屏幕物理像素。 */
function siMove(x, y) {
const { nx, ny } = toAbsolute(x, y);
send([mouseEvent(ME.MOVE | ME.ABSOLUTE | ME.VIRTUALDESK, nx, ny)]);
return { ok: true, x, y };
}
function siButton(x, y, action, button) {
const b = BUTTON_FLAGS[button] || BUTTON_FLAGS.left;
if (action === "move") return siMove(x, y);
siMove(x, y); // 先到位,再按键:否则会点到上一个位置
if (action === "click") {
send([mouseEvent(b[0]), mouseEvent(b[1])]);
} else if (action === "doubleclick") {
send([mouseEvent(b[0]), mouseEvent(b[1]), mouseEvent(b[0]), mouseEvent(b[1])]);
} else if (action === "tripleclick") {
send([
mouseEvent(b[0]), mouseEvent(b[1]),
mouseEvent(b[0]), mouseEvent(b[1]),
mouseEvent(b[0]), mouseEvent(b[1]),
]);
} else if (action === "mousedown") {
send([mouseEvent(b[0])]);
} else if (action === "mouseup") {
send([mouseEvent(b[1])]);
} else {
return { ok: false, error: "unsupported action " + action };
}
return { ok: true, x, y, action, button: button || "left" };
}
/**
* 拖拽:按下 → 分步移动 → 释放。
* 分步是必需的:canvas 拖拽、列表排序、滑动条依赖中间 mousemove 事件,
* 一步到位会失效。
*/
function siDrag(sx, sy, tx, ty, button, steps) {
const b = BUTTON_FLAGS[button] || BUTTON_FLAGS.left;
const n = Math.max(1, Math.min(60, steps || 12));
siMove(sx, sy);
send([mouseEvent(b[0])]);
for (let i = 1; i <= n; i++) {
const t = i / n;
siMove(sx + (tx - sx) * t, sy + (ty - sy) * t);
}
siMove(tx, ty);
send([mouseEvent(b[1])]);
return { ok: true, from: { x: sx, y: sy }, to: { x: tx, y: ty }, steps: n };
}
/**
* 滚轮。dy/dx 单位为「格」(1 格 = WHEEL_DELTA=120)。
* 约定:dy > 0 = 向上滚(与 Windows 惯例一致,也与本仓既有实现一致)。
*/
function siScroll(dy, dx) {
const events = [];
if (dy) events.push(mouseEvent(ME.WHEEL, 0, 0, (Math.round(dy) * WHEEL_DELTA) >>> 0));
if (dx) events.push(mouseEvent(ME.HWHEEL, 0, 0, (Math.round(dx) * WHEEL_DELTA) >>> 0));
if (events.length) send(events);
return { ok: true, dy: dy || 0, dx: dx || 0 };
}
/** 组合键。spec 形如 "ctrl+shift+t"。 */
function siKeyTap(spec) {
const parts = String(spec || "")
.split("+")
.map((x) => x.trim().toLowerCase())
.filter(Boolean);
if (!parts.length) return { ok: false, error: "empty key" };
const last = parts[parts.length - 1];
const mods = parts.slice(0, -1);
const codes = [];
for (const m of mods) {
const c = VK[m];
if (c === undefined) return { ok: false, error: "unknown modifier: " + m };
codes.push(c);
}
let keyCode = VK[last];
if (keyCode === undefined) {
if (last.length === 1) keyCode = last.toUpperCase().charCodeAt(0);
else return { ok: false, error: "unknown key: " + last };
}
// 按下的按正序、释放按反序 —— 与真实手指动作一致
const events = [
...codes.map((c) => keyEvent(c, false, false, false)),
keyEvent(keyCode, false, isExtVK(keyCode), false),
keyEvent(keyCode, true, isExtVK(keyCode), false),
...[...codes].reverse().map((c) => keyEvent(c, true, false, false)),
];
send(events);
return { ok: true, key: spec };
}
/**
* 文本输入:用 KEYEVENTF.UNICODE 逐字符发送。
* 比 WM_CHAR / SendKeys 可靠:不受键盘布局和输入法状态影响,
* 中文、emoji 也能进(emoji 需按 UTF-16 代理对逐个发)。
*/
function siType(text) {
const t = String(text || "");
if (!t) return { ok: false, error: "empty text" };
const events = [];
for (let i = 0; i < t.length; i++) {
const ch = t[i];
if (ch === "\n") {
events.push(keyEvent(VK.enter, false, false, false), keyEvent(VK.enter, true, false, false));
continue;
}
if (ch === "\r") continue;
if (ch === "\t") {
events.push(keyEvent(VK.tab, false, false, false), keyEvent(VK.tab, true, false, false));
continue;
}
const unit = t.charCodeAt(i); // UTF-16 code unit(代理对会被拆成两个,正好)
events.push(keyEvent(unit, false, false, true), keyEvent(unit, true, false, true));
}
if (events.length) send(events);
return { ok: true, chars: t.length };
}
// ── PostMessage 实现(后台,不干扰用户)────────────────────────────
function windowAtPoint(sx, sy) {
const k = lib();
const pt = Buffer.alloc(8);
@ -125,7 +400,6 @@ function windowAtPoint(sx, sy) {
pt.writeInt32LE(Math.round(sy), 4);
let h = k.WindowFromPoint(pt);
if (!h) return null;
// WindowFromPoint 只返回顶层窗口;对 UI 框架要下钻到真实接收消息的子窗口
const flags = CWP_SKIPINVISIBLE | CWP_SKIPTRANSPARENT;
let guard = 0;
for (;;) {
@ -136,7 +410,6 @@ function windowAtPoint(sx, sy) {
return h;
}
// 屏幕坐标 → 目标窗口客户区坐标(lParam 用的就是客户区坐标)
function toClient(hwnd, sx, sy) {
const k = lib();
const pt = Buffer.alloc(8);
@ -146,68 +419,53 @@ function toClient(hwnd, sx, sy) {
return { x: pt.readInt32LE(0), y: pt.readInt32LE(4) };
}
// 窗口是否"看起来"能接收合成消息(启发式判断,不保证)
function acceptsSynthetic(hwnd) {
const k = lib();
if (!hwnd || !k.IsWindow(hwnd)) return { ok: false, why: "invalid hwnd" };
if (!k.IsWindowVisible(hwnd)) return { ok: false, why: "window hidden" };
const ex = k.GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
// WS_EX_TRANSPARENT 的窗口通常不接收命中测试类消息
if (ex & WS_EX_TRANSPARENT) return { ok: false, why: "WS_EX_TRANSPARENT" };
return { ok: true, ex };
}
function post(hwnd, msg, w, l) {
const k = lib();
const r = k.PostMessageW(hwnd, msg, w, l);
return r !== 0;
return lib().PostMessageW(hwnd, msg, w, l) !== 0;
}
const pack = (x, y) => ((y & 0xffff) << 16) | (x & 0xffff);
function pack(x, y) {
// lParam: 低 16 位 x,高 16 位 y(signed 16-bit)
return ((y & 0xffff) << 16) | (x & 0xffff);
}
// ── 鼠标操作 ───────────────────────────────────────────────────────
const BTN_MSG = {
const PM_BTN = {
left: { down: WM_LBUTTONDOWN, up: WM_LBUTTONUP, dbl: WM_LBUTTONDBLCLK, mk: MK_LBUTTON },
right: { down: WM_RBUTTONDOWN, up: WM_RBUTTONUP, mk: MK_RBUTTON },
middle: { down: WM_MBUTTONDOWN, up: WM_MBUTTONUP, mk: MK_MBUTTON },
};
function mouseOp(sx, sy, action, opts) {
function pmMouse(sx, sy, action, opts) {
const hwnd = windowAtPoint(sx, sy);
if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空(该坐标无窗口)" };
const c = toClient(hwnd, sx, sy);
const lp = pack(c.x, c.y);
const btn = BTN_MSG[(opts && opts.button) || "left"] || BTN_MSG.left;
const post_ = (m, w) => post(hwnd, m, w, lp);
const btn = PM_BTN[(opts && opts.button) || "left"] || PM_BTN.left;
const P = (m, w) => post(hwnd, m, w, lp);
switch (action) {
case "move":
return { ok: post_(WM_MOUSEMOVE, 0), hwnd, client: c };
case "click": {
post_(btn.down, btn.mk);
const up = post_(btn.up, 0);
return { ok: up, hwnd, client: c };
}
case "doubleclick": {
post_(btn.down, btn.mk);
post_(btn.up, 0);
post_(btn.dbl, MK_LBUTTON);
const up = post_(btn.up, 0);
return { ok: up, hwnd, client: c };
}
return { ok: P(WM_MOUSEMOVE, 0), hwnd, client: c };
case "click":
P(btn.down, btn.mk);
return { ok: P(btn.up, 0), hwnd, client: c };
case "doubleclick":
P(btn.down, btn.mk); P(btn.up, 0);
P(btn.dbl, MK_LBUTTON);
return { ok: P(btn.up, 0), hwnd, client: c };
case "mousedown":
return { ok: post_(btn.down, btn.mk), hwnd, client: c };
return { ok: P(btn.down, btn.mk), hwnd, client: c };
case "mouseup":
return { ok: post_(btn.up, 0), hwnd, client: c };
return { ok: P(btn.up, 0), hwnd, client: c };
default:
return { ok: false, error: "unsupported mouse action " + action };
}
}
function tripleClick(sx, sy) {
function pmTripleClick(sx, sy) {
const hwnd = windowAtPoint(sx, sy);
if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空" };
const c = toClient(hwnd, sx, sy);
@ -216,59 +474,46 @@ function tripleClick(sx, sy) {
post(hwnd, WM_LBUTTONUP, 0, lp);
post(hwnd, WM_LBUTTONDBLCLK, MK_LBUTTON, lp);
post(hwnd, WM_LBUTTONUP, 0, lp);
return { ok: true, hwnd, client: c };
return { ok: true, hwnd };
}
function drag(sx, sy, tx, ty, opts) {
function pmDrag(sx, sy, tx, ty, opts) {
const hwnd = windowAtPoint(sx, sy);
if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空" };
const btn = BTN_MSG[(opts && opts.button) || "left"] || BTN_MSG.left;
const btn = PM_BTN[(opts && opts.button) || "left"] || PM_BTN.left;
const steps = Math.max(1, Math.min(60, (opts && opts.steps) || 12));
const c0 = toClient(hwnd, sx, sy);
post(hwnd, btn.down, btn.mk, pack(c0.x, c0.y));
// 分步移动:部分应用(canvas 拖拽、排序、滑块)依赖中间 move 事件
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const c = toClient(hwnd, sx + (tx - sx) * t, sy + (ty - sy) * t);
post(hwnd, WM_MOUSEMOVE, btn.mk, pack(c.x, c.y));
}
const cEnd = toClient(hwnd, tx, ty);
const up = post(hwnd, btn.up, 0, pack(cEnd.x, cEnd.y));
return { ok: up, hwnd, steps };
const ce = toClient(hwnd, tx, ty);
const ok = post(hwnd, btn.up, 0, pack(ce.x, ce.y));
return { ok, hwnd, steps };
}
// 滚轮:lParam 用**屏幕**坐标,不是客户区坐标
function scroll(sx, sy, dy, dx) {
function pmScroll(sx, sy, dy, dx) {
const hwnd = windowAtPoint(sx, sy);
if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空" };
const lp = pack(Math.round(sx), Math.round(sy));
const lp = pack(Math.round(sx), Math.round(sy)); // 滚轮用屏幕坐标
let ok = true;
if (dy) ok = post(hwnd, WM_MOUSEWHEEL, deltaWord(Math.round(dy)), lp) && ok;
if (dx) ok = post(hwnd, WM_MOUSEHWHEEL, deltaWord(Math.round(dx)), lp) && ok;
if (dy) ok = post(hwnd, WM_MOUSEWHEEL, (Math.round(dy) * WHEEL_DELTA) >>> 0, lp) && ok;
if (dx) ok = post(hwnd, WM_MOUSEHWHEEL, (Math.round(dx) * WHEEL_DELTA) >>> 0, lp) && ok;
return { ok, hwnd };
}
// 把像素量换算成滚轮"格"(WHEEL_DELTA=120)
function deltaWord(v) {
const clicks = Math.round(v / 120) || (v > 0 ? 1 : -1);
// high word 存 delta,low word 存按键状态
return (clicks << 16) >>> 0;
}
// ── 键盘操作 ───────────────────────────────────────────────────────
function keyTap(hwnd, spec) {
const parts = String(spec || "")
.split("+")
.map((x) => x.trim().toLowerCase())
.filter(Boolean);
function pmKeyTap(hwnd, spec) {
const parts = String(spec || "").split("+").map((x) => x.trim().toLowerCase()).filter(Boolean);
if (!parts.length) return { ok: false, error: "empty key" };
const last = parts[parts.length - 1];
const mods = parts.slice(0, -1);
const modCodes = [];
const codes = [];
for (const m of mods) {
const c = VK[m];
if (c === undefined) return { ok: false, error: "unknown modifier: " + m };
modCodes.push(c);
codes.push(c);
}
let keyCode = VK[last];
if (keyCode === undefined) {
@ -276,20 +521,14 @@ function keyTap(hwnd, spec) {
else return { ok: false, error: "unknown key: " + last };
}
let ok = true;
for (const c of modCodes) {
ok = post(hwnd, WM_KEYDOWN, c, 0) && ok;
}
for (const c of codes) ok = post(hwnd, WM_KEYDOWN, c, 0) && ok;
ok = post(hwnd, WM_KEYDOWN, keyCode, isExtVK(keyCode) ? 1 : 0) && ok;
ok = post(hwnd, WM_KEYUP, keyCode, isExtVK(keyCode) ? 1 : 0) && ok;
for (const c of modCodes.slice().reverse()) {
ok = post(hwnd, WM_KEYUP, c, 0) && ok;
}
return { ok, mods: modCodes, key: keyCode };
for (const c of codes.slice().reverse()) ok = post(hwnd, WM_KEYUP, c, 0) && ok;
return { ok, key: spec };
}
// 文本注入:走 WM_CHAR,不做输入法合成,因此对原生控件最可靠。
// 换行/回车单独发 VK_RETURN,否则 WM_CHAR 的 \n 多数控件不认。
function typeText(hwnd, text) {
function pmTypeText(hwnd, text) {
const t = String(text || "");
if (!t) return { ok: false, error: "empty text" };
let ok = true;
@ -318,18 +557,43 @@ module.exports = {
return false;
}
},
// 坐标/桌面
virtualScreen,
toAbsolute,
hwndStr,
// 用户活动感知
idleMs,
userIsActive,
waitForUserIdle,
markOwnInput,
cursorNow,
// SendInput(默认路径)
si: {
move: siMove,
button: siButton,
drag: siDrag,
scroll: siScroll,
key: siKeyTap,
type: siType,
},
// PostMessage(后台路径)
pm: {
mouse: pmMouse,
tripleClick: pmTripleClick,
drag: pmDrag,
scroll: pmScroll,
key: pmKeyTap,
type: pmTypeText,
},
// 兼容旧名(PostMessage)
windowAtPoint,
toClient,
acceptsSynthetic,
getSystemMetrics,
hwndStr,
// 鼠标
mouse: mouseOp,
tripleClick,
drag,
scroll,
// 键盘
keyTap,
typeText,
mouse: pmMouse,
tripleClick: pmTripleClick,
drag: pmDrag,
scroll: pmScroll,
keyTap: pmKeyTap,
typeText: pmTypeText,
VK,
};

View File

@ -194,9 +194,41 @@ check(
"指针参数未用 Buffer ⇒ WindowFromPoint 拿不到真实 hwnd,点击会落错窗口",
);
check(
"★ overlay 默认开启且可切回 real",
/cursorMode = "overlay"/.test(main) && /cursorMode = "real"/.test(main),
"未提供 overlay/real 切换",
"★ 注入模式默认 sendinput,且可切 overlay/real",
// overlay/real 只出现在条件判断里(不是赋值),不能要求 "cursorMode = \"overlay\""
/let cursorMode = "sendinput"/.test(main) &&
/_ac\.mode === "overlay" \|\| _ac\.mode === "real"\) cursorMode = _ac\.mode/.test(main),
"未提供 sendinput/overlay/real 三档切换",
);
check(
"★ 用户活动感知排除自身注入(ownTick/ownCursor)",
// GetLastInputInfo 是全局的,agent 自己的 SendInput 也会被算作「用户输入」。
// 不排除则永远判定「用户正在操作」,所有命令被暂缓(实测踩到)。
/ownTick/.test(injectSrc) && /ownCursor/.test(injectSrc),
"未排除自身注入 ⇒ 永远处于「用户正在操作」状态",
);
check(
"★ send() 成功后必须记录自身注入(否则 idleMs 归零)",
// 这个断言必须盯**调用点**而不是函数名:只检查 /markOwnInput/ 时,
// 把 send() 里的调用删掉仍会误报绿(已用品变异验证)。
// function send(...) { ...; markOwnInput(); return true; }
/function send\(events\)[\s\S]{0,600}?markOwnInput\(\);[\s\S]{0,60}?return true;/.test(
injectSrc,
),
"send() 内未调用 markOwnInput ⇒ 自己的注入会被误判为用户操作",
);
check(
"★ SendInput 绝对坐标按虚拟桌面归一化到 0–65535",
// 像素直接传给 SendInput 会落在错误位置(它要的是归一化值)。
/65535/.test(injectSrc) && /VIRTUALDESK/.test(injectSrc),
"绝对坐标未归一化,或未带 VIRTUALDESK(多屏会错位)",
);
check(
"★ sendinput 分支结束后必须 return(防双执行)",
// runAction 里的 return 只退出它自己;少了外层 return 会 fall through
// 到旧 real 路径 —— 实测 8 条命令回了 16 个结果。
/startDefer\(\(proceed\)[\s\S]{0,400}?\n\s+return;\n\s*\} catch \(e\) \{/.test(main),
"缺少外层 return,同一条命令会被执行两遍",
);
check(
"自绘光标层不吃点击(setIgnoreMouseEvents)",

View File

@ -1423,6 +1423,15 @@ function sendCmdResult(a, b) {
}
}
// 安全设置自绘光标的「忙碌」呼吸态(agentCursor 可能未就绪)
function agentCursorBusySafe(dispIdx, busy) {
try {
if (agentCursor && typeof agentCursor.agentCursorBusy === "function") {
agentCursor.agentCursorBusy(busy, dispIdx);
}
} catch (e) {}
}
function baseResult(reqId, status, output, error) {
return {
op: "cmd_result",
@ -1948,17 +1957,33 @@ function executeHomeagentCmd(capability, reqId) {
}
const action = params.action || "click";
// ★ 模式 A:agent 独立光标 + 后台注入。
// ★ 注入模式:sendinput(默认)/ overlay / real
//
// 取值 "overlay"(不动用户鼠标)/ "real"(旧行为,操作真实鼠标)。
// 未配置时默认 "overlay" —— 用户明确要求不要抢鼠标。
let cursorMode = "overlay";
// sendinput SendInput 注入到系统输入队列。等价真实硬件事件,
// Chromium/Electron/游戏等自绘界面都能接收 —— 可靠。
// 代价:会移动用户的真实光标,故操作前先等用户停手。
// 参考 Pal-AI-Lab/Coopanion 的 cortico-world-cua。
// overlay PostMessage 直投窗口句柄。用户鼠标纹丝不动,但自绘
// 界面常忽略合成消息 ⇒ 只能操作原生 Win32 程序。
// real 同上但用旧代码路径(保留以兼容)。
//
// 默认 sendinput:用户要求「可靠 + 用户正在操作则暂缓」。
let cursorMode = "sendinput";
try {
const _p = loadGuiPrefs();
const _ac = (_p && _p.deviceBridge && _p.deviceBridge.agentCursor) || {};
if (_ac.mode === "real") cursorMode = "real";
if (_ac.mode === "overlay" || _ac.mode === "real") cursorMode = _ac.mode;
} catch (e) {}
const injOk = agentInject.available();
const useOverlay = cursorMode === "overlay" && injOk;
const useSendInput = cursorMode === "sendinput" && injOk;
// 用户活动暂缓:等用户停手再注入(毫秒)。0 = 不等待。
let deferMs = 3000;
try {
const _p2 = loadGuiPrefs();
const _ac2 = (_p2 && _p2.deviceBridge && _p2.deviceBridge.agentCursor) || {};
if (typeof _ac2.deferMs === "number") deferMs = Math.max(0, Math.min(10000, _ac2.deferMs));
} catch (e) {}
const useOverlay = cursorMode === "overlay" && agentInject.available();
// 把内部参数提到 action 分支之前共用
const cuNumber = (v, dflt) => {
@ -2019,6 +2044,147 @@ function executeHomeagentCmd(capability, reqId) {
const absX = Math.round(ox + (physical ? rawX : rawX * scale));
const absY = Math.round(oy + (physical ? rawY : rawY * scale));
// === 模式 sendinput:SendInput 注入 + 用户在操作则暂缓 ===
//
// SendInput 会动真实光标,因此注入前先等用户停手(deferMs 上限)。
// 等不到就跳过本次操作并明确报错 —— 宁可失败,也不打断用户。
if (os_ === "win32" && useSendInput) {
try {
const busy = () => agentCursorBusySafe(dispIdx, true);
const idle = () => agentCursorBusySafe(dispIdx, false);
const fin = (ok, msg) => {
idle();
sendCmdResult(reqId, baseResult(reqId, ok ? "ok" : "error", msg, ok ? "" : msg));
};
const fail = (m) => fin(false, m);
// 等待用户停手
//
// 注意:executeHomeagentCmd 是**同步函数**,不能 await。
// 故改为「同步查询 + 轮询回调」:先看用户是否在操作;若是,
// 定时轮询到空闲或超时再继续;全程不阻塞主进程。
const startDefer = (cb) => {
if (deferMs <= 0 || !agentInject.userIsActive(1200)) return cb(true);
const before = agentInject.idleMs();
const t0 = Date.now();
const tick = () => {
if (!agentInject.userIsActive(800)) return cb(true);
if (Date.now() - t0 >= deferMs) {
idle();
sendCmdResult(
reqId,
baseResult(
reqId,
"error",
"",
"用户正在操作(已等待 " + deferMs + "ms,开始时空闲仅 " + before +
"ms)。为避免抢走光标已跳过本次操作。可在设置里调整 agentCursor.deferMs。",
),
);
return cb(false);
}
setTimeout(tick, 120);
};
tick();
};
const runAction = () => {
const btnName = params.button === "right" ? "right" : params.button === "middle" ? "middle" : "left";
const toX = () => Math.round(ox + (physical ? (parseFloat(params.tox || params.tx) || 0) : (parseFloat(params.tox || params.tx) || 0) * scale));
const toY = () => Math.round(oy + (physical ? (parseFloat(params.toy || params.ty) || 0) : (parseFloat(params.toy || params.ty) || 0) * scale));
// 自绘光标同步到目标位置(视觉指示 agent 在做什么)
const needsXY = ["click", "doubleclick", "rightclick", "middleclick", "tripleclick",
"mousedown", "mouseup", "move", "hover", "drag", "scroll"].includes(action);
if (needsXY) agentCursor.agentCursorMove(absX, absY, dispIdx);
busy();
switch (action) {
case "move":
case "hover": {
const r = agentInject.si.move(absX, absY);
return fin(r.ok, "sendinput " + action + " @ (" + absX + "," + absY + ")");
}
case "click":
case "rightclick":
case "middleclick":
case "doubleclick":
case "tripleclick":
case "mousedown":
case "mouseup": {
const btn = action === "rightclick" ? "right" : action === "middleclick" ? "middle" : btnName;
const act = action === "rightclick" || action === "middleclick" ? "click" : action;
const r = agentInject.si.button(absX, absY, act, btn);
if (act === "click" || act === "doubleclick" || act === "tripleclick") {
agentCursor.agentCursorClick(dispIdx);
}
return fin(r.ok, "sendinput " + action + " @ (" + absX + "," + absY + ")");
}
case "drag": {
const steps = Math.max(1, Math.min(60, parseInt(params.steps || 12, 10) || 12));
const r = agentInject.si.drag(absX, absY, toX(), toY(), btnName, steps);
agentCursor.agentCursorMove(toX(), toY(), dispIdx);
agentCursor.agentCursorClick(dispIdx);
return fin(r.ok, "sendinput drag (" + absX + "," + absY + ") -> (" + toX() + "," + toY() + ")");
}
case "scroll": {
const dy = cuNumber(params.dy !== undefined ? params.dy : params.y, 0);
const dx = cuNumber(params.dx !== undefined ? params.dx : params.x, 0);
// GUI 侧约定:dy 正=向上。SendInput 的 WHEEL 正值即向上,直接传。
const r = agentInject.si.scroll(dy, dx);
return fin(r.ok, "sendinput scroll dx=" + dx + " dy=" + dy);
}
case "keypress":
case "hotkey":
case "combo": {
const keys = String(params.keys || params.key || params.text || "");
const r = agentInject.si.key(keys);
return fin(r.ok, "sendinput keypress " + keys);
}
case "type": {
const r = agentInject.si.type(String(params.text || ""));
return fin(r.ok, "sendinput typed " + r.chars + " chars");
}
case "wait":
case "sleep": {
const ms = Math.max(0, Math.min(10000, parseInt(params.ms || params.duration || 500, 10) || 500));
idle();
setTimeout(() => sendCmdResult(reqId, baseResult(reqId, "ok", "sendinput waited " + ms + "ms", "")), ms);
return;
}
case "display": {
const info = displays.map((d, i) => ({
index: i, bounds: d.bounds, scaleFactor: d.scaleFactor,
primary: d.id === screen.getPrimaryDisplay().id,
}));
const v = agentInject.virtualScreen();
idle();
sendCmdResult(reqId, baseResult(reqId, "ok",
"displays: " + JSON.stringify(info) +
" | virtualScreen: " + JSON.stringify(v) +
" | inputMode: sendinput | coordinateSpace: physical-pixel", ""));
return;
}
default:
return fail("unknown action " + action);
}
};
startDefer((proceed) => { if (proceed) runAction(); });
// ★ 必须在这里 return。
//
// runAction 里的 `return fin(...)` 只退出 runAction,不会退出
// executeHomeagentCmd。少了这句,执行完 sendinput 后会继续往下
// fall through 到旧的 real 鼠标路径 —— 同一条命令被彻底执行两遍
// (实测:8 条命令回了 16 个 cmd_result,且真光标被额外抢一次)。
return;
} catch (e) {
try { agentCursorBusySafe(dispIdx, false); } catch (e2) {}
console.error("[sendinput] failed: " + e.message);
sendCmdResult(reqId, baseResult(reqId, "error", "", "sendinput: " + e.message));
return;
}
}
// === 模式 A:后台注入(PostMessage 到目标窗口),不动用户真实鼠标 ===
if (os_ === "win32" && useOverlay) {
const btnName = params.button === "right" ? "right" : params.button === "middle" ? "middle" : "left";