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 对象(业务层判空后拒)'); +});