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))