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

@ -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";