跳到主要内容

状态模型:每回合的 UI 快照(无障碍树 + 截图)

30 秒导读: 这个项目让 AI 用 macOS 应用。AI 不能"看见"屏幕,所以每回合动手前,它先调 get_app_state 把目标窗口读成一份 AppSnapshot——一段带序号的无障碍树文本一张压缩截图。那些序号(element_index)就是 AI 后续说"点第 12 号"时用的地址。本章只讲怎么把世界读成快照;真正的点击/输入怎么落地,见 03-action-execution.md


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

一句话定义: AppSnapshot 是"某个 macOS 窗口在某一刻的可读快照"——把一个原本给屏幕阅读器用的无障碍树(Accessibility Tree),压成 AI 能读、能引用的一段文本,外加一张这个窗口的截图。

为什么需要它。 大语言模型没有眼睛。要让它操作"飞书"的某个按钮,你得先用它能读的形式告诉它:这个窗口里有哪些元素、各自是什么、在哪儿。截图给它"看个大概",无障碍树给它"精确的元素清单和地址"。两者一起,构成模型这一回合的全部输入

它长什么样。 一次 get_app_state 返回的文本大致是这样(示意,格式见 §5):

App=com.apple.TextEdit (pid 8123)
Window: "未命名", App: TextEdit.
0 standard window 未命名
1 button 关闭 Secondary Actions: ...
2 text entry area Value: Hello world
...

The focused UI element is 2 text entry area.

每一行开头的数字就是 element_index。模型想改那段文本,就说"对第 2 号元素 set_value";想点关闭,就说"点第 1 号"。序号是模型与真实 UI 之间唯一的地址。

一句话直觉/类比: 把无障碍树当成"这一屏的目录页"——每个可交互元素一个编号条目;截图是"这一屏的照片"。模型先读目录、再瞄照片,然后按编号动手。

本章只讲"怎么生成这份目录和照片"。生成之后模型怎么用编号去点、去打字,是 03-action-execution.md 的事。


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

2.1 为什么"每回合必须先调"

这是整个交互协议的硬性前提,写死在两处对外文本里:

  • 服务器说明书(MCPServer.swift:8,computerUseServerInstructions):

    "Begin by calling get_app_state every turn ... Codex will automatically stop the session after each assistant turn, so this step is required before interacting with apps in a new assistant turn."

  • 工具描述(ToolDefinitions.swift:69,get_app_state):

    "This must be called once per assistant turn before interacting with the app."

原因是会话在每个助手回合后被强制结束——上一回合抓的状态到这一回合已经作废(用户可能动过窗口、界面可能变了)。所以模型每回合的第一步永远是"重新读一遍世界",拿到最新的序号,再动手。这也解释了为什么后面所有动作工具执行完都要顺带回一份新快照让模型自查(见 §5.2)。

2.2 主线走一遍

从一句 get_app_state(app: "飞书") 到返回文本+图,数据这样流:

get_app_state(app) ← MCP 工具调用


ComputerUseService.getAppState ComputerUseService.swift:363
│ refreshSnapshot(showFullText) ComputerUseService.swift:658

SnapshotBuilder.build(for: app) AccessibilitySnapshot.swift:98

├─(1) 找焦点窗口 ─ preferredFocusedWindow / recoverVisibleWindow
│ 对 Electron 先 enableBestEffortAccessibilityModes

├─(2) 抓窗口图 ─ WindowCapture.resolve(ScreenCaptureKit)

├─(3) 压树成文本行 ─ TreeRenderer.render(rootWindow)
│ 分配 element_index、写 records、省略/合成/截断


AppSnapshot { treeLines, elements, screenshotPNGData, ... }


snapshotResult(style:) ComputerUseService.swift:1684
│ text = renderedText(style) + image = base64 PNG

返回给模型(1 段文本 + 1 张图)

怎么读这张图:从上到下是一次调用的顺序;build 内部的三步(找窗口 / 抓图 / 压树)是本章的三根主线,分别对应 §3、§4、§5。

