Files
goodbuddy/src/main/browser/browser-service.ts
T
mesalogo c037d7fb20 fix: complete runtime and workspace workflows
Restore Ask workspace reads and output paging without granting write access,
and let programming Subagents reuse their parent's browser page. Allocate
browser resources only when used, keep Agent pages aligned with workbar tabs,
recover released bindings, and remove unused automatic screenshot work.

Preserve inherited Runtime selections during remote usage persistence and
manual compression. Keep viable channel model fallbacks, display the actual
scheduled-task mode and Harness model source, and avoid rediscovery delays
when calling healthy MCP tools. Fix directory/history refresh, case-only
renames, rename-aware commit diffs, and FastGPT probe success reporting.

Add regression coverage and align feature documentation. The Agent-side
connection timeout fix requires deploying an updated Agent; existing Ask
read-only and Execute authorization boundaries remain unchanged.

Final validation: 3839 tests passed, 66 skipped; typecheck, lint, development
build, and diff checks passed. Isolated Electron UI/MCP workflows passed with
a loopback model fixture. Current-source Linux x64 Host checks passed with
three successful real model requests. Chromium loadURL rejection was not
reproduced in the UI; released-binding recovery has automated coverage.

Release note: 修复 Ask 读取项目文件、浏览器页面显示与恢复、文件管理及模型配置跟随中的不一致,并减少无用截图和 MCP 等待。定时任务显示实际工作模式,远程会话可正常保存继承 Runtime 的用量;远端连接超时修复需更新 Agent 后生效。
2026-09-10 12:48:30 +08:00

