@glittering0717/ai-browser 0.0.0-stage → 1.2.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.
@@ -0,0 +1,371 @@
1
+ // main/snapshot_diff.js — structural diff for ui.snapshot / ui.diff.
2
+ //
3
+ // Kept in its own module (not inside page_manager) so the diff logic can be
4
+ // unit-tested in plain Node with no Electron — which matters, because the diff
5
+ // IS the thing that decides "did my action actually do anything". A bug here
6
+ // silently turns every verification into a false green.
7
+ //
8
+ // Design notes:
9
+ // - Arrays are compared **element-wise by index**, not as sets. Workflow graphs
10
+ // (ComfyUI's app.graph._nodes, React Flow's nodes array) are order-sensitive
11
+ // in ways callers care about, and set-diffing would hide a moved node behind
12
+ // "removed + added", which reads as if two things happened when one did.
13
+ // - Every changed entry carries `path`, `from`, `to` so a caller can check the
14
+ // REASON, not just that some byte moved.
15
+ // - Output is capped. An un-capped diff of a big page can be megabytes, which
16
+ // would blow the agent's context instead of informing it.
17
+
18
+ export const DIFF_MAX_ENTRIES = 200;
19
+
20
+ // Cap on how many snapshot baselines are kept alive at once.
21
+ export const SNAPSHOT_MAX = 50;
22
+
23
+ // The in-page scan that recognises "this window property is the app's own data
24
+ // model", plus the derived order that decides which candidate wins. Imported
25
+ // rather than re-implemented: the first version of the ranking matched the hint
26
+ // prefix wrong, ranked everything -1, and silently downgraded a real page. Two
27
+ // hand-maintained copies of that table would drift the same way.
28
+ import { APP_KEY_RE, MODEL_PROBE_ORDER } from './capability_probe.js';
29
+
30
+ /**
31
+ * Drop oldest-inserted entries from a Map until it holds at most `max`.
32
+ *
33
+ * Map preserves insertion order and callers re-insert an entry when they use it,
34
+ * so this evicts least-recently-USED. Lives here rather than inline in
35
+ * page_manager because a bug in it would silently discard a baseline an agent
36
+ * still needs, and that surfaces much later as "unknown-snapshot" — far from
37
+ * the cause. Kept pure so it is unit-testable without booting Electron.
38
+ *
39
+ * @returns {number} how many entries were dropped
40
+ */
41
+ export function evictOldest(map, max = SNAPSHOT_MAX) {
42
+ let dropped = 0;
43
+ while (map.size > max) {
44
+ const oldest = map.keys().next();
45
+ if (oldest.done) break;
46
+ map.delete(oldest.value);
47
+ dropped++;
48
+ }
49
+ return dropped;
50
+ }
51
+
52
+ /**
53
+ * Deep structural diff of two JSON-ish values.
54
+ * @returns {{changed:boolean, added:Array, removed:Array, changed:Array, dropped:number}}
55
+ */
56
+ export function diffValues(before, after) {
57
+ const added = [];
58
+ const removed = [];
59
+ const changedArr = [];
60
+ let dropped = 0;
61
+ const cap = DIFF_MAX_ENTRIES;
62
+
63
+ const push = (arr, entry) => {
64
+ if (arr.length < cap) arr.push(entry); else dropped++;
65
+ };
66
+
67
+ const walk = (a, b, path) => {
68
+ if (a === b) return;
69
+
70
+ // NaN never equals itself; treat two NaNs as equal so a numeric field that
71
+ // is NaN on both sides isn't reported as a change on every single diff.
72
+ if (typeof a === 'number' && typeof b === 'number' && Number.isNaN(a) && Number.isNaN(b)) return;
73
+
74
+ const ta = typeOf(a);
75
+ const tb = typeOf(b);
76
+ if (ta !== tb) {
77
+ push(changedArr, { path, from: previewValue(a), to: previewValue(b), reason: 'type' });
78
+ return;
79
+ }
80
+
81
+ if (ta === 'array') {
82
+ const n = Math.max(a.length, b.length);
83
+ for (let i = 0; i < n; i++) {
84
+ const p = `${path}[${i}]`;
85
+ // Primitives keep their ORIGINAL value here (not the stringified
86
+ // preview): callers routinely branch on added/removed payloads, e.g.
87
+ // "was a node added?" or "was the count 13 or '13'?". Only objects and
88
+ // arrays get summarised, because inlining their contents would blow up
89
+ // the diff. Objects/arrays appear in `changed` via previewValue for the
90
+ // same reason — a 300-key graph node must not land in the output.
91
+ if (i >= a.length) push(added, { path: p, value: summarise(b[i]) });
92
+ else if (i >= b.length) push(removed, { path: p, value: summarise(a[i]) });
93
+ else walk(a[i], b[i], p);
94
+ }
95
+ return;
96
+ }
97
+
98
+ if (ta === 'object') {
99
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
100
+ // Sort for deterministic output — otherwise the same logical diff can
101
+ // come back in a different order each run, which makes it impossible to
102
+ // eyeball "is this the same diff as last time?".
103
+ for (const k of [...keys].sort()) {
104
+ const p = path ? `${path}.${k}` : k;
105
+ if (!(k in a)) push(added, { path: p, value: summarise(b[k]) });
106
+ else if (!(k in b)) push(removed, { path: p, value: summarise(a[k]) });
107
+ else walk(a[k], b[k], p);
108
+ }
109
+ return;
110
+ }
111
+
112
+ // Primitives — compare with a tolerance for floats. A canvas pan produces
113
+ // coordinates like 201354.48743718592 vs 201354.4874371859; reporting that
114
+ // as a change is technically true and practically noise.
115
+ if (typeof a === 'number' && typeof b === 'number') {
116
+ if (nearlyEqual(a, b)) return;
117
+ push(changedArr, { path, from: a, to: b, reason: 'value' });
118
+ return;
119
+ }
120
+ push(changedArr, { path, from: previewValue(a), to: previewValue(b), reason: 'value' });
121
+ };
122
+
123
+ walk(before, after, '');
124
+ return {
125
+ // NOTE: do not name this `changed` — the list of changed entries below is
126
+ // also called `changed`, and a duplicate key in an object literal silently
127
+ // drops the boolean (the array wins). That bug made "identical values"
128
+ // report changed:true, i.e. every diff would look like a change.
129
+ hasChanges: added.length > 0 || removed.length > 0 || changedArr.length > 0,
130
+ added,
131
+ removed,
132
+ changed: changedArr,
133
+ dropped,
134
+ };
135
+ }
136
+
137
+ function typeOf(v) {
138
+ if (v === null) return 'null';
139
+ if (Array.isArray(v)) return 'array';
140
+ return typeof v;
141
+ }
142
+
143
+ function nearlyEqual(a, b) {
144
+ if (a === b) return true;
145
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
146
+ const diff = Math.abs(a - b);
147
+ if (diff === 0) return true;
148
+ // Relative tolerance: exact for large magnitudes, forgiving for tiny ones.
149
+ return diff <= Math.max(1e-9, Math.abs(a) * 1e-12, Math.abs(b) * 1e-12);
150
+ }
151
+
152
+ const MAX_PREVIEW = 120;
153
+
154
+ /**
155
+ * Value renderer for added/removed entries: primitives keep their real type,
156
+ * containers become a short structural summary. See the array branch above for
157
+ * why the distinction matters.
158
+ */
159
+ function summarise(v) {
160
+ const t = typeOf(v);
161
+ if (t === 'object' || t === 'array') return previewValue(v);
162
+ if (t === 'string' && v.length > MAX_PREVIEW) return v.slice(0, MAX_PREVIEW) + '…';
163
+ return v;
164
+ }
165
+
166
+ export function previewValue(v) {
167
+ if (v === null || v === undefined) return v === null ? null : undefined;
168
+ const t = typeOf(v);
169
+ if (t === 'array') return `[${v.length} items]`;
170
+ if (t === 'object') {
171
+ const keys = Object.keys(v);
172
+ const head = keys.slice(0, 4).join(',');
173
+ return `{${keys.length} keys${head ? ': ' + head : ''}}`;
174
+ }
175
+ const s = String(v);
176
+ return s.length > MAX_PREVIEW ? s.slice(0, MAX_PREVIEW) + '…' : s;
177
+ }
178
+
179
+ export function previewOf(value) {
180
+ try {
181
+ const s = JSON.stringify(value);
182
+ if (s === undefined) return null;
183
+ return s.length > 300 ? s.slice(0, 300) + '…' : s;
184
+ } catch (e) {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ export function safeByteLength(value) {
190
+ try {
191
+ const s = JSON.stringify(value);
192
+ return s === undefined ? 0 : Buffer.byteLength(s, 'utf8');
193
+ } catch (e) {
194
+ return -1;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Default snapshot expression: a site-agnostic structural summary of the page.
200
+ *
201
+ * Why a DOM summary and not something cleverer: the point of a default is that
202
+ * the caller needs NO per-site knowledge. It walks the elements that carry
203
+ * meaning (role/label/value/checked/placeholder) and records them, so a caller
204
+ * can diff "what the page offers" without writing any JS.
205
+ *
206
+ * Runs through CDP Runtime.evaluate, so it is not subject to page CSP. Wrapped
207
+ * in try/catch returning {__error}: a throwing snapshot must be REPORTABLE, not
208
+ * silently empty — an empty snapshot diffs as "unchanged", which is the most
209
+ * dangerous possible failure for this tool.
210
+ *
211
+ * Three properties are non-negotiable, each written after a measured failure:
212
+ *
213
+ * 1. ELEMENT KEYS COME FROM IDENTITY, NEVER FROM POSITION.
214
+ * The key used to be `tag#index`. Inserting one element at the top re-keyed
215
+ * every element below it — measured, one insertion read as
216
+ * `4 added + 3 removed + 3 changed` instead of `1 added`. Pages that prepend
217
+ * a notification bar / list row / lazily-loaded item (i.e. most pages) buried
218
+ * the real signal under a wall of noise. Keys are therefore derived from
219
+ * id / name / placeholder / href, and only when several elements share an
220
+ * identity does a second pass disambiguate — first by label (content, and
221
+ * stable under insertion), finally by occurrence order for genuinely
222
+ * interchangeable siblings. A unique identity is used as-is so that a label
223
+ * change reads as `changed` rather than `removed + added`.
224
+ *
225
+ * 2. THE TEXT FALLBACK IS FINGERPRINTED OVER ITS WHOLE LENGTH.
226
+ * The element walk only covers elements that carry meaning; plenty of pages
227
+ * put their real state in a plain <div> (a status line, a result count, an
228
+ * "idle"→"clicked" readout). Whole-page text is the fallback for that — but
229
+ * truncating it to 4000 chars left a hole: a SAME-LENGTH change past char
230
+ * 4000 produced an identical `text` and an identical `textLen`, so the diff
231
+ * said "unchanged" while the page had changed. Measured: flipping a <div>
232
+ * from AAAA to BBBB beyond char 4000 → verdict=unchanged, while the same
233
+ * flip inside the first 4000 chars was caught. `textHash` (whole text) closes
234
+ * it; `textSegs` (capped per-chunk hashes) only localises WHERE it moved.
235
+ *
236
+ * 3. THE EXPRESSION STAYS LEAN.
237
+ * It is shipped to the page on every snapshot, and `ui.evaluate` refuses
238
+ * scripts over `EVAL_MAX_LENGTH` (5000) chars. The default is evaluated
239
+ * internally rather than through that guard, so it would not be *rejected* —
240
+ * but a default that no longer fits the documented eval contract is a trap
241
+ * for whoever later routes it through the guarded path. Rationale lives in
242
+ * this comment block, not inside the string; SD-031 pins the length.
243
+ *
244
+ * 4. A CANVAS APP'S MODEL IS FINGERPRINTED, NOT JUST ITS DOM.
245
+ * Properties 1-3 all describe the DOM, and on a canvas-rendered page the DOM
246
+ * is a still photograph: adding a node or editing a widget repaints <canvas>
247
+ * and changes nothing the element walk can see. So a DOM-only diff answers
248
+ * "unchanged" to a write that demonstrably happened — on ComfyUI, changing
249
+ * `node.widgets[].value` (the documented write path) moves the DOM by zero
250
+ * bytes. When the page hangs its model off window, the snapshot therefore
251
+ * fingerprints that too.
252
+ *
253
+ * Selection is by "what can actually be read back", not by how promising the
254
+ * name looks: a library namespace scores well on names and has no model to
255
+ * hand back, so `amFp` returning null is what disqualifies it. Two things are
256
+ * deliberately kept apart inside `amFp`: the RANK comes from the strongest
257
+ * accessor the object HAS, while the READ goes through `serialize()` when one
258
+ * exists (it returns JSON-safe data; the live collections are usually
259
+ * circular). Collapsing the two makes every candidate report 'serialize',
260
+ * they all tie, and a mere serializer can outrank the real model — SD-048
261
+ * caught exactly that. Ties break by MODEL_PROBE_ORDER, and an accessor
262
+ * absent from that order is skipped rather than silently sorted first.
263
+ * Reading is strictly read-only: `loadGraphData` is a LOADER and is never
264
+ * called, and a throwing `serialize()` or a circular `_nodes` is caught and
265
+ * simply yields no fingerprint.
266
+ */
267
+ export const DEFAULT_SNAPSHOT_JS = `(function(){
268
+ try {
269
+ var amRe = new RegExp(${JSON.stringify(APP_KEY_RE.source)}, 'i');
270
+ var h32 = function (s) {
271
+ var h = 5381;
272
+ for (var p = 0; p < s.length; p++) h = ((h << 5) + h + s.charCodeAt(p)) | 0;
273
+ return (h >>> 0).toString(36);
274
+ };
275
+ var amFp = function (v) {
276
+ try {
277
+ var s = Array.isArray(v._nodes) ? '_nodes' : Array.isArray(v.nodes) ? 'nodes'
278
+ : Array.isArray(v.links) ? 'links'
279
+ : (typeof v.serialize === 'function' ? 'serialize' : null);
280
+ if (!s) return null;
281
+ var d = null;
282
+ try { d = (typeof v.serialize === 'function') ? v.serialize() : v[s]; } catch (_) { return null; }
283
+ if (d == null) return null;
284
+ var j = (typeof d === 'string') ? d : JSON.stringify(d);
285
+ if (!j) return null;
286
+ var c = null;
287
+ if (Array.isArray(d)) c = d.length;
288
+ else if (d && d.nodes && typeof d.nodes.length === 'number') c = d.nodes.length;
289
+ return { kind: s, count: c, fp: h32(j) };
290
+ } catch (_) { return null; }
291
+ };
292
+ var out = { url: location.href, title: document.title, nodes: {} };
293
+ var all = document.querySelectorAll(
294
+ 'a,button,input,textarea,select,option,[role],[aria-label],[contenteditable="true"],canvas,img'
295
+ );
296
+ var recs = [];
297
+ for (var i = 0; i < all.length && recs.length < 1500; i++) {
298
+ var e = all[i];
299
+ var r = e.getBoundingClientRect();
300
+ var cs = null;
301
+ try { cs = getComputedStyle(e); } catch (_) {}
302
+ if (cs && (cs.display === 'none' || cs.visibility === 'hidden')) continue;
303
+ if (r.width < 1 && r.height < 1 && e.tagName !== 'CANVAS') continue;
304
+ var label = e.getAttribute('aria-label')
305
+ || (e.labels && e.labels[0] && e.labels[0].textContent)
306
+ || (e.tagName === 'INPUT' || e.tagName === 'TEXTAREA' ? e.getAttribute('placeholder') : '')
307
+ || (e.textContent || '').trim().slice(0, 60);
308
+ var val = null;
309
+ if ('value' in e) { try { val = String(e.value).slice(0, 120); } catch (_) {} }
310
+ else if ('checked' in e) { try { val = !!e.checked; } catch (_) {} }
311
+ var tag = e.tagName.toLowerCase();
312
+ recs.push({
313
+ tag: tag,
314
+ ident: e.id ? '#' + e.id
315
+ : (e.getAttribute('name') ? '[name=' + e.getAttribute('name') + ']' : '')
316
+ || (e.getAttribute('placeholder') ? '[ph=' + e.getAttribute('placeholder') + ']' : '')
317
+ || (tag === 'a' ? '[' + String(e.getAttribute('href') || '').slice(0, 60) + ']' : ''),
318
+ l: String(label || '').slice(0, 80),
319
+ r: e.getAttribute('role') || '',
320
+ v: val,
321
+ d: e.disabled === true ? 1 : 0,
322
+ w: Math.round(r.width),
323
+ h: Math.round(r.height),
324
+ });
325
+ }
326
+ var cnt = {}, used = {};
327
+ for (var b = 0; b < recs.length; b++) { var bo = recs[b].tag + recs[b].ident; cnt[bo] = (cnt[bo] || 0) + 1; }
328
+ for (var j = 0; j < recs.length; j++) {
329
+ var c = recs[j], t = c.tag + c.ident;
330
+ var cand = (cnt[t] > 1 && c.l) ? t + '~' + c.l.slice(0, 40) : t;
331
+ var key = cand, k = 1;
332
+ while (used[key]) { key = cand + '#' + k++; }
333
+ used[key] = 1;
334
+ out.nodes[key] = { t: c.tag, r: c.r, l: c.l, v: c.v, d: c.d, w: c.w, h: c.h };
335
+ }
336
+ out.count = recs.length;
337
+ try {
338
+ var txt = (document.body && (document.body.innerText || document.body.textContent)) || '';
339
+ txt = txt.replace(/\\s+/g, ' ').trim();
340
+ out.text = txt.slice(0, 4000);
341
+ out.textLen = txt.length;
342
+ out.textHash = h32(txt);
343
+ var SEG = 1024, segs = [];
344
+ for (var q = 0; q < 256 && q * SEG < txt.length; q++) segs.push(h32(txt.substr(q * SEG, SEG)));
345
+ out.textSegs = segs;
346
+ } catch (_) {}
347
+ try {
348
+ var MOK = ${JSON.stringify(MODEL_PROBE_ORDER)};
349
+ var ks = Object.keys(window), cd = [];
350
+ for (var gi = 0; gi < ks.length; gi++) {
351
+ if (!amRe.test(ks[gi])) continue;
352
+ var gv = null;
353
+ try { gv = window[ks[gi]]; } catch (_) { continue; }
354
+ if (!gv || (typeof gv !== 'object' && typeof gv !== 'function')) continue;
355
+ var f = amFp(gv);
356
+ if (!f) continue;
357
+ var r = MOK.indexOf(f.kind);
358
+ if (r < 0) continue;
359
+ cd.push({ k: ks[gi], r: r, f: f });
360
+ }
361
+ if (cd.length) {
362
+ cd.sort(function (a, b) { return a.r - b.r; });
363
+ var p = cd[0];
364
+ out.model = { global: p.k, kind: p.f.kind, count: p.f.count, fp: p.f.fp };
365
+ }
366
+ } catch (_) {}
367
+ return out;
368
+ } catch (e) {
369
+ return { __error: String(e && e.message || e) };
370
+ }
371
+ })()`;