跳到主要内容

手脚:混合 GUI+API 自动化与命令模式(Puppeteer)

30 秒导读: 上游流水线已经决定了「点第 5 个控件」或「把这段文字存成 PDF」,这一层负责把 这句话真正落到运行中的应用上。UFO² 的招牌是混合执行:同一个目标,能走原生 API(Word 的 COM 接口直接 SaveAs)就走 API,走不了才退回 GUI(pywinauto 模拟鼠标点击)。中间用命令模式 把两条完全不同的通路,统一成一句 puppeteer.execute_command(name, params)

本章聚焦动作落地的执行层。上游怎么「看见控件」是 03 感知与定位 的事,怎么「决定做什么」是 02 处理器与策略流水线 的事; 到了这里,动作已经是一个 (函数名, 参数) 的元组,我们只管把它可靠地执行出去


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

一句话定义

Puppeteer(操偶师)是 UFO² 里「一个应用的动作代理」:你给它一个动作名和一包参数,它找到 能执行这个动作的「接收者」,把动作做出去,再把结果字符串还给你。

它要解决的真问题:同一件事有两种做法

假设 AppAgent 决定「把当前 Word 文档另存为 PDF」。有两条路可以走:

通路怎么做优点缺点
GUI(模拟人)移动鼠标点「文件 → 另存为 → 选 PDF → 确定」任何应用都能用慢、脆、依赖屏幕坐标和控件树
API(直接调)调 Word 的 COM 接口 document.SaveAs(path, 17)一步到位、精确、稳只有少数应用暴露了 API

UFO² 的取舍很明确:能用 API 就走 API,否则退回 GUI。Word / Excel / PowerPoint 有成熟的 COM 自动化接口,就用接口;一个没有 API 的第三方应用,就只能靠 pywinauto 点鼠标。这就是「混合执行」 (hybrid GUI + API automation)——同一套上层调用,底下自动选最稳的那条腿。

一句话直觉/类比

把 Puppeteer 想成餐厅的传菜口:前台(AppAgent)喊一声「一份 SaveAs」,传菜口不关心这道菜是 哪个厨师(接收者)做的、用煤气灶(GUI)还是用微波炉(API)做的——它只负责把订单路由到对的 厨师,端出成品。前台永远只会说菜名,不碰锅。


2. 顶层全景(它大概怎么转)

三个核心角色

整个执行层是一套经典的命令模式(Command Pattern),拆成三种角色:

角色是什么在哪个文件
Command(命令)一个动作的封装,只有一个 execute() 方法ufo/automator/basic.py CommandBasic
Receiver(接收者)真正干活的对象;命令调它的方法ufo/automator/basic.py ReceiverBasic
Invoker(调用方)AppPuppeteer,组装命令、按名分发、执行ufo/automator/puppeteer.py AppPuppeteer

外加两个「工厂 + 登记处」把角色串起来:

  • ReceiverFactory / ReceiverManager——按应用类型造出接收者,并维护「命令名 → 接收者」的路由表。
  • command_registry——每个接收者自带一张「命令名 → 命令类」的登记表,靠装饰器 @Receiver.register 填。

一次动作的数据流

下面这张图是本章主干,从左到右看:上游给一个动作,Puppeteer 把它翻译成命令、路由到接收者、 接收者选 GUI 或 API 落地。

上游(02/03) AppPuppeteer(操偶师) 接收者(真正干活)
┌──────────────┐ 动作名+参数 ┌─────────────────────────┐ ┌─────────────────────────┐
│ AppAgent 决定 │ ────────────▶ │ execute_command( │ │ ① GUI 通路 │
│「function= │ │ name, params) │ 命令名匹配 │ ControlReceiver │
│ set_edit_text│ │ │ ───UI 类──▶ │ pywinauto 点击/输入/滚动 │
│ arguments=…」│ │ ┌───────────────────┐ │ └─────────────────────────┘
└──────────────┘ │ │ ReceiverManager │ │ ┌─────────────────────────┐
│ │ 命令名→接收者路由表 │ │ 命令名匹配 │ ② API 通路 │
│ └───────────────────┘ │ ──COM/Web──▶│ WinCOM / Web / Shell │
│ create_command()→命令 │ /Shell │ 直接调原生接口 │
│ command.execute() │ └─────────────────────────┘
└─────────────────────────┘

