Repository navigation
Expand file tree
/
Copy pathbrowser.js
More file actions
383 lines (355 loc) · 13.3 KB
/
Copy pathbrowser.js
File metadata and controls
383 lines (355 loc) · 13.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
/**
* Playwright-backed browser session for the DSH browser-use plugin.
*
* One lazily launched Chromium serves every tool call for the life of the
* plugin. Tabs live in a single browser context; the model addresses page
* elements through the `data-dsh-ref` attributes that {@link snapshot} writes,
* so a reference stays valid until the next snapshot.
*
* The browser runs headless by default; `DSH_BROWSER_HEADLESS=false` opens a
* visible window instead. Either way the current page is published through
* {@link browserState} so the DSH web client can show it in the right
* sidebar's built-in Browser tab (`lib/client.js`).
*
* @module dsh-zcode-browser-use/browser
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
/** Selector for the elements a snapshot exposes as addressable references. */
const SNAPSHOT_SELECTOR =
'a[href],button,input,select,textarea,summary,[role],[contenteditable="true"],[onclick]'
/** Upper bound on references returned by one snapshot. */
const MAX_REFS = 300
/** Milliseconds allowed for a routine page action. */
const ACTION_TIMEOUT_MS = 10_000
/** Milliseconds allowed for a navigation. */
const NAVIGATION_TIMEOUT_MS = 30_000
let session = null
/** Whether the browser runs headless; set `DSH_BROWSER_HEADLESS=false` to show it. */
function headless() {
return process.env.DSH_BROWSER_HEADLESS !== 'false'
}
/** Load the Playwright entry point, reporting an actionable error when absent. */
async function loadChromium() {
try {
const playwright = await import('playwright')
return playwright.chromium
} catch (error) {
throw new Error(
'The browser-use plugin needs the "playwright" package and a Chromium build. ' +
'Install the package dependency, then run: npx playwright install chromium. ' +
`Original error: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
/**
* Launch candidates in order: the configured channel, Playwright's bundled
* Chromium, then the installed Google Chrome and Microsoft Edge channels.
* A missing bundled browser is the common case, so falling back to a system
* browser avoids requiring Playwright's large Chromium download.
*/
function launchCandidates() {
const configured = process.env.DSH_BROWSER_CHANNEL
if (configured) return [{ channel: configured }]
return [{}, { channel: 'chrome' }, { channel: 'msedge' }]
}
/** Launch a browser and create its single shared context. */
async function launch() {
const chromium = await loadChromium()
const errors = []
for (const candidate of launchCandidates()) {
try {
const browser = await chromium.launch({ headless: headless(), ...candidate })
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
ignoreHTTPSErrors: false,
})
session = { browser, context, page: null }
browser.on('disconnected', () => {
session = null
})
return session
} catch (error) {
errors.push(`${candidate.channel ?? 'bundled chromium'}: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`)
}
}
throw new Error(
'No browser could be launched. Install Google Chrome or Microsoft Edge, or run: ' +
'npx playwright install chromium. Attempts: ' +
errors.join(' | '),
)
}
/** Return the live session, launching Chromium on first use. */
async function getSession() {
if (session) return session
return launch()
}
/** Return the current page, creating one when the context has none. */
export async function getPage() {
const current = await getSession()
if (current.page && !current.page.isClosed()) return current.page
current.page = await current.context.newPage()
await current.page.bringToFront().catch(() => undefined)
return current.page
}
/**
* Resolve one addressable element from a snapshot reference or a CSS selector.
* @param page - the active Playwright page.
* @param target - the reference, selector, and role/name pair to match.
* @returns the Playwright locator for the first matching element.
*/
export function locatorFor(page, target) {
if (target.ref) return page.locator(`[data-dsh-ref="${target.ref}"]`).first()
if (target.selector) return page.locator(target.selector).first()
if (target.role || target.name) {
return page.getByRole(target.role ?? 'button', { name: target.name ?? undefined }).first()
}
throw new Error('Provide ref, selector, or role/name to identify the target element.')
}
/** Wait briefly for a navigation an action may have started. */
async function settle(page) {
await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => undefined)
}
/** Read the page identity used by every tool result. */
async function describe(page) {
return { url: page.url(), title: await page.title() }
}
/**
* Open a URL in the shared page.
* @param url - absolute http(s), or `about:blank`.
* @returns page identity and the response status when one exists.
*/
export async function navigate(url) {
const page = await getPage()
const response = await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: NAVIGATION_TIMEOUT_MS,
})
return { ...(await describe(page)), status: response ? response.status() : null }
}
/**
* Read the current page as a compact interactive snapshot with references.
* @returns page identity, visible text, and the addressable elements.
*/
export async function snapshot() {
const page = await getPage()
const value = await page.evaluate(
({ selector, maxRefs }) => {
const implicitRole = (element) => {
const tag = element.tagName.toLowerCase()
if (tag === 'a') return 'link'
if (tag === 'button') return 'button'
if (tag === 'select') return 'combobox'
if (tag === 'textarea') return 'textbox'
if (tag === 'summary') return 'button'
if (tag !== 'input') return tag
const type = (element.getAttribute('type') ?? 'text').toLowerCase()
if (type === 'checkbox') return 'checkbox'
if (type === 'radio') return 'radio'
if (type === 'submit' || type === 'button' || type === 'reset') return 'button'
return 'textbox'
}
const elements = []
let index = 0
for (const element of document.querySelectorAll(selector)) {
const rect = element.getBoundingClientRect()
const style = window.getComputedStyle(element)
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue
if (rect.width < 1 && rect.height < 1) continue
index += 1
const ref = `r${index}`
element.setAttribute('data-dsh-ref', ref)
const label = (
element.getAttribute('aria-label') ??
element.getAttribute('placeholder') ??
element.innerText ??
element.value ??
element.getAttribute('title') ??
''
)
.toString()
.trim()
.replace(/\s+/g, ' ')
const tag = element.tagName.toLowerCase()
const type = element.getAttribute('type')
const entry = {
ref,
tag,
role: element.getAttribute('role') ?? implicitRole(element),
name: label.slice(0, 160),
disabled: element.disabled === true,
}
if (type) entry.type = type
if (tag === 'input' || tag === 'select' || tag === 'textarea') {
entry.value = typeof element.value === 'string' ? element.value.slice(0, 80) : ''
}
if (tag === 'input' && (type === 'checkbox' || type === 'radio')) entry.checked = element.checked === true
const href = element.getAttribute('href')
if (href) entry.href = href
elements.push(entry)
if (elements.length >= maxRefs) break
}
return {
url: location.href,
title: document.title,
text: (document.body?.innerText ?? '').slice(0, 4000),
elements,
}
},
{ selector: SNAPSHOT_SELECTOR, maxRefs: MAX_REFS },
)
return value
}
/**
* Click one addressable element.
* @param target - ref, selector, or role/name identifying the element.
* @returns page identity after the action settles.
*/
export async function click(target) {
const page = await getPage()
await locatorFor(page, target).click({ timeout: ACTION_TIMEOUT_MS })
await settle(page)
return describe(page)
}
/**
* Fill one addressable element and optionally submit it.
* @param target - ref, selector, or role/name identifying the field.
* @param text - the text to type.
* @param submit - whether to press Enter after typing.
* @returns page identity after the action settles.
*/
export async function type(target, text, submit) {
const page = await getPage()
const locator = locatorFor(page, target)
await locator.fill(text, { timeout: ACTION_TIMEOUT_MS })
if (submit) await locator.press('Enter', { timeout: ACTION_TIMEOUT_MS })
await settle(page)
return describe(page)
}
/**
* Press a keyboard key on the focused element.
* @param key - a Playwright key name, for example `Enter` or `Control+A`.
* @returns page identity after the action settles.
*/
export async function press(key) {
const page = await getPage()
await page.keyboard.press(key)
await settle(page)
return describe(page)
}
/**
* Scroll the page or one addressable element.
* @param target - optional ref, selector, or role/name to scroll into view.
* @param deltaY - additional vertical wheel distance in pixels.
* @returns page identity after the action settles.
*/
export async function scroll(target, deltaY) {
const page = await getPage()
if (target.ref || target.selector || target.role || target.name) {
await locatorFor(page, target).scrollIntoViewIfNeeded({ timeout: ACTION_TIMEOUT_MS })
}
if (deltaY) await page.mouse.wheel(0, deltaY)
await settle(page)
return describe(page)
}
/**
* Capture the page as a PNG file.
* @param path - absolute output path resolved by the harness filesystem.
* @param fullPage - whether to capture the full scrollable page.
* @returns the written path and PNG byte length.
*/
export async function screenshot(path, fullPage) {
const page = await getPage()
const bytes = await page.screenshot({ fullPage, type: 'png' })
await mkdir(dirname(path), { recursive: true })
await writeFile(path, bytes)
return { path, bytes: bytes.length, ...(await describe(page)) }
}
/**
* Evaluate a read-only expression in the page and return its JSON value.
* @param expression - a JavaScript expression evaluated in the page context.
* @returns the JSON-serializable evaluation result.
*/
export async function evaluate(expression) {
const page = await getPage()
const value = await page.evaluate(expression)
return { value: value === undefined ? null : value, ...(await describe(page)) }
}
/**
* List, select, or close the open tabs of the shared context.
* @param action - `list`, `select`, or `close`.
* @param index - zero-based tab index for `select` and `close`; defaults to the active tab.
* @returns the remaining tab identities and the active index.
*/
export async function tabs(action, index) {
const current = await getSession()
const pages = current.context.pages()
if (action === 'select') {
const target = pages[index ?? 0]
if (!target) throw new Error(`No tab at index ${index ?? 0}; ${pages.length} tab(s) open.`)
current.page = target
await target.bringToFront()
}
if (action === 'close') {
const target = pages[index ?? pages.length - 1]
if (!target) throw new Error(`No tab at index ${index ?? 0}; ${pages.length} tab(s) open.`)
await target.close()
if (current.page === target) current.page = null
}
const remaining = current.context.pages()
const listed = []
for (let i = 0; i < remaining.length; i += 1) {
listed.push({
index: i,
url: remaining[i].url(),
title: await remaining[i].title(),
active: remaining[i] === (current.page ?? remaining[remaining.length - 1]),
})
}
return { tabs: listed }
}
/** Close the browser and drop the session. */
export async function closeBrowser() {
if (!session) return { closed: false }
const closing = session.browser
session = null
await closing.close()
return { closed: true }
}
/**
* The page of the running session without creating one.
* @returns the active page, or undefined when no browser or tab is open.
*/
function livePage() {
if (!session) return undefined
const current =
session.page && !session.page.isClosed() ? session.page : session.context.pages().at(-1)
if (!current || current.isClosed()) return undefined
return current
}
/**
* The page the agent is on, for the DSH web client's right-sidebar Browser tab.
* Reading it never launches a browser: with no session the state is inactive.
* @returns whether a browser runs, and the current page's URL and title.
*/
export async function browserState() {
const page = livePage()
let url = null
let title = null
if (page) {
url = page.url()
title = await page.title().catch(() => null)
}
return { active: session !== null, url, title }
}
/**
* The page of the running session without creating one, for read-only views.
*
* `getPage` launches a browser when none runs, which a passive viewer must
* never do: the live view polls this once per frame.
*
* @returns the active page, or undefined when no browser or tab is open.
*/
export function peekPage() {
return livePage()
}