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 后生效。
This commit is contained in:
@@ -151,7 +151,6 @@ function createHarness(options: {
|
||||
maximumTabsPerWindow: options.maximumTabsPerWindow,
|
||||
idleTimeoutMs: options.idleTimeoutMs,
|
||||
cleanupTimeoutMs: options.cleanupTimeoutMs,
|
||||
liveFrameDelayMs: 0,
|
||||
createSession,
|
||||
createDriver: (contents) => {
|
||||
const slot = byContents.get(contents)
|
||||
@@ -173,6 +172,185 @@ describe('BrowserService', () => {
|
||||
const secondLeaseToken = 'db8a2c28-43a6-4aab-93e0-1c01b9374dca'
|
||||
const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251'
|
||||
|
||||
it('reserves four unused requests without resources, events, or consuming session capacity', async () => {
|
||||
const harness = createHarness({ maximumSessions: 3 })
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
const leases = ['one', 'two', 'three', 'four'].map((id) =>
|
||||
harness.service.reserveRequestTab(id, `request-${id}`, 21)
|
||||
)
|
||||
expect(harness.service.getSessionCount()).toBe(0)
|
||||
expect(harness.service.getTabCount()).toBe(0)
|
||||
expect(harness.createSession).not.toHaveBeenCalled()
|
||||
expect(states).toEqual([])
|
||||
expect(harness.service.getOwnerWindowId('one')).toBe(21)
|
||||
for (const lease of leases) {
|
||||
lease.release()
|
||||
lease.release()
|
||||
expect(lease.signal.aborted).toBe(true)
|
||||
expect(harness.service.getOwnerWindowId(lease.conversationId)).toBeUndefined()
|
||||
}
|
||||
const replacement = harness.service.reserveRequestTab('one', 'replacement', 22)
|
||||
expect(replacement.tabId).not.toBe(leases[0]!.tabId)
|
||||
await expect(harness.service.navigate('one', 'https://example.com/', new AbortController().signal, leases[0]!.tabId, 22))
|
||||
.rejects.toThrow('不属于当前对话')
|
||||
expect(harness.createSession).not.toHaveBeenCalled()
|
||||
replacement.release()
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('shares the reserved identity through navigation, workbar lookup, and all usage leases', async () => {
|
||||
const harness = createHarness()
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
const first = harness.service.reserveRequestTab('conversation', 'first', 21)
|
||||
const second = harness.service.reserveRequestTab('conversation', 'second', 21)
|
||||
expect(second.tabId).toBe(first.tabId)
|
||||
expect(() => harness.service.reserveRequestTab('conversation', 'wrong-window', 22)).toThrow('不属于当前窗口')
|
||||
const signal = new AbortController().signal
|
||||
await Promise.all([
|
||||
harness.service.navigate('conversation', 'https://example.com/first', signal, first.tabId),
|
||||
harness.service.navigate('conversation', 'https://example.com/second', signal, second.tabId)
|
||||
])
|
||||
expect(harness.createSession).toHaveBeenCalledOnce()
|
||||
expect(states[0]).toMatchObject({
|
||||
status: 'creating',
|
||||
tabId: first.tabId,
|
||||
workbarInstanceId: first.tabId,
|
||||
ownerWindowId: 21
|
||||
})
|
||||
expect(states.every((state) => state.workbarInstanceId === first.tabId)).toBe(true)
|
||||
expect(harness.service.listTabs('conversation', 21)).toEqual([
|
||||
expect.objectContaining({ tabId: first.tabId, workbarInstanceId: first.tabId })
|
||||
])
|
||||
const restored = await harness.service.createTab('conversation', 21, signal, first.tabId)
|
||||
expect(restored.tabId).toBe(first.tabId)
|
||||
expect(harness.service.getTabCount()).toBe(1)
|
||||
first.release()
|
||||
await expect(harness.service.closeTab('conversation', first.tabId, 21)).rejects.toThrow('正在被活动请求使用')
|
||||
second.release()
|
||||
await harness.service.closeTab('conversation', first.tabId, 21)
|
||||
await harness.service.closeTab('conversation', first.tabId, 21)
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('keeps a fresh explicit workbar separate from a reserved request target', async () => {
|
||||
const harness = createHarness()
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
const request = harness.service.reserveRequestTab('conversation', 'request', 21)
|
||||
const signal = new AbortController().signal
|
||||
const explicit = await harness.service.createTab('conversation', 21, signal, workbarInstanceId)
|
||||
expect(explicit.tabId).not.toBe(request.tabId)
|
||||
expect(explicit.workbarInstanceId).toBe(workbarInstanceId)
|
||||
expect(harness.service.getTabCount()).toBe(1)
|
||||
expect(states.every((state) => state.tabId === explicit.tabId)).toBe(true)
|
||||
await harness.service.navigate('conversation', 'https://example.com/', signal, request.tabId, 21)
|
||||
expect(harness.service.listTabs('conversation', 21)).toHaveLength(2)
|
||||
expect(states.find((state) => state.tabId === request.tabId)).toMatchObject({
|
||||
status: 'creating',
|
||||
workbarInstanceId: request.tabId
|
||||
})
|
||||
expect(explicit.url).toBeUndefined()
|
||||
request.release()
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('publishes the explicit workbar identity from the first creating event', async () => {
|
||||
const harness = createHarness()
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
const tab = await harness.service.createTab('conversation', 21, new AbortController().signal, workbarInstanceId)
|
||||
expect(states[0]).toMatchObject({ status: 'creating', tabId: tab.tabId, workbarInstanceId })
|
||||
expect(states.every((state) => state.workbarInstanceId === workbarInstanceId)).toBe(true)
|
||||
expect(tab.workbarInstanceId).toBe(workbarInstanceId)
|
||||
await expect(harness.service.createTab('conversation', 22, new AbortController().signal, workbarInstanceId)).rejects.toThrow('不属于当前窗口')
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('cleans unused reservations on conversation release, service clear, and disposal', async () => {
|
||||
const harness = createHarness()
|
||||
const first = harness.service.reserveRequestTab('first', 'first', 21)
|
||||
await expect(harness.service.releaseConversation('first', 22)).rejects.toThrow('不属于当前窗口')
|
||||
expect(first.signal.aborted).toBe(false)
|
||||
await harness.service.releaseConversation('first', 21)
|
||||
expect(first.signal.aborted).toBe(true)
|
||||
expect(harness.service.getOwnerWindowId('first')).toBeUndefined()
|
||||
const second = harness.service.reserveRequestTab('second', 'second', 21)
|
||||
await harness.service.clearSessions()
|
||||
expect(second.signal.aborted).toBe(true)
|
||||
const third = harness.service.reserveRequestTab('third', 'third', 21)
|
||||
await harness.service.dispose()
|
||||
expect(third.signal.aborted).toBe(true)
|
||||
expect(harness.createSession).not.toHaveBeenCalled()
|
||||
expect(() => harness.service.reserveRequestTab('fourth', 'fourth', 21)).toThrow('已关闭')
|
||||
})
|
||||
|
||||
it('abandons materialization when the final request lease is released', async () => {
|
||||
const gate = deferred<void>()
|
||||
const harness = createHarness({ sessionGate: gate.promise })
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
const request = harness.service.reserveRequestTab('conversation', 'request', 21)
|
||||
const navigation = harness.service.navigate('conversation', 'https://example.com/', new AbortController().signal, request.tabId)
|
||||
const rejection = expect(navigation).rejects.toThrow('已取消')
|
||||
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
|
||||
request.release()
|
||||
gate.resolve()
|
||||
await rejection
|
||||
expect(harness.service.getSessionCount()).toBe(0)
|
||||
await vi.waitFor(() => expect(harness.slots[0]!.session.dispose).toHaveBeenCalledOnce())
|
||||
expect(states.at(-1)?.status).toBe('stopped')
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('preserves shared reserved materialization when one request cancels', async () => {
|
||||
const gate = deferred<void>()
|
||||
const harness = createHarness({ sessionGate: gate.promise })
|
||||
const first = harness.service.reserveRequestTab('conversation', 'first', 21)
|
||||
const second = harness.service.reserveRequestTab('conversation', 'second', 21)
|
||||
const firstNavigation = harness.service.navigate('conversation', 'https://example.com/first', first.signal, first.tabId)
|
||||
const canceled = expect(firstNavigation).rejects.toThrow('租约已终止')
|
||||
const secondNavigation = harness.service.navigate('conversation', 'https://example.com/second', second.signal, second.tabId)
|
||||
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
|
||||
first.release()
|
||||
await canceled
|
||||
gate.resolve()
|
||||
await secondNavigation
|
||||
expect(harness.service.listTabs('conversation', 21)).toEqual([
|
||||
expect.objectContaining({ tabId: second.tabId, workbarInstanceId: second.tabId, url: 'https://example.com/second' })
|
||||
])
|
||||
expect(harness.slots[0]!.session.dispose).not.toHaveBeenCalled()
|
||||
second.release()
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('retains a materialized reserved tab until the request lease stops protecting it from idle expiry', async () => {
|
||||
vi.useFakeTimers()
|
||||
const harness = createHarness({ idleTimeoutMs: 100 })
|
||||
const lease = harness.service.reserveRequestTab('conversation', 'request', 21)
|
||||
await harness.service.navigate('conversation', 'https://example.com/', lease.signal, lease.tabId)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(harness.service.getSessionCount()).toBe(1)
|
||||
lease.release()
|
||||
await vi.advanceTimersByTimeAsync(101)
|
||||
expect(harness.service.getSessionCount()).toBe(0)
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('keeps stale close idempotent but rejects another conversation or window', async () => {
|
||||
const harness = createHarness()
|
||||
const first = await harness.service.createTab('first', 21)
|
||||
const sibling = await harness.service.createTab('first', 21)
|
||||
const other = await harness.service.createTab('other', 21)
|
||||
await harness.service.closeTab('first', sibling.tabId, 21)
|
||||
await harness.service.closeTab('first', sibling.tabId, 21)
|
||||
await expect(harness.service.closeTab('first', sibling.tabId, 22)).rejects.toThrow('不属于当前窗口')
|
||||
await expect(harness.service.closeTab('first', other.tabId, 21)).rejects.toThrow('不属于当前对话')
|
||||
await expect(harness.service.closeTab('missing', first.tabId, 21)).rejects.toThrow('不属于当前对话')
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('presents one session in the shared viewport and tracks user navigation', async () => {
|
||||
const harness = createHarness()
|
||||
const bounds = { x: 900, y: 120, width: 420, height: 640 }
|
||||
@@ -219,7 +397,7 @@ describe('BrowserService', () => {
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('publishes browser status and live frames through session cleanup', async () => {
|
||||
it('publishes lightweight browser status through session cleanup', async () => {
|
||||
const harness = createHarness()
|
||||
const states: Array<{
|
||||
status: string
|
||||
@@ -261,9 +439,7 @@ describe('BrowserService', () => {
|
||||
isLoading: false,
|
||||
canGoBack: false
|
||||
})
|
||||
expect(states.filter((state) => state.status === 'ready').at(-1)?.frameDataUrl).toBe(
|
||||
'data:image/jpeg;base64,/9j/2Q=='
|
||||
)
|
||||
expect(states.every((state) => state.frameDataUrl === undefined)).toBe(true)
|
||||
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
|
||||
const replayed: string[] = []
|
||||
const removeReplayListener = harness.service.onState((state) => {
|
||||
@@ -275,163 +451,55 @@ describe('BrowserService', () => {
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('does not publish ready after a session is stopped during frame capture', async () => {
|
||||
it('ordinary actions only refresh navigation metadata and never capture images', async () => {
|
||||
const nativeCapture = vi.fn(async () => {
|
||||
throw new Error('native capture unavailable while hidden')
|
||||
})
|
||||
const harness = createHarness({ captureScreenshot: nativeCapture })
|
||||
const signal = new AbortController().signal
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
await harness.service.navigate('conversation', 'https://example.com/', signal)
|
||||
await harness.service.snapshot('conversation', signal)
|
||||
await harness.service.click('conversation', 'button_ref', signal)
|
||||
await harness.service.type('conversation', 'input_ref', 'text', signal)
|
||||
await harness.service.select('conversation', 'select_ref', 'value', signal)
|
||||
await harness.service.reload('conversation', signal)
|
||||
await harness.service.back('conversation', signal)
|
||||
const slot = harness.slots[0]!
|
||||
expect(nativeCapture).not.toHaveBeenCalled()
|
||||
expect(slot.driver.screenshot).not.toHaveBeenCalled()
|
||||
expect(slot.driver.getNavigationMetadata).toHaveBeenCalledTimes(7)
|
||||
expect(states.at(-1)?.status).toBe('ready')
|
||||
expect(states.every((state) => !('frameDataUrl' in state))).toBe(true)
|
||||
|
||||
const screenshot = await harness.service.screenshot('conversation', signal)
|
||||
expect(screenshot.data).toBe('/9j/2Q==')
|
||||
expect(nativeCapture).toHaveBeenCalledOnce()
|
||||
expect(slot.driver.screenshot).toHaveBeenCalledOnce()
|
||||
expect(states.at(-1)?.status).toBe('ready')
|
||||
expect(states.every((state) => !('frameDataUrl' in state))).toBe(true)
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('does not publish ready after a session is stopped during metadata refresh', async () => {
|
||||
const harness = createHarness()
|
||||
const signal = new AbortController().signal
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
signal
|
||||
)
|
||||
const slot = harness.slots[0]
|
||||
if (!slot) {
|
||||
throw new Error('slot missing')
|
||||
}
|
||||
vi.mocked(slot.driver.screenshot).mockImplementationOnce(
|
||||
async (operationSignal) =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
operationSignal.addEventListener(
|
||||
'abort',
|
||||
() => reject(operationSignal.reason),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const click = harness.service.click(
|
||||
'conversation',
|
||||
'button_ref',
|
||||
signal
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(slot.driver.screenshot).toHaveBeenCalledTimes(2)
|
||||
await harness.service.navigate('conversation', 'https://example.com/', signal)
|
||||
const slot = harness.slots[0]!
|
||||
vi.mocked(slot.driver.getNavigationMetadata).mockImplementationOnce(
|
||||
async (operationSignal) => new Promise<never>((_resolve, reject) => {
|
||||
operationSignal.addEventListener('abort', () => reject(operationSignal.reason), { once: true })
|
||||
})
|
||||
)
|
||||
const click = harness.service.click('conversation', 'button_ref', signal)
|
||||
const rejected = expect(click).rejects.toThrow('浏览器会话已释放')
|
||||
await vi.waitFor(() => expect(slot.driver.getNavigationMetadata).toHaveBeenCalledTimes(2))
|
||||
await harness.service.releaseConversation('conversation')
|
||||
|
||||
await expect(click).rejects.toThrow('浏览器会话已释放')
|
||||
await rejected
|
||||
expect(states.at(-1)?.status).toBe('stopped')
|
||||
})
|
||||
|
||||
it('falls back to CDP when native capture cannot produce the live frame', async () => {
|
||||
const nativeCapture = vi.fn(async () => {
|
||||
throw new Error('native capture unavailable while hidden')
|
||||
})
|
||||
const harness = createHarness({
|
||||
captureScreenshot: nativeCapture
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(nativeCapture).toHaveBeenCalledOnce()
|
||||
expect(harness.slots[0]?.driver.screenshot).toHaveBeenCalledOnce()
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('retries live capture while a newly committed page starts painting', async () => {
|
||||
let attempts = 0
|
||||
const harness = createHarness({
|
||||
captureScreenshot: async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) {
|
||||
throw new Error('page has not painted yet')
|
||||
}
|
||||
return {
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: '/9j/2Q=='
|
||||
}
|
||||
},
|
||||
driverScreenshot: async () => {
|
||||
throw new Error('CDP frame not ready')
|
||||
}
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(attempts).toBe(2)
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('reports a live-frame failure instead of waiting indefinitely', async () => {
|
||||
const harness = createHarness({
|
||||
captureScreenshot: async () => {
|
||||
throw new Error('native capture failed')
|
||||
},
|
||||
driverScreenshot: async () => {
|
||||
throw new Error('CDP capture failed')
|
||||
}
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'failed',
|
||||
error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作'
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('keeps the last frame when a later refresh cannot capture a minimized window', async () => {
|
||||
let nativeAttempts = 0
|
||||
const harness = createHarness({
|
||||
captureScreenshot: async () => {
|
||||
nativeAttempts += 1
|
||||
if (nativeAttempts === 1) {
|
||||
return {
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: '/9j/2Q=='
|
||||
}
|
||||
}
|
||||
throw new Error('minimized native capture unavailable')
|
||||
},
|
||||
driverScreenshot: async () => {
|
||||
throw new Error('minimized CDP capture unavailable')
|
||||
}
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
const signal = new AbortController().signal
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
signal
|
||||
)
|
||||
|
||||
await harness.service.click('conversation', 'button_ref', signal)
|
||||
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
@@ -1180,7 +1248,7 @@ describe('BrowserService', () => {
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('does not resolve DNS again while refreshing frame metadata', async () => {
|
||||
it('does not resolve DNS again while refreshing navigation metadata', async () => {
|
||||
const harness = createHarness()
|
||||
const signal = new AbortController().signal
|
||||
await harness.service.navigate(
|
||||
|
||||
+177
-100
@@ -84,7 +84,6 @@ export type BrowserServiceOptions = {
|
||||
maximumTabsPerWindow?: number
|
||||
idleTimeoutMs?: number
|
||||
cleanupTimeoutMs?: number
|
||||
liveFrameDelayMs?: number
|
||||
parentWindow?: BrowserParentWindowHandle
|
||||
createSession?: (
|
||||
policy: BrowserUrlPolicy,
|
||||
@@ -134,12 +133,20 @@ type BrowserConversationSlot = {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -227,7 +234,6 @@ export class BrowserService {
|
||||
private readonly maximumTabsPerWindow: 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 conversations = new Map<string, BrowserConversationSlot>()
|
||||
@@ -238,6 +244,7 @@ export class BrowserService {
|
||||
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>
|
||||
@@ -253,7 +260,6 @@ export class BrowserService {
|
||||
options.maximumTabsPerWindow ?? DEFAULT_MAXIMUM_TABS_PER_WINDOW
|
||||
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 ??
|
||||
((policy, signal) => defaultCreateSession(policy, signal, options.parentWindow))
|
||||
@@ -269,9 +275,6 @@ export class BrowserService {
|
||||
throw new Error('浏览器服务限制配置无效')
|
||||
}
|
||||
}
|
||||
if (!Number.isSafeInteger(this.liveFrameDelayMs) || this.liveFrameDelayMs < 0) {
|
||||
throw new Error('浏览器服务限制配置无效')
|
||||
}
|
||||
}
|
||||
|
||||
getOrigin(conversationId: string, tabId?: BrowserTabId): string | undefined {
|
||||
@@ -292,7 +295,8 @@ export class BrowserService {
|
||||
|
||||
getOwnerWindowId(conversationId: string): number | undefined {
|
||||
return this.conversations.get(conversationId)?.ownerWindowId ??
|
||||
this.creations.get(conversationId)?.ownerWindowId
|
||||
this.creations.get(conversationId)?.ownerWindowId ??
|
||||
this.requestTabs.get(conversationId)?.ownerWindowId
|
||||
}
|
||||
|
||||
onState(listener: (state: BrowserLiveState) => void): () => void {
|
||||
@@ -349,9 +353,9 @@ export class BrowserService {
|
||||
}
|
||||
|
||||
private emitState(
|
||||
tab: Pick<BrowserTabSlot, 'conversationId' | 'tabId'>,
|
||||
tab: Pick<BrowserTabSlot, 'conversationId' | 'tabId' | 'workbarInstanceId'>,
|
||||
status: BrowserLiveState['status'],
|
||||
update: Partial<Pick<BrowserLiveState, 'url' | 'frameDataUrl' | 'error' | 'canGoBack' | 'isLoading'>> = {}
|
||||
update: Partial<Pick<BrowserLiveState, 'url' | 'error' | 'canGoBack' | 'isLoading'>> = {}
|
||||
): void {
|
||||
const key = stateKey(tab.conversationId, tab.tabId)
|
||||
const previous = this.liveStates.get(key)
|
||||
@@ -359,6 +363,9 @@ export class BrowserService {
|
||||
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 }
|
||||
@@ -375,7 +382,6 @@ export class BrowserService {
|
||||
(status === 'creating' ? true : status === 'stopped' ? false : current?.isLoading ?? false),
|
||||
canGoBack: update.canGoBack ?? previous?.canGoBack ?? false,
|
||||
...(previous?.url ? { url: previous.url } : {}),
|
||||
...(status !== 'stopped' && previous?.frameDataUrl ? { frameDataUrl: previous.frameDataUrl } : {}),
|
||||
...update,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
@@ -392,7 +398,7 @@ export class BrowserService {
|
||||
}
|
||||
|
||||
private assertWindowOwner(
|
||||
conversation: BrowserConversationSlot,
|
||||
conversation: { ownerWindowId?: number },
|
||||
ownerWindowId?: number
|
||||
): void {
|
||||
if (ownerWindowId === undefined) return
|
||||
@@ -540,11 +546,19 @@ export class BrowserService {
|
||||
private async getOrCreateConversation(
|
||||
conversationId: string,
|
||||
signal: AbortSignal,
|
||||
ownerWindowId?: number
|
||||
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)
|
||||
@@ -562,23 +576,30 @@ export class BrowserService {
|
||||
const releaseReservation = this.reserveTab(conversationId, ownerWindowId)
|
||||
const controller = new AbortController()
|
||||
const waiters = new Set<symbol>()
|
||||
const tabId = createTabId()
|
||||
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
|
||||
() => waiters.size > 0,
|
||||
instanceId
|
||||
)
|
||||
void promise.finally(releaseReservation).catch(() => undefined)
|
||||
creation = { controller, promise, waiters, ownerWindowId }
|
||||
creation = { tabId, controller, promise, waiters, ownerWindowId }
|
||||
this.creations.set(conversationId, creation)
|
||||
this.emitState({ conversationId, tabId }, 'creating')
|
||||
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)
|
||||
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)
|
||||
@@ -600,7 +621,8 @@ export class BrowserService {
|
||||
tabId: BrowserTabId,
|
||||
ownerWindowId: number | undefined,
|
||||
signal: AbortSignal,
|
||||
hasWaiters: () => boolean
|
||||
hasWaiters: () => boolean,
|
||||
workbarInstanceId: string
|
||||
): Promise<BrowserConversationSlot> {
|
||||
const session = await this.createSession(this.policy, signal)
|
||||
if (this.disposed || signal.aborted || !hasWaiters() || this.releaseRequests.has(conversationId)) {
|
||||
@@ -618,7 +640,16 @@ export class BrowserService {
|
||||
lastUsedAt: Date.now(),
|
||||
released: false
|
||||
}
|
||||
const tab = await this.createTabSlot(undefined, conversationId, session, tabId)
|
||||
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')
|
||||
@@ -627,6 +658,14 @@ export class BrowserService {
|
||||
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,
|
||||
@@ -636,6 +675,9 @@ export class BrowserService {
|
||||
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)
|
||||
@@ -677,17 +719,13 @@ export class BrowserService {
|
||||
workbarInstanceId?: string
|
||||
): Promise<BrowserTabSummary> {
|
||||
const existed = this.conversations.has(conversationId) || this.creations.has(conversationId)
|
||||
const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId)
|
||||
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)
|
||||
const primary = conversation.tabs.get(conversation.primaryTabId)
|
||||
if (primary && !primary.workbarInstanceId && !existed) {
|
||||
primary.workbarInstanceId = workbarInstanceId
|
||||
conversation.ownedTabs.set(workbarInstanceId, primary.tabId)
|
||||
return this.summarizeTab(primary)
|
||||
}
|
||||
} else if (!existed) {
|
||||
return this.summarizeTab(conversation.tabs.get(conversation.primaryTabId)!)
|
||||
}
|
||||
@@ -709,9 +747,14 @@ export class BrowserService {
|
||||
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs)
|
||||
throw effectiveSignal.reason ?? new Error('浏览器会话已关闭')
|
||||
}
|
||||
const tabId = createTabId()
|
||||
this.emitState({ conversationId, tabId }, 'creating')
|
||||
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')
|
||||
@@ -748,6 +791,36 @@ export class BrowserService {
|
||||
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,
|
||||
@@ -758,18 +831,18 @@ export class BrowserService {
|
||||
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)
|
||||
if (!tab || tab.released) {
|
||||
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) {
|
||||
if (conversation?.idleTimer) {
|
||||
clearTimeout(conversation.idleTimer)
|
||||
conversation.idleTimer = undefined
|
||||
}
|
||||
@@ -782,9 +855,24 @@ export class BrowserService {
|
||||
release: (): void => {
|
||||
if (released) return
|
||||
released = true
|
||||
if (tab.usageLeases.delete(token) && this.hasTab(tab)) {
|
||||
this.touchConversation(conversationId)
|
||||
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('浏览器标签页使用租约已终止'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -794,6 +882,7 @@ export class BrowserService {
|
||||
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',
|
||||
@@ -807,12 +896,25 @@ export class BrowserService {
|
||||
|
||||
async closeTab(conversationId: string, tabId: BrowserTabId, ownerWindowId?: number): Promise<void> {
|
||||
const conversation = this.conversations.get(conversationId)
|
||||
if (!conversation || conversation.released) throw new Error('浏览器标签页不存在或不属于当前对话')
|
||||
this.assertWindowOwner(conversation, ownerWindowId)
|
||||
const tab = conversation.tabs.get(tabId)
|
||||
if (!tab) throw new Error('浏览器标签页不存在或不属于当前对话')
|
||||
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)
|
||||
await this.releaseTab(conversation!, tab)
|
||||
}
|
||||
|
||||
private requireTab(conversationId: string, tabId?: BrowserTabId, ownerWindowId?: number): BrowserTabSlot {
|
||||
@@ -948,6 +1050,15 @@ export class BrowserService {
|
||||
): 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('浏览器标签页不存在或不属于当前对话')
|
||||
@@ -972,7 +1083,7 @@ export class BrowserService {
|
||||
if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined)
|
||||
throw new Error('浏览器快照来源与当前会话不一致')
|
||||
}
|
||||
await this.captureFrame(tab, effectiveSignal, target.href)
|
||||
await this.refreshNavigationState(tab, effectiveSignal, target.href)
|
||||
return { ...snapshot, url: target.href }
|
||||
}, tabId, ownerWindowId)
|
||||
}
|
||||
@@ -981,7 +1092,7 @@ export class BrowserService {
|
||||
await this.runInTab(conversationId, signal, 'acting', true, '浏览器点击', async (tab, effectiveSignal) => {
|
||||
await this.verifyCurrentOriginOrRelease(tab)
|
||||
await tab.driver.click(ref, effectiveSignal)
|
||||
await this.captureFrame(tab, effectiveSignal)
|
||||
await this.refreshNavigationState(tab, effectiveSignal)
|
||||
}, tabId, ownerWindowId)
|
||||
}
|
||||
|
||||
@@ -989,7 +1100,7 @@ export class BrowserService {
|
||||
await this.runInTab(conversationId, signal, 'acting', false, '浏览器输入', async (tab, effectiveSignal) => {
|
||||
await this.verifyCurrentOriginOrRelease(tab)
|
||||
await tab.driver.type(ref, text, effectiveSignal)
|
||||
await this.captureFrame(tab, effectiveSignal)
|
||||
await this.refreshNavigationState(tab, effectiveSignal)
|
||||
}, tabId, ownerWindowId)
|
||||
}
|
||||
|
||||
@@ -997,7 +1108,7 @@ export class BrowserService {
|
||||
await this.runInTab(conversationId, signal, 'acting', false, '浏览器选择', async (tab, effectiveSignal) => {
|
||||
await this.verifyCurrentOriginOrRelease(tab)
|
||||
await tab.driver.select(ref, value, effectiveSignal)
|
||||
await this.captureFrame(tab, effectiveSignal)
|
||||
await this.refreshNavigationState(tab, effectiveSignal)
|
||||
}, tabId, ownerWindowId)
|
||||
}
|
||||
|
||||
@@ -1030,7 +1141,7 @@ export class BrowserService {
|
||||
}
|
||||
}
|
||||
screenshot ??= await tab.driver.screenshot(effectiveSignal)
|
||||
await this.captureFrame(tab, effectiveSignal, undefined, screenshot)
|
||||
await this.refreshNavigationState(tab, effectiveSignal)
|
||||
return screenshot
|
||||
}, tabId, ownerWindowId)
|
||||
}
|
||||
@@ -1082,7 +1193,7 @@ export class BrowserService {
|
||||
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.captureFrame(tab, signal, finalTarget.url.href)
|
||||
await this.refreshNavigationState(tab, signal, finalTarget.url.href)
|
||||
return { url: finalTarget.url.href, origin: finalTarget.origin }
|
||||
} catch (error) {
|
||||
if (error instanceof BrowserNavigationStoppedError) {
|
||||
@@ -1095,11 +1206,10 @@ export class BrowserService {
|
||||
}
|
||||
}
|
||||
|
||||
private async captureFrame(
|
||||
private async refreshNavigationState(
|
||||
tab: BrowserTabSlot,
|
||||
signal: AbortSignal,
|
||||
url?: string,
|
||||
screenshot?: BrowserScreenshot
|
||||
url?: string
|
||||
): Promise<void> {
|
||||
let committedUrl = url
|
||||
const previous = this.liveStates.get(stateKey(tab.conversationId, tab.tabId))
|
||||
@@ -1114,65 +1224,19 @@ export class BrowserService {
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
let frame = screenshot
|
||||
if (!frame) {
|
||||
if (this.liveFrameDelayMs > 0) {
|
||||
await waitFor(new Promise<void>((resolve) => setTimeout(resolve, this.liveFrameDelayMs)), signal)
|
||||
}
|
||||
const deadline = 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)), deadline)
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
break
|
||||
}
|
||||
}
|
||||
if (tab.session.captureScreenshot) {
|
||||
try {
|
||||
frame = await tab.session.captureScreenshot(AbortSignal.any([deadline, AbortSignal.timeout(1_500)]))
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
}
|
||||
if (!frame && !deadline.aborted) {
|
||||
try {
|
||||
frame = await tab.driver.screenshot(AbortSignal.any([deadline, AbortSignal.timeout(1_500)]))
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (!this.hasTab(tab)) return
|
||||
if (!frame) {
|
||||
if (previous?.frameDataUrl) {
|
||||
this.emitState(tab, 'ready', {
|
||||
...(committedUrl ? { url: committedUrl } : {}),
|
||||
canGoBack,
|
||||
frameDataUrl: previous.frameDataUrl
|
||||
})
|
||||
} else {
|
||||
this.emitState(tab, 'failed', {
|
||||
...(committedUrl ? { url: committedUrl } : {}),
|
||||
canGoBack,
|
||||
error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作'
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
this.emitState(tab, 'ready', {
|
||||
...(committedUrl ? { url: committedUrl } : {}),
|
||||
canGoBack,
|
||||
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
|
||||
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 &&
|
||||
@@ -1182,6 +1246,7 @@ export class BrowserService {
|
||||
throw new Error('浏览器对话不属于当前窗口')
|
||||
}
|
||||
this.releaseRequests.add(conversationId)
|
||||
this.releaseRequestTab(conversationId)
|
||||
let emitted = false
|
||||
try {
|
||||
const pendingCreation = this.creations.get(conversationId)
|
||||
@@ -1215,6 +1280,16 @@ export class BrowserService {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1287,6 +1362,7 @@ export class BrowserService {
|
||||
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 {
|
||||
@@ -1308,6 +1384,7 @@ export class BrowserService {
|
||||
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('浏览器服务已关闭'))
|
||||
|
||||
Reference in New Issue
Block a user