跳到主要内容

动作落地引擎:AX 优先的点击级联与输入合成(最深一章)

30 秒导读: 模型看完一屏 UI,吐出一句"点第 12 号元素"或"点 (340, 512)"。本章讲的就是这句话怎么变成一次真实点击——而且尽量不去动物理鼠标:先用系统无障碍(Accessibility,下称 AX)接口直接对控件发"按下/确认/打开"这种语义动作,只有当所有语义路径都失败,才退回去合成真的指针事件。这是整个项目工程含量最高的一章。

本章是 Open Codex Computer Use 系列的第 3 章。前置背景: 01-state-snapshot.md 讲每回合怎么把 UI 拍成"无障碍树 + 截图"的快照,本章的输入(ElementRecordAppSnapshot)就来自那里; 02-mcp-tools-surface.md 讲对外的 9 个工具,本章是 click/type_text/scroll/drag/press_key/set_value 这几个工具的内部实现


1. 这是什么(零基础也能懂)

一句话定义

动作落地(action grounding) = 把模型的抽象意图("点这个按钮")翻译成一次能真正触发 UI 反应的操作。

为什么这事很难

难点从来不是"调用模型",而是**"把模型说的那段话,精确、可靠地落到某个真实控件上"**。

一个按钮在屏幕上,你看到的是像素;可程序要触发它,有两条完全不同的路子:

  • 物理路子: 把鼠标移到那个像素,合成"按下—抬起"事件。简单,但侵入——它真的会抢走你的鼠标、可能点错、后台窗口点不到。
  • 语义路子: 绕过鼠标,直接问系统"这个控件叫什么、能干什么",然后对它发一个"按下(AXPress)"的抽象命令。控件自己去响应,鼠标一动不动。

本项目的第一性原理:非侵入优先

先用语义化 AX 动作,物理指针只作兜底。

这句话是本章所有代码的总纲。它带来的直接好处:

好处为什么
后台也能操作AX 动作不要求窗口在最前、不抢鼠标
更准直接命中控件对象,不受坐标换算/缩放误差影响
更安全默认根本不发全局物理事件,除非你显式打开开关

用起来什么样

对外就是一次工具调用(见 02),两种点法二选一:

# 按快照里的元素编号点(首选,最准)
click(app="Notes", element_index="12")

# 按截图像素坐标点(模型只会"看图指点"时的退路)
click(app="Notes", x=340, y=512)

这两种入参,进到 ComputerUseService.click 后走的是两条不同的落地路径——下一节讲。


2. 顶层全景(一次点击怎么转)

两条入口路径

click 一进来就按参数分叉(ComputerUseService.swift:367-479,ComputerUseService.click):

入参走哪条拿谁去点关键区别
element_index编号路径快照里那条 ElementRecord允许"就近命中测试"和"激活兜底"
x / y坐标路径坐标处命中的候选元素列表允许就近扩散、允许激活兜底

两条路径最后都调用同一个核心 performAXClickSequence,只是把 includeNearbyHitTesting / allowActivationFallback 两个开关设得不一样(编号路径两个都为 true,坐标路径都为 false)。为什么坐标路径更保守?因为坐标本身就模糊——再往四周扩散命中,极易点错;编号路径已经锁定了具体控件,可以更激进地找它的可点子孙。

一次点击的分层(从上到下)

click(工具入口)
│ 按 element_index / x,y 分叉

performAXClickSequence ← 语义层核心:多级 AX 动作级联
│ 逐级尝试:选中列表项 → Press → Confirm → Open
│ → 扫子孙 → 就近命中 → 含它的 web 行 → 激活
│ 任一级成功即返回 true,整个链条命中即停
▼ (全部失败,返回 false)
performNonAXClickFallback ← 物理层兜底:合成指针事件
│ 默认:clickTargeted(postToPid 定向,只发给目标进程)
│ 开关开:clickGlobally(cghidEventTap 全局,真抢鼠标)

InputSimulation ← 真正造 CGEvent 的地方

