@zumer/snapdom-plugins 1.0.3 → 1.1.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/package.json +1 -1
- package/prompt-export.js +293 -47
package/package.json
CHANGED
package/prompt-export.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* promptExport – Official SnapDOM Plugin
|
|
3
|
-
* Produces an LLM-ready package: annotated screenshot + structured element
|
|
3
|
+
* Produces an LLM-ready package: annotated screenshot + structured element
|
|
4
|
+
* map + prompt text. Tuned for vision-capable LLMs reading the image + map
|
|
5
|
+
* together (Set-of-Mark pattern).
|
|
4
6
|
*
|
|
5
7
|
* Usage:
|
|
6
8
|
* import { promptExport } from '@zumer/snapdom-plugins/prompt-export';
|
|
@@ -9,12 +11,14 @@
|
|
|
9
11
|
*
|
|
10
12
|
* @param {Object} [options]
|
|
11
13
|
* @param {boolean} [options.annotate=true] - Overlay numbered badges on interactive elements
|
|
12
|
-
* @param {string} [options.imageFormat='png']
|
|
13
|
-
* @param {number} [options.imageQuality=0.8]
|
|
14
|
-
* @param {number} [options.maxImageWidth=1024]
|
|
15
|
-
* @param {string} [options.interactiveSelector]
|
|
16
|
-
* @param {string} [options.semanticSelector]
|
|
17
|
-
* @param {Object} [options.labelStyle={}]
|
|
14
|
+
* @param {string} [options.imageFormat='png'] - Output image format ('png'|'jpg'|'webp')
|
|
15
|
+
* @param {number} [options.imageQuality=0.8] - Quality for lossy formats (0..1)
|
|
16
|
+
* @param {number} [options.maxImageWidth=1024] - Max width in px (downscales if larger)
|
|
17
|
+
* @param {string} [options.interactiveSelector] - Custom CSS selector for interactive elements
|
|
18
|
+
* @param {string} [options.semanticSelector] - Custom CSS selector for semantic elements
|
|
19
|
+
* @param {Object} [options.labelStyle={}] - Override styles for annotation badges
|
|
20
|
+
* @param {'compact'|'verbose'} [options.promptMode='compact'] - Prompt text verbosity
|
|
21
|
+
* @param {boolean} [options.includeCoords=true] - Include bbox in the prompt text
|
|
18
22
|
* @returns {Object} SnapDOM plugin
|
|
19
23
|
*/
|
|
20
24
|
|
|
@@ -27,6 +31,33 @@ const DEFAULT_SEMANTIC =
|
|
|
27
31
|
'h1, h2, h3, h4, h5, h6, p, li, img[alt], nav, main, article, section, ' +
|
|
28
32
|
'header, footer, label, td, th, figcaption, blockquote, legend';
|
|
29
33
|
|
|
34
|
+
const COLLECTED_ATTRS = [
|
|
35
|
+
'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled',
|
|
36
|
+
'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const VISUAL_FIELDS = [
|
|
40
|
+
'display', 'visibility', 'opacity',
|
|
41
|
+
'color', 'backgroundColor',
|
|
42
|
+
'fontSize', 'fontWeight',
|
|
43
|
+
'cursor', 'overflow',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Common computed-style values that carry no information. Keeping the
|
|
47
|
+
// `styles` object small is the difference between a useful LLM input and
|
|
48
|
+
// token bloat on every element.
|
|
49
|
+
const VISUAL_SKIP = new Set(['initial', 'normal', 'visible', 'auto', 'static', '0']);
|
|
50
|
+
function isDefaultStyleValue(prop, value) {
|
|
51
|
+
if (!value) return true;
|
|
52
|
+
if (VISUAL_SKIP.has(value)) return true;
|
|
53
|
+
if (prop === 'cursor' && value === 'none') return true;
|
|
54
|
+
if (prop === 'color' && value === 'rgb(0, 0, 0)') return true;
|
|
55
|
+
if (prop === 'backgroundColor' && value === 'rgba(0, 0, 0, 0)') return true;
|
|
56
|
+
if (prop === 'fontWeight' && (value === '400' || value === 'normal')) return true;
|
|
57
|
+
if (prop === 'opacity' && value === '1') return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
30
61
|
export function promptExport(options = {}) {
|
|
31
62
|
const {
|
|
32
63
|
annotate = true,
|
|
@@ -36,6 +67,8 @@ export function promptExport(options = {}) {
|
|
|
36
67
|
interactiveSelector = DEFAULT_INTERACTIVE,
|
|
37
68
|
semanticSelector = DEFAULT_SEMANTIC,
|
|
38
69
|
labelStyle = {},
|
|
70
|
+
promptMode = 'compact',
|
|
71
|
+
includeCoords = true,
|
|
39
72
|
} = options;
|
|
40
73
|
|
|
41
74
|
return {
|
|
@@ -43,10 +76,8 @@ export function promptExport(options = {}) {
|
|
|
43
76
|
|
|
44
77
|
afterClone(ctx) {
|
|
45
78
|
const meta = extractMetadata(ctx.element, interactiveSelector, semanticSelector);
|
|
46
|
-
//
|
|
47
|
-
// prompt()
|
|
48
|
-
// on both so (a) standalone tests that pass a minimal ctx still see it
|
|
49
|
-
// and (b) the prompt() call can read it through the shared options ref.
|
|
79
|
+
// snapdom spreads a fresh ctx for the export phase from ctx.options,
|
|
80
|
+
// so write to both so the prompt() call below can read it.
|
|
50
81
|
ctx.__promptMetadata = meta;
|
|
51
82
|
if (ctx.options) ctx.options.__promptMetadata = meta;
|
|
52
83
|
|
|
@@ -71,6 +102,8 @@ export function promptExport(options = {}) {
|
|
|
71
102
|
const format = opts.imageFormat || imageFormat;
|
|
72
103
|
const quality = opts.imageQuality || imageQuality;
|
|
73
104
|
const maxWidth = opts.maxImageWidth || maxImageWidth;
|
|
105
|
+
const mode = opts.promptMode || promptMode;
|
|
106
|
+
const withCoords = opts.includeCoords !== undefined ? opts.includeCoords : includeCoords;
|
|
74
107
|
|
|
75
108
|
const img = new Image();
|
|
76
109
|
img.src = ctx.export.url;
|
|
@@ -79,23 +112,19 @@ export function promptExport(options = {}) {
|
|
|
79
112
|
img.onerror = rej;
|
|
80
113
|
});
|
|
81
114
|
|
|
82
|
-
const ratio =
|
|
83
|
-
img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
|
|
115
|
+
const ratio = img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
|
|
84
116
|
const w = Math.round(img.naturalWidth * ratio);
|
|
85
117
|
const h = Math.round(img.naturalHeight * ratio);
|
|
86
118
|
|
|
87
119
|
const canvas = document.createElement('canvas');
|
|
88
120
|
canvas.width = w;
|
|
89
121
|
canvas.height = h;
|
|
90
|
-
|
|
91
|
-
c2d.drawImage(img, 0, 0, w, h);
|
|
122
|
+
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
92
123
|
|
|
93
124
|
const mime =
|
|
94
|
-
format === 'jpg' || format === 'jpeg'
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
? 'image/webp'
|
|
98
|
-
: 'image/png';
|
|
125
|
+
format === 'jpg' || format === 'jpeg' ? 'image/jpeg'
|
|
126
|
+
: format === 'webp' ? 'image/webp'
|
|
127
|
+
: 'image/png';
|
|
99
128
|
const dataURL = canvas.toDataURL(mime, quality);
|
|
100
129
|
|
|
101
130
|
const sx = w / (meta.dimensions.width || 1);
|
|
@@ -111,11 +140,18 @@ export function promptExport(options = {}) {
|
|
|
111
140
|
},
|
|
112
141
|
}));
|
|
113
142
|
|
|
143
|
+
// In compact + annotate, badges already encode positions on the
|
|
144
|
+
// image itself, so repeating coords in the prompt text is noise.
|
|
145
|
+
const emitCoords = mode === 'verbose' ? true : (withCoords && !annotate);
|
|
146
|
+
const prompt = mode === 'verbose'
|
|
147
|
+
? formatPromptVerbose(elements, { width: w, height: h }, emitCoords)
|
|
148
|
+
: formatPromptCompact(elements, { width: w, height: h }, emitCoords);
|
|
149
|
+
|
|
114
150
|
return {
|
|
115
151
|
image: dataURL,
|
|
116
152
|
elements,
|
|
117
153
|
dimensions: { width: w, height: h },
|
|
118
|
-
prompt
|
|
154
|
+
prompt,
|
|
119
155
|
};
|
|
120
156
|
},
|
|
121
157
|
};
|
|
@@ -123,6 +159,134 @@ export function promptExport(options = {}) {
|
|
|
123
159
|
};
|
|
124
160
|
}
|
|
125
161
|
|
|
162
|
+
/* ── Accessible name ──────────────────────────── */
|
|
163
|
+
|
|
164
|
+
function truncate(str, max) {
|
|
165
|
+
if (!str) return '';
|
|
166
|
+
if (str.length <= max) return str;
|
|
167
|
+
return str.slice(0, max - 1) + '…';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Compute the element's accessible name following a simplified WAI-ARIA order.
|
|
172
|
+
* This is what an LLM agent reads first to know what the element IS.
|
|
173
|
+
*/
|
|
174
|
+
function accessibleName(el) {
|
|
175
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
176
|
+
if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim();
|
|
177
|
+
|
|
178
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
179
|
+
if (labelledBy) {
|
|
180
|
+
const root = el.getRootNode();
|
|
181
|
+
const getById = (id) =>
|
|
182
|
+
root && typeof root.getElementById === 'function'
|
|
183
|
+
? root.getElementById(id)
|
|
184
|
+
: document.getElementById(id);
|
|
185
|
+
const parts = labelledBy.trim().split(/\s+/)
|
|
186
|
+
.map((id) => {
|
|
187
|
+
const ref = getById(id);
|
|
188
|
+
return ref ? (ref.textContent || '').trim() : '';
|
|
189
|
+
})
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
if (parts.length) return parts.join(' ');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (el.tagName === 'IMG') {
|
|
195
|
+
const alt = el.getAttribute('alt');
|
|
196
|
+
if (alt && alt.trim()) return alt.trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const title = el.getAttribute('title');
|
|
200
|
+
if (title && title.trim()) return title.trim();
|
|
201
|
+
|
|
202
|
+
if (el.labels && el.labels[0]) {
|
|
203
|
+
const t = (el.labels[0].textContent || '').trim();
|
|
204
|
+
if (t) return t;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const text = (el.textContent || '').trim();
|
|
208
|
+
return text ? truncate(text, 40) : '';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ── DOM state ────────────────────────────────── */
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Capture real runtime state (not just ARIA attributes). This is what
|
|
215
|
+
* separates a useful map from a static screenshot annotation: an agent can
|
|
216
|
+
* see "checkbox is unchecked / select has value=X / details is closed".
|
|
217
|
+
*/
|
|
218
|
+
function computeState(el) {
|
|
219
|
+
const state = {};
|
|
220
|
+
try {
|
|
221
|
+
if (el.matches(':checked')) state.checked = true;
|
|
222
|
+
if (el.matches(':disabled')) state.disabled = true;
|
|
223
|
+
if (el.matches(':focus')) state.focus = true;
|
|
224
|
+
} catch { /* some selectors may fail on exotic nodes */ }
|
|
225
|
+
|
|
226
|
+
const tag = el.tagName;
|
|
227
|
+
if (tag === 'INPUT') {
|
|
228
|
+
// Checkbox/radio: `value` defaults to "on" when no explicit value attr
|
|
229
|
+
// is set — noise. The meaningful signal is `checked`. Skip value here.
|
|
230
|
+
const type = (el.type || 'text').toLowerCase();
|
|
231
|
+
if (type !== 'checkbox' && type !== 'radio' && el.value) {
|
|
232
|
+
state.value = el.value;
|
|
233
|
+
}
|
|
234
|
+
} else if (tag === 'TEXTAREA') {
|
|
235
|
+
if (el.value) state.value = el.value;
|
|
236
|
+
} else if (tag === 'SELECT') {
|
|
237
|
+
state.value = el.value;
|
|
238
|
+
const opt = el.options && el.options[el.selectedIndex];
|
|
239
|
+
if (opt) state.selectedText = opt.text || '';
|
|
240
|
+
} else if (tag === 'DETAILS') {
|
|
241
|
+
state.open = !!el.open;
|
|
242
|
+
} else if (el.hasAttribute && el.hasAttribute('open')) {
|
|
243
|
+
state.open = true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return Object.keys(state).length ? state : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ── Visual styles ────────────────────────────── */
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Pull a small set of visually-meaningful computed styles. Reads from the
|
|
253
|
+
* live element (the clone uses class-based styling so its inline style is
|
|
254
|
+
* empty and a detached `getComputedStyle(cloneNode)` returns initial values).
|
|
255
|
+
* The computed style of the original is what snapdom used to build the
|
|
256
|
+
* capture, so the result matches what the screenshot shows.
|
|
257
|
+
*/
|
|
258
|
+
function computeVisualStyles(el) {
|
|
259
|
+
let cs;
|
|
260
|
+
try { cs = getComputedStyle(el); } catch { return null; }
|
|
261
|
+
if (!cs) return null;
|
|
262
|
+
const out = {};
|
|
263
|
+
for (const prop of VISUAL_FIELDS) {
|
|
264
|
+
const v = cs[prop];
|
|
265
|
+
if (isDefaultStyleValue(prop, v)) continue;
|
|
266
|
+
out[prop] = v;
|
|
267
|
+
}
|
|
268
|
+
return Object.keys(out).length ? out : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ── Covered detection ────────────────────────── */
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* True when another element is painted on top of the center of this one.
|
|
275
|
+
* An agent that knows a button is covered by a modal won't try to click it.
|
|
276
|
+
*/
|
|
277
|
+
function isCovered(el, rect) {
|
|
278
|
+
if (!rect.width || !rect.height) return false;
|
|
279
|
+
const cx = rect.left + rect.width / 2;
|
|
280
|
+
const cy = rect.top + rect.height / 2;
|
|
281
|
+
if (cx < 0 || cy < 0) return false;
|
|
282
|
+
const doc = el.ownerDocument || document;
|
|
283
|
+
if (!doc.elementFromPoint) return false;
|
|
284
|
+
const top = doc.elementFromPoint(cx, cy);
|
|
285
|
+
if (!top || top === el) return false;
|
|
286
|
+
if (el.contains && el.contains(top)) return false;
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
126
290
|
/* ── Metadata extraction ──────────────────────── */
|
|
127
291
|
|
|
128
292
|
function extractMetadata(element, interactiveSelector, semanticSelector) {
|
|
@@ -156,11 +320,6 @@ function extractMetadata(element, interactiveSelector, semanticSelector) {
|
|
|
156
320
|
};
|
|
157
321
|
}
|
|
158
322
|
|
|
159
|
-
const COLLECTED_ATTRS = [
|
|
160
|
-
'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled',
|
|
161
|
-
'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled',
|
|
162
|
-
];
|
|
163
|
-
|
|
164
323
|
function buildEntry(el, rootRect, id, type) {
|
|
165
324
|
const rect = el.getBoundingClientRect();
|
|
166
325
|
const bbox = {
|
|
@@ -173,15 +332,58 @@ function buildEntry(el, rootRect, id, type) {
|
|
|
173
332
|
if (bbox.width <= 0 && bbox.height <= 0) return null;
|
|
174
333
|
|
|
175
334
|
const tag = el.tagName.toLowerCase();
|
|
176
|
-
const
|
|
335
|
+
const maxText = type === 'interactive' ? 200 : 120;
|
|
336
|
+
const text = (el.textContent || '').trim().slice(0, maxText);
|
|
177
337
|
|
|
178
338
|
const attributes = {};
|
|
179
339
|
for (const attr of COLLECTED_ATTRS) {
|
|
180
340
|
const val = el.getAttribute(attr);
|
|
181
|
-
if (val
|
|
341
|
+
if (val == null || val === '' || val === 'false') continue;
|
|
342
|
+
attributes[attr] = val;
|
|
182
343
|
}
|
|
183
344
|
|
|
184
|
-
|
|
345
|
+
// For <img>, replace the (potentially long, cache-busted) src with just the
|
|
346
|
+
// filename — "logo.svg" is more useful to an LLM than the full URL.
|
|
347
|
+
if (tag === 'img' && el.src) {
|
|
348
|
+
try {
|
|
349
|
+
const base = (el.ownerDocument && el.ownerDocument.location)
|
|
350
|
+
? el.ownerDocument.location.href
|
|
351
|
+
: (typeof location !== 'undefined' ? location.href : undefined);
|
|
352
|
+
const url = base ? new URL(el.src, base) : new URL(el.src);
|
|
353
|
+
attributes.src = url.pathname.split('/').pop() || el.src;
|
|
354
|
+
} catch {
|
|
355
|
+
attributes.src = el.src;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const entry = {
|
|
360
|
+
id,
|
|
361
|
+
tag,
|
|
362
|
+
type,
|
|
363
|
+
name: accessibleName(el),
|
|
364
|
+
text,
|
|
365
|
+
bbox,
|
|
366
|
+
attributes,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const styles = computeVisualStyles(el);
|
|
370
|
+
if (styles) entry.styles = styles;
|
|
371
|
+
|
|
372
|
+
if (type === 'interactive') {
|
|
373
|
+
const state = computeState(el);
|
|
374
|
+
if (state) {
|
|
375
|
+
// Drop state.value when it just echoes attributes.value — no
|
|
376
|
+
// divergence between the initial HTML attribute and the current
|
|
377
|
+
// property, nothing for the LLM to learn from the repeat.
|
|
378
|
+
if (state.value !== undefined && state.value === attributes.value) {
|
|
379
|
+
delete state.value;
|
|
380
|
+
}
|
|
381
|
+
if (Object.keys(state).length) entry.state = state;
|
|
382
|
+
}
|
|
383
|
+
if (isCovered(el, rect)) entry.covered = true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return entry;
|
|
185
387
|
}
|
|
186
388
|
|
|
187
389
|
/* ── Visual annotations ───────────────────────── */
|
|
@@ -207,10 +409,14 @@ function addAnnotations(clone, elements, customStyle) {
|
|
|
207
409
|
const badge = document.createElement('span');
|
|
208
410
|
badge.textContent = String(el.id);
|
|
209
411
|
badge.setAttribute('data-snap-prompt-label', String(el.id));
|
|
412
|
+
// Center the badge on the element's bbox, not on its top-left corner:
|
|
413
|
+
// `translate(-50%, -50%)` offsets from the anchor point.
|
|
414
|
+
const cx = el.bbox.x + el.bbox.width / 2;
|
|
415
|
+
const cy = el.bbox.y + el.bbox.height / 2;
|
|
210
416
|
Object.assign(badge.style, {
|
|
211
417
|
position: 'absolute',
|
|
212
|
-
left: `${
|
|
213
|
-
top: `${
|
|
418
|
+
left: `${cx}px`,
|
|
419
|
+
top: `${cy}px`,
|
|
214
420
|
transform: 'translate(-50%, -50%)',
|
|
215
421
|
minWidth: '18px',
|
|
216
422
|
height: '18px',
|
|
@@ -234,11 +440,56 @@ function addAnnotations(clone, elements, customStyle) {
|
|
|
234
440
|
clone.appendChild(overlay);
|
|
235
441
|
}
|
|
236
442
|
|
|
237
|
-
/* ── Prompt text
|
|
443
|
+
/* ── Prompt text formatters ───────────────────── */
|
|
444
|
+
|
|
445
|
+
function stateToStr(state) {
|
|
446
|
+
if (!state) return '';
|
|
447
|
+
const flags = [];
|
|
448
|
+
const pairs = [];
|
|
449
|
+
for (const k of Object.keys(state)) {
|
|
450
|
+
const v = state[k];
|
|
451
|
+
if (v === true) flags.push(k);
|
|
452
|
+
else pairs.push(`${k}=${JSON.stringify(v)}`);
|
|
453
|
+
}
|
|
454
|
+
if (!flags.length && !pairs.length) return '';
|
|
455
|
+
return ` {${[...flags, ...pairs].join(', ')}}`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function coordsToStr(bbox) {
|
|
459
|
+
return ` (${bbox.x},${bbox.y} ${bbox.width}×${bbox.height})`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function formatPromptCompact(elements, dimensions, withCoords) {
|
|
463
|
+
const lines = [`Screenshot (${dimensions.width}×${dimensions.height}px).`];
|
|
464
|
+
|
|
465
|
+
const interactive = elements.filter((e) => e.type === 'interactive');
|
|
466
|
+
const semantic = elements.filter((e) => e.type === 'semantic');
|
|
238
467
|
|
|
239
|
-
|
|
468
|
+
if (interactive.length) {
|
|
469
|
+
lines.push('', 'Interactive:');
|
|
470
|
+
for (const el of interactive) {
|
|
471
|
+
const name = el.name ? ` "${truncate(el.name, 60)}"` : '';
|
|
472
|
+
const st = stateToStr(el.state);
|
|
473
|
+
const cov = el.covered ? ' (covered)' : '';
|
|
474
|
+
const coords = withCoords ? coordsToStr(el.bbox) : '';
|
|
475
|
+
lines.push(` [${el.id}] ${el.tag}${name}${st}${cov}${coords}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (semantic.length) {
|
|
480
|
+
lines.push('', 'Semantic:');
|
|
481
|
+
for (const el of semantic) {
|
|
482
|
+
const name = el.name ? ` "${truncate(el.name, 60)}"` : '';
|
|
483
|
+
lines.push(` [${el.id}] ${el.tag}${name}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return lines.join('\n');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function formatPromptVerbose(elements, dimensions, withCoords) {
|
|
240
491
|
const lines = [
|
|
241
|
-
`Screenshot of a web page (${dimensions.width}
|
|
492
|
+
`Screenshot of a web page (${dimensions.width}×${dimensions.height}px).`,
|
|
242
493
|
'',
|
|
243
494
|
];
|
|
244
495
|
|
|
@@ -248,13 +499,13 @@ function formatPromptText(elements, dimensions) {
|
|
|
248
499
|
if (interactive.length) {
|
|
249
500
|
lines.push('Interactive elements:');
|
|
250
501
|
for (const el of interactive) {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
);
|
|
254
|
-
const text = el.text ? ` "${truncate(el.text, 60)}"` : '';
|
|
255
|
-
const pos = `(${el.bbox.x},${el.bbox.y} ${el.bbox.width}\u00d7${el.bbox.height})`;
|
|
502
|
+
const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
|
|
503
|
+
const attrParts = Object.entries(el.attributes).map(([k, v]) => `${k}="${v}"`);
|
|
256
504
|
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
257
|
-
|
|
505
|
+
const pos = withCoords ? coordsToStr(el.bbox) : '';
|
|
506
|
+
const st = stateToStr(el.state);
|
|
507
|
+
const cov = el.covered ? ' (covered)' : '';
|
|
508
|
+
lines.push(` [${el.id}] <${el.tag}>${name}${pos}${attrs}${st}${cov}`);
|
|
258
509
|
}
|
|
259
510
|
lines.push('');
|
|
260
511
|
}
|
|
@@ -262,20 +513,15 @@ function formatPromptText(elements, dimensions) {
|
|
|
262
513
|
if (semantic.length) {
|
|
263
514
|
lines.push('Semantic structure:');
|
|
264
515
|
for (const el of semantic) {
|
|
265
|
-
const
|
|
516
|
+
const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
|
|
266
517
|
const attrParts = [];
|
|
267
518
|
if (el.attributes.alt) attrParts.push(`alt="${el.attributes.alt}"`);
|
|
268
519
|
if (el.attributes.role) attrParts.push(`role="${el.attributes.role}"`);
|
|
269
520
|
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
270
|
-
lines.push(` [${el.id}] <${el.tag}>${
|
|
521
|
+
lines.push(` [${el.id}] <${el.tag}>${name}${attrs}`);
|
|
271
522
|
}
|
|
272
523
|
lines.push('');
|
|
273
524
|
}
|
|
274
525
|
|
|
275
526
|
return lines.join('\n');
|
|
276
527
|
}
|
|
277
|
-
|
|
278
|
-
function truncate(str, max) {
|
|
279
|
-
if (str.length <= max) return str;
|
|
280
|
-
return str.slice(0, max - 1) + '\u2026';
|
|
281
|
-
}
|