mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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:
178
cmd/gui/main.js
178
cmd/gui/main.js
@ -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";
|
||||
|
||||
Reference in New Issue
Block a user