866 lines
24 KiB
TypeScript
866 lines
24 KiB
TypeScript
import { BrowserUrlPolicy, canonicalizeBrowserUrl } from './browser-url-policy'
|
|
import {
|
|
CdpBrowserDriver,
|
|
type BrowserHistoryTarget,
|
|
type BrowserSnapshot
|
|
} from './cdp-browser-driver'
|
|
import type { BrowserScreenshot } from './browser-screenshot'
|
|
import {
|
|
ElectronBrowserSession,
|
|
type BrowserWebContents
|
|
} from './electron-browser-session'
|
|
import type { BrowserLiveState } from '../../shared/contracts'
|
|
|
|
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000
|
|
const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000
|
|
|
|
export type BrowserSessionLike = {
|
|
readonly webContents: BrowserWebContents
|
|
approveNavigation(
|
|
target: Awaited<ReturnType<BrowserUrlPolicy['validate']>>
|
|
): void
|
|
getCurrentOrigin(): string | undefined
|
|
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
|
|
dispose(): Promise<void>
|
|
}
|
|
|
|
export type BrowserDriverLike = {
|
|
navigate(url: string, signal: AbortSignal): Promise<{ url: string }>
|
|
snapshot(signal: AbortSignal): Promise<BrowserSnapshot>
|
|
click(ref: string, signal: AbortSignal): Promise<void>
|
|
type(ref: string, text: string, signal: AbortSignal): Promise<void>
|
|
select(ref: string, value: string, signal: AbortSignal): Promise<void>
|
|
getBackTarget(signal: AbortSignal): Promise<BrowserHistoryTarget>
|
|
backTo(
|
|
target: BrowserHistoryTarget,
|
|
signal: AbortSignal
|
|
): Promise<{ url: string }>
|
|
screenshot(signal: AbortSignal): Promise<BrowserScreenshot>
|
|
dispose(): void
|
|
}
|
|
|
|
export type BrowserServiceOptions = {
|
|
policy?: BrowserUrlPolicy
|
|
maximumSessions?: number
|
|
idleTimeoutMs?: number
|
|
cleanupTimeoutMs?: number
|
|
liveFrameDelayMs?: number
|
|
createSession?: (
|
|
policy: BrowserUrlPolicy,
|
|
signal: AbortSignal
|
|
) => Promise<BrowserSessionLike>
|
|
createDriver?: (webContents: BrowserWebContents) => BrowserDriverLike
|
|
}
|
|
|
|
type BrowserSlot = {
|
|
conversationId: string
|
|
session: BrowserSessionLike
|
|
driver: BrowserDriverLike
|
|
origin?: string
|
|
tail: Promise<void>
|
|
active?: AbortController
|
|
idleTimer?: ReturnType<typeof setTimeout>
|
|
lastUsedAt: number
|
|
released: boolean
|
|
}
|
|
|
|
type SlotCreation = {
|
|
controller: AbortController
|
|
promise: Promise<BrowserSlot>
|
|
waiters: Set<symbol>
|
|
}
|
|
|
|
function waitFor<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
signal.throwIfAborted()
|
|
return new Promise<T>((resolve, reject) => {
|
|
const abort = (): void => reject(signal.reason)
|
|
signal.addEventListener('abort', abort, { once: true })
|
|
void promise.then(
|
|
(value) => {
|
|
signal.removeEventListener('abort', abort)
|
|
resolve(value)
|
|
},
|
|
(error: unknown) => {
|
|
signal.removeEventListener('abort', abort)
|
|
reject(error)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
async function boundedCleanup(
|
|
cleanup: Promise<void>,
|
|
timeoutMs: number
|
|
): Promise<void> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
try {
|
|
await Promise.race([
|
|
cleanup,
|
|
new Promise<never>((_resolve, reject) => {
|
|
timer = setTimeout(
|
|
() => reject(new Error('浏览器会话清理超时')),
|
|
timeoutMs
|
|
)
|
|
})
|
|
])
|
|
} finally {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function defaultCreateSession(
|
|
policy: BrowserUrlPolicy,
|
|
signal: AbortSignal
|
|
): Promise<BrowserSessionLike> {
|
|
return ElectronBrowserSession.create({ policy }, signal)
|
|
}
|
|
|
|
function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike {
|
|
return new CdpBrowserDriver(webContents)
|
|
}
|
|
|
|
export class BrowserService {
|
|
private readonly policy: BrowserUrlPolicy
|
|
private readonly maximumSessions: number
|
|
private readonly idleTimeoutMs: number
|
|
private readonly cleanupTimeoutMs: number
|
|
private readonly liveFrameDelayMs: number
|
|
private readonly createSession: NonNullable<
|
|
BrowserServiceOptions['createSession']
|
|
>
|
|
private readonly createDriver: NonNullable<
|
|
BrowserServiceOptions['createDriver']
|
|
>
|
|
private readonly slots = new Map<string, BrowserSlot>()
|
|
private readonly creations = new Map<string, SlotCreation>()
|
|
private readonly releaseRequests = new Set<string>()
|
|
private readonly stateListeners = new Set<
|
|
(state: BrowserLiveState) => void
|
|
>()
|
|
private readonly liveStates = new Map<string, BrowserLiveState>()
|
|
private lifecycle = new AbortController()
|
|
private clearOperation?: Promise<void>
|
|
private clearing = false
|
|
private disposed = false
|
|
|
|
constructor(options: BrowserServiceOptions = {}) {
|
|
this.policy = options.policy ?? new BrowserUrlPolicy()
|
|
this.maximumSessions = options.maximumSessions ?? 3
|
|
this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
|
|
this.cleanupTimeoutMs =
|
|
options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS
|
|
this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100
|
|
this.createSession = options.createSession ?? defaultCreateSession
|
|
this.createDriver = options.createDriver ?? defaultCreateDriver
|
|
if (
|
|
!Number.isSafeInteger(this.maximumSessions) ||
|
|
this.maximumSessions < 1 ||
|
|
!Number.isSafeInteger(this.idleTimeoutMs) ||
|
|
this.idleTimeoutMs < 1 ||
|
|
!Number.isSafeInteger(this.cleanupTimeoutMs) ||
|
|
this.cleanupTimeoutMs < 1 ||
|
|
!Number.isSafeInteger(this.liveFrameDelayMs) ||
|
|
this.liveFrameDelayMs < 0
|
|
) {
|
|
throw new Error('浏览器服务限制配置无效')
|
|
}
|
|
}
|
|
|
|
getOrigin(conversationId: string): string | undefined {
|
|
return this.slots.get(conversationId)?.origin
|
|
}
|
|
|
|
getSessionCount(): number {
|
|
return this.slots.size
|
|
}
|
|
|
|
onState(listener: (state: BrowserLiveState) => void): () => void {
|
|
this.stateListeners.add(listener)
|
|
for (const state of this.liveStates.values()) {
|
|
listener(state)
|
|
}
|
|
return () => {
|
|
this.stateListeners.delete(listener)
|
|
}
|
|
}
|
|
|
|
private emitState(
|
|
conversationId: string,
|
|
status: BrowserLiveState['status'],
|
|
update: Partial<
|
|
Pick<BrowserLiveState, 'url' | 'frameDataUrl' | 'error'>
|
|
> = {}
|
|
): void {
|
|
const previous = this.liveStates.get(conversationId)
|
|
const state: BrowserLiveState = {
|
|
conversationId,
|
|
status,
|
|
...(previous?.url ? { url: previous.url } : {}),
|
|
...update,
|
|
updatedAt: Date.now()
|
|
}
|
|
if (status !== 'failed') {
|
|
delete state.error
|
|
}
|
|
if (status === 'stopped') {
|
|
this.liveStates.delete(conversationId)
|
|
} else {
|
|
this.liveStates.set(conversationId, state)
|
|
}
|
|
for (const listener of this.stateListeners) {
|
|
try {
|
|
listener(state)
|
|
} catch {
|
|
// A UI observer must not interrupt browser control.
|
|
}
|
|
}
|
|
}
|
|
|
|
private async captureFrame(
|
|
conversationId: string,
|
|
slot: BrowserSlot,
|
|
signal: AbortSignal,
|
|
url?: string,
|
|
screenshot?: BrowserScreenshot
|
|
): Promise<void> {
|
|
let frame = screenshot
|
|
if (!frame) {
|
|
if (this.liveFrameDelayMs > 0) {
|
|
await waitFor(
|
|
new Promise<void>((resolve) =>
|
|
setTimeout(resolve, this.liveFrameDelayMs)
|
|
),
|
|
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)
|
|
}
|
|
}
|
|
signal.throwIfAborted()
|
|
if (slot.released || this.slots.get(conversationId) !== slot) {
|
|
return
|
|
}
|
|
this.emitState(conversationId, 'ready', {
|
|
...(url ? { url } : {}),
|
|
...(frame
|
|
? {
|
|
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
|
|
}
|
|
: {})
|
|
})
|
|
}
|
|
|
|
private emitFailure(
|
|
conversationId: string,
|
|
stage: string,
|
|
error: unknown
|
|
): void {
|
|
const detail =
|
|
error instanceof Error && error.message
|
|
? error.message.slice(0, 180)
|
|
: '未知错误'
|
|
this.emitState(conversationId, 'failed', {
|
|
error: `${stage}失败:${detail}`.slice(0, 240)
|
|
})
|
|
}
|
|
|
|
private shouldEmitFailure(
|
|
conversationId: string,
|
|
signal: AbortSignal,
|
|
error: unknown
|
|
): boolean {
|
|
if (signal.aborted || this.releaseRequests.has(conversationId)) {
|
|
return false
|
|
}
|
|
const message = error instanceof Error ? error.message : ''
|
|
return ![
|
|
'浏览器会话已释放',
|
|
'浏览器会话已清除',
|
|
'浏览器会话已关闭',
|
|
'浏览器服务已关闭'
|
|
].some((reason) => message.includes(reason))
|
|
}
|
|
|
|
private async getOrCreateSlot(
|
|
conversationId: string,
|
|
signal: AbortSignal
|
|
): Promise<BrowserSlot> {
|
|
if (this.disposed || this.clearing || this.lifecycle.signal.aborted) {
|
|
throw new Error('浏览器服务已关闭')
|
|
}
|
|
const existing = this.slots.get(conversationId)
|
|
if (existing && !existing.released) {
|
|
return existing
|
|
}
|
|
let creation = this.creations.get(conversationId)
|
|
const pendingCreations = [...this.creations.keys()].filter(
|
|
(id) => !this.slots.has(id)
|
|
).length
|
|
if (
|
|
!creation &&
|
|
this.slots.size + pendingCreations >= this.maximumSessions
|
|
) {
|
|
throw new Error(`浏览器会话已达到 ${this.maximumSessions} 个上限`)
|
|
}
|
|
if (!creation) {
|
|
const controller = new AbortController()
|
|
const waiters = new Set<symbol>()
|
|
const promise = this.createSlot(
|
|
conversationId,
|
|
AbortSignal.any([this.lifecycle.signal, controller.signal]),
|
|
() => waiters.size > 0
|
|
)
|
|
const currentCreation = { controller, promise, waiters }
|
|
creation = currentCreation
|
|
this.creations.set(conversationId, creation)
|
|
const removeCreation = (): void => {
|
|
if (this.creations.get(conversationId) === creation) {
|
|
this.creations.delete(conversationId)
|
|
}
|
|
}
|
|
void promise.then(removeCreation, removeCreation)
|
|
}
|
|
const waiter = Symbol(conversationId)
|
|
creation.waiters.add(waiter)
|
|
try {
|
|
return await waitFor(creation.promise, signal)
|
|
} finally {
|
|
creation.waiters.delete(waiter)
|
|
if (
|
|
creation.waiters.size === 0 &&
|
|
this.creations.get(conversationId) === creation
|
|
) {
|
|
this.creations.delete(conversationId)
|
|
creation.controller.abort(new Error('浏览器会话创建已取消'))
|
|
}
|
|
}
|
|
}
|
|
|
|
private async createSlot(
|
|
conversationId: string,
|
|
signal: AbortSignal,
|
|
hasWaiters: () => boolean
|
|
): Promise<BrowserSlot> {
|
|
const session = await this.createSession(
|
|
this.policy,
|
|
signal
|
|
)
|
|
if (
|
|
this.disposed ||
|
|
signal.aborted ||
|
|
!hasWaiters() ||
|
|
this.releaseRequests.has(conversationId)
|
|
) {
|
|
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs)
|
|
throw new Error('浏览器服务已关闭')
|
|
}
|
|
let driver: BrowserDriverLike
|
|
try {
|
|
driver = this.createDriver(session.webContents)
|
|
} catch (error) {
|
|
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs).catch(
|
|
() => undefined
|
|
)
|
|
throw error
|
|
}
|
|
const slot: BrowserSlot = {
|
|
conversationId,
|
|
session,
|
|
driver,
|
|
tail: Promise.resolve(),
|
|
lastUsedAt: Date.now(),
|
|
released: false
|
|
}
|
|
this.slots.set(conversationId, slot)
|
|
this.scheduleIdleExpiry(slot)
|
|
return slot
|
|
}
|
|
|
|
private requireSlot(conversationId: string): BrowserSlot {
|
|
if (this.disposed) {
|
|
throw new Error('浏览器服务已关闭')
|
|
}
|
|
const slot = this.slots.get(conversationId)
|
|
if (!slot || slot.released || !slot.origin) {
|
|
throw new Error('当前对话尚未建立浏览器会话,请先导航')
|
|
}
|
|
return slot
|
|
}
|
|
|
|
private scheduleIdleExpiry(slot: BrowserSlot): void {
|
|
if (slot.idleTimer) {
|
|
clearTimeout(slot.idleTimer)
|
|
}
|
|
if (slot.released || this.disposed) {
|
|
return
|
|
}
|
|
slot.idleTimer = setTimeout(() => {
|
|
if (Date.now() - slot.lastUsedAt < this.idleTimeoutMs) {
|
|
this.scheduleIdleExpiry(slot)
|
|
return
|
|
}
|
|
void this.releaseSlot(slot).catch(() => undefined)
|
|
}, this.idleTimeoutMs)
|
|
}
|
|
|
|
private async serialize<T>(
|
|
slot: BrowserSlot,
|
|
signal: AbortSignal,
|
|
operation: (effectiveSignal: AbortSignal) => Promise<T>,
|
|
status?: 'loading' | 'acting'
|
|
): Promise<T> {
|
|
signal.throwIfAborted()
|
|
if (slot.released || this.disposed) {
|
|
throw new Error('浏览器会话已关闭')
|
|
}
|
|
let releaseGate!: () => void
|
|
const gate = new Promise<void>((resolve) => {
|
|
releaseGate = resolve
|
|
})
|
|
const predecessor = slot.tail.catch(() => undefined)
|
|
slot.tail = predecessor.then(() => gate)
|
|
let operationController: AbortController | undefined
|
|
try {
|
|
await waitFor(predecessor, signal)
|
|
if (slot.released || this.disposed) {
|
|
throw new Error('浏览器会话已关闭')
|
|
}
|
|
if (status) {
|
|
this.emitState(slot.conversationId, status)
|
|
}
|
|
if (slot.idleTimer) {
|
|
clearTimeout(slot.idleTimer)
|
|
slot.idleTimer = undefined
|
|
}
|
|
operationController = new AbortController()
|
|
slot.active = operationController
|
|
const effectiveSignal = AbortSignal.any([
|
|
signal,
|
|
this.lifecycle.signal,
|
|
operationController.signal
|
|
])
|
|
return await operation(effectiveSignal)
|
|
} finally {
|
|
if (operationController && slot.active === operationController) {
|
|
slot.active = undefined
|
|
}
|
|
releaseGate()
|
|
if (operationController) {
|
|
slot.lastUsedAt = Date.now()
|
|
this.scheduleIdleExpiry(slot)
|
|
}
|
|
}
|
|
}
|
|
|
|
private verifyCurrentOrigin(slot: BrowserSlot): string {
|
|
const current = slot.session.getCurrentOrigin()
|
|
if (!slot.origin || current !== slot.origin) {
|
|
throw new Error('浏览器页面来源已改变,会话已被拒绝')
|
|
}
|
|
return current
|
|
}
|
|
|
|
private async verifyCurrentOriginOrRelease(
|
|
slot: BrowserSlot
|
|
): Promise<string> {
|
|
try {
|
|
return this.verifyCurrentOrigin(slot)
|
|
} catch (error) {
|
|
await this.releaseSlot(slot).catch(() => undefined)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private async runInSession<T>(
|
|
conversationId: string,
|
|
signal: AbortSignal,
|
|
status: 'loading' | 'acting',
|
|
failureStage: string,
|
|
operation: (
|
|
slot: BrowserSlot,
|
|
effectiveSignal: AbortSignal
|
|
) => Promise<T>
|
|
): Promise<T> {
|
|
try {
|
|
const slot = this.requireSlot(conversationId)
|
|
return await this.serialize(
|
|
slot,
|
|
signal,
|
|
(effectiveSignal) => operation(slot, effectiveSignal),
|
|
status
|
|
)
|
|
} catch (error) {
|
|
if (this.shouldEmitFailure(conversationId, signal, error)) {
|
|
this.emitFailure(conversationId, failureStage, error)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async navigate(
|
|
conversationId: string,
|
|
url: string,
|
|
signal: AbortSignal
|
|
): Promise<{ url: string; origin: string }> {
|
|
if (
|
|
!this.slots.has(conversationId) &&
|
|
!this.creations.has(conversationId)
|
|
) {
|
|
this.emitState(conversationId, 'creating', { url })
|
|
}
|
|
try {
|
|
const slot = await this.getOrCreateSlot(conversationId, signal)
|
|
return await this.serialize(slot, signal, async (effectiveSignal) => {
|
|
const target = await this.policy.validate(url, effectiveSignal)
|
|
slot.session.approveNavigation(target)
|
|
try {
|
|
const result = await slot.driver.navigate(
|
|
target.url.href,
|
|
effectiveSignal
|
|
)
|
|
const finalTarget = await this.policy.validateRedirect(
|
|
result.url,
|
|
target.origin,
|
|
effectiveSignal
|
|
)
|
|
if (slot.session.getCurrentOrigin() !== finalTarget.origin) {
|
|
throw new Error('浏览器导航结果来源不一致')
|
|
}
|
|
slot.origin = finalTarget.origin
|
|
await this.captureFrame(
|
|
conversationId,
|
|
slot,
|
|
effectiveSignal,
|
|
finalTarget.url.href
|
|
)
|
|
return {
|
|
url: finalTarget.url.href,
|
|
origin: finalTarget.origin
|
|
}
|
|
} catch (error) {
|
|
await this.releaseSlot(slot).catch(() => undefined)
|
|
throw error
|
|
}
|
|
}, 'loading')
|
|
} catch (error) {
|
|
if (this.shouldEmitFailure(conversationId, signal, error)) {
|
|
this.emitFailure(conversationId, '浏览器导航', error)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async snapshot(
|
|
conversationId: string,
|
|
signal: AbortSignal
|
|
): Promise<BrowserSnapshot> {
|
|
return this.runInSession(
|
|
conversationId,
|
|
signal,
|
|
'acting',
|
|
'读取浏览器页面',
|
|
async (slot, effectiveSignal) => {
|
|
await this.verifyCurrentOriginOrRelease(slot)
|
|
const snapshot = await slot.driver.snapshot(effectiveSignal)
|
|
const target = canonicalizeBrowserUrl(snapshot.url)
|
|
if (target.origin !== slot.origin) {
|
|
await this.releaseSlot(slot).catch(() => undefined)
|
|
throw new Error('浏览器快照来源与当前会话不一致')
|
|
}
|
|
await this.captureFrame(
|
|
conversationId,
|
|
slot,
|
|
effectiveSignal,
|
|
target.href
|
|
)
|
|
return { ...snapshot, url: target.href }
|
|
}
|
|
)
|
|
}
|
|
|
|
async click(
|
|
conversationId: string,
|
|
ref: string,
|
|
signal: AbortSignal
|
|
): Promise<void> {
|
|
await this.runInSession(
|
|
conversationId,
|
|
signal,
|
|
'acting',
|
|
'浏览器点击',
|
|
async (slot, effectiveSignal) => {
|
|
await this.verifyCurrentOriginOrRelease(slot)
|
|
await slot.driver.click(ref, effectiveSignal)
|
|
await this.captureFrame(conversationId, slot, effectiveSignal)
|
|
}
|
|
)
|
|
}
|
|
|
|
async type(
|
|
conversationId: string,
|
|
ref: string,
|
|
text: string,
|
|
signal: AbortSignal
|
|
): Promise<void> {
|
|
await this.runInSession(
|
|
conversationId,
|
|
signal,
|
|
'acting',
|
|
'浏览器输入',
|
|
async (slot, effectiveSignal) => {
|
|
await this.verifyCurrentOriginOrRelease(slot)
|
|
await slot.driver.type(ref, text, effectiveSignal)
|
|
await this.captureFrame(conversationId, slot, effectiveSignal)
|
|
}
|
|
)
|
|
}
|
|
|
|
async select(
|
|
conversationId: string,
|
|
ref: string,
|
|
value: string,
|
|
signal: AbortSignal
|
|
): Promise<void> {
|
|
await this.runInSession(
|
|
conversationId,
|
|
signal,
|
|
'acting',
|
|
'浏览器选择',
|
|
async (slot, effectiveSignal) => {
|
|
await this.verifyCurrentOriginOrRelease(slot)
|
|
await slot.driver.select(ref, value, effectiveSignal)
|
|
await this.captureFrame(conversationId, slot, effectiveSignal)
|
|
}
|
|
)
|
|
}
|
|
|
|
async back(
|
|
conversationId: string,
|
|
signal: AbortSignal
|
|
): Promise<{ url: string; origin: string }> {
|
|
return this.runInSession(
|
|
conversationId,
|
|
signal,
|
|
'loading',
|
|
'浏览器返回',
|
|
async (slot, effectiveSignal) => {
|
|
await this.verifyCurrentOriginOrRelease(slot)
|
|
const historyTarget =
|
|
await slot.driver.getBackTarget(effectiveSignal)
|
|
const target = await this.policy.validate(
|
|
historyTarget.url,
|
|
effectiveSignal
|
|
)
|
|
slot.session.approveNavigation(target)
|
|
try {
|
|
const result = await slot.driver.backTo(
|
|
historyTarget,
|
|
effectiveSignal
|
|
)
|
|
const finalTarget = await this.policy.validateRedirect(
|
|
result.url,
|
|
target.origin,
|
|
effectiveSignal
|
|
)
|
|
if (slot.session.getCurrentOrigin() !== finalTarget.origin) {
|
|
throw new Error('浏览器返回结果来源不一致')
|
|
}
|
|
slot.origin = finalTarget.origin
|
|
await this.captureFrame(
|
|
conversationId,
|
|
slot,
|
|
effectiveSignal,
|
|
finalTarget.url.href
|
|
)
|
|
return {
|
|
url: finalTarget.url.href,
|
|
origin: finalTarget.origin
|
|
}
|
|
} catch (error) {
|
|
await this.releaseSlot(slot).catch(() => undefined)
|
|
throw error
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
async screenshot(
|
|
conversationId: string,
|
|
signal: AbortSignal
|
|
): Promise<BrowserScreenshot> {
|
|
return this.runInSession(
|
|
conversationId,
|
|
signal,
|
|
'acting',
|
|
'浏览器截图',
|
|
async (slot, effectiveSignal) => {
|
|
await this.verifyCurrentOriginOrRelease(slot)
|
|
const screenshot =
|
|
slot.session.captureScreenshot
|
|
? await slot.session.captureScreenshot(effectiveSignal)
|
|
: await slot.driver.screenshot(effectiveSignal)
|
|
await this.captureFrame(
|
|
conversationId,
|
|
slot,
|
|
effectiveSignal,
|
|
undefined,
|
|
screenshot
|
|
)
|
|
return screenshot
|
|
}
|
|
)
|
|
}
|
|
|
|
async releaseConversation(conversationId: string): Promise<void> {
|
|
this.releaseRequests.add(conversationId)
|
|
let releasedSlot = false
|
|
try {
|
|
const creation = this.creations.get(conversationId)
|
|
if (creation) {
|
|
creation.controller.abort(new Error('浏览器会话已释放'))
|
|
const slot = await creation.promise.catch(() => undefined)
|
|
if (slot) {
|
|
await this.releaseSlot(slot)
|
|
releasedSlot = true
|
|
}
|
|
}
|
|
const slot = this.slots.get(conversationId)
|
|
if (slot) {
|
|
await this.releaseSlot(slot)
|
|
releasedSlot = true
|
|
}
|
|
if (!releasedSlot) {
|
|
this.emitState(conversationId, 'stopped')
|
|
}
|
|
} finally {
|
|
if (!this.disposed) {
|
|
this.releaseRequests.delete(conversationId)
|
|
}
|
|
}
|
|
}
|
|
|
|
private async releaseSlot(slot: BrowserSlot): Promise<void> {
|
|
if (slot.released) {
|
|
return
|
|
}
|
|
slot.released = true
|
|
this.slots.delete(slot.conversationId)
|
|
if (slot.idleTimer) {
|
|
clearTimeout(slot.idleTimer)
|
|
slot.idleTimer = undefined
|
|
}
|
|
slot.active?.abort(new Error('浏览器会话已释放'))
|
|
try {
|
|
slot.driver.dispose()
|
|
} finally {
|
|
try {
|
|
await boundedCleanup(slot.session.dispose(), this.cleanupTimeoutMs)
|
|
} finally {
|
|
this.emitState(slot.conversationId, 'stopped')
|
|
}
|
|
}
|
|
}
|
|
|
|
clearSessions(): Promise<void> {
|
|
if (this.disposed) {
|
|
return Promise.resolve()
|
|
}
|
|
if (this.clearOperation) {
|
|
return this.clearOperation
|
|
}
|
|
const operation = this.performClearSessions()
|
|
this.clearOperation = operation
|
|
void operation.then(
|
|
() => {
|
|
if (this.clearOperation === operation) {
|
|
this.clearOperation = undefined
|
|
}
|
|
},
|
|
() => {
|
|
if (this.clearOperation === operation) {
|
|
this.clearOperation = undefined
|
|
}
|
|
}
|
|
)
|
|
return operation
|
|
}
|
|
|
|
private async performClearSessions(): Promise<void> {
|
|
this.clearing = true
|
|
const lifecycle = this.lifecycle
|
|
lifecycle.abort(new Error('浏览器会话已清除'))
|
|
const requestedReleases = new Set(this.creations.keys())
|
|
for (const conversationId of requestedReleases) {
|
|
this.releaseRequests.add(conversationId)
|
|
}
|
|
const slots = new Set(this.slots.values())
|
|
try {
|
|
await Promise.allSettled(
|
|
[...this.creations.values()].map((creation) => creation.promise)
|
|
)
|
|
for (const slot of this.slots.values()) {
|
|
slots.add(slot)
|
|
}
|
|
await Promise.allSettled(
|
|
[...slots].map((slot) => this.releaseSlot(slot))
|
|
)
|
|
this.slots.clear()
|
|
} finally {
|
|
if (!this.disposed) {
|
|
for (const conversationId of requestedReleases) {
|
|
this.releaseRequests.delete(conversationId)
|
|
}
|
|
this.lifecycle = new AbortController()
|
|
this.clearing = false
|
|
}
|
|
}
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
if (this.disposed) {
|
|
return
|
|
}
|
|
this.disposed = true
|
|
this.clearing = true
|
|
this.lifecycle.abort(new Error('浏览器服务已关闭'))
|
|
const slots = new Set(this.slots.values())
|
|
for (const [conversationId, creation] of this.creations) {
|
|
this.releaseRequests.add(conversationId)
|
|
creation.controller.abort(new Error('浏览器服务已关闭'))
|
|
const created = await creation.promise.catch(() => undefined)
|
|
if (created) {
|
|
slots.add(created)
|
|
}
|
|
}
|
|
for (const slot of this.slots.values()) {
|
|
slots.add(slot)
|
|
}
|
|
await Promise.allSettled([...slots].map((slot) => this.releaseSlot(slot)))
|
|
this.slots.clear()
|
|
this.stateListeners.clear()
|
|
this.liveStates.clear()
|
|
}
|
|
}
|