293 lines
15 KiB
TypeScript
293 lines
15 KiB
TypeScript
import { defineTool } from '@earendil-works/pi-coding-agent'
|
||
import { Type } from '@earendil-works/pi-ai'
|
||
import { getBrowserController } from '../browser/browser-controller'
|
||
|
||
/**
|
||
* 内置受管浏览器工具(进程内一等工具,非 MCP)。
|
||
*
|
||
* 使用范式(与 Playwright MCP / Proma 一致的 AX 快照 + ref 体系):
|
||
* 1. browser_observe / browser_find 拿到带 ref 的可交互元素快照;
|
||
* 2. 用 ref 调 click / fill / act 等操作;页面变化后旧 ref 失效,必须重新观察;
|
||
* 3. 语义结构足够时优先 observe,需要视觉验证时才 browser_screenshot。
|
||
*/
|
||
|
||
const TAB_ID_PARAM = Type.Optional(
|
||
Type.String({ description: '可选,目标标签页 ID。默认为 Agent 工作标签页(与用户可见面板同步)。' })
|
||
)
|
||
|
||
export const browserTools = [
|
||
defineTool({
|
||
name: 'browser_navigate',
|
||
label: '打开网页',
|
||
description:
|
||
'在内置受管浏览器中打开 URL 或搜索词。完整 URL、裸域名、localhost 与 IP 直达;普通文本用 Google 搜索。支持 about:blank 空白页。下载与弹窗会留在受管浏览器内。',
|
||
promptSnippet: 'browser_navigate: 打开网页/搜索(URL 或关键词)',
|
||
parameters: Type.Object({
|
||
url: Type.String({ description: 'URL、域名或搜索关键词' }),
|
||
tabId: TAB_ID_PARAM
|
||
}),
|
||
async execute(_toolCallId, params) {
|
||
const state = await getBrowserController().navigate(params.url, params.tabId)
|
||
return { content: [{ type: 'text', text: JSON.stringify(state.tabs.find((t) => t.tabId === state.activeTabId) ?? state) }], details: state }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_observe',
|
||
label: '观察网页',
|
||
description:
|
||
'读取当前页面的 URL、标题与紧凑可访问性快照(元素带 ref,标注 editable)。默认返回 240 个元素(约 2/3 可交互优先)。每次 observe/find 或页面导航后旧 ref 全部失效,操作必须使用最新一次观察结果中的 ref。',
|
||
promptSnippet: 'browser_observe: 读页面结构拿 ref(先观察再操作)',
|
||
parameters: Type.Object({
|
||
maxElements: Type.Optional(
|
||
Type.Number({ minimum: 20, maximum: 400, description: '返回元素上限,默认 240;仅当目标不在快照中时才提高到 400' })
|
||
),
|
||
tabId: TAB_ID_PARAM
|
||
}),
|
||
async execute(_toolCallId, params) {
|
||
const observation = await getBrowserController().observe(params.tabId, params.maxElements)
|
||
return { content: [{ type: 'text', text: JSON.stringify(observation) }], details: observation }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_find',
|
||
label: '语义定位元素',
|
||
description:
|
||
'按可访问性 role 和/或 name 定位少量新 ref,不返回整页快照。适合 observe 结果过大或目标被截掉时使用。与 observe 一样会作废同标签页的全部旧 ref。',
|
||
promptSnippet: 'browser_find: 按 role/name 精准找元素',
|
||
parameters: Type.Object({
|
||
role: Type.Optional(Type.String({ description: '可访问性 role,如 button/textbox/link/checkbox' })),
|
||
name: Type.Optional(Type.String({ description: '可访问性名称查询' })),
|
||
exact: Type.Optional(Type.Boolean({ description: '精确匹配 name(默认包含匹配)' })),
|
||
maxResults: Type.Optional(Type.Number({ minimum: 1, maximum: 50, description: '最多返回条数,默认 20' })),
|
||
tabId: TAB_ID_PARAM
|
||
}),
|
||
async execute(_toolCallId, params) {
|
||
const result = await getBrowserController().find(
|
||
{ role: params.role, name: params.name, exact: params.exact, maxResults: params.maxResults },
|
||
params.tabId
|
||
)
|
||
return { content: [{ type: 'text', text: JSON.stringify(result) }], details: result }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_click',
|
||
label: '点击元素',
|
||
description: '点击最新 observe/find 结果中的指定 ref。页面会短暂高亮目标。点击导致的重渲染会让旧 ref 失效,操作后需重新观察。',
|
||
promptSnippet: 'browser_click: 点击 ref',
|
||
parameters: Type.Object({ ref: Type.String({ description: '元素引用(来自 observe/find)' }), tabId: TAB_ID_PARAM }),
|
||
async execute(_toolCallId, params) {
|
||
const state = await getBrowserController().click(params.ref, params.tabId)
|
||
return { content: [{ type: 'text', text: JSON.stringify({ clicked: params.ref, url: state.tabs.find((t) => t.tabId === state.activeTabId)?.url }) }], details: state }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_act',
|
||
label: '点击并等待',
|
||
description:
|
||
'点击 ref 后在同一次串行操作中等待一个固定条件(URL 片段/可见文本/CSS selector)。已知点击后的预期状态时优先用它,避免 click+wait 两次调用。',
|
||
promptSnippet: 'browser_act: 点击 + 等待条件(原子操作)',
|
||
parameters: Type.Object({
|
||
ref: Type.String({ description: '元素引用(来自 observe/find)' }),
|
||
waitFor: Type.Optional(
|
||
Type.Object({
|
||
kind: Type.Union([Type.Literal('url'), Type.Literal('text'), Type.Literal('selector')]),
|
||
value: Type.String({ description: 'URL 片段 / 可见文本 / CSS selector' })
|
||
})
|
||
),
|
||
timeoutMs: Type.Optional(Type.Number({ minimum: 250, maximum: 30000, description: '等待超时毫秒,默认 10000' })),
|
||
tabId: TAB_ID_PARAM
|
||
}),
|
||
async execute(_toolCallId, params) {
|
||
const result = await getBrowserController().act(params.ref, params.waitFor, params.timeoutMs, params.tabId)
|
||
return { content: [{ type: 'text', text: JSON.stringify({ clicked: params.ref, matched: result.matched }) }], details: result.state }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_fill',
|
||
label: '填写输入框',
|
||
description:
|
||
'整体替换指定 ref 的 input/textarea/contenteditable 内容(先全选再插入,支持空格、标点、Unicode 与换行)。完整消息、搜索词、多行文本都优先用它。',
|
||
promptSnippet: 'browser_fill: 替换输入框内容',
|
||
parameters: Type.Object({
|
||
ref: Type.String({ description: '可编辑字段的 ref(observe 结果中 editable=true)' }),
|
||
text: Type.String({ description: '要输入的完整文本,≤10000 字符' }),
|
||
tabId: TAB_ID_PARAM
|
||
}),
|
||
async execute(_toolCallId, params) {
|
||
const state = await getBrowserController().fill(params.ref, params.text, params.tabId)
|
||
return { content: [{ type: 'text', text: JSON.stringify({ filled: params.ref, chars: Array.from(params.text).length }) }], details: state }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_press',
|
||
label: '按键',
|
||
description:
|
||
'按导航键(Enter/Tab/Escape/方向键/PageDown 等)或向已聚焦的编辑器一次性插入完整文本。字段有 ref 时优先 browser_fill。',
|
||
promptSnippet: 'browser_press: Enter/Tab 等按键或向焦点插入文本',
|
||
parameters: Type.Object({ key: Type.String({ description: '导航键名,或要插入的完整文本' }), tabId: TAB_ID_PARAM }),
|
||
async execute(_toolCallId, params) {
|
||
const state = await getBrowserController().press(params.key, params.tabId)
|
||
return { content: [{ type: 'text', text: JSON.stringify({ pressed: params.key }) }], details: state }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_hover',
|
||
label: '悬停元素',
|
||
description: '把指针移到指定 ref 上(真实 mouseMoved 事件),用于展开悬浮菜单/提示,随后需重新观察再点击新内容。',
|
||
promptSnippet: 'browser_hover: 悬停展开菜单',
|
||
parameters: Type.Object({ ref: Type.String({ description: '元素引用' }), tabId: TAB_ID_PARAM }),
|
||
async execute(_toolCallId, params) {
|
||
const state = await getBrowserController().hover(params.ref, params.tabId)
|
||
return { content: [{ type: 'text', text: JSON.stringify({ hovered: params.ref }) }], details: state }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_scroll',
|
||
label: '滚动页面',
|
||
description:
|
||
'滚动页面或 CSS selector 指定的内部滚动容器(SPA 信息流常在内部容器里,导航键滚不动)。返回前后 scrollTop 指标,用 moved 核验是否真的滚动。',
|
||
promptSnippet: 'browser_scroll: 滚动页面/内部容器',
|
||
parameters: Type.Object({
|
||
deltaY: Type.Optional(Type.Number({ description: '滚动像素,正向下负向上,默认 600' })),
|
||
position: Type.Optional(Type.Union([Type.Literal('top'), Type.Literal('bottom')], { description: '直接滚到顶部/底部(优先于 deltaY)' })),
|
||
selector: Type.Optional(Type.String({ description: '内部滚动容器的 CSS selector,缺省滚主文档' })),
|
||
tabId: TAB_ID_PARAM
|
||
}),
|
||
async execute(_toolCallId, params) {
|
||
const result = await getBrowserController().scroll(
|
||
{ deltaY: params.deltaY, position: params.position, selector: params.selector },
|
||
params.tabId
|
||
)
|
||
return { content: [{ type: 'text', text: JSON.stringify(result) }], details: result }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_extract',
|
||
label: '提取页面文本',
|
||
description:
|
||
'从页面正文或 selector 区域提取受限长度的纯文本,替代为读内容而执行 JS。优先传 selector 限定正文/列表区域,避免导航栏与页脚噪声。',
|
||
promptSnippet: 'browser_extract: 提取正文/区域文本',
|
||
parameters: Type.Object({
|
||
selector: Type.Optional(Type.String({ description: '限定提取区域的 CSS selector,缺省取 body' })),
|
||
maxChars: Type.Optional(Type.Number({ description: '最多提取字符数,默认 50000' })),
|
||
tabId: TAB_ID_PARAM
|
||
}),
|
||
async execute(_toolCallId, params) {
|
||
const result = await getBrowserController().extract({ selector: params.selector, maxChars: params.maxChars }, params.tabId)
|
||
return { content: [{ type: 'text', text: result.text }], details: { url: result.url, title: result.title, truncated: result.truncated } }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_wait_for',
|
||
label: '等待页面状态',
|
||
description: '等待一个固定条件:URL 片段、可见文本或 CSS selector。超时返回 matched=false,不执行任意 JavaScript。',
|
||
promptSnippet: 'browser_wait_for: 等待 url/text/selector 条件',
|
||
parameters: Type.Object({
|
||
kind: Type.Union([Type.Literal('url'), Type.Literal('text'), Type.Literal('selector')]),
|
||
value: Type.String({ minLength: 1, maxLength: 2000, description: 'URL 片段 / 可见文本 / CSS selector' }),
|
||
timeoutMs: Type.Optional(Type.Number({ minimum: 250, maximum: 30000, description: '超时毫秒,默认 10000' })),
|
||
tabId: TAB_ID_PARAM
|
||
}),
|
||
async execute(_toolCallId, params) {
|
||
const result = await getBrowserController().waitFor({ kind: params.kind, value: params.value }, params.timeoutMs, params.tabId)
|
||
return { content: [{ type: 'text', text: JSON.stringify(result) }], details: result }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_screenshot',
|
||
label: '截图',
|
||
description: '截取当前页面。语义结构(observe)足够时不要截图;需要视觉验证、布局或渲染证据时使用。',
|
||
promptSnippet: 'browser_screenshot: 视觉验证用',
|
||
parameters: Type.Object({ tabId: TAB_ID_PARAM }),
|
||
async execute(_toolCallId, params) {
|
||
const shot = await getBrowserController().screenshot(params.tabId)
|
||
return {
|
||
content: [
|
||
{
|
||
type: 'image' as const,
|
||
data: shot.dataUrl.replace(/^data:image\/\w+;base64,/, ''),
|
||
mimeType: 'image/png'
|
||
},
|
||
{ type: 'text', text: JSON.stringify({ url: shot.url, title: shot.title }) }
|
||
],
|
||
details: { url: shot.url, title: shot.title }
|
||
}
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_list_tabs',
|
||
label: '列出标签页',
|
||
description: '列出受管浏览器的全部标签页(tabId/URL/标题/加载状态)与当前激活页。操作其他标签页时先调它拿 tabId。',
|
||
promptSnippet: 'browser_list_tabs: 查看标签页列表',
|
||
parameters: Type.Object({}),
|
||
async execute() {
|
||
const state = getBrowserController().listTabs()
|
||
return { content: [{ type: 'text', text: JSON.stringify({ tabs: state.tabs, activeTabId: state.activeTabId }) }], details: state }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_new_tab',
|
||
label: '新建标签页',
|
||
description: '新建 Agent 工作标签页并激活到用户可见的浏览器面板;完成后不再需要的标签页应主动 browser_close_tab。',
|
||
promptSnippet: 'browser_new_tab: 新建工作标签页',
|
||
parameters: Type.Object({
|
||
url: Type.Optional(Type.String({ description: '初始地址(URL/域名/搜索词),缺省开空白起始页' }))
|
||
}),
|
||
async execute(_toolCallId, params) {
|
||
const state = await getBrowserController().newTab(params.url, true)
|
||
return { content: [{ type: 'text', text: JSON.stringify({ tabId: state.activeTabId, tabs: state.tabs.length }) }], details: state }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_select_tab',
|
||
label: '切换标签页',
|
||
description: '把指定标签页设为 Agent 工作页,并同步激活到用户可见面板。后续不带 tabId 的操作都作用于它。',
|
||
promptSnippet: 'browser_select_tab: 切换工作标签页',
|
||
parameters: Type.Object({ tabId: Type.String({ description: 'browser_list_tabs 返回的标签页 ID' }) }),
|
||
async execute(_toolCallId, params) {
|
||
const state = await getBrowserController().selectTab(params.tabId)
|
||
return { content: [{ type: 'text', text: JSON.stringify({ activeTabId: state.activeTabId }) }], details: state }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_close_tab',
|
||
label: '关闭标签页',
|
||
description: '关闭指定标签页(缺省关闭当前工作页)。绝不关闭用户手动打开的标签页。',
|
||
promptSnippet: 'browser_close_tab: 关闭工作标签页',
|
||
parameters: Type.Object({ tabId: Type.Optional(Type.String({ description: '要关闭的标签页 ID,缺省当前工作页' })) }),
|
||
async execute(_toolCallId, params) {
|
||
const state = await getBrowserController().closeTab(params.tabId)
|
||
return { content: [{ type: 'text', text: JSON.stringify({ closed: params.tabId ?? 'current', remaining: state.tabs.length }) }], details: state }
|
||
}
|
||
}),
|
||
|
||
defineTool({
|
||
name: 'browser_close',
|
||
label: '关闭浏览器',
|
||
description: '关闭整个内置受管浏览器会话(销毁全部标签页)。任务结束、不再需要网页时主动调用。',
|
||
promptSnippet: 'browser_close: 关闭受管浏览器',
|
||
parameters: Type.Object({}),
|
||
async execute() {
|
||
const state = getBrowserController().close()
|
||
return { content: [{ type: 'text', text: JSON.stringify({ closed: true }) }], details: state }
|
||
}
|
||
})
|
||
]
|
||
|
||
export const browserToolNames = new Set(browserTools.map((tool) => tool.name))
|