怎么读: 中间的 AppPuppeteer 不知道也不关心动作最终走 GUI 还是 API——它只用命令名去 ReceiverManager 的路由表里查「谁能接这个命令」,查到谁就交给谁。GUI 和 API 是两个平行的接收者, 挂在同一张路由表下。


3. 核心原理(逐个机制,由浅入深)

3.1 命令模式骨架:把「动作」变成可路由、可排队的对象

它要解决的小问题: 上层不该知道「点击」和「另存为 PDF」在底层是天差地别的两种操作。得有一层 抽象,让所有动作长得一样。

思路: 每个动作封成一个 CommandBasic 子类,统一只暴露 execute()。谁来执行、怎么执行,都藏在 命令持有的 receiver 里。

真实的抽象基类非常薄——一个命令就是「接收者 + 参数 + execute()」:

# ufo/automator/basic.py:68 CommandBasic —— 所有命令的抽象基类(节选)
class CommandBasic(ABC):
def __init__(self, receiver, params=None):
self.receiver = receiver # 谁来真正执行
self.params = params or {}

@abstractmethod
def execute(self): # 唯一对外方法
pass

AppPuppeteer 是调用方(Invoker)。 它的 create_command 做三件事:按命令名找接收者、从接收者的 登记表取命令类、把接收者和参数塞进去实例化。见 ufo/automator/puppeteer.py:39 create_command:

# ufo/automator/puppeteer.py:47 create_command 的核心三行(节选)
receiver = self.receiver_manager.get_receiver_from_command_name(command_name)
command = receiver.command_registry.get(command_name.lower(), None)
return command(receiver, params, *args, **kwargs) # 组装成命令对象

execute_command 则是「造出来立即执行」的便捷封装(puppeteer.py:73),内部就是 create_command(...).execute()

为什么值得做成命令对象而不是直接调函数? 因为命令对象可以排队AppPuppeteer 自带一个 command_queue(puppeteer.py:36),add_command 把命令入队、execute_all_commands (puppeteer.py:87)一次性顺序执行——这让「一步里连做几个动作」成为可能。命令模式还预留了 undo/redo(basic.py:88),尽管当前多数命令没实现撤销。

3.2 command_registry:装饰器登记「命令名 → 命令类」

它要解决的小问题: create_command 拿到字符串 "set_edit_text",怎么知道对应哪个 Python 类?

思路: 每个接收者类持有一张类级字典 _command_registry,命令类用装饰器 @接收者.register 把自己登记进去,键是 command_class.name() 返回的字符串。

真实登记逻辑就一行(ufo/automator/basic.py:52 ReceiverBasic.register):

# ufo/automator/basic.py:52 用装饰器把命令类登记到接收者的注册表
@classmethod
def register(cls, command_class):
cls._command_registry[command_class.name()] = command_class # 名字→类
return command_class

于是每个命令类只要顶上加一行装饰器 + 一个 name(),就自动可被路由。例如 GUI 的点击命令 (ui_control/controller.py:574 ClickInputCommand)与 Shell 的读文件命令 (shell/shell_client.py:1113 ReadFileCommand)都是这个套路:

# ufo/automator/ui_control/controller.py:574 一个 GUI 命令的完整样子(节选)
@ControlReceiver.register # 登记到 GUI 接收者
class ClickInputCommand(ControlCommand):
def execute(self):
return self.receiver.click_input(self.params) # 委托给接收者的方法

@classmethod
def name(cls):
return "click_input" # 这就是上游用的 function 名

关键细节: create_command 查表时用的是 command_name.lower()(puppeteer.py:48), 而登记用的是 name() 的原样返回值。所以命令名约定都用小写(click_inputweb_crawlersave_as),大小写不一致会查不到。

3.3 ReceiverManager + ReceiverFactory:按应用把命令名路由到对的接收者

它要解决的小问题: 一个 AppPuppeteer 底下可能同时挂着 GUI 接收者和(如果是 Word 就还有)COM 接收者。命令名 set_edit_text 该找 GUI,save_as 该找 COM——谁来分?