怎么读这张图: 从上往下是"越来越暴力"的降级顺序。上面是"跟控件对象讲道理"(语义),下面是"直接合成硬件事件"(物理)。命中即停,绝大多数点击在语义层就解决了,根本走不到物理层。

部件一句话职责

部件干什么在哪
ComputerUseService.click工具入口、路径分叉、驱动视觉光标ComputerUseService.swift:367
performAXClickSequenceAX 语义点击的多级 fallback 级联ComputerUseService.swift:805
performPreferredClick对单个控件按 press/confirm/open 顺序试ComputerUseService.swift:699
performNonAXClickFallback语义全失败后合成物理指针事件ComputerUseService.swift:1647
InputSimulation底层造 CGEvent:定向 vs 全局InputSimulation.swift:45
KeyPressParser"cmd+shift+a" 解析成键码+修饰键KeyMapping.swift:16

3. 核心机制一:AX 点击级联(本章的心脏)

3.1 它要解决的小问题

模型说"点第 12 号元素",但第 12 号可能是一段纯文本(没有任何可点动作)、可能是个外壳容器(真正能点的是它的子节点)、也可能它自己就是个规规矩矩的按钮。你不能只对它硬发一个 AXPress 就完事——很多时候会失败。于是要有一条降级级联:从"最像它本身"一路退到"它周围/它的容器/纯激活"。

3.2 单控件怎么点:performPreferredClick

级联的每一格,最终都落到 performPreferredClick(ComputerUseService.swift:699)——对一个控件,按固定优先级试几种语义动作:

// ComputerUseService.swift:705-722(节选,真实源码)
if clickCount <= 1,
!hasAncestorRole("AXWebArea", of: element),
try selectContainingListItem(for: element) { return true } // ① 先试"选中所在列表项"
if try performAction(named: kAXPressAction ...) { return true } // ② AXPress
if try performAction(named: kAXConfirmAction ...) { return true }// ③ AXConfirm
if try performAction(named: "AXOpen" ...) { return true } // ④ AXOpen

四步的意图:

动作什么时候有用
selectContainingListItem侧边栏/列表:选中某一行(设 AXSelectedChildren),而不是"按"它。跳过 web 区域和多击
AXPress最标准的"按下",按钮/链接绝大多数吃这个
AXConfirm有些控件用"确认"语义(如某些输入框回车行为)
AXOpen文件/项目"打开"语义;还特判了 attributeUnsupported 也算成功(:904)

右键走的是另一支:只试 AXShowMenu(:725);中键无 AX 动作可发,直接返回 false 落到物理层。

3.3 五级降级级联:整条链

performAXClickSequence(:805)把上面的"点单个控件"包成一条多级 fallback 流水。这是全章最该看懂的一张图:

performAXClickSequence(on: record)

├─0 含 web 行特判? shouldPreferContainingWebRowAXClick == true
│ └─ 是 → performContainingWebRowClick ── 命中即停 ✓ (Electron/Lark 专用,见 3.5)

│ (以下是常规链,仅当没走 web 行特判)
├─1 首选目标 performPreferredClick(on: record) ── 命中即停 ✓

├─2 扫它的子孙 descendantClickCandidates(for: record) ── 逐个 performPreferredClick,命中即停 ✓
│ (深度≤3,按 clickPriority 排序,过滤掉"疑似副动作")

├─3 就近命中测试 (仅 includeNearbyHitTesting=true,即编号路径)
│ 对 record 里的几个点做 hitTest / bestElement:
│ 3a 命中的元素本身 performPreferredClick(hitRecord) ── 命中即停 ✓
│ 3b 命中元素的子孙 descendantClickCandidates(hitRecord) ── 命中即停 ✓
│ (先过 shouldScanDescendantsOfHitRecord 防止命中巨块)

└─4 激活兜底 (仅 allowActivationFallback=true 且是窗口角色 AXWindow 且非合成文本)
activateClickTarget: AXRaise / 设 AXMain / 设 AXFocused ── 命中即停 ✓

全部失败 → 返回 false → 上层落到 performNonAXClickFallback(物理层)

