From 982eb9b1fa3b4bbdb9e9723ef4f8dacf1ddbab01 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:24:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(v1.8):=20=E7=9C=9F=E6=9C=BA=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E6=8A=93=E5=88=B0=E7=9A=84=E4=B8=A4=E4=B8=AA=20where?= =?UTF-8?q?=20=E7=9B=B8=E5=85=B3=E7=9C=9F=20bug=EF=BC=88macOS=20=E4=B8=8A?= =?UTF-8?q?=E6=B0=B8=E8=BF=9C=E6=B5=8B=E4=B8=8D=E5=87=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 皇上问"能不能连上 Windows 真机",连上后顺带做积压的真机验证,当场暴露两处: ## ① screen.find 的 where-only 被自己的入口校验拦下 v1.7 声称"control 条件可只用 where 不传 text",但只改了 gui.await 侧, screen.find 入口仍要求 text 非空 → 直连与 await 的 where-only 全部报 "text 不能为空"。修:text 与 where 至少给一个;macOS 侧明确说明 "无 UIA 等价物,where 不可用,请给 text"(而非笼统的参数错误)。 ## ② where 的 PowerShell 注入语法错误(更关键) 原实现直接把 JSON 字面量注入 PS:$where = {"enabled":true} —— PowerShell 不认这种语法(哈希表要 @{enabled=$true}),ParseError 让 **整份助手脚本**失败。真机现象:带 where 的调用一律"窗口操作失败", 不带 where 的完全正常。 修:改为 JSON 字符串 + ConvertFrom-Json 摊平成哈希表(Test-UiaWhere 依赖 .Keys, PSCustomObject 没有 Keys)。 ## ③ 补上此前"只在注释里承诺"的 note manifest 写明"OCR/image 引擎忽略 where 并带回 note",代码里没实现。 现补 note + where_applied 字段(UIA 时 true,降级时 false 并说明)。 不补的话,调用方会以为"按属性过滤过了"而误判结果。 ## 真机已确认可用的部分(无需修复) - UIA 属性读取 ✓:QQ音乐精简窗的 Text 控件读到 enabled=true, value/selected/toggle 均为 null —— **"拿不到就留 null、绝不猜"的设计在真实 第三方应用上验证成立**(Qt/Electron 类控件给不出这些属性)。 - monitor/log 能力已在被控端就绪(v1.8 构建已部署):monitor.list 返回 ok。 ## 验证 单测 250 全绿(新增 where-only 放行断言);e2e 30 全绿;7 包构建通过。 ⚠️ 两个修复都只在 Windows UIA 路径生效,**必须部署到被控端才能真机复验**。 --- apps/agent/src/capabilities/darwin.ts | 10 +++++- apps/agent/src/capabilities/window.ts | 49 +++++++++++++++++++++++---- tests/unit/gui-await.test.mjs | 8 +++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/apps/agent/src/capabilities/darwin.ts b/apps/agent/src/capabilities/darwin.ts index 8e6b28a..dcc9a6e 100644 --- a/apps/agent/src/capabilities/darwin.ts +++ b/apps/agent/src/capabilities/darwin.ts @@ -317,7 +317,15 @@ async function ensureOcrBin(): Promise { export async function macScreenFind(args: Args): Promise { assertDarwin('screen.find'); const text = args['text'] as string; - if (!text) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空'); + if (!text) { + // macOS 走 Vision OCR,**没有控件属性可读** —— where-only 无法满足, + // 明确说清原因,别让调用方以为是参数写错。 + throw new CapabilityError( + ErrorCodes.UNSUPPORTED_PLATFORM, + 'macOS 无 UIA 等价物:where 属性过滤不可用,请提供 text(OCR 按文字查找)', + { platform: 'darwin', engine: 'ocr' }, + ); + } const limit = (args['limit'] as number | undefined) ?? 20; const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0)); const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400)); diff --git a/apps/agent/src/capabilities/window.ts b/apps/agent/src/capabilities/window.ts index cc9ff4f..3a384b2 100644 --- a/apps/agent/src/capabilities/window.ts +++ b/apps/agent/src/capabilities/window.ts @@ -197,6 +197,7 @@ function Test-UiaWhere($props, $where) { 'enabled' { if ($props.enabled -ne [bool]$want) { return $false } } 'selected' { if ($null -eq $props.selected -or $props.selected -ne [bool]$want) { return $false } } 'value' { if ($props.value -eq $null -or $props.value -notlike [string]$want) { return $false } } + default { } # 未知键忽略(Node 侧已白名单校验) 'toggle' { if ($props.toggle -eq $null -or $props.toggle -notlike [string]$want) { return $false } } } } @@ -709,8 +710,20 @@ export async function screenFind(args: Args): Promise { } const text = (args['text'] as string | undefined) ?? ''; const wantImage = (args['method'] as string | undefined) === 'image'; - if (!wantImage && (typeof text !== 'string' || text.length === 0)) { - throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空(method=image 时用 template 代替)'); + // v1.8 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比 + // 「找一个叫某名字的按钮」更贴近真实意图。此前只在 gui.await 侧放开了, + // screen.find 自身的入口校验没同步,导致直连调用(以及 await 的 where-only) + // 被这里的"text 不能为空"拦下(真机 2026-10-09 验证时暴露)。 + const hasWhere = (() => { + const w = args['where']; + return typeof w === 'object' && w !== null && !Array.isArray(w) && Object.keys(w as object).length > 0; + })(); + if (!wantImage && text.length === 0 && !hasWhere) { + throw new CapabilityError( + ErrorCodes.PARAM_INVALID, + 'text 与 where 至少给一个(method=image 时用 template)', + { hint: 'where 仅 UIA 引擎生效,如 {"enabled":true}' }, + ); } const windowTitle = args['window'] as string | undefined; const controlType = args['control_type'] as string | undefined; @@ -739,10 +752,22 @@ $text = ${JSON.stringify(text)} $limit = ${Number(limit)} $ct = ${JSON.stringify(controlType ?? '')} $winTitle = ${JSON.stringify(windowTitle ?? '')} -# v1.7 属性过滤:where 只含 enabled/selected/value/toggle 四类键 -# (白名单式过滤,避免把任意参数塞进 PowerShell) -$where = ${JSON.stringify(whereArg ?? {})} -if ($where -ne $null -and $where.Count -eq 0) { $where = $null } +# v1.7 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。 +# ⚠️ 绝不能直接注入 JSON 字面量 —— PowerShell 不认 {"enabled":true} 这种语法, +# 会 ParseError 让**整份助手脚本**失败(真机 2026-10-09 验证:带 where 的调用 +# 全部报"窗口操作失败",而不带 where 的正常)。这里用字符串 + ConvertFrom-Json, +# 再摊平成哈希表(Test-UiaWhere 依赖 .Keys 遍历,PSCustomObject 没有 Keys)。 +$whereJson = ${JSON.stringify(whereArg ? JSON.stringify(whereArg) : '')} +$where = $null +if ($whereJson -ne '') { + $where = @{} + try { + ($whereJson | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $where[$_.Name] = $_.Value } + } catch { + $where = $null # JSON 不合法就当没传(业务层已在 Node 侧校验过键名) + } + if ($where -ne $null -and $where.Count -eq 0) { $where = $null } +} $out = New-Object System.Collections.ArrayList if ($winTitle -ne '') { # 指定窗口:直接在该窗口子树内找 @@ -1006,10 +1031,22 @@ $out | ConvertTo-Json -Compress await new Promise((r) => setTimeout(r, intervalMs)); last = await attempt(); } + // v1.8:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image, + // 必须**明确告知**,否则调用方会以为"按属性过滤过了"而误判结果。 + const whereIgnored = hasWhere && last.engine !== 'uia'; return { matches: last.matches, engine: last.engine, waited_ms: Date.now() - startedAt, ...(waitMs > 0 ? { wait_ms: waitMs } : {}), + ...(whereIgnored + ? { + note: + `where 已忽略:实际引擎为 ${last.engine}(无控件属性可读)。` + + '属性过滤仅在 UIA 生效;text 过滤仍然有效。', + where_applied: false, + } + : {}), + ...(hasWhere && last.engine === 'uia' ? { where_applied: true } : {}), }; } diff --git a/tests/unit/gui-await.test.mjs b/tests/unit/gui-await.test.mjs index 1afb588..375e729 100644 --- a/tests/unit/gui-await.test.mjs +++ b/tests/unit/gui-await.test.mjs @@ -106,3 +106,11 @@ test('any_of 求值语义:任一命中即算(等价于逻辑 OR)', () => { assert.ok(anyHit, '任一命中 → 整体命中'); assert.ok(![{ hit: false }, { hit: false }].some((s) => s.hit), '全不命中 → 不命中'); }); + +test('screen.find:where-only 必须放行(v1.8 修——真机验证暴露的缺口)', () => { + // 之前入口校验只认 text,导致 where-only 被"text 不能为空"拦下; + // 真机 2026-10-09 验证时抓到。此处锁住:where 非空即可,text 可省。 + assert.equal(check('screen.find', { where: { enabled: true } }).length, 0, 'where-only 应通过'); + assert.equal(check('screen.find', {}).length, 0, 'schema 层 text/where 均非必填(类型校验);「两者都缺」由**业务层**拒 —— 见真机验证'); + assert.equal(check('screen.find', { where: {} }).length, 0, 'schema 允许空 where 对象(业务层判空后拒)'); +});