思路: ReceiverManager 维护一张扁平的路由表 receiver_registry:命令名 → 接收者实例。 每装上一个新接收者,就把它「支持的所有命令名」全登记进这张表。

登记逻辑在 _update_receiver_registry(puppeteer.py:243),它调每个接收者的 self_command_mapping()——即「我登记表里每个命令名,都映射到我自己」(basic.py:46):

# ufo/automator/basic.py:46 接收者把「我的每个命令名 → 我自己」摊平
def self_command_mapping(self):
return {name: self for name in self.supported_command_names}

两种接收者是怎么被造出来并挂上去的? 靠两个工厂方法,时机不同:

方法何时调造出什么源码
create_ui_control_receiver每次执行动作前,针对当前选中控件现造GUI 的 ControlReceiverpuppeteer.py:202
create_api_receiver选中应用窗口时,一次性造好该应用的所有 API 接收者COM/Web/Shell 接收者puppeteer.py:225

create_api_receiver 的巧妙处:它遍历所有已注册的接收者工厂,只挑 is_api() 为真的,逐个 尝试造接收者;造得出来(应用类型匹配)就挂上,造不出来(返回 None)就跳过:

# ufo/automator/puppeteer.py:231 只实例化「API 类」的工厂,匹配不上就跳过
for receiver_factory_dict in self.receiver_factory_registry.values():
if receiver_factory_dict.get("is_api"):
receiver = receiver_factory_dict.get("factory").create_receiver(
app_root_name, process_name)
if receiver is not None: # Word→COM 造得出;记事本→造不出→None
self.receiver_list.append(receiver)

工厂本身怎么进「注册表」? 又是一个装饰器 @ReceiverManager.register(puppeteer.py:293), 在类定义时就把工厂登记进类级的 _receiver_factory_registry,并记下它 is_api() 的标志。 COMReceiverFactoryWebReceiverFactoryShellReceiverFactory(都在 app_apis/factory.py) 和 UIControlReceiverFactory(controller.py:484)都是这样自注册的。

一句话串起来: 命令类用 @Receiver.register 登记进接收者;接收者工厂用 @ReceiverManager.register 登记进管理器;运行时管理器造出接收者、摊平成「命令名→接收者」路由表; Puppeteer 拿命令名一查就知道交给谁。


4. 两类接收者对比:GUI 通路 vs 原生 API 通路

这是 UFO² 混合执行的核心。同一张路由表下,挂着两种气质完全不同的接收者。

4.1 GUI 通路:ControlReceiver(pywinauto 模拟人)

ControlReceiver(ufo/automator/ui_control/controller.py:45)是万能但脆弱的那条腿:它拿到 pywinauto 的控件包装 UIAWrapper,靠模拟鼠标键盘把动作做出去。任何 Windows 应用都能用,因为它不 依赖应用暴露 API,只依赖「屏幕上有这个控件」。

它构造时就先把控件聚焦并等到可用(controller.py:64,调 set_focus() + wait_enabled()), 这是 GUI 自动化稳健性的第一道保险——控件还没 enable 就点,必然失败。

它支持的动作(每个都是一个注册命令):

命令名干什么底层方法
click_input点击控件pywinauto click_inputcontroller.py:98
set_edit_text往输入框写文字set_edit_text / type_keyscontroller.py:182
keyboard_input发按键序列type_keyscontroller.py:261
wheel_mouse_input滚轮滚动wheel_mouse_inputcontroller.py:313
click_on_coordinates / drag_on_coordinates按坐标点击/拖拽pyautoguicontroller.py:113 / 140

稳健性藏在多级 fallback 里。 以「写文字」set_edit_text 为例,它不是简单调一次 API 就完事, 而是层层退让(controller.py:182):

set_edit_text 的降级链(命中即用,失败则退到下一级)
① set_edit_text(API 直接设值) ──失败──▶
② type_keys(逐键模拟) ──失败──▶
③ ^a{BACKSPACE} 清空后再 type_keys ──失败──▶
④ pyautogui 兜底逐字符敲入

而且设值后还会校验:如果用的是 set_text 类方法,会检查期望文本是否真出现在控件里 (controller.py:210),没出现就抛异常、触发降级。这就是「模拟人」为什么脆——每一步都可能没生效, 所以每一步都要留后路。

