Logo
热心市民王先生

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>
*/

关键设计点

  1. 智能过滤:排除不可见、无交互能力的元素,减少索引大小
  2. 文本摘要:截断长文本,保留关键信息
  3. 属性提取:提取 href、type 等有助于 LLM 理解的属性
  4. 映射关系:维护数字索引到 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 的核心实现模式可以总结为:

  1. DOM 文本化索引:将复杂的 DOM 树压缩为 LLM 友好的文本格式
  2. Prompt 工程:精心设计的系统提示词确保 LLM 输出格式正确
  3. 多策略执行:针对每种动作类型提供多种执行策略,提高成功率
  4. 渐进式增强:支持从简单的 Script 标签到深度定制的多种集成方式

这些模式使 PageAgent 在保持易用性的同时,具备了处理复杂任务的能力。


参考资料