Midscene — 架构与原理(视觉驱动的计算机操作)
30 秒导读: Midscene 让你用大白话指挥 AI 操作界面——"点登录按钮""在搜索框输入 coffee"。 它不读 DOM、不认 CSS 选择器,而是截一张图丢给多模态大模型,模型看图回答"下一步做什么、 点哪个坐标",引擎把这个坐标精确映射回真实屏幕像素并落成一次点击,然后再截一张图问模型 下一步——如此循环直到任务完成。同一套机制既能自动化浏览器,也能操作整台电脑桌面(computer-use)。
1. 这是什么(零基础也能懂)
一句话定义: Midscene 是一个视觉驱动的 UI 自动化 / 测试引擎——你用自然语言描述要做的事, 它靠"看屏幕截图"的多模态模型来完成操作,而不是靠页面结构。
解决什么问题 / 给谁用。 传统 UI 自动化(包括读 DOM、读无障碍树的 AI 工具)都依赖页面结构, 而结构既脆弱又不全:
- 选择器一重构就失效,维护成本高;
- 图标按钮、自定义控件、
<canvas>这类没有语义标记的元素,结构层根本"看不见"; - 原生 App 、跨域 iframe 够不着;
- 它判断不了"东西到底长得对不对"(颜色、高亮、布局)。
Midscene 换了个思路:只要人眼能看见,它就能操作。因为它只从截图工作,你只需一句句地用自然 语言描述每一步。这段"为什么"来自 README 的 "Why Midscene" 一节 1。
它能做什么(功能):
- 用 JS SDK 或 YAML 写自动化脚本,或交给 AI agent 自主执行;
- 覆盖 Web 浏览器、Android、iOS、HarmonyOS、桌面电脑,以及任意自定义界面——一套 API;
- 核心方法:
aiAct(执行一段自然语言任务)、aiQuery(从页面抽数据)、aiAssert(断言页面状态)等。
用起来什么样。 面向"整台电脑"的最小用法(computer-use):拿到一个操作本机桌面的 agent, 然后用一句话下命令 2。
// 示意,非源码:core 用法一瞥
import { agentForComputer } from '@midscene/computer';
const agent = await agentForComputer(); // 连接到本机主显示器
await agent.aiAct('打开浏览器, 搜索 "midscene",点第一个结果');
// 引擎内部:截图 → 问模型 → 点击/输入 → 再截图 → …… 直到完成
一句话直觉 / 类比。 把它想成一个只会看屏幕、只会用鼠标键盘的实习生:你不给它页面源码, 只给它"你现在看到的这张屏幕",它看图、决定动哪、你替它动手,然后你再拍一张新屏幕给它看。 它的全部智能都建立在"看图 → 说下一步"这一个动作上。
本节不涉及底层代码。目标:完全不懂的人读完知道"它是干嘛的、凭什么不看 DOM 也能操作"。
2. 顶层全景(它大概怎么转)
2.1 一张图看懂主循环
Midscene 的心脏是一个**「计划 → 执行 → 再截图 → 重规划」的闭环**。怎么读这张图:从上往下是一轮, 最后一步"没做完就回到顶上再来一轮",做完就退出。
用户一句话任务:"点登录按钮"
│
▼
┌─────────────────────────────┐
│ ① 截图 │ 截当前屏幕 → base64 (Device.screenshotBase64)
└─────────────────────────────┘
│ 截图 + 任务 + 可用动作清单(actionSpace)
▼
┌─────────────────────────────┐
│ ② 计划(问多模态模型) │ 模型看图,输出:下一步动作 + 目标坐标 + 是否完成
└─────────────────────────────┘ (genericXmlPlan / 自定义 planFn)
│ 模型给的坐标是"归一化 bbox"(如 0–1000)
▼
┌─────────────────────────────┐
│ ③ 视觉定位:坐标 → 真实像素 │ 按图像尺寸把归一化坐标映射回像素、取中心点
└─────────────────────────────┘ (mapLocateResultToPixelBboxByCoordinates)
│ 得到 {x, y} 像素中心
▼
┌─────────────────────────────┐
│ ④ 执行:落成真实点击/输入 │ {x,y} → 移动鼠标 → 按下 (InputPrimitives.pointer.tap)
└─────────────────────────────┘
│
▼
模型说"完成了"吗?
├── 否 → 回到 ①,再截一张图,带着历史重新规划(replan)
└── 是 → 结束,返回结果
这套循环的代码主线分三层:Agent.aiAct 3 收下任务 → 调 TaskExecutor.action(公开入口,
内部委托私有 runAction)4,runAction 里是一个 while (true) 的统一 plan/replan 循环 5;
每轮先问模型拿计划,再把计划"翻译成可执行任务"交给 TaskRunner 跑掉,若模型没说"完成"
(shouldContinuePlanning)就再转一圈 6。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪(包/文件) |
|---|---|---|
Agent (PageAgent) | 门面层:对外提供 aiAct/aiQuery/aiAssert,管模型运行时与缓存 | packages/core/src/agent/agent.ts |
TaskExecutor | 编排层:runAction 主循环——plan → convert → 执行 → 判断是否 replan | packages/core/src/agent/tasks.ts |
TaskRunner | 执行层:带状态机的任务队列,逐个跑 Planning/Action/Insight 任务并管截图与快照 | packages/core/src/task-runner.ts |
规划器 (genericXmlPlan / planFn) | 把"截图+任务+动作空间"喂给模型,解析出下一步动作 | packages/core/src/ai-model/llm-planning.ts |
| 模型适配层 (model-adapter / models) | 每个模型家族一套:如何组 prompt、如何解析坐标、坐标怎么归一化 | packages/core/src/ai-model/models/* |
| 视觉定位映射 | 归一化 bbox → 真实像素 bbox → 中心点 | .../shared/model-locate-result/* |
AbstractInterface + InputPrimitives | 设备抽象:声明有哪些原子动作(tap/type/scroll…) | packages/core/src/device/index.ts |
ComputerDevice / RDPDevice | 具体设备:把 {x,y} 落成真实鼠标键盘(本地桌面 / 远程 RDP) | packages/computer/src/* |
agent-tools(computer_*) | 把 agent 暴露成 MCP / CLI 工具 | packages/computer/src/agent-tools.ts |
2.3 主线走一遍(高层,不进代码)
- 你调
agent.aiAct("点登录按钮")。 TaskExecutor截一张屏,连同任务和"这台设备支持哪些动作"一起发给模型。- 模型回一段结构化文本:
<action-type>Tap</action-type>+ 一个归一化的目标坐标 + 一段思考。 - 引擎把归一化坐标按截图尺寸换算成真实像素,取 bbox 中心得到
{x, y}。 TaskRunner把动作 task 跑掉:设备层把{x, y}映射到真实屏幕(桌面多显示器时还要加显示器偏移),移动鼠标、按下。- 模型若没在回复里标
<complete>,引擎再截一张新图、带上历史,重复 2–5,直到完成或超过重规划上限。
3. 阅读地图(建议顺序)
Midscene 是个大型 monorepo(20+ 包),但理解它只需抓住一条主线:视觉驱动的动作闭环。 下面按"由浅入深"排好,建议顺序阅读:
-
主循环:aiAct 的「计划—执行—重规划」 —— 先看这条主线怎么转:
aiAct → TaskExecutor.runAction的while循环、什么时候 replan、计划怎么被"翻译成可执行任务" 交给TaskRunner、计划缓存(YAML)如何复用。这是全库的骨架。 -
纯视觉定位:从截图到屏幕像素 —— 全库工程含量最高的一环: 模型只会给"图上 大概哪块"(归一化 bbox),引擎如何按图像尺寸精确换算成像素、取中心、裁掉给模型 补的 padding、再加显示器偏移落到真实屏幕。定位准不准,全在这里。
-
可插拔模型家族:standard 与 custom 两条路 —— 为什么 Qwen、 Doubao、Gemini、UI-TARS、GLM 能即插即用:每个家族一套适配器,声明"坐标顺序 xy 还是 yx、归一化到 多少、怎么解析、要不要 padding";两条规划路线(标准 XML 规划 vs 完全自定义
planFn)。 -
设备抽象与动作落地:从 InputPrimitives 到真实桌面 —— 一套
InputPrimitives(tap/type/scroll/drag…)如何自动生成"动作空间"喂给模型;ComputerDevice如何用 libnut / AppleScript 把动作落成真实鼠标键盘;截图、多显示器几何、坐标偏移的处理。 -
对外形态:computer_* 工具、本地/RDP 两种后端 —— Midscene 怎么 被别的 agent 使用:
computer_connect / computer_list_displays等 MCP/CLI 工具,以及同一套 agent 如何切换"操作本机桌面"与"通过 RDP 操作远程 Windows"两种后端。
只想要一句话精华,读完 §2 就够;想真正读源码,按上面 1→5 顺序下钻。
4. 巧妙之处(可借鉴的技术)
这些是读完值得带走的设计决 策。每条先点"妙在哪",再给源码锚点。
4.1 「纯视觉」把"定位"变成模型的事,把"精确"留给引擎
传统方案要引擎去理解页面结构才能定位元素;Midscene 把定位职责整个交给多模态模型——模型只需 在图上圈出"大概这块"(一个归一化 bbox 或一个点)。引擎不负责"理解界面",只负责一件确定性的事: 把模型给的归一化坐标,按当前图像尺寸精确换算回像素,再取 bbox 中心作为落点 7 8。
妙在分工:模糊的语义理解交给模型(它擅长),精确的像素换算交给代码(它可靠)。两边都做自己
最擅长的事。pointFromLocate 最后就是简单地取 locate.center 当 {x, y} 9——所有
复杂度都被前面的映射吸收掉了。
4.2 "给模型补 padding,再把补的裁掉":preparedSize vs contentSize
有些模型要求输入图像的边长是某个块大小的整数倍(如 Qwen 的 padBlockSize: 28)。Midscene 会给截图
补边到合规尺寸再发给模型,但记住两个尺寸:preparedSize(补过的,模型看到的)和 contentSize
(真实截图内容)10。
妙在:坐标按 preparedSize 解析(因为模型是对着补过的图说的),但最终裁剪到 contentSize——
这样模型偶尔"点到"补出来的空白 padding 上,也不会被当成有效 UI 位置 11。一个尺寸对模型、
一个尺寸对现实,互不污染。
4.3 坐标系"数据化":一张配置表适配所有模型家族
不同模型吐坐标的习惯不一样:Qwen/Doubao 是 xy 顺序、归一化到 1000;Gemini 是 yx 顺序、也归一化到
1000;还有的直接给绝对像素。Midscene 没有为每个模型写 if-else,而是把这些差异声明成一份数据配置
{ shape, order, normalizedBy },由同一个映射函数消费 12 13 14。
妙在:加一个新模型 = 加一条配置,不动映射逻辑。order: 'yx' 时映射函数自动把坐标翻成 xy
再算 15。数据驱动,而非逻辑分叉。
4.4 动作空间"从原语自动长出来",还顺带喂给模型
设备只要声明自己有哪些输入原语(InputPrimitives:tap、typeText、scroll、dragAndDrop…),
defineActionsFromInputPrimitives 就自动把它们编译成一组带 zod schema 的 DeviceAction 16。
这份"动作空间"既是执行时的分发表,又被序列化进 prompt 告诉模型"你现在只能用这些动作" 17。
妙在:新设备只需实现几个原语,动作空间与模型提示自动同步。桌面、浏览器、手机共用这套机制, 所以"一套 API 打通所有平台"不是口号,而是这层抽象的自然结果。
4.5 计划可缓存成 YAML,命中就跳过问模型
aiAct 成功后,会把这一轮规划出来的动作序列存成 YAML 工作流缓存;下次同样的 prompt 命中缓存时,
直接回放 YAML、不再问模型 18。若回放失败(界面变了),再退回正常的"问模型"路径并让旧缓存
失效 19。妙在用一次真实规划的结果换后续的确定性与速度,同时对界面漂移有兜底。
4.6 同一个 agent,本地桌面与远程 RDP 无缝切换
agentForComputer 造本地 ComputerDevice,agentForRDPComputer 造 RDPDevice,两者都实现同一个
AbstractInterface 20。computer_* 工具里,只要调用方传了 host 就自动切到 RDP 模式,
不传就是本机 21。妙在:上层 aiAct 循环完全不知道自己在操作本机还是一台远程 Windows——
差异被死死封在设备层。
5. 代码地图(导航索引)
用符号名可 grep,比行号抗漂移。all as-of commit 948eded9。
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| 对外门面 / aiAct 入口 | packages/core/src/agent/agent.ts | Agent、aiAct |
| 主循环 plan/replan | packages/core/src/agent/tasks.ts | TaskExecutor.runAction、convertPlanToExecutable |
| 单批执行 / 任务状态机 | packages/core/src/task-runner.ts | TaskRunner.flush |
| 是否继续规划的判定 | packages/core/src/agent/tasks.ts | shouldContinuePlanning |
| 标准 XML 规划器 | packages/core/src/ai-model/llm-planning.ts | plan、parseXMLPlanningResponse |
| 规划器导出别名 | packages/core/src/ai-model/workflows/planning/index.ts | genericXmlPlan |
| 归一化坐标→像素 bbox | .../ai-model/shared/model-locate-result/pixel-bbox-mapper.ts | mapLocateResultToPixelBboxByCoordinates |
| 归一化换算与裁剪 | .../ai-model/shared/model-locate-result/bbox.ts | mapNormalizedCoordinatesToPixelBbox、finalizePixelBbox |
| 定位坐标系默认配置 | packages/core/src/ai-model/model-adapter/locate.ts | defaultLocateResultAdapterDefinition、resolveLocate |
| 标准/自定义规划解析 | packages/core/src/ai-model/model-adapter/planning.ts | resolvePlanning |
| 模型家族注册表 | packages/core/src/ai-model/models/registry.ts | MODEL_ADAPTER_CONFIGS、getModelAdapter |
| 各家族坐标配置 | packages/core/src/ai-model/models/{qwen,doubao,gemini}.ts | qwenAdapters、doubaoVisionAdapter、geminiAdapters |
| 图像预处理(补边/双尺寸) | packages/core/src/ai-model/workflows/image-preprocess.ts | prepareModelImage、preparedSize/contentSize |
| 设备抽象 & 动作空间生成 | packages/core/src/device/index.ts | AbstractInterface、InputPrimitives、defineActionsFromInputPrimitives、pointFromLocate |
| 桌面设备实现 | packages/computer/src/device.ts | ComputerDevice、inputPrimitives、toGlobalPoint、screenshotBase64、actionSpace |
| 局部坐标→全局屏幕偏移 | packages/computer/src/device.ts | mapDisplayLocalPointToGlobal |
| 底层鼠标键盘驱动 | packages/computer/src/input-driver.ts | ComputerInputDriver |
| 本地/RDP agent 工厂 | packages/computer/src/agent.ts | agentForComputer、agentForRDPComputer |
| 远程桌面后端 | packages/computer/src/rdp/device.ts | RDPDevice |
| MCP/CLI 工具 | packages/computer/src/agent-tools.ts | ComputerMidsceneTools、computer_connect |
| CLI 入口 | packages/computer/src/cli.ts | runToolsCLI(stripPrefix: 'computer_') |