一个坑: 上游给的坐标是相对窗口的分数(0~1),不是屏幕绝对像素。ControlReceivertransform_point(controller.py:421)把分数乘以窗口宽高换算成绝对坐标。这样即使窗口被移动 或缩放,同一个「相对位置」仍然点得准。反向换算 transfrom_absolute_point_to_fractional (controller.py:439)则用于把 OpenAI Operator 风格的绝对坐标动作(clickscroll 等, controller.py:814 起)转回分数再执行。

4.2 API 通路:WinCOM / Web / Shell(直接调原生接口)

另一条腿是精确但受限的原生 API。它们都继承各自的 API 接收者基类,直接调用应用的编程接口, 不碰鼠标、不看屏幕。

Office 三件套走 WinCOM。 WinCOMReceiverBasic(app_apis/basic.py:21)在构造时用 win32com.client.Dispatch(clsid) 连上正在运行的 Office 进程,之后所有动作都是直接操作 COM 对象。 比如 Word 的「插入表格」就是一句 com_object.Tables.Add(...)(word/wordclient.py:33 insert_table),「另存为 PDF」就是 com_object.SaveAs(...)(word/wordclient.py:147 save_as)—— 一步到位,没有 GUI 那种「点错了怎么办」的问题。

COMReceiverFactory 靠一张映射表决定「这个应用能不能走 COM」。app_apis/factory.py:32 COMReceiverFactory:

# ufo/automator/app_apis/factory.py:63 应用可执行名 → COM 客户端类
win_com_client_mapping = {
"WINWORD.EXE": WordWinCOMReceiver,
"EXCEL.EXE": ExcelWinCOMReceiver,
"POWERPNT.EXE": PowerPointWinCOMReceiver,
}
com_receiver = win_com_client_mapping.get(app_root_name, None) # 不在表里→None→退回 GUI

create_receiver 里,如果应用名不在映射表(或没有对应 CLSID),直接返回 None (factory.py:51)——这正是「退回 GUI」的实现:API 工厂造不出接收者,该应用的动作就只剩 GUI 一条路。

其余两个 API 接收者:

接收者应用/场景命令举例源码
WebReceiverEdge / Chrome(msedge.exe/chrome.exe)web_crawlernavigate_to_urlclick_elementapp_apis/web/webclient.py:18;工厂 factory.py:96
ShellReceiver执行 PowerShell / 文件操作run_shellread_filelist_filesapp_apis/shell/shell_client.py:331;工厂 factory.py:133

WebReceiverFactory 同样靠 supported_app_roots(factory.py:118,只认两个浏览器可执行名)决定 是否造接收者,不匹配就返回 None,退回 GUI。

4.3 取舍总结:能 API 就 API,否则 GUI

把两条腿并排看:

维度GUI(ControlReceiver)API(WinCOM/Web/Shell)
覆盖面任何应用只有登记过的少数应用
精度依赖控件树/坐标,可能点错直接操作对象,精确
速度慢(要聚焦、等待、逐键)快(一次调用)
稳健性靠多级 fallback + 校验硬撑接口成功即成功
选择逻辑API 工厂返回 None 时的默认腿工厂能造出接收者时优先

收益: 对 Office 这类高频、有 API 的应用,UFO² 用 COM 拿到接近脚本级的可靠性;对长尾的任意应用, 又能靠 GUI 兜底不至于抓瞎。两者共享同一套命令模式和路由表,上层调用毫无感知——这就是混合执行 在工程上的价值。


5. 动作执行编排与安全

命令能落地只是第一步。真正跑在生产里,还要防两件事:点到不该点的控件碰到不该碰的路径/命令

5.1 ActionExecutor:执行前的控件校验与接收者装配

ActionExecutor(ufo/automator/action_execution.py:19)是动作与 Puppeteer 之间的编排层。它的 execute 方法(action_execution.py:85)按固定顺序把一个 ActionCommandInfo 落地:

ActionExecutor.execute 的流程
① 从 control_dict 按 id 取出目标控件
② 控件校验:_control_validation —— 不可用/不可见就直接抛错,不硬点
③ 为当前控件现造 GUI 接收者(create_ui_control_receiver)
④ puppeteer.execute_command(function, arguments) 真正执行
⑤ 结果不可 JSON 序列化就抹成 ""(避免把 COM 对象塞进日志)