2.3 部件一句话职责

部件干什么在哪(AccessibilitySnapshot.swift)
AppSnapshot一份快照的最终产物:树文本行 + 元素表 + 截图 + 焦点信息52,public struct AppSnapshot
SnapshotBuilder.build快照的总装配线:定权限 → 找窗口 → 抓图 → 压树98
WindowCapture用 ScreenCaptureKit 找到并抓下目标窗口的位图360
TreeRenderer递归遍历 AX 树,压成带序号的文本行,填 records604
ElementRecord每个序号背后的实体:AX 引用、局部坐标、可用动作7
boundedScreenshotPNGData把截图降采样到 ≤900KB / ≤1280px481

3. 核心原理之一:先锁定"该读哪个窗口"

它要解决的小问题: 一个 app 可能有多个窗口、可能被最小化、可能是 Electron 藏了半棵树。build 要先稳稳定位到"用户此刻真正在看的那个窗口",否则后面抓的图和树都是错的。

3.1 前置闸门:没有无障碍权限直接报错

build 第一件事是查权限(AccessibilitySnapshot.swift:103-106)。没授权就抛 permissionDenied,提示去跑 open-computer-use doctor。整个状态模型建立在系统无障碍 API之上——没有这层授权,一个元素都读不到。

3.2 Electron 特调:先"敲醒"隐藏的树

Chromium/Electron 应用(飞书、VS Code、Slack 之类)默认不吐出完整 AX 树,要设一个私有属性才肯给。所以拿到 app 元素后立刻调 enableBestEffortAccessibilityModes(AccessibilitySnapshot.swift:109,实现在 352):

