/** * web_browser — 统一浏览器交互工具 * * 将 9 个浏览器操作合并为单个工具,通过 `action` 参数路由: * 1. open — 打开 URL * 2. screenshot — 截图(视口/元素/全页三模式) * 3. evaluate — 执行 JavaScript * 4. extract — 提取页面文本与链接 * 5. click — 点击元素 * 6. type — 输入文本 * 7. scroll — 滚动页面 * 8. wait — 等待条件 * 9. close — 关闭窗口 * * 基于单例 BrowserWindowManager 实现。 * * @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计 */ import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { BrowserWindowManager } from './browser-window-manager'; import { logTool } from './network-utils'; // ===== 单例 Manager ===== let managerInstance: BrowserWindowManager | null = null; function getManager(): BrowserWindowManager { if (!managerInstance) { managerInstance = new BrowserWindowManager(); } return managerInstance; } /** 获取共享浏览器管理器单例(供 web_fetch 等工具复用) */ export function getBrowserManager(): BrowserWindowManager { return getManager(); } /** 应用退出时清理(由 main.ts 调用) */ export function cleanupBrowser(): void { BrowserWindowManager.cleanup(managerInstance); managerInstance = null; } // ===== WebBrowserTool ===== export class WebBrowserTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'web_browser', description: [ 'Control a hidden browser window to navigate, inspect, and interact with web pages.', 'Use the "action" parameter to specify the operation:', ' open — Load a URL; optionally wait for a CSS selector before returning. Returns page title and URL.', ' screenshot — Capture the viewport (default), full page, or a specific element by CSS selector. Returns base64 PNG.', ' evaluate — Execute JavaScript in the page context. Returns the result of the last expression.', ' extract — Extract clean text and up to 50 links from the page or a specific element.', ' click — Click an element by CSS selector. Scrolls into view first. Can optionally wait for the selector.', ' type — Type text into an input element. Optionally clear first and submit the form.', ' scroll — Scroll the page: down/up (500px), top/bottom, or scroll to an element by selector.', ' wait — Wait for a CSS selector to appear (with timeout) or a fixed duration.', ' close — Close the browser window and release resources.', ].join('\n'), parameters: { type: 'object', properties: { action: { type: 'string', enum: ['open', 'screenshot', 'evaluate', 'extract', 'click', 'type', 'scroll', 'wait', 'close'], description: 'Browser operation to perform', }, url: { type: 'string', description: '[open] Target URL (http/https only)', }, wait_selector: { type: 'string', description: '[open] CSS selector to wait for before returning (optional)', }, script: { type: 'string', description: '[evaluate] JavaScript code to execute (must return a value)', }, selector: { type: 'string', description: '[screenshot|extract|click|type|scroll] CSS selector of the target element', }, full_page: { type: 'boolean', description: '[screenshot] Capture entire scrollable page (default false)', }, text: { type: 'string', description: '[type] Text to type into the element', }, clear: { type: 'boolean', description: '[type] Clear the field before typing (default true)', }, submit: { type: 'boolean', description: '[type] Submit the form after typing (default false)', }, direction: { type: 'string', enum: ['down', 'up', 'top', 'bottom'], description: '[scroll] Scroll direction (default down). Ignored if selector is provided.', }, wait: { type: 'boolean', description: '[click] Wait for selector to appear before clicking (default false)', }, time_ms: { type: 'number', description: '[wait] Fixed wait time in milliseconds (default 1000). Used as timeout when selector is also provided.', }, }, required: ['action'], }, category: MetonaToolCategory.NETWORK, riskLevel: MetonaRiskLevel.HIGH, requiresPermission: true, timeoutMs: 60_000, }; async execute(args: Record, _context: ToolExecutionContext): Promise { const action = args.action as string; if (!action) { return { success: false, error: 'Missing required parameter: action' }; } logTool('web_browser', `action=${action}`); switch (action) { // ===== open ===== case 'open': { const url = args.url as string; if (!url || !/^https?:\/\//i.test(url)) { return { success: false, error: 'URL must start with http:// or https://' }; } const waitSelector = args.wait_selector as string | undefined; try { const result = await getManager().open({ url, waitSelector }); return { success: true, action, ...result }; } catch (err) { return { success: false, action, error: (err as Error).message }; } } // ===== screenshot ===== case 'screenshot': { const fullPage = (args.full_page as boolean) ?? false; const selector = args.selector as string | undefined; try { const result = await getManager().screenshot({ fullPage, selector }); return { success: true, action, image: result.data, width: result.width, height: result.height, mime_type: 'image/png', }; } catch (err) { return { success: false, action, error: (err as Error).message }; } } // ===== evaluate ===== case 'evaluate': { const script = args.script as string; if (!script) { return { success: false, action, error: 'No script provided' }; } try { const result = await getManager().evaluate(script); return { success: true, action, result }; } catch (err) { return { success: false, action, error: (err as Error).message }; } } // ===== extract ===== case 'extract': { const selector = args.selector as string | undefined; try { const result = await getManager().extract(selector); return { success: true, action, text: result.text, links: result.links, link_count: result.links.length, }; } catch (err) { return { success: false, action, error: (err as Error).message }; } } // ===== click ===== case 'click': { const selector = args.selector as string; const wait = (args.wait as boolean) ?? false; if (!selector) { return { success: false, action, error: 'No selector provided' }; } try { await getManager().click(selector, wait); return { success: true, action, selector, clicked: true }; } catch (err) { return { success: false, action, error: (err as Error).message }; } } // ===== type ===== case 'type': { const selector = args.selector as string; const text = args.text as string; const clear = (args.clear as boolean) ?? true; const submit = (args.submit as boolean) ?? false; if (!selector) { return { success: false, action, error: 'No selector provided' }; } if (text === undefined || text === null) { return { success: false, action, error: 'No text provided' }; } try { await getManager().type(selector, text, { clear, submit }); return { success: true, action, selector, typed: text.length }; } catch (err) { return { success: false, action, error: (err as Error).message }; } } // ===== scroll ===== case 'scroll': { const direction = (args.direction as string) ?? 'down'; const selector = args.selector as string | undefined; try { await getManager().scroll({ direction: direction as 'down' | 'up' | 'top' | 'bottom', selector, }); return { success: true, action, direction, selector }; } catch (err) { return { success: false, action, error: (err as Error).message }; } } // ===== wait ===== case 'wait': { const selector = args.selector as string | undefined; const timeMs = (args.time_ms as number) ?? 1_000; try { await getManager().wait({ selector, timeMs }); return { success: true, action, waited_for: selector ?? `${timeMs}ms` }; } catch (err) { return { success: false, action, error: (err as Error).message }; } } // ===== close ===== case 'close': { getManager().close(); return { success: true, action, closed: true }; } default: return { success: false, error: `Unknown action: ${action}` }; } } }