dsh-inner-browser-use 0.18.0 → 0.27.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/docs/BENCH.md +109 -0
- package/docs/ROADMAP.md +58 -0
- package/docs/VERIFY.md +14 -0
- package/package.json +5 -2
- package/read.js +298 -35
- package/tools.js +140 -43
package/docs/BENCH.md
CHANGED
|
@@ -232,3 +232,112 @@ Measured with `/tmp/probe-unseen.mjs` (tool layer, example.com, profile
|
|
|
232
232
|
`probe-tool`): 1344 ms with nothing watching and no column asked for, 3236 ms in
|
|
233
233
|
the first version of the same wait that did not skip the case where no column was
|
|
234
234
|
ever requested — the whole difference was the wasted 1.2 s.
|
|
235
|
+
|
|
236
|
+
## Phase 9 — finding the field behind the words, 0.19.0
|
|
237
|
+
|
|
238
|
+
A read of a `/bare` form (two div labels, two bare inputs, no aria anywhere on
|
|
239
|
+
the fields) and one `{field: "Work email"}` step:
|
|
240
|
+
|
|
241
|
+
| what | result |
|
|
242
|
+
| --- | --- |
|
|
243
|
+
| the click lands on the field, not on the div | yes, value `me@x.y` in `input[name=wk]` |
|
|
244
|
+
| the second, untouched field | still empty |
|
|
245
|
+
| the tool's own words | `typed into "Work email"` |
|
|
246
|
+
| worst case when nothing matches | one extra in-page query, then the old behaviour |
|
|
247
|
+
|
|
248
|
+
The query walks the open shadow roots (capped at 400) and, per visible editable
|
|
249
|
+
element, builds a list of candidate names from real labels, `aria-labelledby`,
|
|
250
|
+
`aria-label`, `placeholder`, `title`, `name`, `label[for]`, the element right
|
|
251
|
+
before it, and its wrapper's text — then scores exact (3) / contains (2) /
|
|
252
|
+
reverse-contains (1).
|
|
253
|
+
|
|
254
|
+
## Phase 10 — typing by name, 0.20.0
|
|
255
|
+
|
|
256
|
+
| what the caller has | what it used to take | now |
|
|
257
|
+
| --- | --- | --- |
|
|
258
|
+
| a name for the field, no number | read the page, find the number, then type | one `sidecar_type` with `field`, one round trip |
|
|
259
|
+
| the field's words live in a div | click landed on the div, text went wherever focus was | resolved to the field, `typed into "Work email"` |
|
|
260
|
+
| a banner over the field | typed under the banner | banner cleared first, then typed |
|
|
261
|
+
|
|
262
|
+
Breadth check `type-by-words` on the `/bare` form: value lands in
|
|
263
|
+
`input[name=wk]`, the page does not move (no stray Enter).
|
|
264
|
+
|
|
265
|
+
## Phase 11 — choosing a dropdown by name, 0.21.0
|
|
266
|
+
|
|
267
|
+
`/bare` now carries a `select[name=seats]` whose only name on screen is a div
|
|
268
|
+
saying `Seats`. One `sidecar_select` with `field: "Seats"`, `value: "Two"`:
|
|
269
|
+
|
|
270
|
+
| what | result |
|
|
271
|
+
| --- | --- |
|
|
272
|
+
| the value after the call | `2` |
|
|
273
|
+
| the tool's own words | `chose Two in Seats` (on-screen name, not `name="seats"`) |
|
|
274
|
+
| a wrong option | answered with the first 12 options instead of a page read |
|
|
275
|
+
| round trips to do it | one; no read beforehand, no read after |
|
|
276
|
+
|
|
277
|
+
The naming rules (`labelSpot` and `selectSpot`) now live in one shared in-page
|
|
278
|
+
block, so the two steps cannot drift apart.
|
|
279
|
+
|
|
280
|
+
## Phase 12 — words split across nodes, 0.22.0
|
|
281
|
+
|
|
282
|
+
`/words` now carries `<button><span>Sign</span><span>in</span></button>`, which
|
|
283
|
+
the page reports as `Signin`.
|
|
284
|
+
|
|
285
|
+
| what | result |
|
|
286
|
+
| --- | --- |
|
|
287
|
+
| `{text: "Sign in"}` before | not found; caller had to fall back to a number |
|
|
288
|
+
| `{text: "Sign in"}` now | clicked, title changes, `ok click "Sign in"` |
|
|
289
|
+
| extra work | none: the same single control list, one comparison changed |
|
|
290
|
+
|
|
291
|
+
Exact equality still wins over the squeezed form, so `Sign in` never lands on
|
|
292
|
+
`Sign up` when both exist.
|
|
293
|
+
|
|
294
|
+
## Phase 13 — the field behind the box, 0.23.0
|
|
295
|
+
|
|
296
|
+
`/bare` gains the date-picker shape: a visible rounded box with a transparent
|
|
297
|
+
`<input>` laid over it, its words in a div two levels up.
|
|
298
|
+
|
|
299
|
+
| what | before | now |
|
|
300
|
+
| --- | --- | --- |
|
|
301
|
+
| `{field: "Birthday"}` | not found, fell back to clicking the div | found the hidden input, clicked its visible box |
|
|
302
|
+
| value after one `sidecar_do` step | whatever already had focus | `2001-02-03` in `#bd`, `typed into "Birthday"` |
|
|
303
|
+
| extra round trips | read the page, guess a number | none |
|
|
304
|
+
|
|
305
|
+
The visible-box rule: a candidate is usable when it, or its nearest visible
|
|
306
|
+
ancestor, has a real box — so a 0-opacity input still counts if it covers
|
|
307
|
+
something the person can see.
|
|
308
|
+
|
|
309
|
+
## Phase 15 — the answer is the picture, 0.25.0
|
|
310
|
+
|
|
311
|
+
`/pic` is one 140x90 PNG and nothing else.
|
|
312
|
+
|
|
313
|
+
| what | result |
|
|
314
|
+
| --- | --- |
|
|
315
|
+
| `sidecar_grab` | 6 ms, `picture-check.png`, 140x90 |
|
|
316
|
+
| before | three round trips: list the images, ask for the full address, fetch it |
|
|
317
|
+
|
|
318
|
+
A picture page has no text at all, so a read of it costs one line and no tokens;
|
|
319
|
+
the picture comes back as a file path the person can open.
|
|
320
|
+
|
|
321
|
+
## Phase 16 — say what the picture looks like, 0.26.0
|
|
322
|
+
|
|
323
|
+
Two 140x90 PNGs: one a gradient, one a single colour.
|
|
324
|
+
|
|
325
|
+
| page | what `sidecar_grab` answers | time |
|
|
326
|
+
| --- | --- | --- |
|
|
327
|
+
| `/pic` | `... 140x90 a picture with detail` | 21 ms (first, page cold) |
|
|
328
|
+
| `/flat` | `... 140x90 mostly one colour` | 4 ms |
|
|
329
|
+
|
|
330
|
+
The sampling is 32x32 inside the page, so it costs no round trip and its result
|
|
331
|
+
travels in the same line as the path.
|
|
332
|
+
|
|
333
|
+
## Phase 17 — the answer is a picture, in the read itself, 0.27.0
|
|
334
|
+
|
|
335
|
+
| page | read line | extra round trips |
|
|
336
|
+
| --- | --- | --- |
|
|
337
|
+
| `/pic` (gradient, no words) | `picture 140x90 a picture with detail "Chart" — sidecar_grab saves it as a file` | 0 |
|
|
338
|
+
| `/flat` (single colour) | `picture 140x90 mostly one colour "Chart" — …` | 0 |
|
|
339
|
+
| `/words` (text page) | no picture line | 0 |
|
|
340
|
+
|
|
341
|
+
The sampling is 32x32 inside the page, in the same call that reads the page, so a
|
|
342
|
+
blank chart is visible before anything is saved.
|
|
343
|
+
|
package/docs/ROADMAP.md
CHANGED
|
@@ -174,3 +174,61 @@ Next in this direction: the drawing half could re-attach by itself. A tab that
|
|
|
174
174
|
reloads re-subscribes on its own, but a tab that never loaded the half cannot be
|
|
175
175
|
told to — that is a load-order problem, so the fix belongs in how the panel half
|
|
176
176
|
is delivered, not in the wait.
|
|
177
|
+
|
|
178
|
+
## Phase 10 — words are not where the field is, 0.19.0
|
|
179
|
+
|
|
180
|
+
The tool used to treat "the control that says Email" as "the field called
|
|
181
|
+
Email". Real forms separate the two, so the writing step now resolves the words
|
|
182
|
+
to the element that takes text, and says which one it used.
|
|
183
|
+
|
|
184
|
+
Next in this direction: the same resolution for `sidecar_type` when the caller
|
|
185
|
+
names a field by its words instead of a ref, and for date/time pickers where the
|
|
186
|
+
visible box is a div and the real input is hidden beside it.
|
|
187
|
+
|
|
188
|
+
## Phase 11 — say where, not which number, 0.20.0
|
|
189
|
+
|
|
190
|
+
Typing by name is in. The same resolution is what other steps should be able to
|
|
191
|
+
use when the caller knows the destination but not the number — the next place
|
|
192
|
+
worth doing this is `sidecar_select` (choose an option by the field it belongs
|
|
193
|
+
to), and after that the date/time case where the visible box is a div and the
|
|
194
|
+
real input sits hidden beside it.
|
|
195
|
+
|
|
196
|
+
## Phase 12 — the same words, the same place, 0.21.0
|
|
197
|
+
|
|
198
|
+
`sidecar_select` joined `sidecar_type` and `sidecar_do` on the shared resolver.
|
|
199
|
+
Left in this direction: the date/time case where the box the user sees is a div
|
|
200
|
+
and the real input is hidden beside it, and `sidecar_click` on a control whose
|
|
201
|
+
words are split across sibling nodes (`<span>Sign</span><span>in</span>`).
|
|
202
|
+
|
|
203
|
+
## Phase 13 — the page's spelling is not the caller's, 0.22.0
|
|
204
|
+
|
|
205
|
+
Whitespace is normalised in both halves now (tool side and in-page side). Next
|
|
206
|
+
in this line: the date/time box where the visible box is a div and the real
|
|
207
|
+
input is hidden beside it, which needs the resolver to accept a hidden target
|
|
208
|
+
and click the thing in front of it.
|
|
209
|
+
|
|
210
|
+
## Phase 14 — behind the box, 0.23.0
|
|
211
|
+
|
|
212
|
+
Both halves now resolve a name to a place: visible field, hidden field behind a
|
|
213
|
+
visible box, words split over nodes, words outside the wrapper. Next: a page
|
|
214
|
+
whose visible box is a `<canvas>` or an `<iframe>` — nothing in the tree to
|
|
215
|
+
name, so the fallback has to be a picture plus a click, not a name lookup.
|
|
216
|
+
|
|
217
|
+
## Phase 16 — a file, not a path, 0.25.0
|
|
218
|
+
|
|
219
|
+
`sidecar_grab` returns a path. Next: say one line about the picture (size, and
|
|
220
|
+
whether it is mostly one colour) so a model that cannot open files still learns
|
|
221
|
+
"the chart is blank" without a full read.
|
|
222
|
+
|
|
223
|
+
## Phase 17 — a picture nobody can see, 0.26.0
|
|
224
|
+
|
|
225
|
+
The description is coarse on purpose. Next: when a page's answer is a picture,
|
|
226
|
+
let a read say so in one line ("the answer is a picture, and it is blank") so a
|
|
227
|
+
model that cannot open files does not need the tool at all to notice a failure.
|
|
228
|
+
|
|
229
|
+
## Phase 18 — a picture that never arrived, 0.27.0
|
|
230
|
+
|
|
231
|
+
A picture is only an answer if it loaded. Next: report a large picture element
|
|
232
|
+
whose source failed (`naturalWidth === 0`) as a broken picture rather than
|
|
233
|
+
staying silent, since that is the commonest way a generated image "disappears".
|
|
234
|
+
|
package/docs/VERIFY.md
CHANGED
|
@@ -149,3 +149,17 @@ hand on the mirror: while the person is using it a read still works
|
|
|
149
149
|
(`hold-refuses-action`), and `sidecar_resume` hands the page back so the same
|
|
150
150
|
click goes through (`resume-lets-go`).
|
|
151
151
|
|
|
152
|
+
## Who the mirror serves
|
|
153
|
+
|
|
154
|
+
The bar that matters most cannot be reached from `node --test`, because it is
|
|
155
|
+
about a column that belongs to one conversation. With DSH running:
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
node scripts/live-safety.mjs http://127.0.0.1:49552
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
It reads the state, then tries the two ways in — a frame stream and a
|
|
162
|
+
navigation — as a session that is not the owner. Both must answer 403, and the
|
|
163
|
+
owner's stream must answer 200. Three `true` lines is the bar; `open · owner
|
|
164
|
+
none` as the first line only means nothing is on screen, and the two refusals
|
|
165
|
+
still have to hold.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-inner-browser-use",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "An agent-owned Chromium sidecar mirrored into the DeepSeek Harness right column: the agent drives it, the person watches and can take the mouse, and logins survive restarts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -48,7 +48,10 @@
|
|
|
48
48
|
"live": "node scripts/live.mjs"
|
|
49
49
|
},
|
|
50
50
|
"license": "MIT",
|
|
51
|
-
"author":
|
|
51
|
+
"author": {
|
|
52
|
+
"name": "regrevia",
|
|
53
|
+
"url": "https://github.com/regrevia"
|
|
54
|
+
},
|
|
52
55
|
"repository": {
|
|
53
56
|
"type": "git",
|
|
54
57
|
"url": "git+https://github.com/regrevia/dsh-inner-browser-use.git"
|
package/read.js
CHANGED
|
@@ -43,6 +43,9 @@ const SELECTOR = [
|
|
|
43
43
|
'[role="option"]',
|
|
44
44
|
'[role="textbox"]',
|
|
45
45
|
'[role="searchbox"]',
|
|
46
|
+
/* A drawing surface has no words in the tree, but it is the whole page in
|
|
47
|
+
some products: number it, and let a picture be taken of it. */
|
|
48
|
+
'canvas',
|
|
46
49
|
].join(',')
|
|
47
50
|
|
|
48
51
|
/** How a role is written in the compact read: short enough to cost little. */
|
|
@@ -133,6 +136,10 @@ function snapshot(mode, budget, max) {
|
|
|
133
136
|
const own = named || el.getAttribute?.('aria-label') || el.getAttribute?.('title') || el.getAttribute?.('placeholder') || ''
|
|
134
137
|
const text = String(el.innerText ?? '').replace(/\\s+/g, ' ').trim()
|
|
135
138
|
const value = el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '') : ''
|
|
139
|
+
if (el.tagName === 'CANVAS' && label === '' && own === '' && text === '' && value === '') {
|
|
140
|
+
const b = el.getBoundingClientRect()
|
|
141
|
+
return 'drawing ' + Math.round(b.width) + 'x' + Math.round(b.height)
|
|
142
|
+
}
|
|
136
143
|
return String(label || own || text || value || el.getAttribute?.('alt') || el.getAttribute?.('name') || '').replace(/\\s+/g, ' ').trim().slice(0, 140)
|
|
137
144
|
}
|
|
138
145
|
const box = (el) => el.getBoundingClientRect()
|
|
@@ -240,6 +247,59 @@ function snapshot(mode, budget, max) {
|
|
|
240
247
|
}
|
|
241
248
|
}
|
|
242
249
|
}
|
|
250
|
+
/* A page with almost no words and one big picture is answering with the
|
|
251
|
+
picture. Say so here, in the same round trip, so a model that cannot open
|
|
252
|
+
files still learns that something came back: and sample it small to tell a
|
|
253
|
+
blank chart from a chart. */
|
|
254
|
+
const picture = (() => {
|
|
255
|
+
if (full >= 40) return null
|
|
256
|
+
let best = null
|
|
257
|
+
const missing = []
|
|
258
|
+
for (const el of document.querySelectorAll('img,canvas')) {
|
|
259
|
+
const b = box(el)
|
|
260
|
+
if (b.width < 64 || b.height < 64 || b.width * b.height < 8000) continue
|
|
261
|
+
const s = getComputedStyle(el)
|
|
262
|
+
if (s.visibility === 'hidden' || s.display === 'none') continue
|
|
263
|
+
/* A picture slot that never got its picture is the commonest way a
|
|
264
|
+
generated image goes missing, and it is silent by default. */
|
|
265
|
+
if (el.tagName === 'IMG' && el.complete && el.naturalWidth === 0) {
|
|
266
|
+
missing.push({ width: Math.round(b.width), height: Math.round(b.height), name: String(el.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) })
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
const w = el.tagName === 'CANVAS' ? el.width : el.naturalWidth || Math.round(b.width)
|
|
270
|
+
const h = el.tagName === 'CANVAS' ? el.height : el.naturalHeight || Math.round(b.height)
|
|
271
|
+
if (best === null || w * h > best.w * best.h) best = { el, w, h, kind: String(el.tagName).toLowerCase() }
|
|
272
|
+
}
|
|
273
|
+
if (best === null) return missing.length === 0 ? null : { missing }
|
|
274
|
+
/* What the read hands back about one picture: not a screen of fields. */
|
|
275
|
+
const said = { name: String(best.el.getAttribute?.('alt') ?? best.el.getAttribute?.('aria-label') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) }
|
|
276
|
+
said.kind = best.kind
|
|
277
|
+
said.width = best.w
|
|
278
|
+
said.height = best.h
|
|
279
|
+
said.colors = 0
|
|
280
|
+
said.flat = 0
|
|
281
|
+
try {
|
|
282
|
+
const board = document.createElement('canvas')
|
|
283
|
+
board.width = 32
|
|
284
|
+
board.height = 32
|
|
285
|
+
const pen = board.getContext('2d', { willReadFrequently: true })
|
|
286
|
+
if (pen === null) return said
|
|
287
|
+
pen.drawImage(best.el, 0, 0, 32, 32)
|
|
288
|
+
const dots = pen.getImageData(0, 0, 32, 32).data
|
|
289
|
+
const tally = new Map()
|
|
290
|
+
for (let at = 0; at < dots.length; at += 4) {
|
|
291
|
+
const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
|
|
292
|
+
tally.set(key, (tally.get(key) || 0) + 1)
|
|
293
|
+
}
|
|
294
|
+
let most = 0
|
|
295
|
+
for (const count of tally.values()) most = Math.max(most, count)
|
|
296
|
+
said.colors = tally.size
|
|
297
|
+
said.flat = Math.round((most / 1024) * 100) / 100
|
|
298
|
+
} catch {
|
|
299
|
+
/* A picture from elsewhere cannot be sampled: report its size only. */
|
|
300
|
+
}
|
|
301
|
+
return said
|
|
302
|
+
})()
|
|
243
303
|
return JSON.stringify({
|
|
244
304
|
url: location.href, // ui-copy-ignore
|
|
245
305
|
title: document.title, // ui-copy-ignore
|
|
@@ -252,6 +312,7 @@ function snapshot(mode, budget, max) {
|
|
|
252
312
|
return { ref: reg.id(el), kind: role(el), name: name(el) }
|
|
253
313
|
})(),
|
|
254
314
|
controls: picked,
|
|
315
|
+
picture,
|
|
255
316
|
})
|
|
256
317
|
})()`
|
|
257
318
|
}
|
|
@@ -278,6 +339,119 @@ export function resolve(ref) {
|
|
|
278
339
|
})()`
|
|
279
340
|
}
|
|
280
341
|
|
|
342
|
+
/**
|
|
343
|
+
* How big the control is and where it sits, so a picture can be of it alone.
|
|
344
|
+
* @param {number} ref - ref from a read.
|
|
345
|
+
* @returns {string} an expression that answers with the rectangle.
|
|
346
|
+
*/
|
|
347
|
+
export function bounds(ref) {
|
|
348
|
+
return `(() => {
|
|
349
|
+
const reg = window.${REGISTRY}
|
|
350
|
+
if (!reg) return JSON.stringify({ gone: true })
|
|
351
|
+
const hit = reg.find(${Number(ref)})
|
|
352
|
+
if (!hit) return JSON.stringify({ gone: true })
|
|
353
|
+
const el = hit.el
|
|
354
|
+
if (el.scrollIntoView) el.scrollIntoView({ block: 'center', inline: 'center' })
|
|
355
|
+
const b = el.getBoundingClientRect()
|
|
356
|
+
if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
|
|
357
|
+
return JSON.stringify({ gone: false, x: b.left, y: b.top, width: b.width, height: b.height })
|
|
358
|
+
})()`
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The pictures on the page, as files a person can open.
|
|
363
|
+
* A generated image or a chart is often the whole answer, and there is no
|
|
364
|
+
* wording in the tree that stands for it: take the picture itself.
|
|
365
|
+
* @param {number} [ref] - only pictures at or inside this control.
|
|
366
|
+
* @param {number} [count] - how many, largest first. Default 1.
|
|
367
|
+
* @returns {string} an expression that answers with `{pictures: [...]}`.
|
|
368
|
+
*/
|
|
369
|
+
export function pictures(ref, count) {
|
|
370
|
+
const scope = Number(ref)
|
|
371
|
+
const limit = Math.max(1, Math.min(4, Number(count) || 1))
|
|
372
|
+
return `(async () => {
|
|
373
|
+
const reg = window.${REGISTRY}
|
|
374
|
+
const where = ${Number.isFinite(scope) ? `(reg ? (reg.find(${scope}) || {}).el : null)` : 'null'}
|
|
375
|
+
const picked = []
|
|
376
|
+
const seen = new Set()
|
|
377
|
+
const add = (el, kind) => {
|
|
378
|
+
if (el === null || el === undefined || seen.has(el)) return
|
|
379
|
+
seen.add(el)
|
|
380
|
+
const b = el.getBoundingClientRect()
|
|
381
|
+
const w = kind === 'canvas' ? el.width : el.naturalWidth || b.width
|
|
382
|
+
const h = kind === 'canvas' ? el.height : el.naturalHeight || b.height
|
|
383
|
+
if (w < 48 || h < 48) return
|
|
384
|
+
if (b.width < 8 || b.height < 8) return
|
|
385
|
+
const s = getComputedStyle(el)
|
|
386
|
+
if (s.visibility === 'hidden' || s.display === 'none' || Number(s.opacity) < 0.05) return
|
|
387
|
+
picked.push({ el, kind, w, h, area: w * h })
|
|
388
|
+
}
|
|
389
|
+
const roots = where ? [where, ...where.querySelectorAll('*')] : [document, ...document.querySelectorAll('*')]
|
|
390
|
+
for (const node of roots) {
|
|
391
|
+
if (node === document || node === where) continue
|
|
392
|
+
const tag = node.tagName
|
|
393
|
+
if (tag === 'IMG') add(node, 'img')
|
|
394
|
+
else if (tag === 'CANVAS') add(node, 'canvas')
|
|
395
|
+
}
|
|
396
|
+
if (where && where.tagName === 'IMG') add(where, 'img')
|
|
397
|
+
if (where && where.tagName === 'CANVAS') add(where, 'canvas')
|
|
398
|
+
picked.sort((a, b) => b.area - a.area)
|
|
399
|
+
/* A picture nobody can look at still has to be describable: how much of it is
|
|
400
|
+
one colour tells a blank chart from a chart. Sampled at 32x32, which is
|
|
401
|
+
enough for that and costs nothing. */
|
|
402
|
+
const look = async (data) => {
|
|
403
|
+
try {
|
|
404
|
+
const img = new Image()
|
|
405
|
+
img.src = data
|
|
406
|
+
await img.decode()
|
|
407
|
+
const board = document.createElement('canvas')
|
|
408
|
+
board.width = 32
|
|
409
|
+
board.height = 32
|
|
410
|
+
const pen = board.getContext('2d', { willReadFrequently: true })
|
|
411
|
+
if (pen === null) return null
|
|
412
|
+
pen.drawImage(img, 0, 0, 32, 32)
|
|
413
|
+
const dots = pen.getImageData(0, 0, 32, 32).data
|
|
414
|
+
const tally = new Map()
|
|
415
|
+
for (let at = 0; at < dots.length; at += 4) {
|
|
416
|
+
const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
|
|
417
|
+
tally.set(key, (tally.get(key) || 0) + 1)
|
|
418
|
+
}
|
|
419
|
+
let most = 0
|
|
420
|
+
for (const count of tally.values()) most = Math.max(most, count)
|
|
421
|
+
return { colors: tally.size, flat: Math.round((most / 1024) * 100) / 100 }
|
|
422
|
+
} catch {
|
|
423
|
+
return null
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const out = []
|
|
427
|
+
for (const item of picked.slice(0, ${limit})) {
|
|
428
|
+
let data = ''
|
|
429
|
+
let type = 'image/png'
|
|
430
|
+
try {
|
|
431
|
+
if (item.kind === 'canvas') {
|
|
432
|
+
data = item.el.toDataURL('image/png')
|
|
433
|
+
} else {
|
|
434
|
+
const url = item.el.currentSrc || item.el.src || ''
|
|
435
|
+
const answer = await fetch(url, { credentials: 'include' })
|
|
436
|
+
const blob = await answer.blob()
|
|
437
|
+
type = blob.type || type
|
|
438
|
+
data = await new Promise((done, fail) => {
|
|
439
|
+
const reader = new FileReader()
|
|
440
|
+
reader.onload = () => done(String(reader.result))
|
|
441
|
+
reader.onerror = () => fail(new Error('unreadable'))
|
|
442
|
+
reader.readAsDataURL(blob)
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
data = ''
|
|
447
|
+
}
|
|
448
|
+
const feel = data ? await look(data) : null
|
|
449
|
+
out.push({ kind: item.kind, width: item.w, height: item.h, type, data, colors: feel?.colors ?? 0, flat: feel?.flat ?? 0 })
|
|
450
|
+
}
|
|
451
|
+
return JSON.stringify({ pictures: out })
|
|
452
|
+
})()`
|
|
453
|
+
}
|
|
454
|
+
|
|
281
455
|
/**
|
|
282
456
|
* What sits on top of a control at click time, when it is not the control.
|
|
283
457
|
* A cookie banner or a modal over the thing the agent meant to click is the
|
|
@@ -521,6 +695,7 @@ export async function readPage(page, options = {}) {
|
|
|
521
695
|
title: String(read?.title ?? page.title),
|
|
522
696
|
text: String(read?.text ?? ''),
|
|
523
697
|
textChars: Number(read?.textChars ?? 0),
|
|
698
|
+
picture: read?.picture ?? null,
|
|
524
699
|
scroll: read?.scroll,
|
|
525
700
|
focused: read?.focused ?? null,
|
|
526
701
|
controls: Array.isArray(read?.controls) ? read.controls : [],
|
|
@@ -591,6 +766,18 @@ export function renderPage(read, options = {}) {
|
|
|
591
766
|
lines.push(body)
|
|
592
767
|
if (read.textChars > body.length) lines.push(`… ${read.textChars - body.length} more characters`)
|
|
593
768
|
}
|
|
769
|
+
if (read.picture && Array.isArray(read.picture.missing)) {
|
|
770
|
+
/* Something was meant to be here and is not: say that instead of nothing. */
|
|
771
|
+
for (const gone of read.picture.missing.slice(0, 2)) {
|
|
772
|
+
const size = `${Math.round(Number(gone.width) || 0)}x${Math.round(Number(gone.height) || 0)}`
|
|
773
|
+
lines.push(`picture ${size}${gone.name === '' ? '' : ` "${gone.name}"`} did not load — the page may need a reload, or the picture is gone`)
|
|
774
|
+
}
|
|
775
|
+
} else if (read.picture) {
|
|
776
|
+
const pic = read.picture
|
|
777
|
+
const look = Number(pic.flat) >= 0.9 ? 'mostly one colour' : Number(pic.colors) <= 3 ? 'a few colours' : 'a picture with detail'
|
|
778
|
+
const size = `${Math.round(Number(pic.width) || 0)}x${Math.round(Number(pic.height) || 0)}`
|
|
779
|
+
lines.push(`picture ${size} ${look}${pic.name === '' ? '' : ` "${pic.name}"`} — sidecar_grab saves it as a file`)
|
|
780
|
+
}
|
|
594
781
|
if (read.scroll && read.scroll.height > read.scroll.viewport + 40) {
|
|
595
782
|
lines.push(`scroll ${read.scroll.y}/${read.scroll.height - read.scroll.viewport}`)
|
|
596
783
|
}
|
|
@@ -634,23 +821,8 @@ export function sameThing(want, control) {
|
|
|
634
821
|
return true
|
|
635
822
|
}
|
|
636
823
|
|
|
637
|
-
/**
|
|
638
|
-
|
|
639
|
-
*
|
|
640
|
-
* `{field: "Email"}` used to mean "click the control that says Email". On a real
|
|
641
|
-
* form the words live in a div above a bare input, so the click landed on the
|
|
642
|
-
* text and the typing went wherever focus already was — the classic silent wrong
|
|
643
|
-
* field. This finds the *editable* element the words point at: a real label, an
|
|
644
|
-
* aria name, a placeholder, a name attribute, the text right before it, or the
|
|
645
|
-
* text of the small wrapper it sits in.
|
|
646
|
-
* @param {string} words - what the field is called.
|
|
647
|
-
* @returns {string} an expression that evaluates to a spot, or {} when nothing fits.
|
|
648
|
-
*/
|
|
649
|
-
export function labelSpot(words) {
|
|
650
|
-
const wanted = JSON.stringify(String(words ?? '').toLowerCase())
|
|
651
|
-
return `(() => {
|
|
652
|
-
const want = ${wanted}
|
|
653
|
-
if (want === '') return JSON.stringify({})
|
|
824
|
+
/** The in-page half shared by `labelSpot` and `selectSpot`: shadow roots, what says what. */
|
|
825
|
+
const NAME_PRELUDE = `
|
|
654
826
|
const roots = [document]
|
|
655
827
|
for (const el of document.querySelectorAll('*')) {
|
|
656
828
|
if (roots.length > 400) break
|
|
@@ -685,34 +857,125 @@ export function labelSpot(words) {
|
|
|
685
857
|
add(el.getAttribute('aria-label'))
|
|
686
858
|
add(el.getAttribute('placeholder'))
|
|
687
859
|
add(el.getAttribute('title'))
|
|
688
|
-
add(el.getAttribute('name'))
|
|
689
860
|
if (el.id) for (const l of document.querySelectorAll('label[for]')) if (l.htmlFor === el.id) add(l.innerText ?? l.textContent)
|
|
690
861
|
const prev = el.previousElementSibling
|
|
691
862
|
if (prev && editable(prev) !== true) add(prev.innerText ?? prev.textContent)
|
|
692
863
|
const parent = el.parentElement
|
|
693
864
|
if (parent && parent.querySelectorAll('input,textarea,select,[contenteditable=true]').length <= 1) add(parent.innerText ?? parent.textContent)
|
|
865
|
+
/* Wrapped a couple of levels deep (a styled box hiding a real input), the
|
|
866
|
+
words sit before the wrapper, not before the input. */
|
|
867
|
+
let up = parent
|
|
868
|
+
for (let depth = 0; depth < 3 && up; depth += 1) {
|
|
869
|
+
const before = up.previousElementSibling
|
|
870
|
+
if (before && editable(before) !== true) add(before.innerText ?? before.textContent)
|
|
871
|
+
up = up.parentElement
|
|
872
|
+
}
|
|
873
|
+
/* The attribute is the last resort for a name on screen: it is often the
|
|
874
|
+
developer's word for the field, not the one the user reads. */
|
|
875
|
+
add(el.getAttribute('name'))
|
|
694
876
|
return out
|
|
695
877
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
878
|
+
const squeeze = (s) => String(s).toLowerCase().replace(/\s+/g, '')
|
|
879
|
+
const scored = (el, want) => {
|
|
880
|
+
let score = 0
|
|
881
|
+
let name = ''
|
|
882
|
+
const flat = squeeze(want)
|
|
883
|
+
for (const nm of names(el)) {
|
|
884
|
+
const low = nm.toLowerCase()
|
|
885
|
+
if (low === want || squeeze(low) === flat) score = Math.max(score, 3)
|
|
886
|
+
else if (want.length >= 2 && low.includes(want)) score = Math.max(score, 2)
|
|
887
|
+
else if (flat.length >= 2 && squeeze(low).includes(flat)) score = Math.max(score, 2)
|
|
888
|
+
else if (low.length >= 3 && want.includes(low)) score = Math.max(score, 1)
|
|
889
|
+
if (score > 0 && name === '') name = nm
|
|
890
|
+
}
|
|
891
|
+
return { score, name }
|
|
892
|
+
}
|
|
893
|
+
const boxOf = (el) => {
|
|
894
|
+
for (let node = el; node; node = node.parentElement) {
|
|
895
|
+
const b = node.getBoundingClientRect()
|
|
896
|
+
if (b.width < 8 || b.height < 8) continue
|
|
897
|
+
const s = getComputedStyle(node)
|
|
898
|
+
if (s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) > 0.05) return b
|
|
899
|
+
}
|
|
900
|
+
return null
|
|
901
|
+
}
|
|
902
|
+
const bestOf = (selector, want, hiddenOk) => {
|
|
903
|
+
let best = null
|
|
904
|
+
for (const root of roots) {
|
|
905
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
906
|
+
if (hiddenOk !== true && shown(el) !== true) continue
|
|
907
|
+
const hit = scored(el, want)
|
|
908
|
+
if (hit.score === 0) continue
|
|
909
|
+
if (best === null || hit.score > best.score) best = { el, score: hit.score, name: hit.name }
|
|
708
910
|
}
|
|
709
|
-
if (score === 0) continue
|
|
710
|
-
if (best === null || score > best.score) best = { el, score, name }
|
|
711
911
|
}
|
|
912
|
+
return best
|
|
913
|
+
}
|
|
914
|
+
`
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* The field a label belongs to, when nothing formally connects the two.
|
|
918
|
+
* ...
|
|
919
|
+
*/
|
|
920
|
+
/**
|
|
921
|
+
* The field a label belongs to, when nothing formally connects the two.
|
|
922
|
+
*
|
|
923
|
+
* `{field: "Email"}` used to mean "click the control that says Email". On a real
|
|
924
|
+
* form the words live in a div above a bare input, so the click landed on the
|
|
925
|
+
* text and the typing went wherever focus already was — the classic silent wrong
|
|
926
|
+
* field. This finds the *editable* element the words point at: a real label, an
|
|
927
|
+
* aria name, a placeholder, a name attribute, the text right before it, or the
|
|
928
|
+
* text of the small wrapper it sits in.
|
|
929
|
+
* @param {string} words - what the field is called.
|
|
930
|
+
* @returns {string} an expression that evaluates to a spot, or {} when nothing fits.
|
|
931
|
+
*/
|
|
932
|
+
export function labelSpot(words) {
|
|
933
|
+
const wanted = JSON.stringify(String(words ?? '').toLowerCase())
|
|
934
|
+
return `(() => {
|
|
935
|
+
const want = ${wanted}
|
|
936
|
+
if (want === '') return JSON.stringify({})
|
|
937
|
+
${NAME_PRELUDE}
|
|
938
|
+
const selector = 'input,textarea,select,[contenteditable=""],[contenteditable=true],[role=textbox],[role=searchbox],[role=combobox]'
|
|
939
|
+
let best = bestOf(selector, want)
|
|
940
|
+
let box = best === null ? null : boxOf(best.el)
|
|
941
|
+
let behind = false
|
|
942
|
+
if (best === null || box === null) {
|
|
943
|
+
/* The field a date picker shows is often a transparent box over a styled
|
|
944
|
+
div: the input stays out of sight, but the words still name it. */
|
|
945
|
+
const under = bestOf(selector, want, true)
|
|
946
|
+
const place = under === null ? null : boxOf(under.el)
|
|
947
|
+
if (under !== null && place !== null) { best = under; box = place; behind = true }
|
|
712
948
|
}
|
|
713
|
-
if (best === null) return JSON.stringify({})
|
|
949
|
+
if (best === null || box === null) return JSON.stringify({})
|
|
950
|
+
if (editable(best.el) !== true) return JSON.stringify({})
|
|
714
951
|
best.el.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
715
|
-
const
|
|
716
|
-
return JSON.stringify({ x: Math.round(
|
|
952
|
+
const fresh = boxOf(best.el) ?? box
|
|
953
|
+
return JSON.stringify({ x: Math.round(fresh.left + fresh.width / 2), y: Math.round(fresh.top + fresh.height / 2), name: best.name, score: best.score, behind })
|
|
954
|
+
})()`
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* The dropdown the words belong to, with the wanted option already chosen.
|
|
959
|
+
* @param {string} words - what the dropdown is called on screen.
|
|
960
|
+
* @param {string} value - option label or value.
|
|
961
|
+
* @returns {string} an expression that picks the option and says what it did.
|
|
962
|
+
*/
|
|
963
|
+
export function selectSpot(words, value) {
|
|
964
|
+
const wanted = JSON.stringify(String(words ?? '').toLowerCase())
|
|
965
|
+
const wanted2 = JSON.stringify(String(value ?? ''))
|
|
966
|
+
return `(() => {
|
|
967
|
+
const want = ${wanted}
|
|
968
|
+
const wanted = ${wanted2}
|
|
969
|
+
if (want === '') return 'no dropdown was named'
|
|
970
|
+
${NAME_PRELUDE}
|
|
971
|
+
const best = bestOf('select', want)
|
|
972
|
+
if (best === null) return 'no dropdown says ' + JSON.stringify(want)
|
|
973
|
+
const select = best.el
|
|
974
|
+
const option = Array.from(select.options).find((item) => item.value === wanted || item.text.trim() === wanted || item.text.trim().toLowerCase() === wanted.toLowerCase())
|
|
975
|
+
if (!option) return 'no option ' + wanted + ' in ' + best.name + '; options are ' + Array.from(select.options).slice(0, 12).map((item) => item.text.trim()).join(' | ')
|
|
976
|
+
select.value = option.value
|
|
977
|
+
select.dispatchEvent(new Event('input', { bubbles: true }))
|
|
978
|
+
select.dispatchEvent(new Event('change', { bubbles: true }))
|
|
979
|
+
return 'chose ' + option.text.trim() + ' in ' + best.name
|
|
717
980
|
})()`
|
|
718
981
|
}
|
package/tools.js
CHANGED
|
@@ -13,7 +13,7 @@ import { homedir } from 'node:os'
|
|
|
13
13
|
import { join } from 'node:path'
|
|
14
14
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
15
15
|
import { VIEW } from './sidecar.js?v=1'
|
|
16
|
-
import { readPage, renderPage, locate, covering, contextFor, identityOf, sameThing, labelSpot, REF_GONE } from './read.js?v=1'
|
|
16
|
+
import { readPage, renderPage, locate, covering, contextFor, identityOf, sameThing, labelSpot, selectSpot, bounds, pictures, REF_GONE } from './read.js?v=1'
|
|
17
17
|
|
|
18
18
|
/** Where a screenshot from `sidecar_shot` lands, under the user's home. */
|
|
19
19
|
export const SHOT_DIR = join('.dsh-inner-browser-use', 'screenshots')
|
|
@@ -419,9 +419,15 @@ async function until(page, expression, timeout) {
|
|
|
419
419
|
async function findWords(page, words) {
|
|
420
420
|
const read = await readPage(page, { mode: 'text', maxChars: 1200, max: 200 })
|
|
421
421
|
const shown = read.controls.filter((control) => control.view !== false)
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
422
|
+
/* The words may sit in separate inline nodes: <span>Sign</span><span>in</span>
|
|
423
|
+
reads as "Signin" to the page, so compare with the spaces taken out too. */
|
|
424
|
+
const squeeze = (text) => String(text).toLowerCase().replace(/\s+/g, '')
|
|
425
|
+
const wanted = squeeze(words)
|
|
426
|
+
const found = shown.find((control) => squeeze(control.name) === wanted) ?? shown.find((control) => squeeze(control.name).includes(wanted))
|
|
427
|
+
if (found === undefined) return null
|
|
428
|
+
/* When only the squeezed form matched, say the words the caller used: the
|
|
429
|
+
page spread them over nodes and "Signin" is not what anyone wrote. */
|
|
430
|
+
const hit = squeeze(found.name) === wanted && found.name.toLowerCase() !== words.toLowerCase() ? { ...found, name: words } : found
|
|
425
431
|
const spot = await locate(page, hit.ref)
|
|
426
432
|
if (spot.gone === true) return null
|
|
427
433
|
return { ...hit, x: Number(spot.x), y: Number(spot.y) }
|
|
@@ -438,12 +444,50 @@ const EDITABLE_KINDS = ['input', 'textarea', 'select', 'combobox', 'textbox', 's
|
|
|
438
444
|
*/
|
|
439
445
|
async function fieldSpot(page, words) {
|
|
440
446
|
const said = await evaluate(page, labelSpot(words))
|
|
441
|
-
if (said
|
|
442
|
-
const spot = said.
|
|
443
|
-
if (spot ===
|
|
447
|
+
if (said === undefined || said === null || said.error !== undefined) return null
|
|
448
|
+
const spot = typeof said === 'string' ? JSON.parse(said) : said
|
|
449
|
+
if (spot === null || spot.name === undefined || spot.name === '') return null
|
|
444
450
|
return { x: Number(spot.x), y: Number(spot.y), name: String(spot.name) }
|
|
445
451
|
}
|
|
446
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Where the field called these words is, clearing a banner over it first.
|
|
455
|
+
* @param {any} page - sidecar page.
|
|
456
|
+
* @param {string} words - what the field is called on screen.
|
|
457
|
+
* @returns {Promise<{x: number, y: number, name: string, lead: string}|null>} the click.
|
|
458
|
+
*/
|
|
459
|
+
async function fieldByName(page, words) {
|
|
460
|
+
const hit = await findWords(page, words)
|
|
461
|
+
const lead = hit === null ? '' : await coverLead(page, hit.ref)
|
|
462
|
+
if (hit !== null && EDITABLE_KINDS.includes(String(hit.kind))) {
|
|
463
|
+
const spot = await locate(page, hit.ref)
|
|
464
|
+
if (spot.gone !== true) return { x: Number(spot.x), y: Number(spot.y), name: String(hit.name), lead }
|
|
465
|
+
}
|
|
466
|
+
const field = await fieldSpot(page, words)
|
|
467
|
+
if (field !== null) return { ...field, lead }
|
|
468
|
+
if (hit === null) return null
|
|
469
|
+
const spot = await locate(page, hit.ref)
|
|
470
|
+
if (spot.gone === true) return null
|
|
471
|
+
return { x: Number(spot.x), y: Number(spot.y), name: String(hit.name), lead }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Click a spot, type into it, and optionally clear it first.
|
|
476
|
+
* @param {any} page - sidecar page.
|
|
477
|
+
* @param {{x: number, y: number}} point - where to click.
|
|
478
|
+
* @param {string} text - what to type.
|
|
479
|
+
* @param {{clear?: boolean, submit?: boolean}} options - how.
|
|
480
|
+
*/
|
|
481
|
+
async function writeAt(page, point, text, options = {}) {
|
|
482
|
+
await page.mouse('mouseMoved', point)
|
|
483
|
+
await page.mouse('mousePressed', { ...point, clickCount: 1 })
|
|
484
|
+
await page.mouse('mouseReleased', { ...point, clickCount: 1 })
|
|
485
|
+
await wait(80)
|
|
486
|
+
if (options.clear === true) await clearFocus(page)
|
|
487
|
+
await page.insertText(text)
|
|
488
|
+
if (options.submit === true) await pressKey(page, 'Enter')
|
|
489
|
+
}
|
|
490
|
+
|
|
447
491
|
/**
|
|
448
492
|
* Where the control behind a ref is now, following it across a re-render.
|
|
449
493
|
* The module-level sibling of findAgain, for runStep, which has no closure.
|
|
@@ -502,40 +546,10 @@ async function runStep(page, step, known) {
|
|
|
502
546
|
if (step.field !== undefined) {
|
|
503
547
|
/* The words may sit on the field or in a div above a bare input. Only a
|
|
504
548
|
click that lands on the field itself can be trusted with the typing. */
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (hit !== null && EDITABLE_KINDS.includes(String(hit.kind))) {
|
|
510
|
-
lead = await coverLead(page, hit.ref)
|
|
511
|
-
const spot = await locate(page, hit.ref)
|
|
512
|
-
if (spot.gone !== true) {
|
|
513
|
-
point = { x: Number(spot.x), y: Number(spot.y) }
|
|
514
|
-
called = String(hit.name)
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
if (point === null) {
|
|
518
|
-
const field = await fieldSpot(page, String(step.field))
|
|
519
|
-
if (field !== null) {
|
|
520
|
-
point = { x: field.x, y: field.y }
|
|
521
|
-
called = field.name
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
if (point === null) {
|
|
525
|
-
if (hit === null) return `no control says ${JSON.stringify(String(step.field))}`
|
|
526
|
-
const spot = await locate(page, hit.ref)
|
|
527
|
-
if (spot.gone === true) return `ref ${String(hit.ref)}: ${REF_GONE}`
|
|
528
|
-
point = { x: Number(spot.x), y: Number(spot.y) }
|
|
529
|
-
called = String(hit.name)
|
|
530
|
-
}
|
|
531
|
-
await page.mouse('mouseMoved', point)
|
|
532
|
-
await page.mouse('mousePressed', { ...point, clickCount: 1 })
|
|
533
|
-
await page.mouse('mouseReleased', { ...point, clickCount: 1 })
|
|
534
|
-
await wait(80)
|
|
535
|
-
if (step.clear === true) await clearFocus(page)
|
|
536
|
-
await page.insertText(String(step.text ?? ''))
|
|
537
|
-
if (step.submit === true) await pressKey(page, 'Enter')
|
|
538
|
-
return `${lead}typed into "${called.slice(0, 60)}"`
|
|
549
|
+
const target = await fieldByName(page, String(step.field))
|
|
550
|
+
if (target === null) return `${String(step.field)}: ${REF_GONE}`
|
|
551
|
+
await writeAt(page, { x: target.x, y: target.y }, String(step.text ?? ''), { clear: step.clear, submit: step.submit })
|
|
552
|
+
return `${target.lead}typed into "${target.name.slice(0, 60)}"`
|
|
539
553
|
}
|
|
540
554
|
if (step.tap !== undefined) {
|
|
541
555
|
const [x, y] = String(step.tap).split(',').map(Number)
|
|
@@ -893,6 +907,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
893
907
|
properties: {
|
|
894
908
|
text: { type: 'string', description: 'Text to type.' },
|
|
895
909
|
ref: { type: 'integer', description: 'Control to click before typing.' },
|
|
910
|
+
field: { type: 'string', description: 'The field is called this on screen, when you have no number for it.' },
|
|
896
911
|
submit: { type: 'boolean', description: 'Press Enter after typing.' },
|
|
897
912
|
clear: { type: 'boolean', description: 'Select all and delete before typing.' },
|
|
898
913
|
read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
|
|
@@ -907,6 +922,15 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
907
922
|
execute: async (args) => withPage(async (page) => {
|
|
908
923
|
let cover = ''
|
|
909
924
|
let followed = ''
|
|
925
|
+
if (args?.field !== undefined && args?.ref === undefined) {
|
|
926
|
+
const target = await fieldByName(page, String(args.field))
|
|
927
|
+
if (target === null) return `${String(args.field)}: ${REF_GONE}`
|
|
928
|
+
cover = target.lead
|
|
929
|
+
await writeAt(page, { x: target.x, y: target.y }, String(args?.text ?? ''), { clear: args?.clear, submit: args?.submit === true })
|
|
930
|
+
await settle(page)
|
|
931
|
+
const called = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), 'type')
|
|
932
|
+
return called + cover + ` (typed into "${target.name.slice(0, 50)}")` // ui-copy-ignore
|
|
933
|
+
}
|
|
910
934
|
if (args?.ref !== undefined) {
|
|
911
935
|
const gate = await clearCover(page, Number(args.ref))
|
|
912
936
|
cover = gate.note
|
|
@@ -1163,9 +1187,10 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1163
1187
|
type: 'object', // ui-copy-ignore
|
|
1164
1188
|
properties: {
|
|
1165
1189
|
ref: { type: 'integer', description: 'Number of the select control from sidecar_view.' },
|
|
1190
|
+
field: { type: 'string', description: 'The dropdown is called this on screen, when you have no number for it.' },
|
|
1166
1191
|
value: { type: 'string', description: 'Option label or value to choose.' },
|
|
1167
1192
|
},
|
|
1168
|
-
required: ['
|
|
1193
|
+
required: ['value'], // ui-copy-ignore
|
|
1169
1194
|
additionalProperties: false,
|
|
1170
1195
|
},
|
|
1171
1196
|
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
@@ -1173,6 +1198,15 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1173
1198
|
isConcurrencySafe: () => false,
|
|
1174
1199
|
/** @param {any} args - arguments. @returns {Promise<{text: string}>} the page after. */
|
|
1175
1200
|
execute: async (args) => withPage(async (page) => {
|
|
1201
|
+
if (args?.ref === undefined) {
|
|
1202
|
+
if (args?.field === undefined) return 'name the dropdown: pass its number from sidecar_view, or the words on screen that name it.' // ui-copy-ignore
|
|
1203
|
+
const chosen = await evaluate(page, selectSpot(String(args.field), String(args?.value ?? '')))
|
|
1204
|
+
const said = typeof chosen === 'string' ? chosen : chosen?.error === undefined ? '' : String(chosen.error)
|
|
1205
|
+
if (said === '' || said.startsWith('no dropdown')) return said === '' ? 'the page did not answer; read it and try again.' : said // ui-copy-ignore
|
|
1206
|
+
await settle(page, 3000)
|
|
1207
|
+
if (args?.read === true) return `${said}\n${await look(page, { mode: 'text', maxChars: 2000 })}`
|
|
1208
|
+
return `${said} · ${await brief(page)}`
|
|
1209
|
+
}
|
|
1176
1210
|
const spot = await findAgain(page, Number(args?.ref))
|
|
1177
1211
|
if (spot.gone === true) return REF_GONE
|
|
1178
1212
|
const wanted = JSON.stringify(String(args?.value ?? ''))
|
|
@@ -1253,7 +1287,18 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1253
1287
|
if (args?.ref !== undefined) {
|
|
1254
1288
|
const spot = await findAgain(page, Number(args.ref))
|
|
1255
1289
|
if (spot.gone === true) return REF_GONE
|
|
1256
|
-
|
|
1290
|
+
/* The picture is of the control, not of a guess around its middle:
|
|
1291
|
+
a canvas can be most of the page, a checkbox a few pixels. */
|
|
1292
|
+
const box = await evaluate(page, bounds(Number(args.ref)))
|
|
1293
|
+
const rect = typeof box === 'string' ? JSON.parse(box) : box
|
|
1294
|
+
if (rect?.gone === true) return REF_GONE
|
|
1295
|
+
const pad = 8
|
|
1296
|
+
clip = {
|
|
1297
|
+
x: Math.max(0, Math.round(Number(rect.x) - pad)),
|
|
1298
|
+
y: Math.max(0, Math.round(Number(rect.y) - pad)),
|
|
1299
|
+
width: Math.max(8, Math.round(Number(rect.width) + pad * 2)),
|
|
1300
|
+
height: Math.max(8, Math.round(Number(rect.height) + pad * 2)),
|
|
1301
|
+
}
|
|
1257
1302
|
}
|
|
1258
1303
|
const data = await page.shot({ full: args?.full === true, rect: clip, scale })
|
|
1259
1304
|
const dir = join(homedir(), SHOT_DIR)
|
|
@@ -1264,6 +1309,58 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1264
1309
|
return file
|
|
1265
1310
|
}),
|
|
1266
1311
|
},
|
|
1312
|
+
{
|
|
1313
|
+
name: 'sidecar_grab', // ui-copy-ignore
|
|
1314
|
+
description: 'Save the picture on the page as a file: an image, a chart, a drawing surface. Picks the largest one, or the one inside a given control.',
|
|
1315
|
+
parameters: {
|
|
1316
|
+
type: 'object', // ui-copy-ignore
|
|
1317
|
+
properties: {
|
|
1318
|
+
ref: { type: 'integer', description: 'Only the picture at or inside this control.' },
|
|
1319
|
+
name: { type: 'string', description: 'File name without extension.' },
|
|
1320
|
+
count: { type: 'integer', description: 'How many, largest first. Default 1, at most 4.' },
|
|
1321
|
+
},
|
|
1322
|
+
additionalProperties: false,
|
|
1323
|
+
},
|
|
1324
|
+
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
1325
|
+
timeoutMs: 40_000,
|
|
1326
|
+
isConcurrencySafe: () => false,
|
|
1327
|
+
/** @param {any} args - arguments. @returns {Promise<string>} the saved paths, one per line. */
|
|
1328
|
+
execute: async (args) => withRead(async (page) => {
|
|
1329
|
+
const said = await evaluate(page, pictures(args?.ref, args?.count))
|
|
1330
|
+
let found
|
|
1331
|
+
try {
|
|
1332
|
+
found = JSON.parse(typeof said === 'string' ? said : JSON.stringify(said))
|
|
1333
|
+
} catch {
|
|
1334
|
+
found = { pictures: [] }
|
|
1335
|
+
}
|
|
1336
|
+
const rows = []
|
|
1337
|
+
const dir = join(homedir(), SHOT_DIR)
|
|
1338
|
+
await mkdir(dir, { recursive: true })
|
|
1339
|
+
const base = String(args?.name ?? '').replace(/[^\w.-]/g, '') || `sidecar-picture-${Date.now()}`
|
|
1340
|
+
const list = Array.isArray(found?.pictures) ? found.pictures : []
|
|
1341
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
1342
|
+
const item = list[index]
|
|
1343
|
+
const body = String(item?.data ?? '')
|
|
1344
|
+
const mark = body.indexOf(',')
|
|
1345
|
+
if (mark < 0) continue
|
|
1346
|
+
const type = String(item?.type ?? 'image/png')
|
|
1347
|
+
const ext = type.includes('jpeg') ? 'jpg' : type.includes('webp') ? 'webp' : type.includes('svg') ? 'svg' : 'png'
|
|
1348
|
+
const file = join(dir, `${base}${list.length > 1 ? `-${index + 1}` : ''}.${ext}`)
|
|
1349
|
+
await writeFile(file, Buffer.from(body.slice(mark + 1), 'base64'))
|
|
1350
|
+
const size = `${Math.round(Number(item.width) || 0)}x${Math.round(Number(item.height) || 0)}`
|
|
1351
|
+
/* Say what the picture looks like in words: a model that cannot open
|
|
1352
|
+
a file still needs to know a chart came back blank. */
|
|
1353
|
+
const flat = Number(item.flat) || 0
|
|
1354
|
+
const colors = Number(item.colors) || 0
|
|
1355
|
+
const look = flat >= 0.9 ? 'mostly one colour' : colors <= 3 ? 'a few colours' : 'a picture with detail'
|
|
1356
|
+
rows.push(`${file} ${size} ${look}`)
|
|
1357
|
+
}
|
|
1358
|
+
if (rows.length === 0) {
|
|
1359
|
+
return list.length === 0 ? 'There is no picture on the page.' : 'That picture could not be saved.'
|
|
1360
|
+
}
|
|
1361
|
+
return rows.join('\n')
|
|
1362
|
+
}),
|
|
1363
|
+
},
|
|
1267
1364
|
{
|
|
1268
1365
|
name: 'sidecar_state', // ui-copy-ignore
|
|
1269
1366
|
description: 'Report the sidecar browser: whether it runs, its profile, address, title, how many people are watching its mirror, and how many times a call had to find a control again after the page redrew it.',
|