Files
inkreach-agent/apps/desktop/src/main/tools/browser-tools.ts
T

293 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))