怎么读这张图: 从上到下是降级顺序,任一级 performPreferredClick 返回 true 就整个 return true、后面都不跑。层级越靠下,越是"死马当活马医"——第 4 级的"激活"甚至不点任何东西,只是把窗口举到前面/设为主窗口,算作"至少让它响应了"。

各级真实锚点:

符号
0performContainingWebRowClickComputerUseService.swift:816-821
1performPreferredClick(on: record):824
2descendantClickCandidates:830
3hitTestElement / bestElement 循环:838-868
4activateClickTarget:882

3.4 子孙候选怎么排:clickPriority

第 2/3 级要"扫子孙找一个能点的",子孙可能有好几个,谁先试?靠 clickPriority(:997)打分,分低者优先:

优先级判据含义
0AXPress/AXConfirm/AXShowMenu/AXRaise 动作明确可点,最优先
1AXMainAXFocused 可设至少能聚焦/激活
2其它兜底

同优先级再按面积从小到大排(frameArea,:1016)——小控件通常是更精确的目标(一个具体按钮 vs 一大块容器)。这条"面积小者优先"在 bestElement(:962)选坐标命中元素时也用同一套逻辑。

3.5 Electron / Lark(飞书)特判:含它的 web 行

问题背景: 在 Electron 套壳的 App(尤其飞书 Lark)里,一行列表项在无障碍树里常常是"一段 AXStaticText 文字",文字本身没有 press 动作;真正可点的是包着这段文字的那一整行(某个祖先容器)。直接点文字点不动。

判定入口 shouldPreferContainingWebRowAXClick(:1107)→ 自由函数 shouldPreferContainingWebRowAXClickCandidate(:327),同时满足才开这条特判:

  • AXWebArea 祖先(hasWebAreaAncestor);
  • App 命中 isElectronScopedWebRowClickOptimizationTarget(:307)——bundle id 含 electron / lark / feishu,或应用名是 lark/feishu/飞书;
  • 角色是 AXStaticText / AXGroup,或是合成文本。

命中后 performContainingWebRowClick(:1123)从该文本向上找最多 6 层祖先,对第一个满足 isLikelyContainingRowActionFrame(:257,即"几乎包住目标中心、宽高≥目标、又没高到离谱"、且自身有 press 动作)的祖先发 AXPress。这就把"点这段字"翻译成了"按这一行"。

3.6 抑制误点:isLikelySyntheticSideAction

问题背景: 扫子孙时,一行的右侧常有个小按钮——"完成"、"archive"、"标记完成"。模型本意是点整行,若级联手滑点到这个右侧小按钮,就会误触"归档/完成"这种破坏性副动作

isLikelySyntheticSideAction(:1089)→ 自由函数 isLikelySyntheticSideActionCandidate(:191)把这类候选排除掉。判据两条,满足其一即视为"副动作"并跳过:

  • 标签命中:标签是/含 完成donecompletearchivemark ... done/complete(:203-215),且父有主动作、且候选够"小巧"(compact);
  • 位置命中:候选落在父容器右侧带内(trailing band,宽度 父宽*0.22 夹在 56~140 之间)、又 compact、且有动作或副动作标签(:224-234)。

一句话:"靠右 + 小 + 长得像'完成/归档'" = 大概率是副动作,别点它。

3.7 就近扩散的刹车:shouldScanDescendantsOfHitRecord

第 3 级做就近命中时,hitTest 可能命中一个巨大的容器(比如整个列表、整个窗口)。若还去扫它的子孙,就等于满屏乱点。shouldScanDescendantsOfHitRecord(:237)是刹车:命中块面积 > 原目标 12 倍(或 > 20000)、或高宽都远超原目标,就不扫子孙,避免扩散到无关区域。


4. 核心机制二:文本输入(typeText / setValue)

4.1 typeText:先设焦点值,再退回键盘

同样是"非侵入优先"。typeText(ComputerUseService.swift:572)的两级:

typeText(text)
├─1 typeTextBySettingFocusedValueIfAvailable ← 首选:直接把值写进焦点控件
│ 焦点元素的 AXValue 可设(isSettable)? → 设 baseValue+text,成功即停 ✓

