Files
thzxx 6f08759f63 v0.1.2: 全项目代码审查修复 + 子代理编排器 + 上下文压缩 + 并行工具执行
后端修复: Ollama adapter 移除死代码/修复超时硬编码/iteration硬编码/pullModel无超时/流异常断开补发DONE; SSE解析器支持非字符串arguments; Sandbox fail-closed安全加固; IPC移除未使用变量

新增功能: TaskOrchestrator子任务编排器; DelegateTaskTool委派工具; Agent Loop并行工具执行; 上下文自动压缩; 记忆检索注入System Prompt

前端修复: useAgentStream text_delta thought累积bug; 工具调用状态正确流转; 修复重复stateChange事件; TraceStep.thought正确填充
2026-07-06 22:48:33 +08:00

275 lines
9.7 KiB
TypeScript

/**
* 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<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
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}` };
}
}
}