dsh-builtin-browser 0.1.23 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +91 -3
- package/README.md +98 -4
- package/client.js +334 -0
- package/cordis.patch.yml +24 -5
- package/desktop-bridge/install.mjs +98 -0
- package/desktop-bridge/plugin-browser-bridge.js +385 -0
- package/docs/README.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/tool-reference.md +2 -1
- package/lib/browser/runtime.d.ts +18 -1
- package/lib/browser/runtime.js +19 -0
- package/lib/browser/types.d.ts +33 -0
- package/lib/browser-electron/bridge-connection.d.ts +58 -0
- package/lib/browser-electron/bridge-connection.js +133 -0
- package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
- package/lib/browser-electron/desktop-bridge-host.js +297 -0
- package/lib/browser-electron/entry.js +195 -11
- package/lib/browser-electron/history-store.d.ts +75 -0
- package/lib/browser-electron/history-store.js +162 -0
- package/lib/browser-electron/host-main.js +28 -1
- package/lib/browser-electron/provider.d.ts +122 -1
- package/lib/browser-electron/provider.js +233 -21
- package/lib/browser-electron/remote-host.d.ts +17 -1
- package/lib/browser-electron/remote-host.js +81 -9
- package/lib/browser-electron/settings-store.d.ts +92 -0
- package/lib/browser-electron/settings-store.js +183 -0
- package/lib/browser-electron/system-browser.d.ts +100 -0
- package/lib/browser-electron/system-browser.js +322 -0
- package/lib/browser-electron/virtual-cursor.d.ts +67 -0
- package/lib/browser-electron/virtual-cursor.js +141 -0
- package/lib/index.d.ts +16 -2
- package/lib/index.js +18 -2
- package/lib/tool-browser/index.js +122 -16
- package/package.json +14 -1
- package/src/browser/runtime.ts +22 -0
- package/src/browser/types.ts +31 -0
- package/src/browser-electron/bridge-connection.ts +145 -0
- package/src/browser-electron/desktop-bridge-host.ts +305 -0
- package/src/browser-electron/entry.ts +217 -11
- package/src/browser-electron/history-store.ts +171 -0
- package/src/browser-electron/host-main.ts +26 -1
- package/src/browser-electron/provider.ts +284 -21
- package/src/browser-electron/remote-host.ts +84 -7
- package/src/browser-electron/settings-store.ts +227 -0
- package/src/browser-electron/system-browser.ts +318 -0
- package/src/browser-electron/virtual-cursor.ts +152 -0
- package/src/index.ts +20 -2
- package/src/tool-browser/index.ts +121 -15
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic pointer feedback for a human watching the tab: an overlay drawn
|
|
3
|
+
* INSIDE the page, never the real system pointer. Its presence is the "the agent
|
|
4
|
+
* has taken over this tab" signal, so it also covers DOM-level operations that
|
|
5
|
+
* move no pointer at all.
|
|
6
|
+
*
|
|
7
|
+
* Implementation notes that matter:
|
|
8
|
+
* - The overlay is `position: fixed` and positioned from viewport coordinates,
|
|
9
|
+
* which is exactly what the element locator already computes and what
|
|
10
|
+
* `Input.dispatchMouseEvent` consumes.
|
|
11
|
+
* - All styling is inline and the ripple uses the Web Animations API: a
|
|
12
|
+
* `<style>` element would be subject to the page's `style-src` CSP, an
|
|
13
|
+
* injected script is not. Injected page feedback must never be silently
|
|
14
|
+
* dropped by a strict CSP.
|
|
15
|
+
* - Every call is best-effort: feedback is never allowed to fail an operation.
|
|
16
|
+
* @module dsh-browser/browser-electron/virtual-cursor
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What the pointer is doing at the reported point. */
|
|
20
|
+
export type CursorAction = 'move' | 'click'
|
|
21
|
+
|
|
22
|
+
/** Element id of the overlay root, stable so repeat calls update one node. */
|
|
23
|
+
const CURSOR_ID = '__dsh_agent_cursor__'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where the overlay was last placed, so an unchanged position is not repainted.
|
|
27
|
+
*
|
|
28
|
+
* A single tool call issues several CDP commands (locate, click, cursor), and the
|
|
29
|
+
* cursor is repainted for each one that moves it. Repainting to the same pixel is a
|
|
30
|
+
* wasted round-trip *and* a visible flicker: the transition restarts from the same
|
|
31
|
+
* place, which reads as a stutter rather than as movement.
|
|
32
|
+
*/
|
|
33
|
+
const lastPlaced = new WeakMap<object, { x: number; y: number; action: CursorAction }>()
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a paint at this position would change anything on screen.
|
|
37
|
+
* @param handle - the view the overlay belongs to.
|
|
38
|
+
* @param x - viewport x in CSS pixels.
|
|
39
|
+
* @param y - viewport y in CSS pixels.
|
|
40
|
+
* @param action - the action being reported.
|
|
41
|
+
* @param force - paint even when unchanged (a click ripple must always fire).
|
|
42
|
+
* @returns true when the overlay should be painted.
|
|
43
|
+
*/
|
|
44
|
+
export function cursorNeedsPaint<TView>(handle: TView, x: number, y: number, action: CursorAction, force = false): boolean {
|
|
45
|
+
if (force) return true
|
|
46
|
+
const key = handle as unknown as object
|
|
47
|
+
if (key === null || typeof key !== 'object') return true
|
|
48
|
+
const previous = lastPlaced.get(key)
|
|
49
|
+
return previous === undefined || previous.x !== x || previous.y !== y || previous.action !== action
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The in-page expression that materializes or moves the overlay. Idempotent: the
|
|
54
|
+
* first call creates the nodes, later calls only move them (and pulse).
|
|
55
|
+
* @param x - viewport x in CSS pixels.
|
|
56
|
+
* @param y - viewport y in CSS pixels.
|
|
57
|
+
* @param action - whether to pulse a click ripple at the point.
|
|
58
|
+
* @param label - optional short description of the operation, shown beside the
|
|
59
|
+
* pointer. The overlay is the only "the agent has taken over this tab" signal, so
|
|
60
|
+
* saying *what* it is doing is what turns it from a decoration into an account of
|
|
61
|
+
* the agent's actions that a human can follow without reading the tool log.
|
|
62
|
+
* @returns a self-contained expression that evaluates to `true`.
|
|
63
|
+
*/
|
|
64
|
+
export function cursorScript(x: number, y: number, action: CursorAction, label?: string): string {
|
|
65
|
+
return `(() => {
|
|
66
|
+
const id = ${JSON.stringify(CURSOR_ID)}
|
|
67
|
+
let root = document.getElementById(id)
|
|
68
|
+
if (!root) {
|
|
69
|
+
root = document.createElement('div')
|
|
70
|
+
root.id = id
|
|
71
|
+
root.setAttribute('aria-hidden', 'true')
|
|
72
|
+
root.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;transition:transform 190ms cubic-bezier(.22,.85,.24,1);will-change:transform'
|
|
73
|
+
const svg = '<svg width="22" height="22" viewBox="0 0 22 22" style="position:absolute;left:-2px;top:-2px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))">'
|
|
74
|
+
+ '<path d="M4 2 L4 18 L8.4 13.8 L11.2 19.6 L13.8 18.4 L11 12.8 L17 12.6 Z" fill="#ffffff" stroke="#111111" stroke-width="1.4" stroke-linejoin="round"/></svg>'
|
|
75
|
+
const ripple = '<span data-dsh-role="ripple" style="position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:2px solid rgba(64,160,255,.9);opacity:0"></span>'
|
|
76
|
+
// The bubble sits below-right of the pointer tip and is capped in width so a long
|
|
77
|
+
// label cannot cover the page it is describing.
|
|
78
|
+
const bubble = '<span data-dsh-role="bubble" style="position:absolute;left:18px;top:16px;max-width:280px;padding:3px 8px;border-radius:5px;background:rgba(17,17,17,.92);color:#fff;font:12px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:0;box-shadow:0 1px 3px rgba(0,0,0,.4)"></span>'
|
|
79
|
+
root.innerHTML = svg + ripple + bubble
|
|
80
|
+
;(document.body || document.documentElement).appendChild(root)
|
|
81
|
+
}
|
|
82
|
+
root.style.transform = 'translate(${x}px, ${y}px)'
|
|
83
|
+
${action === 'click' ? `const ripple = root.querySelector('[data-dsh-role="ripple"]')
|
|
84
|
+
if (ripple && typeof ripple.animate === 'function') {
|
|
85
|
+
ripple.animate(
|
|
86
|
+
[{ transform: 'scale(.25)', opacity: 0.95 }, { transform: 'scale(1.6)', opacity: 0 }],
|
|
87
|
+
{ duration: 550, easing: 'ease-out' },
|
|
88
|
+
)
|
|
89
|
+
}` : ''}
|
|
90
|
+
${label !== undefined && label !== '' ? `const bubble = root.querySelector('[data-dsh-role="bubble"]')
|
|
91
|
+
if (bubble) {
|
|
92
|
+
bubble.textContent = ${JSON.stringify(label)}
|
|
93
|
+
if (typeof bubble.animate === 'function') {
|
|
94
|
+
// Fade in, hold, fade out: the label is a narration of the action, not a
|
|
95
|
+
// permanent caption, so a stale one never lingers over later work.
|
|
96
|
+
bubble.animate(
|
|
97
|
+
[{ opacity: 0, transform: 'translateY(-3px)' }, { opacity: 1, transform: 'translateY(0)' }, { opacity: 1, offset: 0.6 }, { opacity: 0 }],
|
|
98
|
+
{ duration: 2200, easing: 'ease-out', fill: 'forwards' },
|
|
99
|
+
)
|
|
100
|
+
} else {
|
|
101
|
+
bubble.style.opacity = '1'
|
|
102
|
+
}
|
|
103
|
+
}` : ''}
|
|
104
|
+
return true
|
|
105
|
+
})()`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Forget the remembered position for a view.
|
|
110
|
+
*
|
|
111
|
+
* Called when the document is replaced: the overlay lived in the old document and
|
|
112
|
+
* went with it, so the cache would otherwise claim the pointer is already where it
|
|
113
|
+
* needs to be and skip every later paint — the pointer would simply never return
|
|
114
|
+
* after a navigation.
|
|
115
|
+
* @param handle - the view whose cache should be dropped.
|
|
116
|
+
*/
|
|
117
|
+
export function forgetCursor<TView>(handle: TView): void {
|
|
118
|
+
const key = handle as unknown as object
|
|
119
|
+
if (key !== null && typeof key === 'object') lastPlaced.delete(key)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Draw (or move) the pointer overlay on one view's page.
|
|
124
|
+
*
|
|
125
|
+
* Fire-and-forget: a page that is mid-navigation, cross-origin-blocked, or
|
|
126
|
+
* otherwise unable to answer must not turn a successful operation into a
|
|
127
|
+
* failure, and the navigation that just committed may still be replacing the
|
|
128
|
+
* document this call would have painted into.
|
|
129
|
+
* @param handle - the view to paint into.
|
|
130
|
+
* @param x - viewport x in CSS pixels.
|
|
131
|
+
* @param y - viewport y in CSS pixels.
|
|
132
|
+
* @param action - whether to pulse a click ripple.
|
|
133
|
+
* @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
|
|
134
|
+
* @param label - optional short description of the operation, shown beside the pointer.
|
|
135
|
+
*/
|
|
136
|
+
export function paintCursor<TView>(
|
|
137
|
+
handle: TView,
|
|
138
|
+
x: number,
|
|
139
|
+
y: number,
|
|
140
|
+
action: CursorAction,
|
|
141
|
+
evaluate: (handle: TView, expression: string) => Promise<unknown>,
|
|
142
|
+
label?: string,
|
|
143
|
+
force = false,
|
|
144
|
+
): void {
|
|
145
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
|
146
|
+
const roundedX = Math.round(x)
|
|
147
|
+
const roundedY = Math.round(y)
|
|
148
|
+
if (!cursorNeedsPaint(handle, roundedX, roundedY, action, force)) return
|
|
149
|
+
const key = handle as unknown as object
|
|
150
|
+
if (key !== null && typeof key === 'object') lastPlaced.set(key, { x: roundedX, y: roundedY, action })
|
|
151
|
+
void evaluate(handle, cursorScript(roundedX, roundedY, action, label)).catch(() => {})
|
|
152
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,11 +4,29 @@
|
|
|
4
4
|
* - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
|
|
5
5
|
* - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
|
|
6
6
|
* - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
7
|
+
* The capability itself is composed by those rows; this root entry exists for
|
|
8
|
+
* programmatic imports AND because the host's client-module scan identifies a
|
|
9
|
+
* package's browser half through a loader row whose specifier resolves to the
|
|
10
|
+
* PACKAGE ROOT. A row named after a subpath (`dsh-builtin-browser/browser`) is
|
|
11
|
+
* not a package specifier and is skipped by design, which is why the patch also
|
|
12
|
+
* registers a row under the bare package name — see cordis.patch.yml.
|
|
9
13
|
* @module dsh-builtin-browser
|
|
10
14
|
*/
|
|
11
15
|
|
|
16
|
+
/** Plugin identity for the root row (the composition surface is the subpath rows). */
|
|
17
|
+
export const name = 'dsh-builtin-browser'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Inert application for the root row: the browser seam, the Electron provider and
|
|
21
|
+
* the tools are mounted by the subpath rows, so this one only exists to give the
|
|
22
|
+
* package a loader row named after the package itself. Without it the host's
|
|
23
|
+
* client scan finds no row to resolve `dsh.client` from, and the settings panel
|
|
24
|
+
* never reaches the browser (the plugin works, its client half is invisible).
|
|
25
|
+
*/
|
|
26
|
+
export function apply(): void {
|
|
27
|
+
// Intentionally empty: see the module comment above.
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
export { BrowserError } from './browser/types.js'
|
|
13
31
|
export type {
|
|
14
32
|
BrowserA11yNode,
|
|
@@ -105,7 +105,12 @@ function taskKey(exec: { agent?: { id?: string } } | undefined): string {
|
|
|
105
105
|
*/
|
|
106
106
|
async function ensureSession(browser: NonNullable<Context['browser']>, state: ToolBrowserState, key: string, agent?: ExecAgent): Promise<BrowserSessionId> {
|
|
107
107
|
const existing = state.sessionsByTask.get(key)
|
|
108
|
-
if (existing !== undefined)
|
|
108
|
+
if (existing !== undefined) {
|
|
109
|
+
// The human closing a browser window ends that session, so a cached id can
|
|
110
|
+
// be stale: never hand a dead session back to a tool call.
|
|
111
|
+
if (browser.exists(existing)) return existing
|
|
112
|
+
state.sessionsByTask.delete(key)
|
|
113
|
+
}
|
|
109
114
|
const pending = state.pendingOpens.get(key)
|
|
110
115
|
if (pending !== undefined) return pending
|
|
111
116
|
// The task key rides along as the session label so the window title shows
|
|
@@ -156,15 +161,26 @@ function parseFillValue(v: string | undefined): string | number | boolean {
|
|
|
156
161
|
return v ?? ''
|
|
157
162
|
}
|
|
158
163
|
|
|
159
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Format a snapshot element list for the model.
|
|
166
|
+
*
|
|
167
|
+
* Coordinates are opt-in. They are only meaningful to a caller that intends to
|
|
168
|
+
* click a pixel position, which means a vision pass; for the far more common case
|
|
169
|
+
* of locating an element by role/label and passing a semantic target, they are the
|
|
170
|
+
* bulkiest and least useful part of every line. Omitting them keeps the listing
|
|
171
|
+
* readable — which is exactly what a model without image input has to work from.
|
|
172
|
+
* @param snapshot - the snapshot payload.
|
|
173
|
+
* @param options - `coords: true` to include each element's viewport position.
|
|
174
|
+
*/
|
|
160
175
|
function formatSnapshot(snapshot: {
|
|
161
176
|
url: string
|
|
162
177
|
title?: string
|
|
163
178
|
elements: readonly { ref: number; kind: string; label: string; x: number; y: number; frame?: boolean }[]
|
|
164
179
|
truncated?: boolean
|
|
165
180
|
challenge?: { blocked: boolean; kind?: string; reason?: string }
|
|
166
|
-
}): string {
|
|
167
|
-
const
|
|
181
|
+
}, options: { coords?: boolean } = {}): string {
|
|
182
|
+
const showCoords = options.coords === true
|
|
183
|
+
const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.frame === true ? ' (iframe)' : ''}${showCoords ? ` (${el.x},${el.y})` : ''}`)
|
|
168
184
|
const header = `URL: ${snapshot.url}${snapshot.title !== undefined ? `\nTitle: ${snapshot.title}` : ''}`
|
|
169
185
|
const body = lines.length > 0 ? lines.join('\n') : '(no interactive elements found)'
|
|
170
186
|
const tail = snapshot.truncated === true ? '\n(snapshot truncated)' : ''
|
|
@@ -236,7 +252,10 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
236
252
|
},
|
|
237
253
|
},
|
|
238
254
|
},
|
|
239
|
-
|
|
255
|
+
// browser_open is an overview of what just loaded, not a locator: it has no
|
|
256
|
+
// `coords` parameter, so coordinates stay off here (browser_snapshot owns
|
|
257
|
+
// that switch for callers that actually target pixels).
|
|
258
|
+
render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: (args as { coords?: boolean }).coords === true }) }],
|
|
240
259
|
},
|
|
241
260
|
timeoutMs,
|
|
242
261
|
isConcurrencySafe: () => false, // opens tabs / navigates; exclusive within a task
|
|
@@ -262,8 +281,10 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
262
281
|
|
|
263
282
|
ctx.tools.register(defineTool({
|
|
264
283
|
name: 'browser_snapshot',
|
|
265
|
-
description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite.
|
|
266
|
-
parameters: {
|
|
284
|
+
description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite. This is the primary way to understand a page, and it needs no image input — every listed element can afterwards be addressed by a semantic target (browser_click, browser_type, browser_check, …) rather than by coordinates.',
|
|
285
|
+
parameters: {
|
|
286
|
+
coords: { type: 'boolean', description: 'Also print each element\'s viewport coordinates. Off by default; only useful when a vision pass is going to click a pixel position.' },
|
|
287
|
+
},
|
|
267
288
|
output: {
|
|
268
289
|
schema: {
|
|
269
290
|
type: 'object',
|
|
@@ -299,7 +320,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
299
320
|
},
|
|
300
321
|
},
|
|
301
322
|
},
|
|
302
|
-
render: (
|
|
323
|
+
render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: args.coords === true }) }],
|
|
303
324
|
},
|
|
304
325
|
timeoutMs,
|
|
305
326
|
isConcurrencySafe: () => true,
|
|
@@ -324,6 +345,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
324
345
|
parameters: {
|
|
325
346
|
includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
|
|
326
347
|
maxNodes: { type: 'number', description: 'Maximum nodes (default 500, range 10-5000).' },
|
|
348
|
+
coords: { type: 'boolean', description: 'Include each element\'s viewport coordinates. Off by default: a semantic target needs no coordinates, and they are pure noise unless a vision pass is going to click a pixel position.' },
|
|
327
349
|
},
|
|
328
350
|
output: {
|
|
329
351
|
schema: {
|
|
@@ -356,14 +378,24 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
356
378
|
},
|
|
357
379
|
},
|
|
358
380
|
},
|
|
359
|
-
render: (
|
|
381
|
+
render: (args, value) => {
|
|
360
382
|
const nodes = value.nodes as Array<{ ref: number; role: string; name: string; value?: string | null; states: string[]; depth: number; tag: string; x: number; y: number; frame?: boolean }>
|
|
383
|
+
// Built for reading, not for imaging: containment is shown as indentation
|
|
384
|
+
// (the depth was already collected and simply thrown away), an empty state
|
|
385
|
+
// list adds nothing, and coordinates appear only when asked for — a model
|
|
386
|
+
// working from the DOM never needs them, and they are the bulkiest part of
|
|
387
|
+
// every line.
|
|
388
|
+
const showCoords = args.coords === true
|
|
361
389
|
const lines = nodes.map(n => {
|
|
362
390
|
const valuePart = n.value !== undefined && n.value !== null ? ` value="${String(n.value).slice(0, 60)}"` : ''
|
|
363
|
-
|
|
391
|
+
const indent = ' '.repeat(Math.max(0, Math.min(8, Number(n.depth) || 0)))
|
|
392
|
+
const coordsPart = showCoords ? ` (${n.x},${n.y})` : ''
|
|
393
|
+
const statePart = Array.isArray(n.states) && n.states.length > 0 ? ` states=[${n.states.join(',')}]` : ''
|
|
394
|
+
return `${indent}[${n.ref}] ${n.role} "${n.name}"${valuePart}${coordsPart}${statePart}${n.frame === true ? ' (iframe)' : ''}`
|
|
364
395
|
})
|
|
365
396
|
const header = `URL: ${value.url}${value.title !== undefined ? `\nTitle: ${value.title}` : ''}`
|
|
366
|
-
|
|
397
|
+
const hint = showCoords ? '' : '\n(coordinates omitted — pass coords: true to include them, or click by semantic target)'
|
|
398
|
+
return [{ type: 'text', text: `${header}\n\n${lines.length > 0 ? lines.join('\n') : '(no accessible interactive nodes)'}${value.truncated === true ? '\n(truncated)' : ''}${hint}` }]
|
|
367
399
|
},
|
|
368
400
|
},
|
|
369
401
|
timeoutMs,
|
|
@@ -596,7 +628,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
596
628
|
|
|
597
629
|
ctx.tools.register(defineTool({
|
|
598
630
|
name: 'browser_click',
|
|
599
|
-
description: 'Click in the shared browser.
|
|
631
|
+
description: 'Click in the shared browser. Prefer the semantic target: pass target {by: css|text|xpath, value, index?} and the element is located from the DOM, scrolled into view and clicked at its centre — this needs no image input and is the only form that works without vision. The alternative is viewport coordinates (x/y), which are meaningful only after a vision model has located the element on a browser_screenshot; coordinate clicks are refused outright under the non-visual strategy. Provide exactly one of target or x/y.',
|
|
600
632
|
parameters: {
|
|
601
633
|
target: {
|
|
602
634
|
type: 'object',
|
|
@@ -608,8 +640,8 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
608
640
|
},
|
|
609
641
|
description: 'Locate the element semantically and click it (css/text/xpath).',
|
|
610
642
|
},
|
|
611
|
-
x: { type: 'number', description: 'Viewport x coordinate (CSS px),
|
|
612
|
-
y: { type: 'number', description: 'Viewport y coordinate (CSS px).' },
|
|
643
|
+
x: { type: 'number', description: 'Viewport x coordinate (CSS px), taken from a browser_screenshot read by a vision model. Prefer a semantic target — it works without image input.' },
|
|
644
|
+
y: { type: 'number', description: 'Viewport y coordinate (CSS px), taken from the same screenshot as x.' },
|
|
613
645
|
},
|
|
614
646
|
output: {
|
|
615
647
|
schema: { type: 'object', additionalProperties: false, properties: { clicked: { type: 'boolean', required: true } } },
|
|
@@ -1084,7 +1116,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
1084
1116
|
|
|
1085
1117
|
ctx.tools.register(defineTool({
|
|
1086
1118
|
name: 'browser_screenshot',
|
|
1087
|
-
description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional).
|
|
1119
|
+
description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — browser_snapshot, browser_a11y and browser_content carry the same page as text, and browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG is only available on the self-hosted native path; the desktop-shell path returns PNG.',
|
|
1088
1120
|
parameters: {
|
|
1089
1121
|
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
|
|
1090
1122
|
savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
|
|
@@ -1327,6 +1359,80 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
1327
1359
|
},
|
|
1328
1360
|
}))
|
|
1329
1361
|
|
|
1362
|
+
ctx.tools.register(defineTool({
|
|
1363
|
+
name: 'browser_visited',
|
|
1364
|
+
description: 'List pages the shared browser has visited, newest first, from the PERSISTENT browsing history: it survives closing the browser and restarting DSH, so it is how you (or the human) find a page again afterwards. Reopen one by passing its url to browser_open. Not to be confused with browser_history, which is only the current session\'s operation log (navigate/click/type) and disappears with the session.',
|
|
1365
|
+
parameters: {
|
|
1366
|
+
limit: { type: 'number', description: 'Maximum entries to return (default 30, capped at 200).' },
|
|
1367
|
+
domain: { type: 'string', description: 'Only visits whose hostname contains this text (case-insensitive).' },
|
|
1368
|
+
query: { type: 'string', description: 'Only visits whose URL or title contains this text (case-insensitive).' },
|
|
1369
|
+
session: { type: 'string', description: 'Only visits recorded by this task/session label — use it to separate your own pages from another session\'s or the human\'s.' },
|
|
1370
|
+
},
|
|
1371
|
+
output: {
|
|
1372
|
+
schema: {
|
|
1373
|
+
type: 'object',
|
|
1374
|
+
additionalProperties: false,
|
|
1375
|
+
properties: {
|
|
1376
|
+
count: { type: 'number', required: true },
|
|
1377
|
+
entries: {
|
|
1378
|
+
type: 'array',
|
|
1379
|
+
required: true,
|
|
1380
|
+
items: {
|
|
1381
|
+
type: 'object',
|
|
1382
|
+
additionalProperties: false,
|
|
1383
|
+
properties: {
|
|
1384
|
+
at: { type: 'number', required: true },
|
|
1385
|
+
url: { type: 'string', required: true },
|
|
1386
|
+
title: { type: 'string' },
|
|
1387
|
+
session: { type: 'string' },
|
|
1388
|
+
},
|
|
1389
|
+
},
|
|
1390
|
+
},
|
|
1391
|
+
},
|
|
1392
|
+
},
|
|
1393
|
+
render: (_args, value) => {
|
|
1394
|
+
const entries = value.entries as Array<{ at: number; url: string; title?: string; session?: string }>
|
|
1395
|
+
if (entries.length === 0) {
|
|
1396
|
+
return [{ type: 'text', text: '(no recorded visits — nothing has been browsed yet, or history recording is turned off in settings)' }]
|
|
1397
|
+
}
|
|
1398
|
+
return [{
|
|
1399
|
+
type: 'text',
|
|
1400
|
+
text: entries.map(entry => {
|
|
1401
|
+
const when = new Date(entry.at).toISOString().replace('T', ' ').slice(0, 19)
|
|
1402
|
+
const title = entry.title !== undefined ? `${entry.title} — ` : ''
|
|
1403
|
+
return `${when} ${title}${entry.url}${entry.session !== undefined ? ` [${entry.session}]` : ''}`
|
|
1404
|
+
}).join('\n'),
|
|
1405
|
+
}]
|
|
1406
|
+
},
|
|
1407
|
+
},
|
|
1408
|
+
timeoutMs,
|
|
1409
|
+
isConcurrencySafe: () => true,
|
|
1410
|
+
async execute(args, _exec) {
|
|
1411
|
+
const browser = ctx.get('browser')
|
|
1412
|
+
if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
|
|
1413
|
+
const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 30
|
|
1414
|
+
const limit = Math.max(1, Math.min(200, Math.floor(rawLimit)))
|
|
1415
|
+
const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined
|
|
1416
|
+
const query = typeof args.query === 'string' && args.query.trim() !== '' ? args.query.trim() : undefined
|
|
1417
|
+
const session = typeof args.session === 'string' && args.session.trim() !== '' ? args.session.trim() : undefined
|
|
1418
|
+
const entries = browser.visited({
|
|
1419
|
+
limit,
|
|
1420
|
+
...domain !== undefined ? { domain } : {},
|
|
1421
|
+
...query !== undefined ? { query } : {},
|
|
1422
|
+
...session !== undefined ? { session } : {},
|
|
1423
|
+
})
|
|
1424
|
+
return {
|
|
1425
|
+
count: entries.length,
|
|
1426
|
+
entries: entries.map(entry => ({
|
|
1427
|
+
at: entry.at,
|
|
1428
|
+
url: entry.url,
|
|
1429
|
+
...entry.title !== undefined ? { title: entry.title } : {},
|
|
1430
|
+
...entry.session !== undefined ? { session: entry.session } : {},
|
|
1431
|
+
})),
|
|
1432
|
+
}
|
|
1433
|
+
},
|
|
1434
|
+
}))
|
|
1435
|
+
|
|
1330
1436
|
ctx.tools.register(defineTool({
|
|
1331
1437
|
name: 'browser_replay',
|
|
1332
1438
|
description: 'Replay one recorded browser operation by its history sequence number (from browser_history). Navigate/click/type are re-issued against the current page; execute re-runs its script. The replayed step is appended to history as a new entry.',
|