└─2 (不可设值时) canTypeTextUsingKeyboardFallback?
能 → InputSimulation.typeText(合成 Unicode 键盘事件,定向 postToPid)
不能 → 报错:请先点一个可编辑文本区

第 1 级(:1214 typeTextBySettingFocusedValueIfAvailable)是精髓:如果焦点控件的 AXValue 可设,就直接 AXUIElementSetAttributeValue已有值 + 新文本 写进去——一个键盘事件都不发

这里有个易忽略的坑:直接设值会覆盖原值,所以要先算出"已有的真实值" editableBaseValue(:1264),还要剔除占位符(placeholder)——否则会把灰色提示文字也当成真内容拼进去。项目甚至硬编码了一个飞书占位符 沟通时请保持"公开可接受"(looksLikeEditablePlaceholder,:1316)做特判。

第 2 级键盘兜底的门槛 canUseKeyboardTextFallback(:285):值可设、或角色是 AXTextField/AXTextArea/AXTextView、或角色描述含 "text field/area/entry"——否则拒绝,免得往一个根本不能打字的地方乱敲。

4.2 setValue:先校验 settable

setValue(:605)更直接——它是"我明确知道要设哪个元素的值"。关键是动手前先校验:isSettableForSetValue(:952)调 AXUIElementIsAttributeSettableAXValue 是否可设,不可设直接抛 Cannot set a value for an element that is not settable(nonSettableSetValueErrorMessage,:159)。校验通过才 AXUIElementSetAttributeValue。全程无物理事件。

4.3 scroll:AXScroll…ByPage 优先

scroll(:510)也贯彻语义优先。当滚动量是整数页且目标控件恰好支持 AXScrollUpByPage / AXScrollDownByPage(等)时,就直接对控件发这个语义动作、循环 N 次(:531-537)——不合成滚轮。只有拿不到整数页动作时,才落到 performScrollEvent(:1601)去发真的滚轮事件。


5. 核心机制三:物理层——定向 vs 全局

语义层全失败,才轮到"真的合成硬件事件"。这里 InputSimulation两套并行实现,区别只在事件发给谁

5.1 两种投递:postToPid vs cghidEventTap

维度定向(targeted)全局(global)
投递 APIevent.postToPid(pid)event.post(tap: .cghidEventTap)
事件源.combinedSessionState.hidSystemState
谁收到只有目标进程整个系统(真抢你的鼠标)
侵入性低——后台也能点,不动真鼠标高——等同你自己动手
代表函数clickTargeted/scrollTargeted/dragTargetedclickGlobally/scrollGlobally/dragGlobally

postToPidCGEvent 只塞给某个进程的事件队列,系统层面的鼠标指针纹丝不动——这是"非侵入"能在物理层依然成立的关键。真实锚点:clickTargeted(InputSimulation.swift:70)用 postMouseEventToPid(:234,内部 event.postToPid(pid));clickGlobally(:58)用 postMouseEvent(:224,内部 event.post(tap: .cghidEventTap))。

5.2 开关:OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS

走定向还是全局,由一个环境变量开关决定 —— globalPointerFallbacksEnabled(ComputerUseService.swift:112,取值 1/true/yes/on 才算开)。逻辑(以 performNonAXClickFallback,:1647 为例):

performNonAXClickFallback(point)
├─ 开关开? ──是──▶ prepareAppForGlobalPointerInput(先把窗口举到前台)
│ → clickGlobally(全局 HID 事件)

└─是否(默认) ─▶ try clickTargeted(定向 postToPid)
└─ 抛错 且 开关仍关 ─▶ 报错并提示:
"set OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS=1"

默认关意味着:开箱即用只会走"定向 postToPid",连兜底都不抢你鼠标;真需要"像人一样物理操作"才手动打开开关(:1656:1676)。scroll/drag 同款开关判断在 performScrollEvent(:1610)、performDragEvent(:1633)。另有一个纯日志开关 OPEN_COMPUTER_USE_DEBUG_INPUT_FALLBACKS(inputFallbackDebugEnabled,:101),打开后每次走物理兜底都往 stderr 打一行,方便排查"为什么这次没走语义"。