第 ② 步是关键防线。_control_validation(action_execution.py:28)只有在控件 is_enabled() is_visible() 时才放行,否则抛出一个带自愈提示的错误——提示上游「刷新应用状态、重新获取可交互 控件」(action_execution.py:108)。这避免了对着一个刚消失/变灰的控件盲点,是「模拟人」路线必须的 前置校验。

真正调用在 action_execution.py:127:

# ufo/automator/action_execution.py:126 校验通过后才真正执行,并做结果净化
try:
result = puppeteer.execute_command(action.function, action.arguments)
if not utils.is_json_serializable(result):
result = "" # 非可序列化结果不外泄
return result
except Exception as e:
raise RuntimeError(f"Failed to execute action {action.function}: {e}")

它挂在哪: 上游的 MCP action 服务器(ufo/client/mcp/local_servers/ui_mcp_server.py:256) 持有一个 ActionExecutor 实例,每个 UI 动作工具都经它 execute 落地 (ui_mcp_server.py:269)。这就是 02 决策流水线与本执行层的 接缝。

5.2 path_validator:路径穿越(CWE-22)防护

一旦允许 Shell 读写文件、允许 Word「另存为任意路径」,就有路径穿越风险:模型被诱导写出 ../../etc/passwdC:\Windows\...ufo/automator/path_validator.py 是一组纯函数专门堵这个洞:

函数保证源码
validate_path_within_base解析后必须仍在允许的 base 目录内path_validator.py:41
validate_path_not_sensitive不得指向系统敏感目录(C:\Windows/etc …)path_validator.py:66
validate_save_path保存操作专用:拒绝 ..、拒绝敏感目录path_validator.py:92

核心手法是「先 resolve() 成绝对路径,再判前缀」——因为 .. 在 resolve 后会被真实展开,骗不过 前缀检查(path_validator.py:59):

# ufo/automator/path_validator.py:59 解析后必须落在 base 之内,否则拒绝
if not (str(resolved).startswith(str(base) + os.sep) or resolved == base):
raise ValueError(
f"Path '{path_str}' resolves outside the allowed base directory '{base}'")

这些校验真的被 API 接收者用上了:Word / Excel / PowerPoint 的另存为都先过 validate_save_path 再落盘(如 word/wordclient.py:184),把 LLM 给的目录关进文档所在目录或 工作目录之内。

5.3 Shell 接收者:允许清单 + 危险模式双重闸门

ShellReceiver 面对的是最危险的动作面——执行命令。它的防护比路径校验更狠,是默认拒绝的 allow-list 模型(shell/shell_client.py):

  • 命令允许清单 ALLOWED_SHELL_COMMANDS(shell_client.py:33)——只放行 Get-ChildItemGet-ContentTest-Path 等一批只读/无害的 PowerShell cmdlet 和常见工具;pingipconfigwhoami 等信息泄露类命令被故意排除(注释里写明理由)。
  • 危险模式黑名单 _DANGEROUS_PATTERNS(shell_client.py:66)——正则拦截 Invoke-ExpressionDownloadString::(.NET 静态调用)、; / &&(语句拼接绕过)、注册表 provider 等。
  • 运行时降权——即便字符串蒙混过关,子进程还被强制进 PowerShell 的 ConstrainedLanguage 模式 (shell_client.py:397,设 __PSLockdownPolicy=4),且 shell=False + 显式参数列表,杜绝注入。
  • 参数级路径校验 _validate_command_paths(shell_client.py:234)——把命令里每个像路径的 token 都验一遍,堵住「用允许清单里的 Get-Content 去读 C:\Users\...\id_rsa」这类绕过。

判定入口 _is_command_allowed(shell_client.py:167)要求基命令在清单内 AND 不含任何危险模式, 两个条件同时满足才放行——这是典型的纵深防御:允许清单是主闸,黑名单和运行时降权是补漏。


