跳到主要内容

给模型装手脚:动作注册表、structured output、落到真实元素

30 秒导读: 上一章(02-perception-dom)给模型装了「眼睛」——把网页压成一份带编号的可交互元素清单。本章装「手脚」:模型看完清单后只会说一句话——「点第 5 号元素」「往第 12 号输入框里打字 hello」。这句话是一段 JSON。本章讲透这段 JSON 的三段旅程:(1) 怎么被 schema 逼着生成(所有动作拼成一个可空联合,喂给 LLM 的 structured output)、(2) 怎么被逐个执行(Navigator 拿到数组,一个个跑,中途页面变了就刹车)、(3) 「第 5 号」怎么变成真实的那次点击(index → selectorMap → DOM 节点 → CSS 选择器 → Puppeteer 点下去)。


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

一句话定义

动作执行层是把「模型嘴上说的操作」变成「浏览器里真实发生的操作」的那一层。模型不会自己去点鼠标,它只会输出结构化文本;这一层负责接住文本、校验、翻译、落地

它要解决的那个尴尬

想象你雇了个助理,但你俩之间隔着一堵墙,只能递纸条。助理(模型)看不到屏幕的实时状态,只能靠你上一秒递进去的那张「页面快照 + 元素清单」。他写回来:

点 5 号,然后往 12 号里填 "hello"

问题来了:

  • 他怎么知道只能写这几种动作、每种要带什么参数? —— 得先把「可选动作 + 参数格式」这份说明书递给他,还得强制他照着填(不能自由发挥)。这是 schema / structured output
  • 「5 号」到底是屏幕上哪个像素? —— 递进去的清单里,5 号背后其实挂着一个 DOM 节点。得靠这个映射反查回真实元素。这是 index → selectorMap → 真实元素
  • 万一他一次写了三步,可点完第一步页面全变了怎么办? —— 后面两步的「5 号 / 12 号」可能已经指向别的东西了,得中途刹车。这是多动作执行 + 页面变更检测

用起来什么样

模型一次返回的东西长这样(简化,真实字段见下文):

{
"current_state": { "next_goal": "登录", "...": "..." },
"action": [
{ "input_text": { "index": 12, "text": "alice@example.com", "intent": "填邮箱" } },
{ "input_text": { "index": 13, "text": "secret", "intent": "填密码" } },
{ "click_element": { "index": 5, "intent": "点登录按钮" } }
]
}

action 是一个数组——模型可以一口气规划多步。本章就是讲这个数组从生成到落地的全过程。

一句话直觉

把动作层想成「翻译官 + 安全员」: 翻译官把「5 号」翻成页面上那个真实按钮;安全员在每步之间盯着页面——只要页面变得和当初递清单时不一样,就叫停,不让后面那些「基于旧编号」的动作乱点。


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

三个文件、三段职责:

部件干什么在哪个文件
动作 schema声明每个动作叫什么、带哪些参数(Zod)agent/actions/schemas.ts
Action 类 + Builder把 schema 包成可调用的动作对象;把所有动作注册成一张表;拼出联合 schemaagent/actions/builder.ts
NavigatorAgent用联合 schema 调 LLM(structured output)、容错解析、逐个执行、检测页面变更agent/agents/navigator.ts
Page真正落地:index→元素、定位、点击/输入browser/page.ts

主线走一遍(从模型开口到按钮被点):

┌─────────────────────────────────────────────┐
│ ① 组装说明书:把 N 个动作拼成一个联合 schema │
│ buildDynamicActionSchema (builder.ts:130) │
└───────────────────┬─────────────────────────┘
│ 喂给 LLM 当 structured output

┌─────────────────────────────────────────────┐
│ ② 调模型 + 容错解析 │
│ navigator.invoke (navigator.ts:93) │
│ → 得到 { current_state, action: [...] } │
└───────────────────┬─────────────────────────┘
│ action 数组