5.3 全局兜底前的准备:prepareAppForGlobalPointerInput

真要发全局事件,得先保证目标窗口在最前(否则事件打到别的 App)。prepareAppForGlobalPointerInput(InputSimulation.swift:48)先试用 AX 举窗口(raiseAppWindowViaAccessibility:AXRaise → 设 AXMain → 设 AXFocused,:244),失败才退回 NSRunningApplication.activate。—— 连"把窗口举起来"这种小事都优先走 AX,可见"非侵入"是贯穿到底的信条。


6. 核心机制四:键盘合成(typeText / pressKey)

6.1 typeText:Unicode 分块直发

键盘兜底不走"字符→键码"映射(那对中文/emoji 根本没法映),而是直接把 Unicode 塞进键盘事件。InputSimulation.typeText(:142)对每个 chunk 造一对 keyDown/keyUp,用 keyboardSetUnicodeString 把该段 Unicode 挂上去,再 postToPid

为什么要分块?keyboardUnicodeChunks(:164)把文本按 ≤64 个 UTF-16 单元切段(maxKeyboardUnicodeChunkLength,:46),且不切断单个字符(先把一个 Character 的 UTF-16 单元凑齐再判断是否超限,:170-178)——避免超长字符串一次性灌进单个事件出问题,也避免把一个 emoji 的代理对(surrogate pair)劈成两半。

6.2 pressKey:KeyPressParser 解析组合键

pressKey(:187)处理的是 "cmd+shift+a" 这种组合键KeyPressParser.parse(KeyMapping.swift:16)按 + 拆词、normalize(小写去空格,:54)后:最后一个词查 keyCodeMap(:61,一张涵盖字母/数字/功能键/方向键/小键盘的大表)当主键,前面的词经 parseModifier(:39)映射成 cmd/shift/option/control(带各自的 CGEventFlags 和键码)。

发送顺序很讲究(:191-219):按下所有修饰键 → 主键 down/up → 逆序抬起修饰键,且每个事件都带上当前累积的 activeFlags。这就还原了人手"按住 cmd+shift 再敲 a 再松手"的真实时序。这些键盘事件全程 postToPid(定向),不受 5.2 的全局开关影响。


7. 巧妙之处(可带走的技术)

  • "讲道理优先于动手"贯彻到每一层。 点击、输入、滚动、甚至"把窗口举到前面",一律先试 AX 语义动作,物理事件是最后手段——而且默认连兜底都用定向 postToPid 不抢鼠标。performAXClickSequence:805 / typeTextBySettingFocusedValueIfAvailable:1214 / raiseAppWindowViaAccessibility:244

  • 点击级联是"由准到糙"的漏斗。 首选目标 → 子孙 → 就近命中 → 含它的行 → 纯激活,命中即停(:805)。把"模型指的那个不一定能直接点"的现实,用层层退让消化掉。

  • 两个"副作用刹车"很实用。 isLikelySyntheticSideAction(:191)防误触"完成/归档"这类破坏性小按钮;shouldScanDescendantsOfHitRecord(:237)防命中巨块后满屏乱扫。都是被真实 App 坑过才会写的启发式。

  • 针对真实生态的特判不回避。 直接把 Electron / Lark(飞书)的"点文字→按整行"写进代码(:307:327:1123),甚至硬编码飞书占位符(:1318)。诚实地承认"通用逻辑搞不定这些壳",专门开小灶。

  • Unicode 分块 + 不切字符(:164)是打字兜底能正确处理中文/emoji 的关键细节。

  • 一个环境变量守住安全边界。 OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS(:112)让"会抢鼠标的全局事件"默认关闭、需显式启用——安全默认(secure by default)。


