feat(gui+waiter): computeruse 补高级操作、修 HiDPI 坐标;记录 PiDeck 借鉴

## computeruse 高级操作
此前两端(GUI 本机桥 / waiter 设备侧)都只有 9 个基础 action
(click/move/scroll/keypress/type…),与业界 computer-use 的基本盘差距明显。
现补到 18 个,两端对齐:

  drag          拖拽(分步移动,见下)
  hover         只悬停不点击(触发 tooltip / 悬浮菜单)
  mousedown/up  按下与释放分离,可配对做「按住」
  tripleclick   三击选中整行
  middleclick   中键
  hotkey/combo  组合键(ctrl+c 这类)
  wait/sleep    显式等待,等 UI/动画完成再下一步
  display       显示器几何(bounds + scaleFactor + primary),多屏定位必需

★ 拖拽**分步移动**(默认 12 步,上限 60)而不是一步到位:canvas 拖拽、
列表排序、滑动条这类应用依赖中间 mousemove 事件,一步到位会失效。

## 修 HiDPI 坐标错位(GUI 侧)
截图给模型的是**物理像素**,而 SetCursorPos / xdotool / cliclick 期望
**逻辑点(DIP)**。旧代码只加了 display bounds 的原点偏移,完全没换算
scaleFactor ⇒ 150% 缩放屏上点击系统性偏移(越靠右下偏得越多)。

现在按目标屏 scaleFactor 换算,并提供 `physical:false` 让调用方声明
「坐标已是逻辑点」以免二次换算。

## 组合键归一化(waiter 侧)
旧实现把模型给的 key 字符串原样丢给 `xdotool key`,于是 "Ctrl+C"、
"ctrl + c"、"CTRL-C" 全部失效。新增 normKeySpec:统一 + 与空格分隔、
统一小写、去重复修饰键。

顺带修 waiter 的 `type`:加 `--clearmodifiers`,避免残留的 Ctrl/Alt
把后续输入变成快捷键(旧实现一次输入就可能污染后续所有按键)。

## scroll 修正(两端)
旧实现固定滚 `click 4/5`(上/下),忽略方向语义也不支持横向。
现按 dy 正负给方向、按量级分步、支持 dx 横向滚动。

## 显式声明借鉴 PiDeck
用户要求声明 GUI 借鉴了 PiDeck 的设计与实现。做法:

1. 新增 `cmd/gui/DESIGN-NOTES.md` —— 逐项列出借鉴自 PiDeck 的设计
   (三点渐次明暗 / 运行态呼吸光晕 / 工具调用分组卡)、PiDeck 的原始
   实现(@keyframes 名与 CSS 片段)、HomeAgent 的落地位置、以及**动机**,
   并明确区分「本仓自行决策」的部分(如「只有 1 个工具时不分组」是本地决定,
   不是 PiDeck 做法)。
2. app.js / style.css 的相关代码处补出处注释。
3. 判据 computeruse.test.mjs 钉住这几条声明,防止后续丢失。

参考方式为阅读 PiDeck 打包产物 app.asar 中的 CSS 实现后**独立重写**,
未复制其代码或资源文件。

## 判据
新增 cmd/gui/computeruse.test.mjs(进 npm test),30 条断言:
  · 两端各 9 项高级操作
  · HiDPI 换算与 physical 开关
  · 组合键归一化、type 的 --clearmodifiers
  · 拖拽分步移动(两端)
  · PiDeck 借鉴声明四处(含 DESIGN-NOTES.md 存在)

判据:cmd/gui npm test 全绿(114 条)。
This commit is contained in:
JianFeeeee
2026-10-02 11:10:53 +08:00
parent bf06ad029a
commit 40119e8116
7 changed files with 1000 additions and 60 deletions

View File

