Page-Agent:阿里开源的页面内 GUI Agent

Chrome上搜到一个比较好玩的插件

试验了下让分析2026苹果大会都发布了哪些好的产品

纯 JS 实现的 GUI agent。使用自然语言操作你的 Web 应用。无须后端、客户端、浏览器插件。

https://github.com/alibaba/page-agent

项目特点是

  • 无需 浏览器插件 / Python / 无头浏览器,纯页面内 JavaScript
  • 基于文本的 DOM 操作,无需截图,无需多模态模型或特殊权限
  • 自备 LLM,可选的 Chrome 扩展,支持跨页面任务

当一个 AI Agent 要帮你在网页上完成一个任务------比如「帮我在电商网站上找到最便宜的蓝牙耳机」------它到底经历了什么?它不像你有眼睛,能一眼看到页面上的按钮和输入框;它也没有手指,没法直接点击屏幕。它唯一拥有的,是一个大语言模型,和一堆精心设计的「桥梁代码」。Page-Agent 就是一个做这件事的开源项目。它把浏览器变成一个 AI 可以操作的「沙盒」,让 LLM 能像人一样看页面、点按钮、填表单、翻页、跨标签页操作。

我让AI帮我通读了下源码,发现里面藏了值得拿出来单独讲一讲的精巧设计。有些是巧妙绕过框架限制的黑魔法,有些是处理边界条件的防御性编程,还有些是架构层面的优雅决策。

这篇文章就带你逐个拆开看看。

整体架构:一场跨上下文的「远程手术」

在深入到细节之前,先理解一个最核心的约束:AI Agent 的代码和实际 DOM 操作跑在不同的上下文中

复制代码
┌──────────────────────────────────────────────────────────────┐
│  扩展页面(sidepanel / popup)                                  │
│  MultiPageAgent ── Agent 循环:LLM 调用 → 工具执行              │
│       │                                                       │
│       ▼                                                       │
│  chrome.runtime.sendMessage                                   │
├──────────────────────────────────────────────────────────────┤
│  Background Service Worker(中转站)                            │
│       │                                                       │
│       ▼                                                       │
│  chrome.tabs.sendMessage                                      │
├──────────────────────────────────────────────────────────────┤
│  Content Script(页面内)                                       │
│  PageController ── 真实 DOM 操作                               │
│  PageController ── 提取浏览器状态                               │
└──────────────────────────────────────────────────────────────┘

绕过 React 值追踪的「原型链劫持」

问题

想象一下,Agent 要往一个 React 应用的输入框里填文字。最直观的做法是什么?

javascript 复制代码
element.value = "你好世界"

然后呢?页面上什么都没发生。React 的表单状态没更新,按钮还是灰色的,表单还是没法提交。

为什么?因为 React 在内部维护了一个值追踪系统。当你在 <input> 上设置 value 时,React 会拦截这个操作,用自己的合成事件系统来管理值。直接通过 DOM API 赋值,React 根本不知道值变了。

解法

Page-Agent 的做法堪称优雅。它没有去模拟键盘输入(那样太慢了),而是直接「劫持」了原生属性设置器:

typescript 复制代码
// utils/index.ts
export function getNativeValueSetter(element: HTMLInputElement | HTMLTextAreaElement) {
    return Object.getOwnPropertyDescriptor(
        Object.getPrototypeOf(element) as object,
        'value'
    )!.set as (v: string) => void
}

// actions.ts
getNativeValueSetter(element).call(element, text)
element.dispatchEvent(new Event('input', { bubbles: true }))

这行代码在做什么?

React 确实在 element 实例上拦截了 value 属性的赋值------但它没有拦截原型链上 的那个原生 setter。通过 Object.getPrototypeOf(element) 拿到 HTMLInputElement.prototype,再取出原始的 set 方法,然后 .call(element, text) 把它绑定回实际的 DOM 元素。

这就像 React 在前门装了保安,但代码从后门(原型链)悄悄进去了。

最后再 dispatch 一个 input 事件,通知 React 的变更检测系统:"嘿,值变了,该更新了。"

contenteditable 的「双计划」策略

问题

如果说 <input><textarea> 还算好对付,那 contenteditable 元素才是真正的泥潭。