8. 边界与局限(诚实)

  • 强依赖 App 的无障碍实现质量。 控件不暴露 AX 动作、frame 不准,语义级联就会一路降级到物理兜底;而物理兜底默认还是关的,可能直接报错。

  • 默认关全局兜底 = 有些点击开箱点不动。 若某 App 既没有可用 AX 动作、又需要真实指针,默认配置下 performNonAXClickFallback 会抛错并提示你去开环境变量(:1676),而不是"悄悄帮你点"。

  • 副动作启发式是"经验值",可能误伤。 isLikelySyntheticSideAction 的标签清单(完成/done/archive/...)和位置阈值(右侧带宽、compact 判定)是硬编码的,遇到布局古怪的界面可能误判——该点的没点,或该躲的没躲。

  • 占位符识别很局部。 looksLikeEditablePlaceholder 只硬编码了一个飞书中文占位符(:1318),其它 App 的占位符靠通用 AXPlaceholder 属性比对(:1280),覆盖不全时打字可能把提示文字一起拼进去。

  • 坐标路径天然更弱。x/y 时关掉了就近扩散和激活兜底(:446-453),命中不到就直接落物理层,精度不如 element_index


9. 横向对比(同货架)

同为"把模型意图落到真实目标",本项目的取舍在 computer-use 一支里颇具代表性:

  • 落点是整台电脑的原生 GUI,而非浏览器 DOM。 浏览器类 agent 有 DOM 选择器可依,能精确定位;本项目只有 macOS 无障碍树 + 截图,所以才需要这么一大套"AX 级联 + 启发式"来补偿定位的不确定性。

  • "非侵入"是它区别于经典 computer-use 的最大特色。 很多 computer-use 实现直接合成全局指针事件(抢鼠标、要求前台);本项目把物理事件降为默认关闭的最后兜底,主路径是 postToPid 定向 + AX 语义动作,因此能后台操作、不打扰用户。

配合本章阅读:01-state-snapshot.md(动作的输入从哪来)、02-mcp-tools-surface.md(动作的对外契约)、04-software-cursor.md(既然不抢真鼠标,靠软件光标让后台操作"看得见")。


10. 代码地图(导航索引)

主题文件符号
点击入口·路径分叉packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swift:367ComputerUseService.click
AX 点击五级级联ComputerUseService.swift:805performAXClickSequence
单控件语义点击ComputerUseService.swift:699performPreferredClick
选中所在列表项ComputerUseService.swift:761selectContainingListItem
子孙候选枚举+排序ComputerUseService.swift:1040descendantClickCandidates
候选优先级打分ComputerUseService.swift:997clickPriority
副动作抑制ComputerUseService.swift:191 / :1089isLikelySyntheticSideActionCandidate
就近扫描刹车ComputerUseService.swift:237shouldScanDescendantsOfHitRecord
Electron/Lark 特判ComputerUseService.swift:307 / :327isElectronScopedWebRowClickOptimizationTarget / shouldPreferContainingWebRowAXClickCandidate
含它的 web 行点击ComputerUseService.swift:1123performContainingWebRowClick
激活兜底ComputerUseService.swift:916activateClickTarget
物理点击兜底ComputerUseService.swift:1647performNonAXClickFallback
打字·设焦点值优先ComputerUseService.swift:1214typeTextBySettingFocusedValueIfAvailable
打字·键盘兜底门槛ComputerUseService.swift:285canUseKeyboardTextFallback
setValue·settable 校验ComputerUseService.swift:952isSettableForSetValue
scroll·ByPage 优先ComputerUseService.swift:531ComputerUseService.scroll
全局兜底开关ComputerUseService.swift:112globalPointerFallbacksEnabled
定向 vs 全局点击InputSimulation.swift:70 / :58clickTargeted / clickGlobally
定向事件投递InputSimulation.swift:234postMouseEventToPid(postToPid)
全局事件投递InputSimulation.swift:224postMouseEvent(cghidEventTap)
打字·Unicode 分块InputSimulation.swift:164keyboardUnicodeChunks
全局兜底前举窗口InputSimulation.swift:48prepareAppForGlobalPointerInput
组合键解析KeyMapping.swift:16KeyPressParser.parse