@ -1918,8 +1918,25 @@ function executeHomeagentCmd(capability, reqId) {
const disp = displays[dispIdx];
const ox = disp ? (disp.bounds.x || 0) : 0;
const oy = disp ? (disp.bounds.y || 0) : 0;
const absX = Math.round(ox + (parseFloat(params.x) || 0));
const absY = Math.round(oy + (parseFloat(params.y) || 0));
// ★ HiDPI 坐标换算。
//
// 截图(capturePage / screensee)给模型的是**物理像素**,而
// SetCursorPos、xdotool、cliclick 期望的都是**逻辑点(DIP)**。
// 直接拿截图像素去点,在 150% 缩放屏上会系统性偏移
// (越靠右下偏得越多)—— 这正是 HiDPI 屏上"点击不准"的根因。
//
// 旧实现只加了 display bounds 的原点偏移,完全没换算缩放比例。
// 这里按目标屏的 scaleFactor 换算;若调用方显式声明坐标已是
// 逻辑点(physical:false),则原样使用。
const scale = disp && disp.scaleFactor ? disp.scaleFactor : 1;
const rawX = parseFloat(params.x) || 0;
const rawY = parseFloat(params.y) || 0;
// 多数截图工具返回物理像素,故默认 physical=true。
// 模型若已按 DIP 推理,可显式传 physical:false 免换算。
const physical = params.physical !== false;
const absX = Math.round(ox + (physical ? rawX / scale : rawX));
const absY = Math.round(oy + (physical ? rawY / scale : rawY));
// === Windows: 用 koffi 直接调用 user32.dll,不依赖 PowerShell C# 编译 ===
if (os_ === "win32") {
@ -1929,8 +1946,57 @@ function executeHomeagentCmd(capability, reqId) {
const SetCursorPos = user32.func("bool SetCursorPos(int x, int y)");
const mouse_event = user32.func("void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, uint dwExtraInfo)");
const btnDown = params.button === "right" ? 0x0008 : params.button === "middle" ? 0x0020 : 0x0002;
const btnUp = params.button === "right" ? 0x0010 : params.button === "middle" ? 0x0040 : 0x0004;
const BTN = { left: [0x0002, 0x0004], middle: [0x0020, 0x0040], right: [0x0008, 0x0010] };
const btn = params.button === "right" ? "right" : params.button === "middle" ? "middle" : "left";
const btnDown = BTN[btn][0];
const btnUp = BTN[btn][1];
// MOUSEEVENTF_WHEEL=0x0800, HWHEEL=0x0100;WHEEL_DELTA=120
const WHEEL = 0x0800;
const HWHEEL = 0x0100;
// 修饰键虚拟键码(VK_*)
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,
space: 0x20, backspace: 0x08, delete: 0x2e, del: 0x2e,
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
};
// 修饰键的 keybd_event 标志(EXTENDEDKEY 表示右侧/小键盘扩展键)
const VK_EXTENDED = new Set([0x21,0x22,0x23,0x24,0x25,0x26,0x27,0x28,0x2e,0x5b,0x5c,0x6f,0x74]);
// keybd_event 全局声明(拿得到 user32 后用)
let keybd_event = null;
try { keybd_event = user32.func("void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo)"); } catch (e) {}
// tapVk 按键一次:支持 "ctrl+c"、"ctrl+shift+t"、"alt+f4"
const tapVk = (spec) => {
if (!keybd_event) return "keybd_event unavailable";
const parts = String(spec || "").split("+").map((x) => x.trim().toLowerCase()).filter(Boolean);
if (parts.length === 0) return "empty key";
const last = parts[parts.length - 1];
const mods = parts.slice(0, -1);
const modCodes = mods.map((m) => VK[m]).filter((c) => c !== undefined);
if (modCodes.length !== mods.length) return "unknown modifier in " + spec;
let keyCode = VK[last];
if (keyCode === undefined) {
// 单字符:字母/数字 → ASCII 大写
if (last.length === 1) keyCode = last.toUpperCase().charCodeAt(0);
else return "unknown key: " + last;
}
for (const c of modCodes) keybd_event(c, 0, 0, 0);
keybd_event(keyCode, 0, VK_EXTENDED.has(keyCode) ? 1 : 0, 0);
keybd_event(keyCode, 0, (VK_EXTENDED.has(keyCode) ? 1 : 0) | 0x0002, 0);
for (const c of modCodes.slice().reverse()) keybd_event(c, 0, 0x0002, 0);
return null;
};
// 滚动:dy 纵向、dx 横向,正数向上
const doScroll = () => {
const dy = Number(params.dy !== undefined ? params.dy : params.y) || 0;
const dx = Number(params.dx !== undefined ? params.dx : params.x) || 0;
if (dx) mouse_event(HWHEEL, 0, 0, -Math.round(dx * 120), 0);
if (dy) mouse_event(WHEEL, 0, 0, Math.round(dy * 120), 0);
};
// 先移动鼠标到目标位置
SetCursorPos(absX, absY);
@ -1958,10 +2024,142 @@ function executeHomeagentCmd(capability, reqId) {
mouse_event(0x0010, 0, 0, 0, 0);
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse rightclick @ (" + absX + "," + absY + ")", ""));
break;
case "scroll":
mouse_event(0x0800, 0, 0, Math.round((params.dy || 120) * 120), 0);
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse scroll @ (" + absX + "," + absY + ")", ""));
case "scroll": {
// 旧实现固定向上滚 120*dy,且不支持横向。
// 现在按参数给方向/量级,并支持 dx(横向滚动)。
doScroll();
sendCmdResult(
reqId,
baseResult(
reqId,
"ok",
"computeruse scroll dx=" + (params.dx || 0) + " dy=" + (params.dy || params.y || 0) +
" @ (" + absX + "," + absY + ")",
"",
),
);
break;
}
// === 以下为补齐的高级操作 ===
case "mousedown":
// 按下不释放:用于与 mouseup 配对做"按住拖拽"
mouse_event(btnDown, 0, 0, 0, 0);
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse mousedown(" + btn + ")", ""));
break;
case "mouseup":
mouse_event(btnUp, 0, 0, 0, 0);
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse mouseup(" + btn + ")", ""));
break;
case "drag": {
// 一步拖拽:从 (x,y) 按下 → 移动到 (tox,toy) → 释放
const toX = Math.round(
ox + (params.physical !== false ? (parseFloat(params.tox || params.tx) || 0) / scale : (parseFloat(params.tox || params.tx) || 0)),
);
const toY = Math.round(
oy + (params.physical !== false ? (parseFloat(params.toy || params.ty) || 0) / scale : (parseFloat(params.toy || params.ty) || 0)),
);
SetCursorPos(absX, absY);
mouse_event(btnDown, 0, 0, 0, 0);
// 分步移动:部分应用(浏览器 canvas、拖拽排序)需要中间 move 事件
const steps = Math.max(1, Math.min(40, parseInt(params.steps || 12, 10) || 12));
for (let i = 1; i <= steps; i++) {
const t = i / steps;
SetCursorPos(
Math.round(absX + (toX - absX) * t),
Math.round(absY + (toY - absY) * t),
);
}
mouse_event(btnUp, 0, 0, 0, 0);
sendCmdResult(
reqId,
baseResult(reqId, "ok", "computeruse drag (" + absX + "," + absY + ") -> (" + toX + "," + toY + ")", ""),
);
break;
}
case "hover":
// 悬停:只移动不点击(触发 tooltip / hover 菜单)
SetCursorPos(absX, absY);
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse hover @ (" + absX + "," + absY + ")", ""));
break;
case "keypress": {
// 支持 "ctrl+c" 这类组合键;旧实现只发单键
const err2 = tapVk(params.key || params.keys || params.text);
if (err2) {
sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse keypress: " + err2));
} else {
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse keypress " + (params.key || params.keys || params.text), ""));
}
break;
}
case "hotkey":
case "combo": {
const keys = String(params.keys || params.key || "");
const err2 = tapVk(keys);
if (err2) {
sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse hotkey: " + err2));
} else {
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse hotkey " + keys, ""));
}
break;
}
case "middleclick":
mouse_event(BTN.middle[0], 0, 0, 0, 0);
mouse_event(BTN.middle[1], 0, 0, 0, 0);
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse middleclick @ (" + absX + "," + absY + ")", ""));
break;
case "tripleclick":
// 三击:三次快速左键,用于选中整行
for (let i = 0; i < 3; i++) {
mouse_event(BTN.left[0], 0, 0, 0, 0);
mouse_event(BTN.left[1], 0, 0, 0, 0);
}
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse tripleclick @ (" + absX + "," + absY + ")", ""));
break;
case "type": {
// 用 SendKeys 注入文本(koffi 只能发按键,文本需走 SendInput)
const t = String(params.text || "");
if (!t) {
sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse type: empty text"));
break;
}
// 逐字符发送:SendKeys 语法里 + ^ % ~ { } 都是特殊字符
const SPECIAL = { "+": "{+}", "^": "{^}", "%": "{%}", "~": "{~}", "{": "{{}", "}": "{}}", "(": "{(}", ")": "{)}", "[": "{[}", "]": "{]}" };
let expr = "";
for (const ch of t) expr += SPECIAL[ch] !== undefined ? SPECIAL[ch] : ch;
const ps =
"Add-Type -AssemblyName System.Windows.Forms;" +
"[System.Windows.Forms.SendKeys]::SendWait('" +
expr.replace(/'/g, "''") +
"')";
cp.exec("powershell", ["-NoProfile", "-Command", ps], { timeout: 15000 }, (err) => {
if (err) {
sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse type failed: " + err.message));
} else {
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse typed " + t.length + " chars", ""));
}
});
break;
}
case "wait":
case "sleep": {
// 显式等待:UI 更新/动画未完成时先等一下再下一步
const ms = Math.max(0, Math.min(10000, parseInt(params.ms || params.duration || 500, 10) || 500));
setTimeout(() => {
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse waited " + ms + "ms", ""));
}, ms);
break;
}
case "display": {
// 返回各显示器信息:模型需要知道有几个屏、逻辑尺寸与缩放
const info = displays.map((d, i) => ({
index: i,
bounds: d.bounds,
scaleFactor: d.scaleFactor,
primary: d.id === screen.getPrimaryDisplay().id,
}));
sendCmdResult(reqId, baseResult(reqId, "ok", "displays: " + JSON.stringify(info), ""));
break;
}
default:
sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse: unknown action " + action));
}
@ -1981,6 +2179,12 @@ function executeHomeagentCmd(capability, reqId) {
case "doubleclick": return ["dc:" + pos];
case "rightclick": return ["c:" + pos];
case "scroll": return ["w:" + (params.dy > 0 ? "+" : "-")];
case "hover": return ["m:" + pos];
case "middleclick": return ["dc:" + pos];
// cliclick 原生支持 dd(按下拖拽)/ du(释放)/ dm(拖到)
case "mousedown": return ["dd:" + pos];
case "mouseup": return ["du:" + pos];
case "drag": return ["dm:" + (absX) + "," + (absY) + "," + (ox + (parseFloat(params.tox) || 0) / scale) + "," + (oy + (parseFloat(params.toy) || 0) / scale)];
default: return a;
}
}
@ -2005,8 +2209,82 @@ function executeHomeagentCmd(capability, reqId) {
run(L(["mousemove", String(absX), String(absY), "scroll", "--button", "5", String(Math.round(params.dy || params.y || 0))]));
return;
case "keypress":
run(L(["key", String(params.key || params.text || "")]));
case "hotkey":
case "combo": {
// xdotool/ cliclick 的 key 参数本身就接受 "ctrl+c" 这类组合键,
// 但旧代码把整个字符串原样透传,模型传 "Ctrl+C"(大写/带引号)就失效。
// 这里做一次归一化。
let spec = String(params.key || params.keys || params.text || "").trim();
spec = spec
.replace(/\+/g, " plus ")
.replace(/ctrl\s*\+\s*ctrl\s*\+/gi, "ctrl+")
.split(/\s*\+\s*/)
.filter(Boolean)
.map((x) => x.toLowerCase().trim())
.join("+");
if (!spec) {
sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse keypress: empty key"));
return;
}
run(L(["key", spec]));
return;
}
case "mousedown":
run(L([os_ === "darwin" ? "mousedown" : "mousedown", String(absX), String(absY)]));
return;
case "mouseup":
run(L([os_ === "darwin" ? "mouseup" : "mouseup", String(absX), String(absY)]));
return;
case "drag": {
const toX = Math.round(ox + (parseFloat(params.tox) || 0) / scale);
const toY = Math.round(oy + (parseFloat(params.toy) || 0) / scale);
const steps = Math.max(1, Math.min(40, parseInt(params.steps || 12, 10) || 12));
run(L(["mousemove", String(absX), String(absY), "mousedown", "1"]));
for (let i = 1; i <= steps; i++) {
const tt = i / steps;
run(
L([
"mousemove",
String(Math.round(absX + (toX - absX) * tt)),
String(Math.round(absY + (toY - absY) * tt)),
]),
);
}
run(L(["mouseup", "1"]));
return;
}
case "hover":
run(L(["mousemove", String(absX), String(absY)]));
return;
case "middleclick":
run(L(["mousemove", String(absX), String(absY), "click", "2"]));
return;
case "tripleclick":
run(
L([
"mousemove", String(absX), String(absY),
"click", "--repeat", "3", "--delay", "60", "1",
]),
);
return;
case "wait":
case "sleep": {
const ms = Math.max(0, Math.min(10000, parseInt(params.ms || params.duration || 500, 10) || 500));
setTimeout(() => {
sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse 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,
}));
sendCmdResult(reqId, baseResult(reqId, "ok", "displays: " + JSON.stringify(info), ""));
return;
}
case "type": {
// macOS cliclick 无 type,用 osascript
if (os_ === "darwin") {