fix(browser): host agent browser in workbench
This commit is contained in:
1 parent
9a1f182397
commit
efb44f9340
12 files changed
+136
-41
No files matched your search
@@ -1,14 +1,16 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { app, BrowserWindow, webContents, type WebContents } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { resolve } from 'node:path'
|
||||
import type { ToolContext, ToolResult } from './agent/tools/browser'
|
||||
import { browserDomScript } from './agent/browser-dom'
|
||||
import { browserHostView, closeAgentBrowser, createAgentBrowser, publishBrowserHostOpened } from './browser-host'
|
||||
|
||||
interface ManagedBrowser {
|
||||
id: string
|
||||
scope: string
|
||||
sessionId?: string
|
||||
window: BrowserWindow
|
||||
owner: BrowserWindow
|
||||
contents: WebContents
|
||||
documentKey: string
|
||||
pending: Promise<unknown>
|
||||
}
|
||||
@@ -26,7 +28,7 @@ export function installBrowserAutomationLifecycle(): void {
|
||||
app.on('before-quit', disposeBrowserAutomation)
|
||||
app.on('browser-window-created', (_event, window) => {
|
||||
window.once('closed', () => {
|
||||
const managed = new Set([...browsers.values()].map(browser => browser.window))
|
||||
const managed = new Set([...browsers.values()].map(browser => browser.owner))
|
||||
if (!BrowserWindow.getAllWindows().some(candidate => !candidate.isDestroyed() && !managed.has(candidate))) disposeBrowserAutomation()
|
||||
})
|
||||
})
|
||||
@@ -58,12 +60,12 @@ function timeout(args: Record<string, unknown>): number {
|
||||
return Math.trunc(args.timeoutMs)
|
||||
}
|
||||
function info(browser: ManagedBrowser): Record<string, unknown> {
|
||||
const contents = browser.window.webContents
|
||||
return { tabId: browser.id, url: contents.getURL(), title: contents.getTitle(), loading: contents.isLoadingMainFrame(), headless: true }
|
||||
const view = browserHostView(browser.owner, browser.id, browser.sessionId)
|
||||
return { tabId: browser.id, url: view.url, title: view.title, loading: view.loading, headless: false }
|
||||
}
|
||||
function release(browser: ManagedBrowser): void {
|
||||
browsers.delete(browser.id)
|
||||
if (!browser.window.isDestroyed()) browser.window.destroy()
|
||||
closeAgentBrowser(browser.owner, browser.id, browser.sessionId)
|
||||
}
|
||||
export function disposeSessionBrowsers(sessionId: string): void {
|
||||
for (const browser of [...browsers.values()]) if (browser.sessionId === sessionId) release(browser)
|
||||
@@ -91,41 +93,38 @@ async function pause(milliseconds: number, ctx: ToolContext): Promise<void> {
|
||||
await bounded(new Promise<void>(done => setTimeout(done, milliseconds)), ctx, milliseconds + 1000)
|
||||
}
|
||||
function owned(args: Record<string, unknown>, key: string): ManagedBrowser {
|
||||
const mine = [...browsers.values()].filter(browser => browser.scope === key && !browser.window.isDestroyed())
|
||||
const mine = [...browsers.values()].filter(browser => browser.scope === key && !browser.contents.isDestroyed())
|
||||
const browser = typeof args.tabId === 'string' ? browsers.get(args.tabId) : mine.length === 1 ? mine[0] : undefined
|
||||
if (!browser || browser.scope !== key || browser.window.isDestroyed()) throw new Error('浏览器标签不存在、不属于当前会话,或存在多个标签;请先 list 并指定 tabId')
|
||||
if (!browser || browser.scope !== key || browser.contents.isDestroyed()) throw new Error('浏览器标签不存在、不属于当前会话,或存在多个标签;请先 list 并指定 tabId')
|
||||
return browser
|
||||
}
|
||||
async function approve(_action: string, _args: Record<string, unknown>, ctx: ToolContext): Promise<void> {
|
||||
active(ctx)
|
||||
}
|
||||
function browserOwner(ctx: ToolContext): BrowserWindow {
|
||||
const sender = ctx.webContentsId ? webContents.fromId(ctx.webContentsId) : undefined
|
||||
const owner = sender ? BrowserWindow.fromWebContents(sender) : BrowserWindow.getFocusedWindow()
|
||||
if (!owner || owner.isDestroyed()) throw new Error('浏览器需要有效的 YCode 窗口')
|
||||
return owner
|
||||
}
|
||||
function create(key: string, ctx: ToolContext): ManagedBrowser {
|
||||
if (!app.isReady()) throw new Error('Electron 浏览器尚未就绪')
|
||||
if (browsers.size >= 16 || [...browsers.values()].filter(browser => browser.scope === key).length >= 4) throw new Error('后台浏览器标签达到上限,请先 close 不再使用的标签')
|
||||
const id = randomUUID()
|
||||
const window = new BrowserWindow({ width: 1280, height: 800, useContentSize: true, show: false, paintWhenInitiallyHidden: true,
|
||||
title: 'YCode 后台浏览器', webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false,
|
||||
webSecurity: true, allowRunningInsecureContent: false, backgroundThrottling: false, offscreen: true, partition: `ycode-agent-browser-${id}` } })
|
||||
const browser: ManagedBrowser = { id, scope: key, sessionId: ctx.sessionId, window, documentKey: randomUUID(), pending: Promise.resolve() }
|
||||
browsers.set(id, browser)
|
||||
const contents = window.webContents
|
||||
contents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
|
||||
contents.session.setPermissionCheckHandler(() => false)
|
||||
contents.session.on('will-download', event => event.preventDefault())
|
||||
contents.session.webRequest.onBeforeRequest((details, callback) => {
|
||||
try { callback({ cancel: !['http:', 'https:', 'ws:', 'wss:', 'data:', 'blob:', 'about:'].includes(new URL(details.url).protocol) }) }
|
||||
catch { callback({ cancel: true }) }
|
||||
})
|
||||
const owner = browserOwner(ctx)
|
||||
if (!ctx.sessionId) throw new Error('浏览器需要有效的会话')
|
||||
const created = createAgentBrowser(owner, ctx.sessionId)
|
||||
const browser: ManagedBrowser = { id: created.id, scope: key, sessionId: ctx.sessionId,
|
||||
owner, contents: created.contents, documentKey: randomUUID(), pending: Promise.resolve() }
|
||||
browsers.set(browser.id, browser)
|
||||
const contents = browser.contents
|
||||
const guard = (event: { preventDefault(): void }, target: string): void => { try { url(target) } catch { event.preventDefault() } }
|
||||
contents.on('will-navigate', guard)
|
||||
contents.on('will-redirect', guard)
|
||||
contents.on('did-navigate', () => { browser.documentKey = randomUUID() })
|
||||
contents.on('render-process-gone', () => release(browser))
|
||||
contents.setWindowOpenHandler(({ url: target }) => {
|
||||
try { void contents.loadURL(url(target)).catch(() => undefined) } catch { /* unsupported popup URL is refused */ }
|
||||
return { action: 'deny' }
|
||||
contents.once('destroyed', () => {
|
||||
if (browsers.get(browser.id) === browser) browsers.delete(browser.id)
|
||||
})
|
||||
window.on('closed', () => browsers.delete(id))
|
||||
try {
|
||||
contents.debugger.attach('1.3')
|
||||
void contents.debugger.sendCommand('Page.enable').catch(() => undefined)
|
||||
@@ -137,7 +136,7 @@ function create(key: string, ctx: ToolContext): ManagedBrowser {
|
||||
}
|
||||
async function dom(browser: ManagedBrowser, request: Record<string, unknown>, ctx: ToolContext): Promise<Record<string, unknown>> {
|
||||
active(ctx)
|
||||
const result = await bounded(browser.window.webContents.executeJavaScriptInIsolatedWorld(WORLD,
|
||||
const result = await bounded(browser.contents.executeJavaScriptInIsolatedWorld(WORLD,
|
||||
[{ code: browserDomScript({ ...request, documentKey: browser.documentKey }) }]), ctx, 8000, () => release(browser))
|
||||
if (result && typeof result.__ycodeBrowserError === 'string') throw new Error(result.__ycodeBrowserError)
|
||||
return result
|
||||
@@ -153,19 +152,19 @@ async function snapshot(browser: ManagedBrowser, ctx: ToolContext): Promise<Tool
|
||||
async function settle(browser: ManagedBrowser, ctx: ToolContext, milliseconds: number): Promise<void> {
|
||||
await pause(80, ctx)
|
||||
const until = Date.now() + milliseconds
|
||||
while (!browser.window.isDestroyed() && browser.window.webContents.isLoadingMainFrame()) {
|
||||
while (!browser.contents.isDestroyed() && browser.contents.isLoadingMainFrame()) {
|
||||
if (Date.now() >= until) throw new Error('网页仍在加载;可稍后 snapshot 或 wait')
|
||||
await pause(50, ctx)
|
||||
}
|
||||
active(ctx)
|
||||
if (browser.window.isDestroyed()) throw new Error('浏览器标签已关闭')
|
||||
if (browser.contents.isDestroyed()) throw new Error('浏览器标签已关闭')
|
||||
}
|
||||
async function input(browser: ManagedBrowser, command: string, parameters: Record<string, unknown>, ctx: ToolContext): Promise<void> {
|
||||
active(ctx)
|
||||
await bounded(browser.window.webContents.debugger.sendCommand(command, parameters), ctx, 8000, () => release(browser))
|
||||
await bounded(browser.contents.debugger.sendCommand(command, parameters), ctx, 8000, () => release(browser))
|
||||
}
|
||||
async function act(browser: ManagedBrowser, action: string, args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const contents = browser.window.webContents
|
||||
const contents = browser.contents
|
||||
const milliseconds = timeout(args)
|
||||
if (action === 'close') { const data = info(browser); release(browser); return { ok: true, content: '浏览器标签已关闭', data } }
|
||||
if (action === 'open' || action === 'navigate') {
|
||||
@@ -235,10 +234,14 @@ export async function executeBrowserTool(args: Record<string, unknown>, ctx: Too
|
||||
const browser = existing ?? create(key, ctx)
|
||||
const operation = browser.pending.catch(() => undefined).then(async () => {
|
||||
active(ctx)
|
||||
if (browsers.get(browser.id) !== browser || browser.window.isDestroyed()) throw new Error('浏览器标签已关闭')
|
||||
if (browsers.get(browser.id) !== browser || browser.contents.isDestroyed()) throw new Error('浏览器标签已关闭')
|
||||
return act(browser, action, args, ctx)
|
||||
})
|
||||
browser.pending = operation.catch(() => undefined)
|
||||
try { return await bounded(operation, ctx, timeout(args) + 10000) }
|
||||
try {
|
||||
const result = await bounded(operation, ctx, timeout(args) + 10000)
|
||||
if (action === 'open' || action === 'navigate') publishBrowserHostOpened(browser.owner, browser.id, browser.sessionId)
|
||||
return result
|
||||
}
|
||||
catch (error) { if (action === 'open') release(browser); throw error }
|
||||
}
|
||||
Reference in new issue
Block a user