feat: improve runtime visibility and browser interaction

This commit is contained in:
lofyer
2026-08-07 17:53:14 +08:00
parent 2c715e5e81
commit 53d18e2b06
36 changed files with 1680 additions and 907 deletions
+121 -35
View File
@@ -7,6 +7,7 @@ import {
import type { BrowserScreenshot } from './browser-screenshot'
import {
ElectronBrowserSession,
type BrowserParentWindowHandle,
type BrowserWebContents
} from './electron-browser-session'
import type { BrowserLiveState } from '../../shared/contracts'
@@ -20,6 +21,7 @@ export type BrowserSessionLike = {
target: Awaited<ReturnType<BrowserUrlPolicy['validate']>>
): void
getCurrentOrigin(): string | undefined
openInteraction(): Promise<BrowserScreenshot | undefined>
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
dispose(): Promise<void>
}
@@ -45,6 +47,7 @@ export type BrowserServiceOptions = {
idleTimeoutMs?: number
cleanupTimeoutMs?: number
liveFrameDelayMs?: number
parentWindow?: BrowserParentWindowHandle
createSession?: (
policy: BrowserUrlPolicy,
signal: AbortSignal
@@ -112,9 +115,16 @@ async function boundedCleanup(
async function defaultCreateSession(
policy: BrowserUrlPolicy,
signal: AbortSignal
signal: AbortSignal,
parentWindow?: BrowserParentWindowHandle
): Promise<BrowserSessionLike> {
return ElectronBrowserSession.create({ policy }, signal)
return ElectronBrowserSession.create(
{
policy,
...(parentWindow ? { parentWindow } : {})
},
signal
)
}
function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike {
@@ -152,7 +162,10 @@ export class BrowserService {
this.cleanupTimeoutMs =
options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS
this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100
this.createSession = options.createSession ?? defaultCreateSession
this.createSession =
options.createSession ??
((policy, signal) =>
defaultCreateSession(policy, signal, options.parentWindow))
this.createDriver = options.createDriver ?? defaultCreateDriver
if (
!Number.isSafeInteger(this.maximumSessions) ||
@@ -198,6 +211,9 @@ export class BrowserService {
conversationId,
status,
...(previous?.url ? { url: previous.url } : {}),
...(status !== 'stopped' && previous?.frameDataUrl
? { frameDataUrl: previous.frameDataUrl }
: {}),
...update,
updatedAt: Date.now()
}
@@ -235,40 +251,73 @@ export class BrowserService {
signal
)
}
const previewController = new AbortController()
const timeout = setTimeout(
() =>
previewController.abort(
new Error('浏览器实时画面捕获超时')
),
2_000
)
try {
const previewSignal = AbortSignal.any([
signal,
previewController.signal
])
frame = slot.session.captureScreenshot
? await slot.session.captureScreenshot(previewSignal)
: await slot.driver.screenshot(previewSignal)
} catch {
signal.throwIfAborted()
// Browser control succeeds even when the optional live frame fails.
} finally {
clearTimeout(timeout)
const captureDeadline = AbortSignal.any([
signal,
AbortSignal.timeout(6_000)
])
for (let attempt = 0; attempt < 3 && !frame; attempt += 1) {
if (attempt > 0) {
try {
await waitFor(
new Promise<void>((resolve) =>
setTimeout(resolve, attempt * 150)
),
captureDeadline
)
} catch {
signal.throwIfAborted()
break
}
}
if (slot.session.captureScreenshot) {
try {
frame = await slot.session.captureScreenshot(
AbortSignal.any([
captureDeadline,
AbortSignal.timeout(1_500)
])
)
} catch {
signal.throwIfAborted()
}
}
if (!frame && !captureDeadline.aborted) {
try {
frame = await slot.driver.screenshot(
AbortSignal.any([
captureDeadline,
AbortSignal.timeout(1_500)
])
)
} catch {
signal.throwIfAborted()
}
}
}
}
signal.throwIfAborted()
if (slot.released || this.slots.get(conversationId) !== slot) {
return
}
if (!frame) {
const previousFrame =
this.liveStates.get(conversationId)?.frameDataUrl
if (previousFrame) {
this.emitState(conversationId, 'ready', {
...(url ? { url } : {}),
frameDataUrl: previousFrame
})
return
}
this.emitState(conversationId, 'failed', {
...(url ? { url } : {}),
error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作'
})
return
}
this.emitState(conversationId, 'ready', {
...(url ? { url } : {}),
...(frame
? {
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
}
: {})
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
})
}
@@ -429,7 +478,7 @@ export class BrowserService {
slot: BrowserSlot,
signal: AbortSignal,
operation: (effectiveSignal: AbortSignal) => Promise<T>,
status?: 'loading' | 'acting'
status?: 'loading' | 'acting' | 'interactive'
): Promise<T> {
signal.throwIfAborted()
if (slot.released || this.disposed) {
@@ -496,7 +545,7 @@ export class BrowserService {
private async runInSession<T>(
conversationId: string,
signal: AbortSignal,
status: 'loading' | 'acting',
status: 'loading' | 'acting' | 'interactive',
failureStage: string,
operation: (
slot: BrowserSlot,
@@ -715,10 +764,17 @@ export class BrowserService {
'浏览器截图',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
const screenshot =
slot.session.captureScreenshot
? await slot.session.captureScreenshot(effectiveSignal)
: await slot.driver.screenshot(effectiveSignal)
let screenshot: BrowserScreenshot | undefined
if (slot.session.captureScreenshot) {
try {
screenshot = await slot.session.captureScreenshot(
effectiveSignal
)
} catch {
effectiveSignal.throwIfAborted()
}
}
screenshot ??= await slot.driver.screenshot(effectiveSignal)
await this.captureFrame(
conversationId,
slot,
@@ -731,6 +787,36 @@ export class BrowserService {
)
}
async interact(
conversationId: string,
signal: AbortSignal
): Promise<void> {
await this.runInSession(
conversationId,
signal,
'interactive',
'浏览器交互',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
const closingFrame = await waitFor(
slot.session.openInteraction(),
effectiveSignal
)
const currentUrl = canonicalizeBrowserUrl(
slot.session.webContents.getURL()
)
slot.origin = currentUrl.origin
await this.captureFrame(
conversationId,
slot,
effectiveSignal,
currentUrl.href,
closingFrame
)
}
)
}
async releaseConversation(conversationId: string): Promise<void> {
this.releaseRequests.add(conversationId)
let releasedSlot = false