private func enableBestEffortAccessibilityModes(_ appElement: AXUIElement) {
// Chromium/Electron apps may withhold parts of their AX tree until manual
// accessibility is enabled. These private attributes are best-effort and
// harmlessly fail on apps that do not support them.
_ = AXUIElementSetAttributeValue(appElement, "AXManualAccessibility" as CFString, kCFBooleanTrue)
_ = AXUIElementSetAttributeValue(appElement, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
}

AXManualAccessibility 是让 Chromium 打开无障碍模式的开关;AXEnhancedUserInterface 对部分 AppKit app 起类似作用。两个 _ = 说明它们对不支持的 app 无害地失败——设不上也不管。

关键细节: 这是"尽力而为"的,不保证第一帧就完整。仓库里 docs/histories/2026-05/ 专门有一整条关于"飞书 Electron 快照稳定性"的迭代记录,说明这块是踩过坑的。

3.3 焦点窗口的多级 fallback

选窗口的逻辑在 preferredFocusedWindow(AccessibilitySnapshot.swift:271)。核心思路:先信"系统级焦点",逐级退到"这个 app 的任一可用窗口"

判断"这个 app 是不是当前前台 app"用的是比对 pid(focusedApplication 的 pid == 目标 app 的 pid);是的话优先走系统级焦点链:

是前台 app → systemWide.focusedWindow (系统级焦点窗口)
→ focusedApplication.focusedWindow (焦点 app 的焦点窗口)
→ firstWindow(focusedApplication) (焦点 app 的第一个可用窗口)
→ appElement.focusedWindow
→ firstWindow(appElement)
不是前台 → appElement.focusedWindow → firstWindow(appElement)

每一级都套 usableWindowElement 过滤——isUsableWindowElement(AccessibilitySnapshot.swift:291)要求角色是窗口、且没被最小化,否则跳过。这道过滤保证选中的是"真能看能操作"的窗口。

3.4 窗口不在场?尝试"抢救"

如果一圈下来 focusedWindow 还是 nil,build 不立刻放弃,而是调 recoverVisibleWindow(AccessibilitySnapshot.swift:196)主动把窗口弄出来,再重试一次找焦点(build113-116)。抢救动作是一串"尽量把窗口带到前台"的组合拳:

抢救手段调用意图
取消隐藏runningApplication.unhide()app 被 Cmd-H 藏了
激活所有窗口activate(options: [.activateAllWindows])把 app 带到前台
/usr/bin/open -b <bundleId>openBundleIdentifier冷启动/唤醒 app
取消最小化unminimize(设 kAXMinimizedAttribute=false)窗口在 Dock 里
抬升 / 置主 / 置焦raise / kAXMainAttribute / kAXFocusedAttribute把某个窗口顶到最前

若确有动作生效,就 Thread.sleep(windowVisibilityRecoveryDelay = 0.7 秒,AccessibilitySnapshot.swift:47)等 UI 稳定再回来重找。这个"抢救 + 短暂等待"是让后台 app 也能被稳定读到的关键。

3.5 树和图必须指向同一个窗口

一个易被忽视的细节:找到 AX 焦点窗口后,build 还要用它的标题去 WindowCapture.resolve 找到对应的 CGWindow(§4)。如果 AX 窗口有、但截图侧找不到(windowCapture == nil),会再次 recoverVisibleWindow 并重选窗口(AccessibilitySnapshot.swift:126-133);仍失败就抛 computerUseNoWindowFoundMessage("Apple event error -10005: cgWindowNotFound",Errors.swift:3)。这保证文本树和截图说的是同一个窗口,不会图文错位。


4. 核心原理之二:抓窗口截图并降采样

它要解决的小问题: 给模型的图既要清楚到能看,又不能大到撑爆上下文/传输。策略是:用 ScreenCaptureKit 抓单窗口,再按上限压缩。

4.1 找到"该抓哪个 CGWindow"

WindowCapture.resolve(AccessibilitySnapshot.swift:366)先用 CGWindowListCopyWindowInfo 列出屏上所有窗口,筛出 ownerPID == 目标 pid 的,组成候选,再交给 preferredWindowCaptureCandidate(449)挑一个。挑选规则:

  • 只留 layer == 0(普通应用窗口层)且面积 ≥ 20000 的("可用窗口");都不满足才退而取面积最大的。
  • 有标题提示(来自 AX 窗口标题)就优先匹配同标题的候选。
  • 但如果最前面的窗口和标题命中的不是同一个、且二者区域相交,则认为有弹窗/前置窗遮挡,改选最前那个(471-476)。这让"当前真正压在最上面的窗口"胜出。

4.2 用 ScreenCaptureKit 抓图

captureImage(AccessibilitySnapshot.swift:404)通过 SCScreenshotManager.captureImage 只抓这一个窗口:

  • SCContentFilter(desktopIndependentWindow:) 把范围锁定到单窗口,不含桌面背景
  • 按屏幕 backingScaleFactor 算清晰度(bestEffortScaleFactor,Retina 抓 2x)。
  • showsCursor = false(不含系统光标——项目自己画软件光标,见 04-software-cursor.md);ignoreShadowsSingleWindow = true(去掉窗口阴影)。

因为 ScreenCaptureKit 是 asyncbuild 是同步的,这里用 BlockingAsyncBridge.run(AccessibilitySnapshot.swift:550)把异步抓图桥接成阻塞调用,并加 screenshotCaptureTimeout = 5 秒超时(42)防卡死。

4.3 降采样到预算内

抓到的 CGImageboundedScreenshotPNGData(AccessibilitySnapshot.swift:481)压到预算内才编成 PNG。预算是三个常量(43-45):

常量含义
screenshotResultMaxPNGBytes900_000PNG 体积上限 ≈ 900KB
screenshotResultMaxDimension1280最长边像素上限 1280
screenshotResultMinScale0.25缩放下限,不会缩得比 1/4 更小

算法思路,分两步:

  1. 先按最长边限尺寸。 scale = min(1, 1280 / 最长边)。若无需缩小(scale≥1)且原图 PNG 已 ≤900KB,直接返回原图(495-497)。
  2. 再按字节数循环缩。 否则进 while 循环:每轮缩放→编 PNG→若 ≤900KB 就返回;否则 scale *= 0.85 再缩一档,直到降到 minScale=0.25 为止(500-513)。缩到底仍超,则返回"缩到最小时的那一版"(best)——宁可略超也要有图

一句话精华: 尺寸和体积双上限,既保证模型看得清(至少 1/4 分辨率),又保证不会因为一张 4K 截图撑爆预算。


5. 核心原理之三:把 AX 树压成带序号的文本(最关键)

它要解决的小问题: macOS 的原始无障碍树又深又杂(包装容器套包装容器、大量无意义节点)。TreeRenderer 要把它压成每行一个有意义元素、开头带序号的紧凑文本,让模型读得懂、引得准。

TreeRenderer.render(AccessibilitySnapshot.swift:616)是深度优先递归。它维护三样东西(604-609):nextIndex(下一个要分配的序号)、lines(输出文本行)、records(序号 → ElementRecord 的表)。

5.1 element_index 与 records:序号即地址

这是本章最重要的一处机制。每当一个节点被决定要输出(没被省略),render 才做三件事:

  1. 用当前 nextIndex 作为该行序号,然后 nextIndex += 1(AccessibilitySnapshot.swift:626706)。
  2. 把该行文本推进 lines,行首缩进 = 深度个 \t(746)。
  3. 建一个 ElementRecord 存进 records[index](748-756)。

ElementRecord(AccessibilitySnapshot.swift:7)记的是这个序号背后的真身:

字段存什么后续给谁用
index序号模型引用的地址
elementAXUIElement 引用动作层直接对它 AXUIElementPerformAction
localFrame窗口内局部坐标坐标回退点击 / 软件光标定位
rawActions / prettyActions原始/美化后的可用动作名perform_secondary_action 校验
isSyntheticText是否合成文本节点标记非真实 AX 元素

关键:序号是"输出序",不是"遍历序"。 被省略的节点根本不占号(见 §5.3),所以模型看到的序号和 records 的 key 严丝合缝。动作层拿到 element_index 后,靠 snapshot.elements[index] 反查 ElementRecord,再对里面的 AXUIElement 动手——这就是从"模型说的号"到"真实 UI"的桥。查表逻辑在 ComputerUseService.swift:675(lookupElement)。

局部坐标由 resolveLocalFrame(AccessibilitySnapshot.swift:1323)算:取元素的屏幕 position/size,再减去窗口原点换成窗口内相对坐标(windowRelativeFrame,1578),行尾以 x=.., y=.., w=.., h=.. 呈现。

5.2 一行文本怎么拼出来

一个被输出的节点,其文本行由一串 segment 拼成(AccessibilitySnapshot.swift:745),骨架是:

<缩进><序号> <角色> <特征> <标题> <行摘要><描述><帮助><URL><ID><值><占位符> Secondary Actions: <动作...>

各段的来源(都尽量"讲人话"):

  • 角色:roleDescription(1587)把 AXButtonbuttonAXGroupcontainer;AXWebArea 若无角色描述则给中文 "HTML 内容"(1605)。
  • 特征:summarizeTraits(903)把状态压成 selected / expanded / disabled / settable / 值类型(string/boolean/float)。
  • 标题/值:preferredDisplayTitle(1093)按角色挑最合适的名字(标题→行文本→标签→值);链接会渲染成 markdown 的 [文字](url)(markdownLinkText,1136)。
  • 动作:meaningfulActions(1619)滤掉主点击等噪声动作(AXPressAXShowMenuAXScrollToVisible…),只留"次要动作",再 prettyActionNameAXZoomWindowzoom the window

5.3 三招压缩:省略 / 合成 / 截断

原始树太啰嗦,render 用三种手段瘦身。这是"让模型读得动"的核心。

(a) 省略无意义包装节点 —— shouldElideNode(AccessibilitySnapshot.swift:1354)。 只针对 AXGroup/AXUnknown 这类通用容器。若一个容器没标题、没标签、没值、没 ID、没特征、没动作,它就是纯包装,render 不给它分配序号,直接下钻渲染其子节点(700-704)。特例:单子节点的透明包装(1379)、Web 区域里有多个子节点的容器(shouldPreserveWebAreaGenericContainer,1398)会保留。效果:模型看到的是"扁平、有内容"的树,而不是层层空壳。

(b) 合成文本 —— renderSyntheticText(AccessibilitySnapshot.swift:790)。 一堆只含静态文字/图片/链接的兄弟节点(比如一条消息气泡里的几段文字),summarizedGenericText(1433)会把它们合并成一句,当作一个 text 合成节点输出。它也占一个序号、也进 records,但打了 isSyntheticText: true(799-807)标记为"非真实 AX 元素"。合并有护栏:shouldMergeTextOnlySiblings(1499)在文本含"日期+时间""计数器 3/5""时间段 09:00-10:00"或总长超 220 字/超 8 段时拒绝合并,避免把该拆开的信息糊成一坨。

(c) 500 字截断 —— sanitizeText(AccessibilitySnapshot.swift:1682)。 每个文本字段(标题、值、描述、选中文本…)都过 sanitizeText:换行转义成 \n,超过 characterLimit 就截断加 ...。默认上限 snapshotTextDefaultCharacterLimit = 500(46)。这个上限从哪来?看 buildAccessibilitySnapshot(164):

let textCharacterLimit = showFullText ? nil : snapshotTextDefaultCharacterLimit

get_app_stateshow_full_text 参数(ToolDefinitions.swift:74)为 true 时,上限变 nil(不截断);默认 false 时套 500 字。让模型能在需要读全文时显式要,平时省 token。

5.4 遍历还有两个边界

  • 广度/深度上限:shouldContinueRendering(AccessibilitySnapshot.swift:899)要求 nextIndex < 1200(accessibilityTreeMaxNodeCount)且 depth < 64(accessibilityTreeMaxDepth)。超了就停,防止巨型 app 把树撑爆。
  • 子节点怎么取:children(828)不是只读 AXChildren,还会合并 AXRows / AXContents / AXVisibleChildren(childTraversalAttributes,866),对表格/大纲/列表这类用行或可见子集当主内容的角色,只取可见的行(visibleRows,1793,最多 20 行),避免把成千上万行全渲出来。
  • 窗口树 + 菜单栏:build 除了渲染窗口根,还额外渲染一次菜单栏(AccessibilitySnapshot.swift:174-178),这样模型也能看到并操作菜单项。

5.5 renderedText 的输出格式:fullState vs actionResult

最终这份快照怎么变成给模型的文本?看 AppSnapshot.renderedText(style:)(AccessibilitySnapshot.swift:71)。它把内容拼成:

App=<bundleId 或名字> (pid <pid>)
Window: "<窗口标题>", App: <应用名>.
<treeLines...>

Selected text: [<选中文本>] ← 若有选中文本
The focused UI element is <焦点行摘要>. ← 否则若有焦点元素

SnapshotTextStyle 有两个值(AccessibilitySnapshot.swift:92):

style谁用语义
.fullStateget_app_state"这是当前完整状态"(ComputerUseService.swift:364)
.actionResult每个动作工具执行后"动作后自动回带的最新状态"(ComputerUseService.swift:391 等十余处)

诚实提示: 在本 commit(8fada12)里,renderedText(style:) 的函数体并未对 style 分支——两种 style 产出的文本目前一样(AccessibilitySnapshot.swift:71-89)。style 参数是留出的语义区分点:.fullState 是模型每回合主动读的整屏状态;.actionResult 是动作执行完顺带回带、让模型自查 UI 是否如预期变化的那份(呼应 §2.1 "动作后要验证")。当前二者内容一致,区分只在调用点的含义上。

文本之外,截图作为第二块内容附上:snapshotResult(ComputerUseService.swift:1684)把 renderedText 当 text 内容,再把 screenshotPNGDataToolResultContentItem.pngImage(ToolResult.swift:15,base64 编码为 type: "image")追加。一次返回 = 一段文本 + 一张图,正是 §1 开头模型收到的那两样东西。


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

  • 序号即地址,且只给"看得见"的元素编号。 省略节点不占号(§5.3a),使模型看到的序号与 records 完全对齐——模型说"点第 12 号"永远指向一个真实 AXUIElement,不会因为中间藏了空容器而错位。(AccessibilitySnapshot.swift:706 只在输出时递增)
  • 文本树与截图强制同源。 找不到对应 CGWindow 就再抢救+重选窗口,宁可报错也不图文错位(AccessibilitySnapshot.swift:126-137)。
  • 对 Electron 的"尽力敲醒"。 一行 AXManualAccessibility=true 换来飞书这类应用愿意吐出完整树(AccessibilitySnapshot.swift:356)。
  • 截图双上限 + 缩到底也给图。 尺寸和字节双限,循环缩放,最坏情况返回最小可用图而非放弃(boundedScreenshotPNGData,481)。
  • 合成文本有语义护栏。 会把碎文字合并省 token,但对日期/计数器/时间段等"该独立"的信息拒绝合并(shouldMergeTextOnlySiblings,1499)。

7. 边界与局限(诚实)

  • 完全依赖无障碍权限。 没授权,build 第一步就抛错,读不到任何元素(AccessibilitySnapshot.swift:104-106)。
  • Electron 第一帧可能不全。 AXManualAccessibility 是"尽力而为",树可能延迟才完整——这是有历史迭代记录的已知痛点(docs/histories/2026-05/ 飞书稳定性)。
  • 列表/大纲只渲可见行,上限 20。 超长列表看不全(visibleRows,AccessibilitySnapshot.swift:1810);节点总数硬顶 1200(accessibilityTreeMaxNodeCount)。
  • style 目前不改变文本。 .fullState.actionResult 产出相同(§5.5),区分只在语义。
  • 合成文本节点不是真元素。 isSyntheticText 节点没有真实 AX 引用可执行动作,只作阅读用途(AccessibilitySnapshot.swift:799-807)。

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

主题文件符号
快照数据结构packages/OpenComputerUseKit/Sources/OpenComputerUseKit/AccessibilitySnapshot.swiftAppSnapshot(52)
快照总装配同上SnapshotBuilder.build(98)
焦点窗口选择同上preferredFocusedWindow(271)、isUsableWindowElement(291)
窗口抢救同上recoverVisibleWindow(196)
Electron 敲醒同上enableBestEffortAccessibilityModes(352)
截图抓取同上WindowCapture.resolve(366)、captureImage(404)
截图降采样同上boundedScreenshotPNGData(481)
树渲染同上TreeRenderer.render(616)
元素记录同上ElementRecord(7)
节点省略同上shouldElideNode(1354)
合成文本同上renderSyntheticText(790)、summarizedGenericText(1433)
文本清洗/截断同上sanitizeText(1682)、snapshotTextDefaultCharacterLimit(46)
输出格式同上AppSnapshot.renderedText(style:)(71)、SnapshotTextStyle(92)
每回合必调说明packages/OpenComputerUseKit/Sources/OpenComputerUseKit/MCPServer.swiftcomputerUseServerInstructions(3)
工具描述与 show_full_textpackages/OpenComputerUseKit/Sources/OpenComputerUseKit/ToolDefinitions.swiftget_app_state(68)
入口与结果封装packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swiftgetAppState(363)、refreshSnapshot(658)、snapshotResult(1684)
序号反查同上lookupElement(675)
截图内容项编码packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ToolResult.swiftpngImage(15)

相邻章节: 全景与阅读地图见 index.md;9 个工具的对外契约见 02-mcp-tools-surface.md;拿到 element_index 后如何点击/输入见 03-action-execution.md;截图里为何看不到系统光标(项目自绘)见 04-software-cursor.md