┌─────────────────────────────────────────────┐
│ ③ 逐个执行 + 页面变更检测 │
│ doMultiAction (navigator.ts:366) │
│ 每步之间:页面变了 → 刹车 │
└───────────────────┬─────────────────────────┘
│ 单个动作 {click_element:{index:5}}

┌─────────────────────────────────────────────┐
│ ④ 校验 + 分发 │
│ Action.call (builder.ts:53) │
│ handler 里:index → selectorMap → 节点 │
└───────────────────┬─────────────────────────┘
│ DOMElementNode

┌─────────────────────────────────────────────┐
│ ⑤ 落到真实元素 │
│ locateElement (page.ts:1027) │
│ CSS 优先 → XPath 兜底 → Puppeteer 点/打字 │
└─────────────────────────────────────────────┘

后面各节顺着这五步由浅入深展开。


3. 第一步:一个动作是什么(Action 类 + schema)

3.1 动作 = schema(声明)+ handler(干活)

每个动作有两半:一半是声明(叫什么、要什么参数),一半是实现(拿到参数后做什么)。

声明这半是一个纯数据对象,在 schemas.ts。例如点击动作:

// agent/actions/schemas.ts:46 clickElementActionSchema
export const clickElementActionSchema: ActionSchema = {
name: 'click_element',
description: 'Click element by index',
schema: z.object({
intent: z.string().default('').describe('purpose of this action'),
index: z.number().int().describe('index of the element'),
xpath: z.string().nullable().optional().describe('xpath of the element'),
}),
};

ActionSchema 接口就三个字段(schemas.ts:3):name(动作名)、description(给模型看的说明)、schema(Zod 校验规则)。

注意两个反复出现的字段设计:

  • intent:每个动作都带,让模型写一句「我为什么做这步」。执行时会 emit 成事件显示给用户(见 builder.ts:225input.intent || t(...))——既是给人看的进度条,也逼模型「想清楚再动手」。
  • index:凡是要操作页面元素的动作都带它。它就是上一章那份元素清单里的编号,是本章后半段「落到真实元素」的钥匙。

3.2 Action 类:把声明变成可调用对象

Action 类(builder.ts:44)把 handler + schema 包在一起,并记一个布尔 hasIndex——这个动作要不要操作元素:

// agent/actions/builder.ts:44 class Action
export class Action {
constructor(
private readonly handler: (input: any) => Promise<ActionResult>,
public readonly schema: ActionSchema,
public readonly hasIndex: boolean = false, // 有没有 index 参数
) {}
```

Action 提供四个关键能力,后面各节都会用到:

方法位置干什么
call(input)builder.ts:53执行前先用 schema 校验入参,不合法就抛 InvalidInputError
getIndexArg(input)builder.ts:101从入参里抠出 index(没有 hasIndex 就返回 null)
setIndexArg(input, n)builder.ts:117把入参里的 index 改成新值(replay 时重定位用,本章不展开)
hasIndex构造参数标记「这动作是否操作元素」——决定要不要做页面变更检测

call 里的校验是第一道防线:模型给的参数不符合 schema,直接拦下:

// agent/actions/builder.ts:66 Action.call
const parsedArgs = this.schema.schema.safeParse(input);
if (!parsedArgs.success) {
throw new InvalidInputError(parsedArgs.error.message);
}
return await this.handler(parsedArgs.data);

有个小细节:如果 schema 是空对象(如 done 之类不需要参数的动作),call 会跳过校验直接 handler({})(builder.ts:58-64)——避免空 schema 的边角问题。

3.3 handler 里长什么样:以 clickElement 为例

ActionBuilder.buildDefaultActions(builder.ts:152)是一个大工厂,把二十来个动作逐个 new Action(...)push 进数组。挑 clickElement 看一次完整 handler(浓缩自 builder.ts:223-277):

// agent/actions/builder.ts:223 clickElement 的 handler(浓缩)
const clickElement = new Action(
async (input) => {
// 1) 发「开始」事件(进度条),intent 优先用模型给的
this.context.emitEvent(NAVIGATOR, ACT_START, input.intent || t('act_click_start', [input.index]));

// 2) 拿当前页面状态,用 index 反查真实元素节点 ★ 本章核心
const page = await this.context.browserContext.getCurrentPage();
const state = await page.getState();
const elementNode = state?.selectorMap.get(input.index);
if (!elementNode) throw new Error(t('act_errors_elementNotExist', [input.index]));

// 3) 记录点击前的 tab 数(用于检测「点出了新标签页」)
const initialTabIds = await this.context.browserContext.getAllTabIds();

// 4) 真正点下去 —— 落到真实元素(见 §6)
await page.clickElementNode(this.context.options.useVision, elementNode);

// 5) 若冒出新 tab,自动切过去
const currentTabIds = await this.context.browserContext.getAllTabIds();
if (currentTabIds.size > initialTabIds.size) { /* switchTab 到新 tab */ }

return new ActionResult({ extractedContent: msg, includeInMemory: true });
},
clickElementActionSchema,
true, // ★ hasIndex = true
);

三个模式在几乎每个 handler 里重复,记住它们就懂了所有动作:

  1. emit 事件:ACT_START → 干活 → ACT_OK / ACT_FAIL,给 UI 和记忆用。
  2. index → selectorMap.get(index) → elementNode:这是「编号变真实元素」的那一步,凡是 hasIndex 的动作都这么干。
  3. 返回 ActionResult:里面 extractedContent 是给模型下一轮看的「这步发生了什么」,includeInMemory 决定要不要进短期记忆(记忆细节见 04-memory-and-security)。

3.4 二十来个动作,分四类

buildDefaultActions 注册的动作按用途分:

类别动作要 index 吗
收尾done
导航search_googlego_to_urlgo_backwait
元素交互click_elementinput_text
标签管理switch_tabopen_tabclose_tab
内容/滚动cache_contentscroll_to_percentscroll_to_top/bottomprevious_pagenext_pagescroll_to_textsend_keys部分可选
下拉框get_dropdown_optionsselect_dropdown_option

注意 scroll_* 系列的 index 是可选的(scrollToPercentActionSchemaindex.nullable().optional(),schemas.ts:122):不给就滚整个文档,给了就滚某个元素内部——handler 用 if (input.index) 分流(builder.ts:385)。


4. 第二步:把所有动作拼成一个 schema,逼模型照着填

4.1 为什么要「拼」

模型不能自由发挥——它必须只从这二十来个动作里选、且参数格式必须对。做法是:把每个动作的 schema 拼成一个大联合 schema,作为 LLM 的 structured output 约束。这样模型的输出天然就落在合法范围里。

4.2 buildDynamicActionSchema:每个动作是一个「可空可选」的键

拼装逻辑很短(builder.ts:130):

// agent/actions/builder.ts:130 buildDynamicActionSchema
export function buildDynamicActionSchema(actions: Action[]): z.ZodType {
let schema = z.object({});
for (const action of actions) {
const actionSchema = action.schema.schema;
schema = schema.extend({
// 每个动作 → 一个可 null、可省略的键
[action.name()]: actionSchema.nullable().optional().describe(action.schema.description),
});
}
return schema;
}

结果是一个每个键都可空可选的对象:

// 概念上等价于(示意,非源码):
z.object({
done: doneSchema.nullable().optional(),
click_element: clickSchema.nullable().optional(),
input_text: inputSchema.nullable().optional(),
// ... 其余二十来个
})

模型每返回一个这样的对象,就代表一个动作——它只填其中一个键,其余留空。文件里那句 TODO: can not make every action optional, don't know why(builder.ts:129)透露了作者也是踩着坑把每个键设成可空的——这是为了兼容不同 LLM 对「联合类型」的处理差异(尤其 Google Generative AI,builder.ts:135 的注释专门提到别用 default: null)。

4.3 外面再套一层:current_state + action 数组

NavigatorActionRegistry.setupModelOutputSchema(navigator.ts:62)在动作联合外面再包一层,得到模型整轮输出的完整 schema:

// agent/agents/navigator.ts:62 setupModelOutputSchema
setupModelOutputSchema(): z.ZodType {
const actionSchema = buildDynamicActionSchema(Object.values(this.actions));
return z.object({
current_state: agentBrainSchema, // 模型的「思考」:评估、记忆、下一步目标
action: z.array(actionSchema), // ★ 一个动作数组 → 支持一次多步
});
}

这就是本章开头那个 JSON 的来源:current_state(模型的自述,三字段结构见 01-agent-loop 与 §5.1 提到的 agentBrainSchema)+ action(要执行的动作数组)。

4.4 NavigatorActionRegistry:一张动作名 → Action 的表

注册表本身就是个 Record<string, Action>(navigator.ts:41),构造时把每个动作按 action.name() 塞进去:

// agent/agents/navigator.ts:41 NavigatorActionRegistry(浓缩)
private actions: Record<string, Action> = {};
registerAction(action) { this.actions[action.name()] = action; }
getAction(name) { return this.actions[name]; }

执行阶段就靠 getAction('click_element') 从名字找回那个 Action 对象。注册表 = 分发表


5. 第三步:调模型 + 逐个执行

5.1 invoke:用 structured output 调 LLM,还得容错

模型不总是乖乖返回合法 JSON,所以 invoke(navigator.ts:93)有三层兜底:

// agent/agents/navigator.ts:93 invoke(浓缩)
const structuredLlm = this.chatLLM.withStructuredOutput(this.jsonSchema, {
includeRaw: true, // ★ 同时留住原始文本,解析失败好补救
name: this.modelOutputToolName,
});
const response = await structuredLlm.invoke(inputMessages, { signal, ...callOptions });

if (response.parsed) return response.parsed; // 层1:直接解析成功

// 层2:解析失败但原始文本里可能有 ```json``` 块 → 手动抠出来
if (errorMessage.includes('is not valid JSON') && response?.raw?.content) {
const parsed = this.manuallyParseResponse(response.raw.content);
if (parsed) return parsed;
}

// 层3:有些模型 content 为空但塞在 tool_calls 里 → 取第一个 tool call
if (rawResponse.tool_calls?.length > 0) {
const toolCall = rawResponse.tool_calls[0];
return { current_state: toolCall.args.currentState, action: [...toolCall.args.action] };
}

值得注意:modelOutputSchema 那个 Zod 对象太复杂,不能直接喂 LLM,构造时先转成 JSON Schema(navigator.ts:90convertZodToJsonSchema)。不支持 structured output 的模型走 super.invoke(navigator.ts:155),用父类的纯文本 JSON 抠取。

5.2 fixActions:把「不是数组」的动作掰成数组

模型偶尔把 action 返回成字符串或单个对象,fixActions(navigator.ts:335)统一成数组、顺手滤掉 null:

  • 是数组 → 过滤掉 null 项(navigator.ts:339)。
  • 是字符串 → 先直接 JSON.parse,失败再 repairJsonString 修一修再 parse(navigator.ts:343-358)。
  • 都不是 → 当成单个对象,包进数组(navigator.ts:361)。

5.3 doMultiAction:逐个执行,页面一变就刹车

这是本章最巧的一段(navigator.ts:366)。模型规划了三步,但只有第一步是基于当前真实页面的——点完第一步,页面很可能变了,后面两步里的 index 就可能指向别的元素。所以每步之间要检测页面是否变了

怎么检测「变了」?calcBranchPathHashSet——把页面上每个可交互元素从根到它的路径算成哈希,得到一个哈希集合(dom/views.ts:586)。执行前先算一份 cachedPathHashes;每步前再算一份 newPathHashes,比较:

// agent/agents/navigator.ts:366 doMultiAction(核心逻辑,浓缩)
const cachedPathHashes = await calcBranchPathHashSet(browserState); // 执行前快照

for (const [i, action] of actions.entries()) {
const actionName = Object.keys(action)[0]; // {click_element:{...}} → "click_element"
const actionArgs = action[actionName];
const actionInstance = this.actionRegistry.getAction(actionName); // 分发表查回 Action

const indexArg = actionInstance.getIndexArg(actionArgs);
// ★ 不是第一步、且这步要操作元素 → 检查页面有没有冒出新元素
if (i > 0 && indexArg !== null) {
const newState = await browserContext.getState(useVision);
const newPathHashes = await calcBranchPathHashSet(newState);
if (!newPathHashes.isSubsetOf(cachedPathHashes)) { // 有新元素 → 页面变了
results.push(new ActionResult({ extractedContent: `Something new appeared after action ${i}`, includeInMemory: true }));
break; // ★ 刹车,剩下动作不执行
}
}

const result = await actionInstance.call(actionArgs); // 校验 + handler(见 §3.2)
// 记录这次交互到的元素(replay 用)
if (indexArg !== null) { result.interactedElement = ...convert(browserState.selectorMap.get(indexArg)); }
results.push(result);
await new Promise(r => setTimeout(r, 1000)); // 每步之间等 1 秒
}

几个关键点,分开说:

判据是「子集」而非「相等」。 !newPathHashes.isSubsetOf(cachedPathHashes) 的含义是:新页面里出现了旧快照没有的元素才算变了(navigator.ts:396)。少了元素不算——因为剩下的动作若只碰还在的元素也许仍安全;但多了元素说明页面结构动了(弹窗、新表单),后面基于旧编号的动作就危险,必须停。

只对「后续步」且「要 index 的步」检查。 第一步(i === 0)天然基于当前页面,不检查;不带 index 的动作(如 waitscroll)不碰具体编号,也不检查(navigator.ts:392)。

错误有上限。 handler 抛错会被 catch,errCount++,超过 3 次直接 throw 'Too many errors in actions'(navigator.ts:446)——避免在坏页面上无限撞墙。

每步 sleep 1 秒。 navigator.ts:431 留了 TODO 说这是暴力等待、待优化——给页面一点时间反应。

记下交互元素供重放。 每个带 index 的动作执行后,把当时那个真实元素转成「特征快照」挂到结果上(interactedElement)——这正是 05-history-replay 里回放定位所依赖的原始记录。


6. 第四步:「5 号」怎么变成真实那次点击(locateElement)

现在到了最底层:handler 已经用 selectorMap.get(index) 拿到了 DOMElementNode(§3.3 的第 2 步),clickElementNode / inputTextElementNode 要把它落到 Puppeteer 能操作的真实句柄上。核心是 locateElement(page.ts:1027)。

6.1 底座:Puppeteer over CDP

Nanobrowser 不是自己造轮子点鼠标,而是用 Puppeteer 通过 CDP(Chrome DevTools Protocol)连到当前标签页(page.ts:1-13 import 的 puppeteer-core + ExtensionTransport)。拿到某元素的 ElementHandle 后,.click() / .type() 就是 Puppeteer 的标准能力。难点全在怎么从 DOM 节点拿到那个 ElementHandle

6.2 定位三部曲:穿 iframe → CSS 优先 → XPath 兜底

locateElement 干三件事:

① 穿透 iframe。 元素可能嵌在 iframe 里,得先从目标节点往上收集所有父节点,挑出其中的 iframe,从外到内逐层 contentFrame() 钻进去,把 currentFrame 换成最内层那个 frame(page.ts:1036-1061)。

② CSS 选择器优先。 对目标节点算一个「增强版 CSS 选择器」,先用它找:

// browser/page.ts:1063 locateElement(核心)
const cssSelector = element.enhancedCssSelectorForElement(this._config.includeDynamicAttributes);
let elementHandle = await currentFrame.$(cssSelector); // ★ 先试 CSS

// ③ CSS 没找到 → 用节点存的 xpath 兜底
if (!elementHandle) {
const xpath = element.xpath;
if (xpath) {
const fullXpath = xpath.startsWith('/') ? xpath : `/${xpath}`;
elementHandle = await currentFrame.$(`::-p-xpath(${fullXpath})`); // Puppeteer 的 XPath 语法
}
}

if (elementHandle) {
const isHidden = await elementHandle.isHidden();
if (!isHidden) await this._scrollIntoViewIfNeeded(elementHandle); // 找到就滚进视口
return elementHandle;
}
return null; // 两种都失败 → null,上层报「元素不在了」

enhancedCssSelectorForElement(dom/views.ts:449)会把 tag、稳定属性等拼成一个尽量唯一的 CSS 选择器。CSS 优先、XPath 兜底的顺序不是随意的:CSS 通常更快、更稳,XPath 作为「结构路径」在 CSS 匹配不到时才上——两条路都指向「同一个真实元素」,给容错留了后手。

6.3 点击:两次尝试

clickElementNode(page.ts:1285)拿到句柄后,先滚进视口,然后两级点击:

// browser/page.ts:1304 clickElementNode(浓缩)
try {
// 尝试一:Puppeteer 原生 click,带 2 秒超时(卡住就放弃)
await Promise.race([
element.click(),
new Promise((_, rej) => setTimeout(() => rej(new Error('Click timeout')), 2000)),
]);
await this._checkAndHandleNavigation(); // 处理点击引发的跳转(含 URL 白名单检查)
} catch (error) {
if (error instanceof URLNotAllowedError) throw error;
// 尝试二:退化成 DOM 原生 el.click()
await element.evaluate(el => (el as HTMLElement).click());
}

原生 .click()(会真实模拟鼠标、走完整事件)可能被遮挡或卡住,所以套了 2 秒超时;失败就退化成 el.click()(直接触发 DOM click,绕过物理点击的种种拦阻)。

6.4 输入:按元素类型选打字方式

inputTextElementNode(page.ts:1101)更讲究——先 _waitForElementStability 等元素别再动、滚进视口,再探测元素类型决定怎么填(page.ts:1132-1182):

元素情况填法
contentEditableinput,且非只读非禁用先清空并 dispatch input/change,再 element.type(text, {delay:50}) 逐字打
其他(如 textarea 等)直接 el.value = text 设值,再 dispatch input/change

两条路都手动派发 inputchange 事件——很多前端框架靠这俩事件感知输入,不派发的话 React/Vue 可能「看不见」这次填写。

敏感数据(如密码)在发给模型的历史里是以 <secret>字段名</secret> 占位出现的;真正把占位还原成明文、再由这里填进输入框的那一步,发生在动作执行路径上——详见 04-memory-and-security


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

① 联合 schema 当护栏,而不是事后校验。 与其让模型自由输出再挑错,不如把所有动作拼成一个 structured-output schema(buildDynamicActionSchema,builder.ts:130),让「非法动作」在生成阶段就基本不可能出现。schema 既是约束、又是给模型的说明书(每个键带 description)。

② 「index」这层间接寻址,把感知和执行解耦。 模型永远只说「5 号」,从不碰 CSS/XPath。真实定位细节全封在 locateElement 里。上游网页怎么变,模型侧的接口(编号)不变——这是感知层(02)和执行层能各自演化的关键(selectorMap.get(index) 是唯一接缝)。

③ 多动作的「页面变更即刹车」。 用「路径哈希子集」判断页面有没有冒出新元素(calcBranchPathHashSet + isSubsetOf,navigator.ts:396),一旦变了就 break,把控制权交回上层重新观察。它接受「一次少做几步」以换取「绝不基于过期编号乱点」——安全优先于吞吐。

④ 处处双重兜底。 解析:structured → 抠 markdown → tool_calls(navigator.ts:108-150);定位:CSS → XPath(page.ts:1067-1077);点击:原生 click → DOM click(page.ts:1306-1319)。每一层都假设「上游 LLM/网页不可靠」,给出退路。

⑤ intent 字段一石三鸟。 逼模型说明意图(提升规划质量)、驱动 UI 进度条(ACT_START emit)、又是天然的可读日志。零成本的设计。


8. 边界与局限

  • 每步硬 sleep 1 秒(navigator.ts:431)、点击超时固定 2 秒(page.ts:1308)——代码自己标了 TODO,是权衡而非最优,慢页面上可能过早放弃或整体偏慢。
  • 变更检测只看「新增元素」(子集判断)。若页面原地替换了某元素的内容但元素路径哈希没变,可能检测不到,后续动作仍会基于旧语义执行。
  • extract_content 动作被注释掉了(builder.ts:335-363schemas.ts:96-103),原因写在注释:输入尺寸问题未解决。当前靠 cache_content 让模型自己从可见状态里摘要。
  • 定位依赖 enhancedCssSelectorForElement 和 xpath 的稳定性:高度动态、每次渲染都换属性的页面上,CSS 和 XPath 双双失效时 locateElement 返回 null,handler 报「元素不在了」(builder.ts:266)。
  • 只支持 Chrome/Edge:底座是 Puppeteer-over-CDP + Manifest V3 service worker,换浏览器内核这套定位/点击都不成立。

9. 横向对比

同货架的 browser-agents(如 browser-use)也走「编号 → 动作 → 落地」这条主线,共同暗线一致:模型给的旧编号几乎从不和真实页面一字不差,所以人人都要做容错定位 + 校验。Nanobrowser 的取舍偏保守——多动作里一旦发现页面新增元素就整体刹车,宁可少走几步也不冒险点错(navigator.ts:396);把定位收敛到「CSS 优先 / XPath 兜底」两条确定路径,而不是引入视觉坐标点击。它刻意不做视觉像素级定位(元素全靠 DOM 选择器),这与 computer-use 类 agent 的「截图 + 坐标」路线形成对照。

替换点击/输入策略前,建议连读 02-perception-dom(编号从哪来)与本章 §6(编号怎么落地),二者共用 selectorMap 这一接缝。


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

主题文件路径符号名
动作声明(name/description/Zod schema)agent/actions/schemas.tsclickElementActionSchemainputTextActionSchemaActionSchema
动作对象:校验 + index 存取agent/actions/builder.ts:44ActioncallgetIndexArgsetIndexArghasIndex
拼联合 schema(所有动作 → 可空联合)agent/actions/builder.ts:130buildDynamicActionSchema
注册所有默认动作agent/actions/builder.ts:152ActionBuilder.buildDefaultActions
动作名 → Action 分发表agent/agents/navigator.ts:41NavigatorActionRegistrygetAction
整轮输出 schema(current_state + action[])agent/agents/navigator.ts:62setupModelOutputSchema
调 LLM(structured output)+ 容错解析agent/agents/navigator.ts:93NavigatorAgent.invoke
把 action 掰成数组agent/agents/navigator.ts:335fixActions
逐个执行 + 页面变更刹车agent/agents/navigator.ts:366doMultiAction
页面变更判据(路径哈希集合)browser/dom/views.ts:586calcBranchPathHashSet
index → 真实元素句柄browser/page.ts:1027locateElement
CSS 选择器生成browser/dom/views.ts:449enhancedCssSelectorForElement
落地:点击(双重尝试)browser/page.ts:1285clickElementNode
落地:输入(按类型选打字方式)browser/page.ts:1101inputTextElementNode
index → selectorMap 反查browser/page.ts:1337getSelectorMapgetElementByIndex

相邻章节:整体循环见 01-agent-loop;编号从哪来见 02-perception-dom;ActionResult 进记忆的规则见 04-memory-and-security;setIndexArg/interactedElement 在重放里的用法见 05-history-replay