PageAgent 关键实现模式验证
技术研究 代码示例 实现模式
通过概念性代码示例展示 PageAgent 的核心实现模式,包括 DOM 索引算法、LLM 集成、Action 执行等关键技术点。
核心逻辑实现
1. DOM 文本化索引算法
这是 PageAgent 最核心的创新。以下是简化版的索引生成逻辑:
/**
* DOM 文本化索引器
* 将 DOM 树压缩为 LLM 可理解的文本格式
*/
class DOMIndexer {
private indexMap = new Map<number, Element>();
private counter = 1;
// 可交互元素选择器
private static INTERACTIVE_SELECTORS = [
'button', 'a[href]', 'input', 'select', 'textarea',
'[role="button"]', '[role="link"]', '[role="menuitem"]',
'[tabindex]:not([tabindex="-1"])', '[onclick]',
'[contenteditable="true"]'
];
/**
* 生成页面索引
* @returns 索引文本和映射关系
*/
generateIndex(): { indexText: string; mapping: Map<number, Element> } {
this.indexMap.clear();
this.counter = 1;
// 1. 获取所有可交互元素
const selector = DOMIndexer.INTERACTIVE_SELECTORS.join(', ');
const elements = Array.from(document.querySelectorAll(selector));
// 2. 过滤可见元素
const visibleElements = elements.filter(el => {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
// 排除不可见元素
if (style.display === 'none') return false;
if (style.visibility === 'hidden') return false;
if (rect.width === 0 || rect.height === 0) return false;
// 排除被遮挡的元素(简化版,实际实现需要更复杂的检测)
const topElement = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2
);
return topElement === el || el.contains(topElement);
});
// 3. 生成索引文本
const indexLines = visibleElements.map(el => {
const index = this.counter++;
this.indexMap.set(index, el);
const tagName = el.tagName.toLowerCase();
const text = this.extractText(el);
const attributes = this.extractKeyAttributes(el);
return `[${index}] <${tagName}${attributes}> ${text} </${tagName}>`;
});
return {
indexText: indexLines.join('\n'),
mapping: new Map(this.indexMap)
};
}
/**
* 提取元素文本(智能摘要)
*/
private extractText(element: Element): string {
// 优先使用 aria-label
const ariaLabel = element.getAttribute('aria-label');
if (ariaLabel) return this.truncateText(ariaLabel);
// 使用可见文本
const text = element.textContent?.trim() || '';
if (text) return this.truncateText(text);
// fallback 到 placeholder 或 title
return this.truncateText(
element.getAttribute('placeholder') ||
element.getAttribute('title') ||
element.getAttribute('alt') ||
''
);
}
/**
* 提取关键属性
*/
private extractKeyAttributes(element: Element): string {
const attrs: string[] = [];
// 链接提取 href
if (element.tagName === 'A') {
const href = element.getAttribute('href');
if (href && href !== '#') {
attrs.push(`href="${this.truncateText(href, 30)}"`);
}
}
// 输入框提取类型
if (element.tagName === 'INPUT') {
const type = element.getAttribute('type') || 'text';
attrs.push(`type="${type}"`);
}
// 提取 role
const role = element.getAttribute('role');
if (role) {
attrs.push(`role="${role}"`);
}
return attrs.length > 0 ? ' ' + attrs.join(' ') : '';
}
/**
* 文本截断(防止 Token 爆炸)
*/
private truncateText(text: string, maxLength = 50): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 3) + '...';
}
/**
* 根据索引查找元素
*/
getElementByIndex(index: number): Element | undefined {
return this.indexMap.get(index);
}
}
// 使用示例
const indexer = new DOMIndexer();
const { indexText, mapping } = indexer.generateIndex();
console.log('页面索引:\n', indexText);
/* 输出示例:
[1] <button> 登录 </button>
[2] <a href="/signup"> 注册账号 </a>
[3] <input type="search" placeholder="搜索...">
[4] <button role="button"> 提交表单 </button>
*/
关键设计点:
- 智能过滤:排除不可见、无交互能力的元素,减少索引大小
- 文本摘要:截断长文本,保留关键信息
- 属性提取:提取 href、type 等有助于 LLM 理解的属性
- 映射关系:维护数字索引到 DOM 元素的双向映射
2. LLM 指令解析器
将自然语言转换为动作序列:
/**
* LLM 指令解析器
*/
class InstructionParser {
private llmProvider: LLMProvider;
constructor(llmProvider: LLMProvider) {
this.llmProvider = llmProvider;
}
/**
* 系统提示词(核心 Prompt 工程)
*/
private buildSystemPrompt(): string {
return `你是一个专业的 UI 自动化助手。用户会给你自然语言指令,
你需要将其转换为具体的 DOM 操作步骤。
## 可用动作类型:
- click[index]: 点击元素
- input[index, text]: 向输入框输入文本
- select[index, value]: 选择下拉选项
- checkbox[index]: 切换复选框
- scroll[index, direction]: 滚动页面 (direction: 'up' | 'down')
- extract[index]: 提取元素文本
- wait[ms]: 等待指定毫秒
- assert[index, condition]: 验证元素状态
## 输出要求:
1. 必须返回严格的 JSON 格式
2. 只能使用索引列表中存在的编号
3. 如果指令模糊,优先选择最可能的操作
4. 复杂任务分解为多个步骤
## 输出格式示例:
{
"actions": [
{"type": "click", "index": 1},
{"type": "input", "index": 3, "text": "hello world"},
{"type": "wait", "ms": 1000}
]
}
如果无法理解指令,返回:
{
"error": "无法识别指令,请重新表述",
"suggestion": "尝试使用更具体的动词,如'点击'、'输入'、'选择'"
}`;
}
/**
* 解析用户指令
*/
async parse(
instruction: string,
indexText: string
): Promise<ParsedAction[]> {
// 构建用户消息
const userMessage = `
当前页面索引:
${indexText}
用户指令:${instruction}
请返回动作序列:
`;
// 调用 LLM
const response = await this.llmProvider.chat([
{ role: 'system', content: this.buildSystemPrompt() },
{ role: 'user', content: userMessage }
]);
// 解析 JSON 响应
try {
const result = JSON.parse(response.content);
if (result.error) {
throw new ParseError(result.error, result.suggestion);
}
return this.validateActions(result.actions);
} catch (error) {
if (error instanceof ParseError) {
throw error;
}
throw new ParseError('LLM 返回格式错误', '请重试或联系技术支持');
}
}
/**
* 验证动作合法性
*/
private validateActions(actions: any[]): ParsedAction[] {
if (!Array.isArray(actions)) {
throw new ParseError('actions 必须是数组');
}
return actions.map((action, idx) => {
// 验证必填字段
if (!action.type || !action.index) {
throw new ParseError(`第${idx + 1}个动作缺少 type 或 index 字段`);
}
// 验证动作类型
const validTypes = [
'click', 'input', 'select', 'checkbox',
'scroll', 'extract', 'wait', 'assert'
];
if (!validTypes.includes(action.type)) {
throw new ParseError(`未知动作类型:${action.type}`);
}
// 验证索引存在(实际实现会检查 indexText 中的索引)
if (typeof action.index !== 'number' || action.index < 1) {
throw new ParseError(`动作${idx + 1}的索引无效:${action.index}`);
}
return action as ParsedAction;
});
}
}
// 使用示例
const parser = new InstructionParser(llmProvider);
const actions = await parser.parse(
'点击登录按钮,然后输入用户名和密码',
indexText
);
/* 返回示例:
[
{ type: 'click', index: 1 },
{ type: 'input', index: 5, text: 'admin' },
{ type: 'input', index: 6, text: 'password123' }
]
*/
3. Action 执行引擎
将解析后的动作转换为真实 DOM 操作:
/**
* 动作执行引擎
*/
class ActionExecutor {
private indexer: DOMIndexer;
constructor(indexer: DOMIndexer) {
this.indexer = indexer;
}
/**
* 执行动作序列
*/
async execute(actions: ParsedAction[]): Promise<ExecutionResult> {
const results: StepResult[] = [];
for (let i = 0; i < actions.length; i++) {
const action = actions[i];
try {
// 高亮显示将要操作的元素(UX 优化)
this.highlightElement(action.index);
// 执行具体动作
const result = await this.executeSingle(action);
results.push({ step: i, success: true, result });
// 等待页面稳定(防止操作过快导致失败)
await this.waitForStability();
} catch (error) {
results.push({
step: i,
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
// 根据错误类型决定是否继续
if (this.isCriticalError(error)) {
break;
}
}
}
return {
success: results.every(r => r.success),
results,
completedSteps: results.filter(r => r.success).length
};
}
/**
* 执行单个动作
*/
private async executeSingle(action: ParsedAction): Promise<any> {
const element = this.indexer.getElementByIndex(action.index);
if (!element) {
throw new Error(`元素未找到:index=${action.index}`);
}
switch (action.type) {
case 'click':
return await this.click(element);
case 'input':
return await this.input(element, action.text);
case 'select':
return await this.select(element, action.value);
case 'checkbox':
return await this.toggleCheckbox(element);
case 'scroll':
return await this.scroll(element, action.direction);
case 'extract':
return this.extractText(element);
case 'wait':
return await this.wait(action.ms);
case 'assert':
return this.assert(element, action.condition);
default:
throw new Error(`未知动作类型:${action.type}`);
}
}
/**
* 点击操作(多策略)
*/
private async click(element: Element): Promise<void> {
// 策略 1: 直接调用 click()
if (element instanceof HTMLElement) {
element.click();
return;
}
// 策略 2: dispatch MouseEvent(针对 SVG 等特殊元素)
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(event);
}
/**
* 输入操作(带焦点管理)
*/
private async input(element: Element, text: string): Promise<void> {
if (!(element instanceof HTMLInputElement) &&
!(element instanceof HTMLTextAreaElement)) {
throw new Error('元素不是可输入类型');
}
// 1. 聚焦
element.focus();
// 2. 清空现有值(可选)
element.value = '';
// 3. 模拟真实输入(触发 input 事件)
for (const char of text) {
element.value += char;
element.dispatchEvent(new InputEvent('input', {
bubbles: true,
cancelable: true,
data: char
}));
await this.wait(50); // 模拟真实输入延迟
}
// 4. 触发 change 事件(某些框架需要)
element.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* 选择下拉选项
*/
private async select(element: Element, value: string): Promise<void> {
if (!(element instanceof HTMLSelectElement)) {
throw new Error('元素不是 select 类型');
}
// 尝试通过值选择
const option = Array.from(element.options).find(
opt => opt.value === value || opt.textContent?.includes(value)
);
if (!option) {
throw new Error(`未找到选项:${value}`);
}
element.value = option.value;
element.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* 切换复选框
*/
private async toggleCheckbox(element: Element): Promise<void> {
if (!(element instanceof HTMLInputElement) ||
element.type !== 'checkbox') {
throw new Error('元素不是复选框');
}
element.checked = !element.checked;
element.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* 等待页面稳定
*/
private async waitForStability(timeout = 2000): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
// 检查是否有网络请求在进行(如果有 fetch/xhr 拦截)
// 检查是否有动画在进行(简化版:固定等待)
await this.wait(100);
}
}
/**
* 高亮元素(UX 优化)
*/
private highlightElement(index: number): void {
const element = this.indexer.getElementByIndex(index);
if (!element) return;
// 添加高亮样式
const originalBorder = element.getAttribute('style') || '';
element.setAttribute(
'style',
`${originalBorder} outline: 3px solid #ff6b6b; outline-offset: 2px;`
);
// 0.5 秒后移除
setTimeout(() => {
element.setAttribute('style', originalBorder);
}, 500);
}
private wait(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private isCriticalError(error: any): boolean {
// 元素未找到、类型不匹配等错误应该停止执行
return error instanceof ElementNotFoundError ||
error instanceof TypeError;
}
private extractText(element: Element): string {
return element.textContent?.trim() || '';
}
private assert(element: Element, condition: string): boolean {
// 简化版断言实现
switch (condition) {
case 'visible':
return element.checkVisibility();
case 'enabled':
return !(element as HTMLElement).hasAttribute('disabled');
case 'has-text':
return element.textContent?.trim().length > 0;
default:
throw new Error(`未知断言条件:${condition}`);
}
}
}
集成方式
场景 1: SaaS 产品集成(Script 标签)
<!DOCTYPE html>
<html>
<head>
<title>我的 SaaS 应用</title>
<!-- 1. 引入 PageAgent -->
<script src="https://cdn.jsdelivr.net/npm/page-agent@1.5.6/dist/iife/page-agent.demo.js"></script>
</head>
<body>
<!-- 你的应用代码 -->
<div id="app">
<!-- ... -->
</div>
<!-- 2. 初始化(可选配置) -->
<script>
const agent = new PageAgent({
model: 'qwen3.5-plus', // 使用阿里云 Qwen 模型
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: 'YOUR_API_KEY', // 替换为你的 API Key
language: 'zh-CN', // 中文界面
requireConfirmation: true // 执行前需要用户确认
});
// 3. 可选:暴露给用户使用
window.helpAssistant = agent;
</script>
</body>
</html>
场景 2: NPM 包集成(深度定制)
// 1. 安装
// npm install page-agent
// 2. 导入和使用
import { PageAgent } from 'page-agent';
// 3. 创建实例
const agent = new PageAgent({
model: 'gpt-4o',
apiKey: process.env.OPENAI_API_KEY,
// 自定义 UI 渲染
ui: {
renderPanel: (container) => {
// 自定义面板 UI
container.innerHTML = `
<div class="custom-agent-panel">
<h3>AI 助手</h3>
<!-- 自定义 UI 组件 -->
</div>
`;
}
},
// 自定义动作
customActions: {
download: async (element, params) => {
// 自定义下载逻辑
const url = element.getAttribute('href');
// ...
}
}
});
// 4. 执行指令
await agent.execute('帮我填写这个表单并提交');
// 5. 监听事件
agent.on('action:start', (action) => {
console.log('开始执行动作:', action);
});
agent.on('action:complete', (result) => {
console.log('动作完成:', result);
});
场景 3: Chrome 扩展集成(跨页任务)
// manifest.json
{
"manifest_version": 3,
"name": "PageAgent Extension",
"version": "1.0.0",
"permissions": ["tabs", "scripting"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html"
}
}
// background.js - 跨标签页协调
import { PageAgent } from 'page-agent';
const agents = new Map(); // tabId -> PageAgent
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
if (changeInfo.status === 'complete') {
// 为新标签页注入 PageAgent
await chrome.scripting.executeScript({
target: { tabId },
files: ['page-agent.js']
});
agents.set(tabId, new PageAgent({ /* config */ }));
}
});
// 处理跨页任务
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.type === 'CROSS_TAB_TASK') {
executeCrossTabTask(request.steps);
}
});
async function executeCrossTabTask(steps) {
for (const step of steps) {
const tabId = step.tabId;
const agent = agents.get(tabId);
if (agent) {
await agent.execute(step.instruction);
}
}
}
配置要点
关键配置项
interface PageAgentConfig {
// === LLM 配置 ===
model: string; // 模型名称
baseURL: string; // API 基础 URL
apiKey: string; // API 密钥
temperature?: number; // 温度 (0-1, 默认 0.7)
maxTokens?: number; // 最大 Token 数
// === 行为配置 ===
language: 'zh-CN' | 'en-US' | 'ja-JP'; // 界面语言
requireConfirmation: boolean; // 是否需要确认
autoScroll: boolean; // 自动滚动到操作元素
highlightDuration: number; // 高亮持续时间 (ms)
// === 性能配置 ===
actionDelay: number; // 动作间隔延迟 (ms)
stabilityTimeout: number; // 页面稳定等待超时 (ms)
maxRetries: number; // 失败重试次数
// === 回调配置 ===
onActionStart?: (action: Action) => void;
onActionComplete?: (result: Result) => void;
onError?: (error: Error) => void;
}
性能优化配置
const optimizedConfig: PageAgentConfig = {
// 选择性价比高的模型
model: 'qwen3.5-plus', // Qwen 性价比高,中文支持好
// 降低 Token 消耗
maxTokens: 500,
// 加快响应速度
temperature: 0.3, // 降低随机性,提高确定性
// 减少等待时间
actionDelay: 50, // 默认 100ms
stabilityTimeout: 1000, // 默认 2000ms
// 关闭非必要功能
autoScroll: false, // 如果页面不长,可以关闭
highlightDuration: 300, // 缩短高亮时间
};
生产环境配置
const productionConfig: PageAgentConfig = {
// 使用企业级模型
model: 'gpt-4o',
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
// 严格的错误处理
requireConfirmation: true, // 关键操作需要确认
maxRetries: 3, // 失败重试 3 次
// 完整的日志记录
onActionStart: (action) => {
analytics.track('agent_action_start', action);
},
onActionComplete: (result) => {
analytics.track('agent_action_complete', result);
},
onError: (error) => {
analytics.track('agent_error', { error: error.message });
Sentry.captureException(error);
},
// 安全配置
allowedActions: ['click', 'input', 'select', 'extract'], // 禁止危险操作
blockedDomains: ['payment.example.com'], // 阻止在敏感页面使用
};
最佳实践
1. 指令优化
不好的指令:
- “处理这个”(太模糊)
- “填写所有字段”(没有指定内容)
好的指令:
- “在用户名输入框输入 ‘admin’,在密码框输入 ‘123456’,然后点击登录按钮”
- “提取表格中所有行的第一列数据”
2. 错误处理
try {
const result = await agent.execute('复杂任务');
if (!result.success) {
// 部分失败,检查哪些步骤失败了
const failedSteps = result.results.filter(r => !r.success);
console.error('失败的步骤:', failedSteps);
// 尝试从失败点恢复
await retryFromStep(failedSteps[0].step);
}
} catch (error) {
// 完全失败,提供友好的错误提示
showErrorToUser('任务执行失败,请重试或联系支持');
logError(error);
}
3. Token 成本控制
// 策略 1: 限制索引范围(只索引可见区域)
const indexer = new DOMIndexer({
scope: 'viewport' // 只索引视口内元素
});
// 策略 2: 使用便宜的模型处理简单任务
const config = isSimpleTask(instruction)
? { model: 'gpt-3.5-turbo' } // 便宜
: { model: 'gpt-4o' }; // 强大
// 策略 3: 缓存索引结果
const cacheKey = location.pathname;
const cached = indexCache.get(cacheKey);
if (cached && !domHasChanged()) {
// 重用缓存索引,跳过 LLM 调用
return cached.actions;
}
结论
PageAgent 的核心实现模式可以总结为:
- DOM 文本化索引:将复杂的 DOM 树压缩为 LLM 友好的文本格式
- Prompt 工程:精心设计的系统提示词确保 LLM 输出格式正确
- 多策略执行:针对每种动作类型提供多种执行策略,提高成功率
- 渐进式增强:支持从简单的 Script 标签到深度定制的多种集成方式
这些模式使 PageAgent 在保持易用性的同时,具备了处理复杂任务的能力。