6. 巧妙之处(可借鉴的技术)

  • 命令模式让「异构执行」变同构。 GUI 点击和 COM 另存为在底层毫无共同点,但都被封成 CommandBasic.execute(),挂在同一张路由表下。上层一句 execute_command(name, params) 通吃—— 新增一种应用的 API,只是多写几个 @Receiver.register 命令类,零改上层。参见 basic.py:52

  • 「返回 None 即退回 GUI」是极简的能力协商。 不需要复杂的「查询这个应用支不支持 API」协议: API 工厂造得出接收者就用 API,造不出(return None)就自然只剩 GUI。降级是缺省行为,不是 显式分支。见 factory.py:51puppeteer.py:238

  • 两层自注册装饰器把「命令→接收者→管理器」全靠 import 时的副作用连好,省掉一大坨手工配置表: 命令类 @Receiver.register(basic.py:52)、工厂 @ReceiverManager.register(puppeteer.py:293)。

  • GUI 写文字的四级 fallback + 写后校验(controller.py:182)是「模拟人」路线的稳健性教科书: 永远假设上一步可能没生效,每一步都留后路,并用「文本是否真出现」来判定成败。

  • Shell 的默认拒绝 + 运行时降权(shell_client.py)体现「不信任 LLM 输出」:允许清单是主闸, 危险模式黑名单、ConstrainedLanguage、shell=False、参数路径校验层层叠加,任一层漏了还有下一层。


7. 边界与局限

  • 强 Windows 绑定。 GUI 依赖 pywinauto/pyautogui,API 依赖 win32com;非 Windows 上这些模块被 条件性置空(basic.py:44controller.py:19),接收者实际不可用。
  • API 覆盖面窄。 只有 Word / Excel / PowerPoint(COM)、两个浏览器(Web)、Shell 走 API;其余 一切应用都退回 GUI,继承 GUI 的全部脆弱性。
  • 撤销基本没实现。 命令模式预留了 undo(basic.py:88),但绝大多数命令没重写它——动作大多 不可回滚,靠的是执行前校验而非事后撤销。
  • GUI 校验只看 enabled/visible。 _control_validation(action_execution.py:28)只检查控件可用 可见,不保证「点下去的语义正确」;点错控件仍可能发生,只能靠上游重新感知纠偏。

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

主题文件路径关键符号
操偶师 / 调用方,组装+分发+执行命令ufo/automator/puppeteer.pyAppPuppeteercreate_commandexecute_command
接收者路由表 + 工厂注册ufo/automator/puppeteer.pyReceiverManagercreate_api_receiverget_receiver_from_command_name
命令/接收者/工厂抽象基类 + 注册装饰器ufo/automator/basic.pyCommandBasicReceiverBasic.registerReceiverFactoryself_command_mapping
GUI 通路:pywinauto 点击/输入/滚动ufo/automator/ui_control/controller.pyControlReceiverset_edit_textclick_inputtransform_point
GUI 命令类(注册)ufo/automator/ui_control/controller.pyClickInputCommandSetEditTextCommandUIControlReceiverFactory
API 工厂:按应用名路由到 COM/Web/Shellufo/automator/app_apis/factory.pyCOMReceiverFactoryWebReceiverFactoryShellReceiverFactory
COM 接收者基类ufo/automator/app_apis/basic.pyWinCOMReceiverBasicWinCOMCommand
Word COM 客户端(API 动作 + 另存为)ufo/automator/app_apis/word/wordclient.pyWordWinCOMReceiverinsert_tablesave_as
Shell 接收者 + 安全闸门ufo/automator/app_apis/shell/shell_client.pyShellReceiverALLOWED_SHELL_COMMANDS_is_command_allowed_validate_command_paths
Web 接收者ufo/automator/app_apis/web/webclient.pyWebReceiverWebCrawlerCommand
执行编排:控件校验 + 接收者装配 + 净化ufo/automator/action_execution.pyActionExecutor.execute_control_validation
路径穿越防护(CWE-22)ufo/automator/path_validator.pyvalidate_path_within_basevalidate_save_pathvalidate_path_not_sensitive
执行层的上游接缝(MCP action server)ufo/client/mcp/local_servers/ui_mcp_server.pycreate_app_action_mcp_serverActionExecutor

相邻章节: 眼睛:控件检测与视觉定位 讲动作「落到哪个控件」的上游; 一步是怎么走的:处理器与策略流水线 讲「决定做什么」; 本章讲「决定后如何可靠落地」。全景见 index