1400 lines
55 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { BrowserUrlPolicy, canonicalizeBrowserUrl } from './browser-url-policy'
import {
CdpBrowserDriver,
type BrowserHistoryTarget,
type BrowserNavigationMetadata,
type BrowserSnapshot
} from './cdp-browser-driver'
import type { BrowserScreenshot } from './browser-screenshot'
import {
ElectronBrowserSession,
type BrowserParentWindowHandle,
type BrowserWindowBounds,
type BrowserWebContents
} from './electron-browser-session'
import type {
BrowserLiveState,
BrowserTabId,
BrowserTabSummary
} from '../../shared/contracts'
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000
const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000
const DEFAULT_MAXIMUM_TABS_PER_CONVERSATION = 8
const DEFAULT_MAXIMUM_TABS_PER_WINDOW = 16
export class BrowserNavigationStoppedError extends Error {
constructor() {
super('浏览器导航已停止,可继续使用当前页面')
this.name = 'BrowserNavigationStoppedError'
}
}
export const BROWSER_TAB_IN_USE_ERROR =
'浏览器标签页正在被活动请求使用,无法关闭'
export type BrowserTabUsageLease = {
readonly conversationId: string
readonly tabId: BrowserTabId
readonly owner: string
readonly signal: AbortSignal
release(): void
}
export type BrowserSessionLike = {
readonly webContents: BrowserWebContents
approveNavigation(
target: Awaited<ReturnType<BrowserUrlPolicy['validate']>>
): void
createTab?(signal: AbortSignal): Promise<BrowserSessionLike>
getCurrentOrigin(): string | undefined
isLoading(): boolean
onLoadingChange(listener: (isLoading: boolean) => void): () => void
onNavigationChange?(listener: (url: string) => void): () => void
setViewport?(bounds?: BrowserWindowBounds): void
stopLoading(): void
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
dispose(): Promise<void>
}
export type BrowserDriverLike = {
navigate(url: string, signal: AbortSignal): Promise<{ url: string }>
reload(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 }>
getNavigationMetadata(
signal: AbortSignal
): Promise<BrowserNavigationMetadata>
screenshot(signal: AbortSignal): Promise<BrowserScreenshot>
dispose(): void
}
export type BrowserServiceOptions = {
policy?: BrowserUrlPolicy
maximumSessions?: number
maximumTabsPerConversation?: number
maximumTabsPerWindow?: number
idleTimeoutMs?: number
cleanupTimeoutMs?: number
parentWindow?: BrowserParentWindowHandle
createSession?: (
policy: BrowserUrlPolicy,
signal: AbortSignal
) => Promise<BrowserSessionLike>
createDriver?: (webContents: BrowserWebContents) => BrowserDriverLike
}
type ActiveOperation = {
controller: AbortController
stopLoadingAllowed: boolean
status: 'loading' | 'acting' | 'interactive'
}
type BrowserTabSlot = {
conversationId: string
tabId: BrowserTabId
session: BrowserSessionLike
driver: BrowserDriverLike
createdAt: number
origin?: string
tail: Promise<void>
active?: ActiveOperation
isLoading: boolean
stopLoadingRequested: boolean
removeLoadingListener: () => void
removeNavigationListener: () => void
released: boolean
workbarInstanceId?: string
usageLeases: Map<symbol, {
owner: string
controller: AbortController
}>
}
type BrowserConversationSlot = {
conversationId: string
ownerWindowId?: number
tabs: Map<BrowserTabId, BrowserTabSlot>
primaryTabId: BrowserTabId
tabCreationTail: Promise<void>
lifecycle: AbortController
ownedTabs: Map<string, BrowserTabId>
idleTimer?: ReturnType<typeof setTimeout>
lastUsedAt: number
released: boolean
}
type ConversationCreation = {
tabId: BrowserTabId
controller: AbortController
promise: Promise<BrowserConversationSlot>
waiters: Set<symbol>
ownerWindowId?: number
}
type RequestTabReservation = Pick<
BrowserTabSlot,
'conversationId' | 'tabId' | 'usageLeases'
> & {
ownerWindowId?: number
}
type ViewportLease = {
leaseToken: string
ownerWindowId?: number
conversationId?: string
tabId?: BrowserTabId
bounds?: BrowserWindowBounds
}
type OwnedTabCreation = {
conversationId: string
controller: AbortController
promise: Promise<BrowserTabSummary>
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,
parentWindow?: BrowserParentWindowHandle
): Promise<BrowserSessionLike> {
return ElectronBrowserSession.create(
{ policy, ...(parentWindow ? { parentWindow } : {}) },
signal
)
}
function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike {
return new CdpBrowserDriver(webContents)
}
function createTabId(): BrowserTabId {
return randomUUID() as BrowserTabId
}
function stateKey(conversationId: string, tabId: BrowserTabId): string {
return `${conversationId}\u0000${tabId}`
}
function ownershipKey(conversationId: string, workbarInstanceId: string): string {
return `${conversationId}\u0000${workbarInstanceId}`
}
export class BrowserService {
private readonly policy: BrowserUrlPolicy
private readonly maximumSessions: number
private readonly maximumTabsPerConversation: number
private readonly maximumTabsPerWindow: number
private readonly idleTimeoutMs: number
private readonly cleanupTimeoutMs: number
private readonly createSession: NonNullable<BrowserServiceOptions['createSession']>
private readonly createDriver: NonNullable<BrowserServiceOptions['createDriver']>
private readonly conversations = new Map<string, BrowserConversationSlot>()
private readonly creations = new Map<string, ConversationCreation>()
private readonly releaseRequests = new Set<string>()
private readonly pendingConversationTabs = new Map<string, number>()
private readonly pendingWindowTabs = new Map<number, number>()
private readonly stateListeners = new Set<(state: BrowserLiveState) => void>()
private readonly liveStates = new Map<string, BrowserLiveState>()
private readonly ownedTabCreations = new Map<string, OwnedTabCreation>()
private readonly requestTabs = new Map<string, RequestTabReservation>()
private viewport?: ViewportLease
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.maximumTabsPerConversation =
options.maximumTabsPerConversation ?? DEFAULT_MAXIMUM_TABS_PER_CONVERSATION
this.maximumTabsPerWindow =
options.maximumTabsPerWindow ?? DEFAULT_MAXIMUM_TABS_PER_WINDOW
this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
this.cleanupTimeoutMs = options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS
this.createSession =
options.createSession ??
((policy, signal) => defaultCreateSession(policy, signal, options.parentWindow))
this.createDriver = options.createDriver ?? defaultCreateDriver
for (const value of [
this.maximumSessions,
this.maximumTabsPerConversation,
this.maximumTabsPerWindow,
this.idleTimeoutMs,
this.cleanupTimeoutMs
]) {
if (!Number.isSafeInteger(value) || value < 1) {
throw new Error('浏览器服务限制配置无效')
}
}
}
getOrigin(conversationId: string, tabId?: BrowserTabId): string | undefined {
const conversation = this.conversations.get(conversationId)
return conversation?.tabs.get(tabId ?? conversation.primaryTabId)?.origin
}
getSessionCount(): number {
return this.conversations.size
}
getTabCount(conversationId?: string): number {
if (conversationId) return this.conversations.get(conversationId)?.tabs.size ?? 0
let count = 0
for (const conversation of this.conversations.values()) count += conversation.tabs.size
return count
}
getOwnerWindowId(conversationId: string): number | undefined {
return this.conversations.get(conversationId)?.ownerWindowId ??
this.creations.get(conversationId)?.ownerWindowId ??
this.requestTabs.get(conversationId)?.ownerWindowId
}
onState(listener: (state: BrowserLiveState) => void): () => void {
this.stateListeners.add(listener)
for (const state of this.liveStates.values()) listener(state)
return () => this.stateListeners.delete(listener)
}
setViewport(
conversationId?: string,
bounds?: BrowserWindowBounds,
tabId?: BrowserTabId,
leaseToken?: string,
ownerWindowId?: number
): boolean {
if (!leaseToken) {
throw new Error('浏览器视口租约无效')
}
if (!conversationId && this.viewport?.leaseToken !== leaseToken) return false
if (!conversationId && ownerWindowId !== undefined && this.viewport?.ownerWindowId !== ownerWindowId) return false
let effectiveTabId = tabId
if (conversationId && bounds) {
const conversation = this.conversations.get(conversationId)
if (conversation) {
this.assertWindowOwner(conversation, ownerWindowId)
effectiveTabId ??= conversation.primaryTabId
if (!conversation.tabs.has(effectiveTabId)) {
throw new Error('浏览器标签页不存在或不属于当前对话')
}
}
} else if (conversationId || bounds || tabId) {
throw new Error('浏览器视口参数不完整')
}
this.viewport = {
leaseToken,
...(ownerWindowId !== undefined ? { ownerWindowId } : {}),
...(conversationId ? { conversationId } : {}),
...(effectiveTabId ? { tabId: effectiveTabId } : {}),
...(bounds ? { bounds } : {})
}
this.applyViewport()
return true
}
private applyViewport(): void {
for (const conversation of this.conversations.values()) {
for (const tab of conversation.tabs.values()) {
const visible =
this.viewport?.conversationId === conversation.conversationId &&
(this.viewport.tabId ?? conversation.primaryTabId) === tab.tabId
tab.session.setViewport?.(visible ? this.viewport?.bounds : undefined)
}
}
}
private emitState(
tab: Pick<BrowserTabSlot, 'conversationId' | 'tabId' | 'workbarInstanceId'>,
status: BrowserLiveState['status'],
update: Partial<Pick<BrowserLiveState, 'url' | 'error' | 'canGoBack' | 'isLoading'>> = {}
): void {
const key = stateKey(tab.conversationId, tab.tabId)
const previous = this.liveStates.get(key)
const current = this.conversations.get(tab.conversationId)?.tabs.get(tab.tabId)
const state: BrowserLiveState = {
conversationId: tab.conversationId,
tabId: tab.tabId,
...((tab.workbarInstanceId ?? current?.workbarInstanceId ?? previous?.workbarInstanceId)
? { workbarInstanceId: tab.workbarInstanceId ?? current?.workbarInstanceId ?? previous?.workbarInstanceId }
: {}),
...(current
? this.conversations.get(tab.conversationId)?.ownerWindowId !== undefined
? { ownerWindowId: this.conversations.get(tab.conversationId)!.ownerWindowId }
: {}
: previous?.ownerWindowId !== undefined
? { ownerWindowId: previous.ownerWindowId }
: this.creations.get(tab.conversationId)?.ownerWindowId !== undefined
? { ownerWindowId: this.creations.get(tab.conversationId)!.ownerWindowId }
: {}),
status,
sessionActive: status !== 'creating' && status !== 'stopped' && Boolean(current && !current.released),
isLoading:
update.isLoading ??
(status === 'creating' ? true : status === 'stopped' ? false : current?.isLoading ?? false),
canGoBack: update.canGoBack ?? previous?.canGoBack ?? false,
...(previous?.url ? { url: previous.url } : {}),
...update,
updatedAt: Date.now()
}
if (status !== 'failed') delete state.error
if (status === 'stopped') this.liveStates.delete(key)
else this.liveStates.set(key, state)
for (const listener of this.stateListeners) {
try {
listener(state)
} catch {
// UI observers cannot interrupt browser control.
}
}
}
private assertWindowOwner(
conversation: { ownerWindowId?: number },
ownerWindowId?: number
): void {
if (ownerWindowId === undefined) return
if (conversation.ownerWindowId === undefined) {
conversation.ownerWindowId = ownerWindowId
return
}
if (conversation.ownerWindowId !== ownerWindowId) {
throw new Error('浏览器对话不属于当前窗口')
}
}
private countWindowTabs(ownerWindowId: number): number {
let count = 0
for (const conversation of this.conversations.values()) {
if (conversation.ownerWindowId === ownerWindowId) count += conversation.tabs.size
}
return count
}
private assertTabCapacity(
conversation: BrowserConversationSlot | undefined,
ownerWindowId?: number
): void {
if (
conversation &&
conversation.tabs.size +
(this.pendingConversationTabs.get(conversation.conversationId) ?? 0) >=
this.maximumTabsPerConversation
) {
throw new Error(`当前对话的浏览器标签页已达到 ${this.maximumTabsPerConversation} 个上限`)
}
if (
ownerWindowId !== undefined &&
this.countWindowTabs(ownerWindowId) +
(this.pendingWindowTabs.get(ownerWindowId) ?? 0) >=
this.maximumTabsPerWindow
) {
throw new Error(`当前窗口的浏览器标签页已达到 ${this.maximumTabsPerWindow} 个上限`)
}
}
private reserveTab(conversationId: string, ownerWindowId?: number): () => void {
const conversation = this.conversations.get(conversationId)
this.assertTabCapacity(conversation, ownerWindowId)
this.pendingConversationTabs.set(
conversationId,
(this.pendingConversationTabs.get(conversationId) ?? 0) + 1
)
if (ownerWindowId !== undefined) {
this.pendingWindowTabs.set(
ownerWindowId,
(this.pendingWindowTabs.get(ownerWindowId) ?? 0) + 1
)
}
return () => {
const conversationPending =
(this.pendingConversationTabs.get(conversationId) ?? 1) - 1
if (conversationPending > 0) {
this.pendingConversationTabs.set(conversationId, conversationPending)
} else {
this.pendingConversationTabs.delete(conversationId)
}
if (ownerWindowId !== undefined) {
const windowPending =
(this.pendingWindowTabs.get(ownerWindowId) ?? 1) - 1
if (windowPending > 0) {
this.pendingWindowTabs.set(ownerWindowId, windowPending)
} else {
this.pendingWindowTabs.delete(ownerWindowId)
}
}
}
}
private async createTabSlot(
conversation: BrowserConversationSlot | undefined,
conversationId: string,
session: BrowserSessionLike,
tabId: BrowserTabId,
workbarInstanceId?: string
): Promise<BrowserTabSlot> {
let driver: BrowserDriverLike
try {
driver = this.createDriver(session.webContents)
} catch (error) {
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs).catch(() => undefined)
throw error
}
const tab: BrowserTabSlot = {
conversationId,
tabId,
session,
driver,
createdAt: Date.now(),
tail: Promise.resolve(),
isLoading: session.isLoading(),
stopLoadingRequested: false,
removeLoadingListener: () => undefined,
removeNavigationListener: () => undefined,
released: false,
usageLeases: new Map(),
...(workbarInstanceId ? { workbarInstanceId } : {})
}
tab.removeLoadingListener = session.onLoadingChange((isLoading) => {
if (!isLoading) tab.stopLoadingRequested = false
if (tab.released || tab.isLoading === isLoading || !this.hasTab(tab)) return
tab.isLoading = isLoading
const current = this.liveStates.get(stateKey(conversationId, tabId))
if (current) {
const status = tab.active?.status ?? (isLoading ? 'loading' : current.status === 'loading' ? 'ready' : current.status)
this.emitState(tab, status, { isLoading })
}
})
tab.removeNavigationListener =
session.onNavigationChange?.((url) => {
if (!this.hasTab(tab)) return
try {
const target = canonicalizeBrowserUrl(url)
tab.origin = target.origin
this.touchConversation(conversationId)
this.emitState(tab, tab.active?.status ?? 'ready', { url: target.href })
void tab.driver
.getNavigationMetadata(AbortSignal.timeout(2_000))
.then((metadata) => {
if (!this.hasTab(tab) || canonicalizeBrowserUrl(metadata.url).href !== target.href) return
this.emitState(tab, tab.active?.status ?? 'ready', {
url: target.href,
canGoBack: metadata.canGoBack
})
})
.catch(() => undefined)
} catch {
// Unsupported top-level URLs are rejected by the session.
}
}) ?? (() => undefined)
if (conversation) conversation.tabs.set(tabId, tab)
return tab
}
private hasTab(tab: BrowserTabSlot): boolean {
return !tab.released && this.conversations.get(tab.conversationId)?.tabs.get(tab.tabId) === tab
}
private async getOrCreateConversation(
conversationId: string,
signal: AbortSignal,
ownerWindowId?: number,
workbarInstanceId?: string,
requestTabId?: BrowserTabId
): Promise<BrowserConversationSlot> {
signal.throwIfAborted()
if (this.disposed || this.clearing || this.lifecycle.signal.aborted) {
throw new Error('浏览器服务已关闭')
}
const requestTab = this.requestTabs.get(conversationId)
if (requestTab) {
this.assertWindowOwner(requestTab, ownerWindowId)
ownerWindowId ??= requestTab.ownerWindowId
}
const existing = this.conversations.get(conversationId)
if (existing && !existing.released) {
this.assertWindowOwner(existing, ownerWindowId)
return existing
}
let creation = this.creations.get(conversationId)
const pendingCreations = [...this.creations.keys()].filter((id) => !this.conversations.has(id)).length
if (!creation && this.conversations.size + pendingCreations >= this.maximumSessions) {
throw new Error(`浏览器会话已达到 ${this.maximumSessions} 个上限`)
}
if (creation && ownerWindowId !== undefined && creation.ownerWindowId !== undefined && creation.ownerWindowId !== ownerWindowId) {
throw new Error('浏览器对话不属于当前窗口')
}
if (!creation) {
const releaseReservation = this.reserveTab(conversationId, ownerWindowId)
const controller = new AbortController()
const waiters = new Set<symbol>()
const tabId = requestTabId ?? createTabId()
const instanceId = workbarInstanceId ?? tabId
const promise = this.createConversation(
conversationId,
tabId,
ownerWindowId,
AbortSignal.any([this.lifecycle.signal, controller.signal]),
() => waiters.size > 0,
instanceId
)
void promise.finally(releaseReservation).catch(() => undefined)
creation = { tabId, controller, promise, waiters, ownerWindowId }
this.creations.set(conversationId, creation)
this.emitState({ conversationId, tabId, workbarInstanceId: instanceId }, 'creating')
const current = creation
const remove = (): void => {
if (this.creations.get(conversationId) === current) this.creations.delete(conversationId)
}
void promise.then(remove, () => {
remove()
if (this.liveStates.get(stateKey(conversationId, tabId))?.status === 'creating') {
this.emitState({ conversationId, tabId, workbarInstanceId: instanceId }, 'stopped')
}
})
}
const waiter = Symbol(conversationId)
creation.waiters.add(waiter)
try {
const result = await waitFor(creation.promise, signal)
this.assertWindowOwner(result, ownerWindowId)
return result
} 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 createConversation(
conversationId: string,
tabId: BrowserTabId,
ownerWindowId: number | undefined,
signal: AbortSignal,
hasWaiters: () => boolean,
workbarInstanceId: string
): Promise<BrowserConversationSlot> {
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('浏览器服务已关闭')
}
const conversation: BrowserConversationSlot = {
conversationId,
...(ownerWindowId !== undefined ? { ownerWindowId } : {}),
tabs: new Map(),
primaryTabId: tabId,
tabCreationTail: Promise.resolve(),
lifecycle: new AbortController(),
ownedTabs: new Map(),
lastUsedAt: Date.now(),
released: false
}
const tab = await this.createTabSlot(undefined, conversationId, session, tabId, workbarInstanceId)
if (signal.aborted || !hasWaiters() || this.releaseRequests.has(conversationId)) {
tab.removeLoadingListener()
tab.removeNavigationListener()
tab.driver.dispose()
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs)
throw signal.reason ?? new Error('浏览器会话创建已取消')
}
this.adoptRequestTab(conversation, tab)
conversation.ownedTabs.set(workbarInstanceId, tabId)
conversation.tabs.set(tabId, tab)
this.conversations.set(conversationId, conversation)
this.emitState(tab, 'ready')
this.scheduleIdleExpiry(conversation)
this.applyViewport()
return conversation
}
private adoptRequestTab(conversation: BrowserConversationSlot, tab: BrowserTabSlot): void {
const reservation = this.requestTabs.get(conversation.conversationId)
if (reservation?.tabId !== tab.tabId) return
tab.usageLeases = reservation.usageLeases
conversation.ownerWindowId ??= reservation.ownerWindowId
this.requestTabs.delete(conversation.conversationId)
}
async createTab(
conversationId: string,
ownerWindowId?: number,
signal: AbortSignal = new AbortController().signal,
workbarInstanceId?: string
): Promise<BrowserTabSummary> {
if (workbarInstanceId) {
const key = ownershipKey(conversationId, workbarInstanceId)
const existingConversation = this.conversations.get(conversationId)
if (existingConversation) this.assertWindowOwner(existingConversation, ownerWindowId)
const pendingConversation = this.creations.get(conversationId)
if (pendingConversation) this.assertWindowOwner(pendingConversation, ownerWindowId)
const existingTabId = existingConversation?.ownedTabs.get(workbarInstanceId)
const existingTab = existingTabId ? existingConversation?.tabs.get(existingTabId) : undefined
if (existingTab && !existingTab.released) return this.summarizeTab(existingTab)
let creation = this.ownedTabCreations.get(key)
if (!creation) {
const controller = new AbortController()
const waiters = new Set<symbol>()
const promise = this.createTabInternal(
conversationId,
ownerWindowId,
AbortSignal.any([this.lifecycle.signal, controller.signal]),
workbarInstanceId
)
creation = { conversationId, controller, promise, waiters }
this.ownedTabCreations.set(key, creation)
const current = creation
void promise.finally(() => {
if (this.ownedTabCreations.get(key) === current) this.ownedTabCreations.delete(key)
}).catch(() => undefined)
}
const waiter = Symbol(workbarInstanceId)
creation.waiters.add(waiter)
try {
return await waitFor(creation.promise, signal)
} finally {
creation.waiters.delete(waiter)
if (creation.waiters.size === 0 && this.ownedTabCreations.get(key) === creation) {
creation.controller.abort(new Error('浏览器标签页创建已取消'))
}
}
}
return this.createTabInternal(conversationId, ownerWindowId, signal)
}
private async createTabInternal(
conversationId: string,
ownerWindowId: number | undefined,
signal: AbortSignal,
workbarInstanceId?: string
): Promise<BrowserTabSummary> {
const existed = this.conversations.has(conversationId) || this.creations.has(conversationId)
const reservation = this.requestTabs.get(conversationId)
const requestTabId = reservation && reservation.tabId === workbarInstanceId ? reservation.tabId : undefined
const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId, workbarInstanceId, requestTabId)
if (workbarInstanceId) {
const ownedTabId = conversation.ownedTabs.get(workbarInstanceId)
const ownedTab = ownedTabId ? conversation.tabs.get(ownedTabId) : undefined
if (ownedTab && !ownedTab.released) return this.summarizeTab(ownedTab)
} else if (!existed) {
return this.summarizeTab(conversation.tabs.get(conversation.primaryTabId)!)
}
this.assertWindowOwner(conversation, ownerWindowId)
const releaseReservation = this.reserveTab(conversationId, ownerWindowId)
let releaseGate!: () => void
const gate = new Promise<void>((resolve) => { releaseGate = resolve })
const predecessor = conversation.tabCreationTail.catch(() => undefined)
conversation.tabCreationTail = predecessor.then(() => gate)
try {
await waitFor(predecessor, signal)
const primary = conversation.tabs.get(conversation.primaryTabId)
if (!primary || conversation.released) throw new Error('浏览器会话已关闭')
const effectiveSignal = AbortSignal.any([signal, this.lifecycle.signal, conversation.lifecycle.signal])
const session = primary.session.createTab
? await primary.session.createTab(effectiveSignal)
: await this.createSession(this.policy, effectiveSignal)
if (effectiveSignal.aborted || conversation.released || this.conversations.get(conversationId) !== conversation) {
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs)
throw effectiveSignal.reason ?? new Error('浏览器会话已关闭')
}
const tabId = requestTabId ?? createTabId()
this.emitState({ conversationId, tabId, workbarInstanceId }, 'creating')
const tab = await this.createTabSlot(conversation, conversationId, session, tabId, workbarInstanceId)
if (effectiveSignal.aborted || conversation.released) {
await this.releaseTab(conversation, tab)
throw effectiveSignal.reason ?? new Error('浏览器会话已关闭')
}
this.adoptRequestTab(conversation, tab)
if (workbarInstanceId) conversation.ownedTabs.set(workbarInstanceId, tabId)
this.touchConversation(conversationId)
this.emitState(tab, 'ready')
this.applyViewport()
return this.summarizeTab(tab)
} finally {
releaseGate()
releaseReservation()
}
}
listTabs(conversationId: string, ownerWindowId?: number): BrowserTabSummary[] {
const conversation = this.conversations.get(conversationId)
if (!conversation || conversation.released) return []
this.assertWindowOwner(conversation, ownerWindowId)
return [...conversation.tabs.values()].map((tab) => this.summarizeTab(tab))
}
getVisibleTabId(
conversationId: string,
ownerWindowId?: number
): BrowserTabId | undefined {
const conversation = this.conversations.get(conversationId)
if (!conversation || conversation.released) return undefined
this.assertWindowOwner(conversation, ownerWindowId)
if (
this.viewport?.conversationId !== conversationId ||
(ownerWindowId !== undefined &&
this.viewport.ownerWindowId !== ownerWindowId)
) {
return undefined
}
const tabId = this.viewport.tabId ?? conversation.primaryTabId
return conversation.tabs.has(tabId) ? tabId : undefined
}
reserveRequestTab(
conversationId: string,
owner: string,
ownerWindowId?: number
): BrowserTabUsageLease {
if (this.disposed || this.clearing || this.releaseRequests.has(conversationId)) {
throw new Error('浏览器服务已关闭')
}
if (!owner || owner.length > 500) {
throw new Error('浏览器标签页使用租约所有者无效')
}
const conversation = this.conversations.get(conversationId)
let reservation = this.requestTabs.get(conversationId)
if (!reservation && conversation && !conversation.released) {
return this.acquireTabUsage(conversationId, conversation.primaryTabId, owner, ownerWindowId)
}
if (!reservation) {
const creation = this.creations.get(conversationId)
if (creation) this.assertWindowOwner(creation, ownerWindowId)
reservation = {
conversationId,
tabId: creation?.tabId ?? createTabId(),
ownerWindowId: ownerWindowId ?? creation?.ownerWindowId,
usageLeases: new Map()
}
this.requestTabs.set(conversationId, reservation)
}
return this.acquireTabUsage(conversationId, reservation.tabId, owner, ownerWindowId)
}
acquireTabUsage(
conversationId: string,
tabId: BrowserTabId,
owner: string,
ownerWindowId?: number
): BrowserTabUsageLease {
if (!owner || owner.length > 500) {
throw new Error('浏览器标签页使用租约所有者无效')
}
const conversation = this.conversations.get(conversationId)
const reservation = this.requestTabs.get(conversationId)
if (conversation) this.assertWindowOwner(conversation, ownerWindowId)
if (reservation) this.assertWindowOwner(reservation, ownerWindowId)
const tab = conversation?.tabs.get(tabId) ??
(reservation?.tabId === tabId ? reservation : undefined)
if (!tab || conversation?.released) {
throw new Error('浏览器标签页不存在或不属于当前对话')
}
const token = Symbol(owner)
const controller = new AbortController()
tab.usageLeases.set(token, { owner, controller })
if (conversation?.idleTimer) {
clearTimeout(conversation.idleTimer)
conversation.idleTimer = undefined
}
let released = false
return {
conversationId,
tabId,
owner,
signal: controller.signal,
release: (): void => {
if (released) return
released = true
if (tab.usageLeases.delete(token)) {
if (this.conversations.get(conversationId)?.tabs.has(tabId)) {
this.touchConversation(conversationId)
} else if (
tab.usageLeases.size === 0 &&
this.requestTabs.get(conversationId) === tab
) {
this.requestTabs.delete(conversationId)
const creation = this.creations.get(conversationId)
if (creation?.tabId === tabId) {
creation.controller.abort(new Error('浏览器会话创建已取消'))
}
this.ownedTabCreations.get(ownershipKey(conversationId, tabId))?.controller.abort(
new Error('浏览器标签页创建已取消')
)
}
}
controller.abort(new Error('浏览器标签页使用租约已终止'))
}
}
}
private summarizeTab(tab: BrowserTabSlot): BrowserTabSummary {
const state = this.liveStates.get(stateKey(tab.conversationId, tab.tabId))
return {
conversationId: tab.conversationId,
tabId: tab.tabId,
...(tab.workbarInstanceId ? { workbarInstanceId: tab.workbarInstanceId } : {}),
primary: this.conversations.get(tab.conversationId)?.primaryTabId === tab.tabId,
status:
state?.status && state.status !== 'stopped' ? state.status : 'ready',
isLoading: tab.isLoading,
canGoBack: state?.canGoBack ?? false,
...(state?.url ? { url: state.url } : {}),
createdAt: tab.createdAt,
updatedAt: state?.updatedAt ?? tab.createdAt
}
}
async closeTab(conversationId: string, tabId: BrowserTabId, ownerWindowId?: number): Promise<void> {
const conversation = this.conversations.get(conversationId)
if (conversation) this.assertWindowOwner(conversation, ownerWindowId)
const reservation = this.requestTabs.get(conversationId)
if (reservation) this.assertWindowOwner(reservation, ownerWindowId)
const creation = this.creations.get(conversationId)
if (creation) this.assertWindowOwner(creation, ownerWindowId)
const tab = conversation?.tabs.get(tabId)
if (!tab) {
if (
[...this.conversations.values()].some((slot) => slot.tabs.has(tabId)) ||
[...this.creations.entries()].some(([id, slot]) => slot.tabId === tabId && id !== conversationId) ||
[...this.requestTabs.values()].some((slot) => slot.tabId === tabId && slot.conversationId !== conversationId)
) throw new Error('浏览器标签页不存在或不属于当前对话')
if (reservation?.tabId === tabId && reservation.usageLeases.size > 0) {
throw new Error(BROWSER_TAB_IN_USE_ERROR)
}
return
}
if (tab.usageLeases.size > 0) throw new Error(BROWSER_TAB_IN_USE_ERROR)
await this.releaseTab(conversation!, tab)
}
private requireTab(conversationId: string, tabId?: BrowserTabId, ownerWindowId?: number): BrowserTabSlot {
if (this.disposed) throw new Error('浏览器服务已关闭')
const conversation = this.conversations.get(conversationId)
if (!conversation || conversation.released) throw new Error('当前对话尚未建立浏览器会话,请先导航')
this.assertWindowOwner(conversation, ownerWindowId)
const tab = conversation.tabs.get(tabId ?? conversation.primaryTabId)
if (!tab || tab.released || !tab.origin) throw new Error('当前浏览器标签页尚未导航')
return tab
}
private scheduleIdleExpiry(conversation: BrowserConversationSlot): void {
if (conversation.idleTimer) clearTimeout(conversation.idleTimer)
if (conversation.released || this.disposed) return
conversation.idleTimer = setTimeout(() => {
if (Date.now() - conversation.lastUsedAt < this.idleTimeoutMs) {
this.scheduleIdleExpiry(conversation)
return
}
if (
[...conversation.tabs.values()].some(
(tab) => tab.usageLeases.size > 0
)
) {
this.scheduleIdleExpiry(conversation)
return
}
void this.releaseConversationSlot(conversation).catch(() => undefined)
}, this.idleTimeoutMs)
}
private touchConversation(conversationId: string): void {
const conversation = this.conversations.get(conversationId)
if (!conversation) return
conversation.lastUsedAt = Date.now()
if ([...conversation.tabs.values()].some((tab) => tab.active)) {
if (conversation.idleTimer) clearTimeout(conversation.idleTimer)
conversation.idleTimer = undefined
return
}
this.scheduleIdleExpiry(conversation)
}
private async serialize<T>(
tab: BrowserTabSlot,
signal: AbortSignal,
operation: (effectiveSignal: AbortSignal) => Promise<T>,
status: ActiveOperation['status'],
stopLoadingAllowed: boolean
): Promise<T> {
signal.throwIfAborted()
if (!this.hasTab(tab) || this.disposed) throw new Error('浏览器会话已关闭')
let releaseGate!: () => void
const gate = new Promise<void>((resolve) => { releaseGate = resolve })
const predecessor = tab.tail.catch(() => undefined)
tab.tail = predecessor.then(() => gate)
let operationController: AbortController | undefined
try {
await waitFor(predecessor, signal)
if (!this.hasTab(tab) || this.disposed) throw new Error('浏览器会话已关闭')
const conversation = this.conversations.get(tab.conversationId)
if (conversation?.idleTimer) {
clearTimeout(conversation.idleTimer)
conversation.idleTimer = undefined
}
this.emitState(tab, status)
tab.stopLoadingRequested = false
operationController = new AbortController()
tab.active = { controller: operationController, stopLoadingAllowed, status }
return await operation(AbortSignal.any([signal, this.lifecycle.signal, operationController.signal]))
} finally {
if (operationController && tab.active?.controller === operationController) tab.active = undefined
releaseGate()
if (operationController) this.touchConversation(tab.conversationId)
}
}
private verifyCurrentOrigin(tab: BrowserTabSlot): string {
const current = tab.session.getCurrentOrigin()
if (!tab.origin || current !== tab.origin) throw new Error('浏览器页面来源已改变,会话已被拒绝')
return current
}
private async verifyCurrentOriginOrRelease(tab: BrowserTabSlot): Promise<string> {
try {
return this.verifyCurrentOrigin(tab)
} catch (error) {
const conversation = this.conversations.get(tab.conversationId)
if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined)
throw error
}
}
private shouldEmitFailure(conversationId: string, signal: AbortSignal, error: unknown): boolean {
if (error instanceof BrowserNavigationStoppedError || signal.aborted || this.releaseRequests.has(conversationId)) return false
const message = error instanceof Error ? error.message : ''
return !['浏览器会话已释放', '浏览器会话已清除', '浏览器会话已关闭', '浏览器服务已关闭'].some((reason) => message.includes(reason))
}
private emitFailure(tab: Pick<BrowserTabSlot, 'conversationId' | 'tabId'>, stage: string, error: unknown): void {
const detail = error instanceof Error && error.message ? error.message.slice(0, 180) : '未知错误'
this.emitState(tab, 'failed', { error: `${stage}失败:${detail}`.slice(0, 240) })
}
private async runInTab<T>(
conversationId: string,
signal: AbortSignal,
status: ActiveOperation['status'],
stopLoadingAllowed: boolean,
failureStage: string,
operation: (tab: BrowserTabSlot, effectiveSignal: AbortSignal) => Promise<T>,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<T> {
let tab: BrowserTabSlot | undefined
try {
tab = this.requireTab(conversationId, tabId, ownerWindowId)
return await this.serialize(tab, signal, (effectiveSignal) => operation(tab!, effectiveSignal), status, stopLoadingAllowed)
} catch (error) {
if (error instanceof BrowserNavigationStoppedError && tab && status !== 'loading') this.recoverStoppedNavigation(tab)
if (tab && this.shouldEmitFailure(conversationId, signal, error)) this.emitFailure(tab, failureStage, error)
throw error
}
}
async navigate(
conversationId: string,
url: string,
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<{ url: string; origin: string }> {
let tab: BrowserTabSlot | undefined
try {
if (tabId) {
const knownTab = this.conversations.get(conversationId)?.tabs.has(tabId) ||
this.requestTabs.get(conversationId)?.tabId === tabId ||
this.creations.get(conversationId)?.tabId === tabId
if (!knownTab) throw new Error('浏览器标签页不存在或不属于当前对话')
if (this.requestTabs.get(conversationId)?.tabId === tabId) {
await this.createTab(conversationId, ownerWindowId, signal, tabId)
}
}
const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId)
tab = conversation.tabs.get(tabId ?? conversation.primaryTabId)
if (!tab || tab.released) throw new Error('浏览器标签页不存在或不属于当前对话')
return await this.serialize(tab, signal, async (effectiveSignal) => {
const target = await this.policy.validate(url, effectiveSignal)
tab!.session.approveNavigation(target)
return this.completeNavigation(tab!, effectiveSignal, () => tab!.driver.navigate(target.url.href, effectiveSignal), '浏览器导航结果来源不一致')
}, 'loading', true)
} catch (error) {
if (tab && this.shouldEmitFailure(conversationId, signal, error)) this.emitFailure(tab, '浏览器导航', error)
throw error
}
}
async snapshot(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise<BrowserSnapshot> {
return this.runInTab(conversationId, signal, 'acting', false, '读取浏览器页面', async (tab, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(tab)
const snapshot = await tab.driver.snapshot(effectiveSignal)
const target = canonicalizeBrowserUrl(snapshot.url)
if (target.origin !== tab.origin) {
const conversation = this.conversations.get(conversationId)
if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined)
throw new Error('浏览器快照来源与当前会话不一致')
}
await this.refreshNavigationState(tab, effectiveSignal, target.href)
return { ...snapshot, url: target.href }
}, tabId, ownerWindowId)
}
async click(conversationId: string, ref: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise<void> {
await this.runInTab(conversationId, signal, 'acting', true, '浏览器点击', async (tab, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(tab)
await tab.driver.click(ref, effectiveSignal)
await this.refreshNavigationState(tab, effectiveSignal)
}, tabId, ownerWindowId)
}
async type(conversationId: string, ref: string, text: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise<void> {
await this.runInTab(conversationId, signal, 'acting', false, '浏览器输入', async (tab, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(tab)
await tab.driver.type(ref, text, effectiveSignal)
await this.refreshNavigationState(tab, effectiveSignal)
}, tabId, ownerWindowId)
}
async select(conversationId: string, ref: string, value: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise<void> {
await this.runInTab(conversationId, signal, 'acting', false, '浏览器选择', async (tab, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(tab)
await tab.driver.select(ref, value, effectiveSignal)
await this.refreshNavigationState(tab, effectiveSignal)
}, tabId, ownerWindowId)
}
async back(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise<{ url: string; origin: string }> {
return this.runInTab(conversationId, signal, 'loading', true, '浏览器返回', async (tab, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(tab)
const historyTarget = await tab.driver.getBackTarget(effectiveSignal)
const target = await this.policy.validate(historyTarget.url, effectiveSignal)
tab.session.approveNavigation(target)
return this.completeNavigation(tab, effectiveSignal, () => tab.driver.backTo(historyTarget, effectiveSignal), '浏览器返回结果来源不一致')
}, tabId, ownerWindowId)
}
async reload(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise<{ url: string; origin: string }> {
return this.runInTab(conversationId, signal, 'loading', true, '浏览器刷新', async (tab, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(tab)
return this.completeNavigation(tab, effectiveSignal, () => tab.driver.reload(effectiveSignal), '浏览器刷新结果来源不一致')
}, tabId, ownerWindowId)
}
async screenshot(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise<BrowserScreenshot> {
return this.runInTab(conversationId, signal, 'acting', false, '浏览器截图', async (tab, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(tab)
let screenshot: BrowserScreenshot | undefined
if (tab.session.captureScreenshot) {
try {
screenshot = await tab.session.captureScreenshot(effectiveSignal)
} catch {
effectiveSignal.throwIfAborted()
}
}
screenshot ??= await tab.driver.screenshot(effectiveSignal)
await this.refreshNavigationState(tab, effectiveSignal)
return screenshot
}, tabId, ownerWindowId)
}
async stopLoading(conversationId: string, tabId?: BrowserTabId, ownerWindowId?: number): Promise<boolean> {
const conversation = this.conversations.get(conversationId)
if (!conversation) return false
this.assertWindowOwner(conversation, ownerWindowId)
const tab = conversation.tabs.get(tabId ?? conversation.primaryTabId)
const active = tab?.active
if (!tab || tab.released || tab.stopLoadingRequested) return false
if (active) {
if (!active.stopLoadingAllowed || active.controller.signal.aborted || (active.status !== 'loading' && !tab.isLoading && !tab.session.isLoading())) return false
tab.isLoading = false
tab.stopLoadingRequested = true
tab.session.stopLoading()
active.controller.abort(new BrowserNavigationStoppedError())
return true
}
if (!tab.isLoading && !tab.session.isLoading()) return false
tab.isLoading = false
tab.stopLoadingRequested = true
tab.session.stopLoading()
this.recoverStoppedNavigation(tab)
return true
}
private recoverStoppedNavigation(tab: BrowserTabSlot): void {
if (!this.hasTab(tab)) return
tab.isLoading = false
try {
const target = canonicalizeBrowserUrl(tab.session.webContents.getURL())
if (tab.session.getCurrentOrigin() !== target.origin) throw new Error('浏览器停止导航后的页面来源不一致')
tab.origin = target.origin
this.emitState(tab, 'ready', { url: target.href, isLoading: false })
} catch {
if (this.hasTab(tab)) this.emitState(tab, 'ready', { isLoading: false })
}
}
private async completeNavigation(
tab: BrowserTabSlot,
signal: AbortSignal,
operation: () => Promise<{ url: string }>,
originMismatchMessage: string
): Promise<{ url: string; origin: string }> {
try {
const result = await operation()
const finalTarget = await this.policy.validateRedirect(result.url, signal)
if (tab.session.getCurrentOrigin() !== finalTarget.origin) throw new Error(originMismatchMessage)
tab.origin = finalTarget.origin
await this.refreshNavigationState(tab, signal, finalTarget.url.href)
return { url: finalTarget.url.href, origin: finalTarget.origin }
} catch (error) {
if (error instanceof BrowserNavigationStoppedError) {
this.recoverStoppedNavigation(tab)
throw error
}
const conversation = this.conversations.get(tab.conversationId)
if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined)
throw error
}
}
private async refreshNavigationState(
tab: BrowserTabSlot,
signal: AbortSignal,
url?: string
): Promise<void> {
let committedUrl = url
const previous = this.liveStates.get(stateKey(tab.conversationId, tab.tabId))
let canGoBack = previous?.canGoBack ?? false
try {
const metadata = await tab.driver.getNavigationMetadata(signal)
const target = canonicalizeBrowserUrl(metadata.url)
if (tab.session.getCurrentOrigin() !== target.origin) throw new Error('浏览器当前页面来源不一致')
tab.origin = target.origin
committedUrl = target.href
canGoBack = metadata.canGoBack
} catch {
signal.throwIfAborted()
}
signal.throwIfAborted()
if (!this.hasTab(tab)) return
this.emitState(tab, 'ready', {
...(committedUrl ? { url: committedUrl } : {}),
canGoBack
})
}
async releaseConversation(conversationId: string, ownerWindowId?: number): Promise<void> {
const ownedConversation = this.conversations.get(conversationId)
if (ownedConversation) this.assertWindowOwner(ownedConversation, ownerWindowId)
const reservation = this.requestTabs.get(conversationId)
if (reservation) this.assertWindowOwner(reservation, ownerWindowId)
const creation = this.creations.get(conversationId)
if (
ownerWindowId !== undefined &&
creation?.ownerWindowId !== undefined &&
creation.ownerWindowId !== ownerWindowId
) {
throw new Error('浏览器对话不属于当前窗口')
}
this.releaseRequests.add(conversationId)
this.releaseRequestTab(conversationId)
let emitted = false
try {
const pendingCreation = this.creations.get(conversationId)
if (pendingCreation) {
pendingCreation.controller.abort(new Error('浏览器会话已释放'))
const created = await pendingCreation.promise.catch(() => undefined)
if (created) {
await this.releaseConversationSlot(created)
emitted = true
}
}
const conversation = this.conversations.get(conversationId)
if (conversation) {
await this.releaseConversationSlot(conversation)
emitted = true
}
const ownedCreations = [...this.ownedTabCreations.values()].filter(
(creation) => creation.conversationId === conversationId
)
for (const creation of ownedCreations) {
creation.controller.abort(new Error('浏览器会话已释放'))
}
await Promise.allSettled(ownedCreations.map((creation) => creation.promise))
if (!emitted) {
for (const state of [...this.liveStates.values()].filter((item) => item.conversationId === conversationId)) {
if (state.tabId) this.emitState({ ...state, tabId: state.tabId }, 'stopped')
}
}
} finally {
if (!this.disposed) this.releaseRequests.delete(conversationId)
}
}
private releaseRequestTab(conversationId: string): void {
const reservation = this.requestTabs.get(conversationId)
if (!reservation) return
this.requestTabs.delete(conversationId)
for (const lease of reservation.usageLeases.values()) {
lease.controller.abort(new Error('浏览器标签页使用租约已终止'))
}
reservation.usageLeases.clear()
}
private async releaseTab(conversation: BrowserConversationSlot, tab: BrowserTabSlot): Promise<void> {
if (tab.released) return
tab.released = true
conversation.tabs.delete(tab.tabId)
tab.removeLoadingListener()
tab.removeNavigationListener()
tab.active?.controller.abort(new Error('浏览器会话已释放'))
for (const lease of tab.usageLeases.values()) {
lease.controller.abort(new Error('浏览器标签页使用租约已终止'))
}
tab.usageLeases.clear()
if (tab.workbarInstanceId) conversation.ownedTabs.delete(tab.workbarInstanceId)
if (conversation.tabs.size === 0) {
conversation.released = true
conversation.lifecycle.abort(new Error('浏览器会话已释放'))
if (conversation.idleTimer) clearTimeout(conversation.idleTimer)
this.conversations.delete(conversation.conversationId)
} else if (conversation.primaryTabId === tab.tabId) {
conversation.primaryTabId = conversation.tabs.keys().next().value!
}
if (this.viewport?.conversationId === conversation.conversationId && this.viewport.tabId === tab.tabId) {
try {
tab.session.setViewport?.(undefined)
} catch {
// The service state is still cleared when the native view is already gone.
}
this.viewport = undefined
this.applyViewport()
}
let disposalError: unknown
try {
tab.driver.dispose()
} catch (error) {
disposalError = error
}
try {
await boundedCleanup(tab.session.dispose(), this.cleanupTimeoutMs)
} catch (error) {
disposalError ??= error
} finally {
this.emitState(tab, 'stopped')
}
if (disposalError) throw disposalError
}
private async releaseConversationSlot(conversation: BrowserConversationSlot): Promise<void> {
if (conversation.released) return
conversation.released = true
conversation.lifecycle.abort(new Error('浏览器会话已释放'))
this.conversations.delete(conversation.conversationId)
if (conversation.idleTimer) clearTimeout(conversation.idleTimer)
const tabs = [...conversation.tabs.values()]
await Promise.allSettled(tabs.map((tab) => this.releaseTab(conversation, tab))).then((results) => {
const failure = results.find((result) => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
})
}
clearSessions(): Promise<void> {
if (this.disposed) return Promise.resolve()
if (this.clearOperation) return this.clearOperation
const operation = this.performClearSessions()
this.clearOperation = operation
void operation.finally(() => {
if (this.clearOperation === operation) this.clearOperation = undefined
}).catch(() => undefined)
return operation
}
private async performClearSessions(): Promise<void> {
this.clearing = true
this.lifecycle.abort(new Error('浏览器会话已清除'))
for (const id of this.requestTabs.keys()) this.releaseRequestTab(id)
const requested = new Set(this.creations.keys())
for (const id of requested) this.releaseRequests.add(id)
try {
await Promise.allSettled([...this.creations.values()].map((creation) => creation.promise))
await Promise.allSettled([...this.ownedTabCreations.values()].map((creation) => creation.promise))
await Promise.allSettled([...this.conversations.values()].map((conversation) => this.releaseConversationSlot(conversation)))
this.conversations.clear()
} finally {
if (!this.disposed) {
for (const id of requested) this.releaseRequests.delete(id)
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('浏览器服务已关闭'))
for (const id of this.requestTabs.keys()) this.releaseRequestTab(id)
for (const [id, creation] of this.creations) {
this.releaseRequests.add(id)
creation.controller.abort(new Error('浏览器服务已关闭'))
}
await Promise.allSettled([...this.creations.values()].map((creation) => creation.promise))
await Promise.allSettled([...this.ownedTabCreations.values()].map((creation) => creation.promise))
await Promise.allSettled([...this.conversations.values()].map((conversation) => this.releaseConversationSlot(conversation)))
this.conversations.clear()
this.stateListeners.clear()
this.liveStates.clear()
}
}