网页上有无数种富文本编辑器:Quill、Slate.js、Draft.js、Monaco/CodeMirror......它们各有各的方式来管理内容。有些监听 input 事件,有些监听 beforeinput,有些完全不管标准事件,只认浏览器原生的编辑命令。

一个 Agent 怎么可能适配所有情况?

解法

Page-Agent 采用了一个「先礼后兵」的双计划策略:

typescript 复制代码
// Plan A:派发合成事件 + 直接设置 innerText
element.dispatchEvent(
    new InputEvent('beforeinput', {
        bubbles: true, cancelable: true,
        inputType: 'insertText', data: text,
    })
)
element.innerText = text
element.dispatchEvent(new InputEvent('input', {
    bubbles: true, inputType: 'insertText', data: text,
}))

// 验证 Plan A 是否成功
const planASucceeded = element.innerText.trim() === text.trim()

// Plan B:回退到 execCommand
if (!planASucceeded) {
    element.focus()
    const range = doc.createRange()
    range.selectNodeContents(element)
    selection?.removeAllRanges()
    selection?.addRange(range)
    doc.execCommand('delete', false)
    doc.execCommand('insertText', false, text)
}

这招妙在哪里?

Plan A 是现代的、干净的方式:派发标准事件,直接设置文本。这能搞定 React 的 contenteditable 和 Quill 等主流编辑器。

但它加了一个验证步骤 ------设完之后检查 innerText 是不是真的变成了目标文本。如果没有,说明这个编辑器不吃这一套。

于是 Plan B 出场:document.execCommand。这个方法已经被 W3C 废弃了,但浏览器仍然支持它。更重要的是,它是浏览器原生的编辑命令,Slate.js 这类富文本编辑器对它处理得非常好。

代码里还明明白白地写了不支持的清单:Monaco/CodeMirror 需要 JS 实例访问,Draft.js 对两种方式都不响应(且本身已经不再维护)。

LLM 告诉 Agent:「点击索引 42 的元素」。Agent 找到了那个元素,是一个 <div>。它直接 div.click() 就行了吗?

不行。因为这个 <div> 里面可能有一个 <button>,一个 <img>,一个 <span> 图标。在真实的浏览器里,用户的鼠标点击永远落在最深层的那个元素上------这是浏览器的事件模型决定的。

如果 Agent 点击的是父容器而不是里面的按钮,事件处理函数可能根本不会触发。

解法

typescript 复制代码
// actions.ts - clickElement
// 1. 移动虚拟指针到元素中心
const { x, y } = getCenterPosition(element)

// 2. 临时启用遮罩穿透(否则 elementFromPoint 只能看到遮罩层)
await enablePassThrough()

// 3. 命中测试:找到坐标处最深的元素
const hitTarget = doc.elementFromPoint(x, y)
await disablePassThrough()

// 4. 安全检查:确保命中的元素确实是原元素的后代
const target = hitTarget instanceof HTMLElement && element.contains(hitTarget)
    ? hitTarget
    : element

这个流程的精妙之处:

第一步enablePassThrough()。Page-Agent 在页面上有一个可视化的遮罩层(SimulatorMask),用来显示 Agent 的操作动画。但遮罩层会挡住 elementFromPoint------因为遮罩层永远在最上层。所以要先设置 pointerEvents: 'none' 让遮罩层「穿透」。

第二步document.elementFromPoint(x, y)。这是浏览器原生 API,返回指定坐标处最深的那个元素。完美模拟了真实用户点击的行为。

第三步 ,安全检查。element.contains(hitTarget) ? hitTarget : element。如果命中测试返回了一个毫不相干的元素(比如一个浮动的 tooltip),它会安全地回退到原始元素。

还有一个细节:element.focus() 是在原始元素 上调用的,不是在命中测试的目标上。这匹配真实浏览器行为------点击一个 <label> 里的图标,焦点仍然会跑到对应的 <input> 上。

Page-Agent 用了一个极简的 lerp(线性插值)方案,每帧移动剩余距离的 20%:

typescript 复制代码
#moveCursorToTarget() {
    const newX = this.#currentCursorX +
        (this.#targetCursorX - this.#currentCursorX) * 0.2
    const newY = this.#currentCursorY +
        (this.#targetCursorY - this.#currentCursorY) * 0.2

    // 距离小于 2px 时直接吸附,避免无限微调
    if (Math.abs(newX - this.#targetCursorX) < 2) {
        this.#currentCursorX = this.#targetCursorX
    } else {
        this.#currentCursorX = newX
    }

    this.#cursor.style.left = `${this.#currentCursorX}px`
    this.#cursor.style.top = `${this.#currentCursorY}px`

    requestAnimationFrame(() => this.#moveCursorToTarget())
}

这招妙在哪?

0.2 的插值因子创造了一条指数衰减的缓动曲线------距离目标远的时候移动快,接近目标时逐渐减速。就像你用手去拿桌上的东西,手也是先快后慢的。

而且它比 CSS transition 更优雅:无论目标距离多远,运动的「感觉」都是一样的,因为缓动是基于比例的,不是基于绝对时间的。

< 2px 的吸附阈值避免了浮点数精度导致的无限微调问题------经典的「Zeno 悖论」式 bug。

点击动画的重启 trick

typescript 复制代码
triggerClickAnimation() {
    this.#cursor.classList.remove(cursorStyles.clicking)
    void this.#cursor.offsetHeight  // 强制 reflow 来重启动画
    this.#cursor.classList.add(cursorStyles.clicking)
}

void this.#cursor.offsetHeight 是一个经典的黑魔法:读取 offsetHeight 会触发浏览器的 layout 计算,从而强制一次 reflow。这个 reflow 会让浏览器「注意到」class 已经先移除再加上了,于是 CSS 动画会重新开始。

LLM 响应格式化器:五种畸形输入的「急诊室」

问题

LLM 是出了名的「不按格式出牌」。即使你给了明确的 JSON schema,它有时也会:

  • 直接把工具名作为最外层 key
  • 把 JSON 藏在字符串里(双序列化)
  • 把 JSON 嵌在自然语言文本中间
  • 只返回反思字段,忘了返回 action
  • 把原始值当成对象({"click_element_by_index": 2} 而不是 {"click_element_by_index": {"index": 2}}

解法

autoFixer.ts 是一个响应急诊室,专门救治各种畸形的 LLM 输出:

typescript 复制代码
// Case 1: 模型直接返回工具名作为 key
// {"click_element_by_index": {"index": 2}}
// → 包装为 { action: {"click_element_by_index": {"index": 2}} }

// Case 2: JSON 藏在 message.content 里
// {"name": "AgentOutput", "arguments": "{...}"}
// → 解包到内部 arguments

// Case 3: 双层嵌套
// {"type": "function", "function": {"arguments": "{...}"}}
// → 解包到 arguments

// Case 4: 缺少 action 包装,原始工具对象
// {"click_element_by_index": 2}
// → 包装为 { action: {"click_element_by_index": 2} }

// Case 5: 完全缺失 action
// {"memory": "remembering stuff"}
// → 补上默认 action: { wait: { seconds: 1 } }

还有两层额外处理:

双字符串参数修复

typescript 复制代码
// LLM 有时会把参数序列化成两次 JSON
if (typeof argString === 'string') {
    try { argString = JSON.parse(argString) } catch {}
}

原始值强制转换

typescript 复制代码
// {"click_element_by_index": 2} 需要转为 {"click_element_by_index": {"index": 2}}
// 方法:读取 Zod schema,找到第一个 required 字段,把原始值塞进去

从散文里提取 JSON

typescript 复制代码
function retrieveJsonFromString(text: string): unknown | null {
    // 用正则 ({[\s\S]*}) 匹配花括号包裹的内容
    const match = text.match(/({[\s\S]*})/)
    if (match) return JSON.parse(match[1])
}

Page-Agent 浏览器操作机制分析

整体架构:跨上下文

复制代码
MultiPageAgent (扩展页面/sidepanel)
    │
    ├── TabsController ──── chrome.runtime.sendMessage ──→ Background ──→ chrome.tabs.* API
    │
    └── RemotePageController ── chrome.runtime.sendMessage ──→ Background ──→ chrome.tabs.sendMessage
                                                                             ↓
                                                                    Content Script
                                                                         ↓
                                                                 PageController (真实 DOM 操作)

核心设计原则:实际 DOM 操作只在 Content Script 中执行,Agent 侧通过消息传递代理调用。


1. DOM 提取:页面状态读取

每步循环调用 pageController.getBrowserState(),内部流程:

复制代码
updateTree()
  │
  ├─ ① 临时禁用遮罩 (pointerEvents='none')
  ├─ ② 清除上次高亮标记
  ├─ ③ dom.getFlatTree()  重建 DOM 树
  ├─ ④ dom.flatTreeToString()  生成 LLM 可读文本
  ├─ ⑤ 重建 selectorMap (index → HTMLElement)
  └─ ⑥ 重建 elementTextMap (index → 描述文本)

交互式元素识别 (isInteractiveElement) 多层检测:

层级 检测方式
1 黑名单/白名单过滤
2 鼠标光标样式(25 种:pointer, text, grab 等)
3 原生 HTML 标签(a, button, input, select, textarea 等)
4 ARIA 角色(button, tab, switch, combobox 等)
5 contenteditable 属性
6 类名启发式(.button, .dropdown-toggle, [data-toggle="dropdown"]
7 事件监听器检测(getEventListeners()onclick 属性)
8 可滚动容器(检查 overflowX/Y + scroll 尺寸)

生成的简化 HTML 格式

复制代码
[0]<a aria-label=首页 />
[1]<div >P />
[5]<a role=button>快速开始 />

2. 点击操作:W3C 指针事件序列

actions.tsclickElement(element) 模拟完整点击流程:

复制代码
1. blur 上次聚焦元素
   → pointerout → pointerleave → mouseout → mouseleave → .blur()

2. 滚动到可视区域
   → scrollIntoViewIfNeeded() / scrollIntoView({block: 'center'})

3. 移动虚拟指针
   → CustomEvent('PageAgent::MovePointerTo') → 视觉光标动画

4. 命中测试 (hit-test)
   → document.elementFromPoint(x, y) 找到最深层元素

5. 完整事件序列:
   pointerover → pointerenter → mouseover → mouseenter
   pointerdown → mousedown
   element.focus({preventScroll: true})
   pointerup → mouseup
   target.click()  ← 触发默认行为(导航/提交等)

6. 等待 200ms 让动画/状态变化生效

3. 文本输入:三种输入方式适配

inputTextElement(element, text) 针对不同元素类型:

元素类型 实现方式
<input> / <textarea> getNativeValueSetter() 绕过 React 合成值追踪 → element.value = text → dispatch input 事件
contenteditable Plan A dispatch beforeinput 事件 → 直接设置 element.innerText(适用于 React/Quill)
contenteditable Plan B document.execCommand('delete') + execCommand('insertText')(Slate.js 等富文本编辑器 fallback)
<select> selectElement.value = option.value → dispatch change 事件

4. 滚动操作

scrollVertically() / scrollHorizontally()

复制代码
指定元素 index:
  → 向上遍历 DOM 树(最多 10 层祖先)
  → 检查 overflowY/X CSS + scrollHeight vs clientHeight
  → 直接设置 scrollTop / scrollLeft

页面级滚动(无元素 index):
  → 从 document.activeElement 向上找可滚动祖先
  → fallback: querySelectorAll('*') 扫描
  → 最终: window.scrollBy()

5. 消息传递协议

Agent → Content Script 消息格式:

typescript 复制代码
{
  type: 'PAGE_CONTROL',
  action: string,        // 'click_element', 'input_text', 'scroll'...
  targetTabId: number,   // 目标标签页 ID
  payload?: any[]        // 操作参数
}

路由流程

复制代码
RemotePageController.clickElement(5)
  → sendMessage({ type: 'PAGE_CONTROL', action: 'click_element', targetTabId, payload: [5] })
    → Background: handlePageControlMessage()
      → chrome.tabs.sendMessage(targetTabId, message)
        → Content Script: PageController.clickElement(5)

遮罩层协调 :Content Script 每 500ms 轮询 chrome.storage.local

  • isAgentRunning:Agent 是否运行中
  • agentHeartbeat:时间戳(每 1000ms 更新)
  • currentTabId:Agent 当前操作的标签页

6. 标签页管理 (TabsController)

复制代码
init(task)
  ├─ 获取当前活动标签页
  ├─ 创建标签页组 `PageAgent(<task>)`
  └─ 连接事件流: chrome.runtime.connect('tab-events')

openNewTab(url)
  ├─ chrome.tabs.create() → 新标签页
  ├─ 加入标签页组
  ├─ 切换到新标签页
  └─ waitUntilTabLoaded()

switchToTab(tabId)
  ├─ 验证 tabId 在追踪列表中
  └─ 更新 currentTabId (本地 + storage)

closeTab(tabId)
  ├─ 不能关闭初始标签页
  └─ 关闭后自动切换到最后一个剩余标签页

summarizeTabs()
  └─ 返回 Markdown 表格,显示在 browser_state 头部给 LLM

与 Agent 循环的协调

  • onBeforeTask:初始化 TabsController + 心跳
  • onBeforeStepwaitUntilTabLoaded() 确保标签页加载完成
  • onAfterTask:停止心跳

7. 视觉反馈系统

Content Script 中 SimulatorMask 监听 CustomEvent 实现:

事件 效果
PageAgent::MovePointerTo 平滑插值动画移动光标(damping 0.2)
PageAgent::ClickPointer 显示点击动画
PageAgent::EnablePassThrough 遮罩穿透(允许用户交互)
PageAgent::DisablePassThrough 遮罩拦截(Agent 独占控制)

同时生成高亮标记框(可选),显示数字标签对应简化 HTML 中的 [index]

总结:一个 Agent 是怎么「活」起来的

读完 Page-Agent 的源码,我最大的感受是:好的 Agent 代码不是「调个 API 就完了」,而是把 LLM 的不可靠性当做一等公民来设计

你看这些设计:

  • LLM 返回的格式不一定对?加一个急诊室级别的格式化器
  • LLM 容易忘记之前做了什么?强制它每一步都写反思
  • LLM 可能无限等待?累计等待时间超过 3 秒就警告
  • LLM 重试时容易犯同样的错?把温度调到 0.7
  • 不同 LLM 行为不同?给每个模型家族开小灶
  • 网络可能抖动?自动重试 2 次
  • 认证可能失效?立即放弃,不浪费时间

每一个设计点都指向同一个哲学:LLM 是一个不可靠的协作者,你需要为它的每个可能的失败模式准备一条路。

这不是一个 LLM 的「客户端」,这是一个 LLM 的「监护人」。

而浏览器操作那一层------绕过 React 的值追踪、双计划 contenteditable、命中测试找最深元素、多点采样检查遮挡------每一个都是在跟真实世界的混乱作斗争。网页不是标准测试页面,它们充满了框架、覆盖层、自定义事件、废弃 API。Agent 要能在这种环境下存活,代码必须比 LLM 的 prompt 更聪明。

Page-Agent 的源码告诉我们:做一个好用的浏览器 AI Agent,LLM 只占一半的功夫,剩下的一半在于------你怎么把 LLM 的「想法」安全、可靠、优雅地变成页面上的真实操作。

这,才是真正有趣的部分。


相关推荐
时光追逐者2 小时前
一个基于 .NET 与 Avalonia 构建、面向 TrinityCore 的开源 WoW 数据库编辑器
数据库·开源·.net
开源推荐官2 小时前
2026 商城系统源码实测,真正适合二开的系统有哪些?
java·架构·开源
sensen_kiss2 小时前
CPT304 SoftwareEngineeringII 软件工程 2 Pt.7 开源开发 (Open Source Development)
开源·软件工程
睡不醒男孩0308238 小时前
第二篇:深入探索开源数据库高可用:构建基于CLup的PostgreSQL生产级高可用与读写分离架构
数据库·postgresql·开源·clup
凌云拓界12 小时前
文件管理:让AI安全操作你的电脑 ——CogitoAgent开发实战(三)
javascript·人工智能·架构·开源·node.js
冬奇Lab13 小时前
每日一个开源项目(第126篇):turbovec - 向量索引的内存杀手,1千万文档从31GB压到4GB
人工智能·开源·llm
CRMEB系统商城14 小时前
CRMEB多商户系统(Java)v2.3公测版发布
java·开发语言·人工智能·小程序·开源·php
网络研究院16 小时前
AgentGG:开源的代理式 SAST 扫描器
开源·github·工具·网络研究观·agentgg
sbjdhjd17 小时前
面试(5)| 3.5 小时面试复盘第五弹:加班出差 + 客户响应 + 压力面全拆解
经验分享·程序人生·面试·职场和发展·开源·跳槽·求职招聘