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

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)",