@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,436 @@
1
+ // main/axExtractor.js — AX-tree read layer (P0 probe foundation)
2
+ //
3
+ // P0 goal (see .trae/documents/replan-ax-tree-bottom-up.md): replace reading the
4
+ // page from hand-written DOM heuristics (preload/extractor.cjs) with the browser's
5
+ // own semantic layer (CDP Accessibility.getFullAXTree). The OPERATION layer stays
6
+ // CDP and is unchanged; AX and CDP are fused by `data-ai-id`.
7
+ //
8
+ // - normalizeAxTree(rawNodes): PURE. Raw CDP AXNode[] -> contract TreeNode.
9
+ // No Electron/DOM dependency, so it is unit-testable under plain Node. No
10
+ // frame/class/script heuristics.
11
+ // - extractFromDebugger(dbg): RUNTIME. Wires Electron's webContents.debugger:
12
+ // Accessibility.enable + getFullAXTree -> normalize -> stamp data-ai-id onto
13
+ // the real DOM via DOM.describeNode(backendNodeId) then DOM.setAttributeValue.
14
+ //
15
+ // This module is ADDITIVE: nothing here is called by the default getTree path yet.
16
+
17
+ // node.value 的截断契约(legacy 读层 src/preload/extractor.cjs 用同一个 helper,
18
+ // 两条读层不得各自实现 slice)。
19
+ import valueContract from '../shared/value_contract.cjs';
20
+ const { truncateNodeValue } = valueContract;
21
+
22
+ // --- Pure: raw AXNode[] -> contract TreeNode -----------------------------
23
+ // Raw AXNode (CDP Accessibility.AXNode):
24
+ // { nodeId, ignored, role:{value}, name:{value}, value:{value},
25
+ // properties:[{name, value:{type, value}}], backendDOMNodeId,
26
+ // bounds:{x,y,width,height}, childIds:[] }
27
+
28
+ const VERSION_TAG = 'ax-0.1';
29
+
30
+ function prop(nodes, nodeId, name) {
31
+ const n = nodes.get(nodeId);
32
+ if (!n) return null;
33
+ const p = (n.properties || []).find((q) => q.name === name);
34
+ return p ? p.value : null;
35
+ }
36
+
37
+ function valueOf(pv) {
38
+ return pv && typeof pv.value !== 'undefined' ? pv.value : null;
39
+ }
40
+
41
+ // AX gives div[contenteditable] the role `generic` plus `editable=richtext`.
42
+ // The only "rule" we keep (plan §5): fold that into a semantic `textbox`.
43
+ function editorTypeFrom(role, editable) {
44
+ if (editable === 'richtext' || editable === 'contenteditable') return 'richtext';
45
+ if (role === 'textbox') {
46
+ if (editable === 'multiline') return 'textarea';
47
+ if (editable === 'plaintext') return 'textbox';
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function actionsFor(role) {
53
+ switch (role) {
54
+ case 'button': case 'link': return ['click', 'focus', 'hover'];
55
+ case 'textbox': return ['type', 'setContent', 'clear', 'focus'];
56
+ case 'select': return ['select', 'focus'];
57
+ case 'slider': return ['scroll_to', 'focus'];
58
+ case 'checkbox': case 'radio': return ['click', 'focus'];
59
+ // dialog/alertdialog 曾经也广告 ['open','close'],与 extractor.cjs 是同一个错:
60
+ // page_manager.performAction 与 preload actions.cjs 都没有这两个 case,调用只会
61
+ // 拿到 "Unknown action"。语义树(AX 路径)自述了做不到的动作,agent 照着做必然
62
+ // 失败。弹窗里真正能点的按钮本身就带 ['click','focus'],给空数组不损失能力。
63
+ case 'dialog': case 'alertdialog': return [];
64
+ default: return [];
65
+ }
66
+ }
67
+
68
+ function statesFor(node) {
69
+ const states = [];
70
+ for (const p of node.properties || []) {
71
+ const v = p.value;
72
+ if (!v || v.type !== 'boolean' || v.value !== true) continue;
73
+ if (p.name === 'read-only') { states.push('readonly'); continue; }
74
+ if (p.name === 'hidden') continue;
75
+ states.push(p.name); // focusable/disabled/focused/checked/selected/settable/multiline/visible...
76
+ }
77
+ // raw `editable` stays available to callers that need the primitive signal
78
+ const editable = nodeStore ? valueOf(prop(nodeStore, node.nodeId, 'editable')) : null;
79
+ if (editable) states.push('editable=' + editable);
80
+ return states;
81
+ }
82
+
83
+ let nodeStore = null; // current normalize pass's node map (see normalizeAxTree)
84
+
85
+ function build(node, idFor) {
86
+ const rawRole = (node.role && node.role.value) || 'generic';
87
+ const editable = valueOf(prop(nodeStore, node.nodeId, 'editable'));
88
+ const editorType = editorTypeFrom(rawRole, editable);
89
+ const role = (rawRole === 'generic' && editorType === 'richtext') ? 'textbox' : rawRole;
90
+
91
+ const tn = {
92
+ id: idFor(node),
93
+ role,
94
+ label: (node.name && node.name.value) || '',
95
+ states: statesFor(node),
96
+ actions: actionsFor(role),
97
+ bounds: {
98
+ x: Math.round((node.bounds && node.bounds.x) || 0),
99
+ y: Math.round((node.bounds && node.bounds.y) || 0),
100
+ width: Math.round((node.bounds && node.bounds.width) || 0),
101
+ height: Math.round((node.bounds && node.bounds.height) || 0),
102
+ },
103
+ };
104
+ if (node.backendDOMNodeId != null) tn.backendDOMNodeId = node.backendDOMNodeId;
105
+ if (editorType) tn.editor_type = editorType;
106
+
107
+ if (role === 'textbox' || role === 'combobox' || role === 'listbox' || role === 'searchbox') {
108
+ let v = valueOf(node.value);
109
+ if ((v == null || v === '') && readValue) v = readValue(node);
110
+ // 密码框:AX 用 protected 属性标记。不返回值、长度或取全量提示。
111
+ const isProtected = valueOf(prop(nodeStore, node.nodeId, 'protected')) === true;
112
+ if (isProtected) {
113
+ tn.value_sensitive = true;
114
+ tn.value_sensitive_reason = 'password';
115
+ } else if (v != null && v !== '') {
116
+ // 与 legacy 读层共用同一截断契约(src/shared/value_contract.cjs):
117
+ // code point 计数,截断时带 value_truncated / value_full_length /
118
+ // value_fetch_ref,未截断只给 value。
119
+ const cut = truncateNodeValue(String(v), tn.id, 200);
120
+ tn.value = cut.value;
121
+ if (cut.value_truncated) {
122
+ tn.value_truncated = true;
123
+ tn.value_full_length = cut.value_full_length;
124
+ tn.value_length_unit = cut.value_length_unit;
125
+ tn.value_fetch_ref = cut.value_fetch_ref;
126
+ }
127
+ }
128
+ }
129
+
130
+ const kids = [];
131
+ for (const cid of node.childIds || []) {
132
+ const c = nodeStore.get(cid);
133
+ if (c && !c.ignored) kids.push(build(c, idFor));
134
+ }
135
+ if (kids.length) tn.children = kids;
136
+ return tn;
137
+ }
138
+
139
+ let readValue = null; // opts.readValue — per-node DOM read hook for textbox values
140
+
141
+ // Public pure API.
142
+ // opts: { idFor: (node)=>string, readValue: (node)=>string }
143
+ export function normalizeAxTree(rawNodes, opts = {}) {
144
+ const live = (rawNodes || []).filter((n) => n && !n.ignored);
145
+ nodeStore = new Map(live.map((n) => [n.nodeId, n]));
146
+ readValue = opts.readValue || null;
147
+ const idFor = opts.idFor || ((n) => 'ax-' + n.nodeId);
148
+
149
+ const childIds = new Set();
150
+ for (const n of live) for (const c of n.childIds || []) if (c != null) childIds.add(c);
151
+ const roots = live.filter((n) => !childIds.has(n.nodeId));
152
+
153
+ const built = roots.map((n) => build(n, idFor));
154
+ const tree = built.length === 1
155
+ ? built[0]
156
+ : { id: 'root', role: 'generic', label: '', states: ['visible'], actions: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, children: built };
157
+ tree._debug = { layer: 'ax', version: VERSION_TAG };
158
+ nodeStore = null;
159
+ readValue = null;
160
+ return tree;
161
+ }
162
+
163
+ // C: PURE MERGE. Splices out-of-process sub-frame trees into the main AX tree.
164
+ // Input `mainTree` is the top-frame normalized tree; each `frame` is
165
+ // { key, label, tree } where `tree` is that child frame's normalized tree
166
+ // (ids already prefixed by the caller's idFor so they cannot collide with the
167
+ // main frame's `ax-{backendDOMNodeId}`). Sub-frame editable/interactive nodes
168
+ // are lifted under a single role=Iframe gate so the caller's tree walker sees
169
+ // them without needing to know about frame topology. Pure — no predicate on
170
+ // mainTree.shape; a single-root main tree is wrapped into a container.
171
+ export function mergeFrameTrees(mainTree, frames = []) {
172
+ if (!mainTree) return mainTree;
173
+ const usable = (frames || []).filter((f) => f && f.tree);
174
+ if (!usable.length) return mainTree;
175
+
176
+ // Lift interactive/editable subtrees out of each frame tree (frame roots are
177
+ // usually generic wrappers; keep their actionable leaves, drop pure layout).
178
+ const lift = (tv) => {
179
+ const out = [];
180
+ (function w(t, depth) {
181
+ if (!t) return;
182
+ // Lift any actionable/editable node (skip generic layout wrappers),
183
+ // regardless of depth — so a single-leaf interactive root is kept.
184
+ const actionable = t.role !== 'generic' && (t.actions || []).length > 0;
185
+ if (actionable) { out.push(t); return; }
186
+ for (const c of t.children || []) w(c, depth + 1);
187
+ })(tv, 0);
188
+ return out;
189
+ };
190
+ const gates = usable.map((f) => ({
191
+ id: f.key,
192
+ role: 'Iframe',
193
+ label: f.label || '子框架',
194
+ states: ['visible'],
195
+ actions: [],
196
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
197
+ children: lift(f.tree),
198
+ }));
199
+
200
+ if (mainTree.children && mainTree.children.length) {
201
+ return { ...mainTree, children: [...mainTree.children, ...gates] };
202
+ }
203
+ // mainTree is a leaf root → promote to a container so gates can attach.
204
+ return {
205
+ id: 'root',
206
+ role: 'generic',
207
+ label: '',
208
+ states: ['visible'],
209
+ actions: [],
210
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
211
+ children: [mainTree, ...gates],
212
+ };
213
+ }
214
+
215
+ // AX role=dialog|alertdialog that is visible, plus its descendant buttons — the
216
+ // AX-native replacement for the class-keyword modal table (extractor's modalSelectors).
217
+ export function modalsFromAx(rawNodes) {
218
+ const live = (rawNodes || []).filter((n) => n && !n.ignored);
219
+ nodeStore = new Map(live.map((n) => [n.nodeId, n]));
220
+ const dialogs = live.filter((n) => {
221
+ const r = n.role && n.role.value;
222
+ if (r !== 'dialog' && r !== 'alertdialog') return false;
223
+ const b = n.bounds || {};
224
+ return (b.width && b.height) || valueOf(prop(nodeStore, n.nodeId, 'visible'));
225
+ });
226
+ const out = [];
227
+ for (const d of dialogs) {
228
+ const buttons = [];
229
+ const walk = (id) => {
230
+ const n = nodeStore.get(id);
231
+ if (!n || n.ignored) return;
232
+ const r = n.role && n.role.value;
233
+ if (r === 'button') {
234
+ const btn = {
235
+ text: ((n.name && n.name.value) || '').slice(0, 30),
236
+ disabled: valueOf(prop(nodeStore, n.nodeId, 'disabled')) === true,
237
+ };
238
+ if (btn.text) buttons.push(btn);
239
+ }
240
+ for (const cid of n.childIds || []) walk(cid);
241
+ };
242
+ walk(d.nodeId);
243
+ if (buttons.length) out.push({ buttons });
244
+ }
245
+ nodeStore = null;
246
+ return out;
247
+ }
248
+
249
+ // --- Runtime: drive Electron webContents.debugger ------------------------
250
+ // dbg = view.webContents.debugger (already attached by page_manager._ensureCdp).
251
+ //
252
+ // C (plan §9.5): READING and STAMPING are now separate. `axRead` builds the
253
+ // semantic tree from AX alone and NEVER touches the DOM (no side effects, no
254
+ // per-read data-ai-id rewrite). `ensureHandles` materializes data-ai-id onto
255
+ // the real interactive elements, but only at the act/peek boundary and
256
+ // idempotently — existing data-ai-id (e.g. the default extractor's `e:...`)
257
+ // is preserved. ids are derived from backendDOMNodeId so they are stable
258
+ // across AX passes (vs AX nodeId which can resequence).
259
+
260
+ const interactiveRoles = { button: 1, link: 1, textbox: 1, combobox: 1, checkbox: 1, radio: 1, select: 1, option: 1, slider: 1, dialog: 1, alertdialog: 1, menuitem: 1, tab: 1 };
261
+
262
+ // Stable public id: backendDOMNodeId stays with its DOM element for the element's
263
+ // lifetime; fall back to AX nodeId for role-only nodes with no backing element.
264
+ function axId(n) { return n.backendDOMNodeId != null ? 'ax-' + n.backendDOMNodeId : 'ax-' + n.nodeId; }
265
+
266
+ function isInteractiveNode(n, byId) {
267
+ const r = n.role && n.role.value;
268
+ const editable = valueOf(prop(byId, n.nodeId, 'editable'));
269
+ return !!interactiveRoles[r] || !!editable || valueOf(prop(byId, n.nodeId, 'focusable')) === true;
270
+ }
271
+
272
+ // Enabled AX + returns raw AXNode[] (also loads DOM doc so backendNodeId is usable).
273
+ async function axRaw(dbg) {
274
+ await dbg.sendCommand('Accessibility.enable');
275
+ const res = await dbg.sendCommand('Accessibility.getFullAXTree', {});
276
+ const raw = (res && res.nodes) || [];
277
+ if (!raw.length) return raw;
278
+ await dbg.sendCommand('DOM.getDocument', { depth: 0, pierce: true });
279
+ return raw;
280
+ }
281
+
282
+ export function interactiveIdSet(raw) {
283
+ const byId = new Map(raw.filter((n) => !n.ignored).map((n) => [n.nodeId, n]));
284
+ const s = new Set();
285
+ for (const n of raw) if (!n.ignored && isInteractiveNode(n, byId)) s.add(n.nodeId);
286
+ return s;
287
+ }
288
+
289
+ // C: PURE READ. Same {tree, context} shape as the default getTree, but derived
290
+ // from AX alone. No DOM mutation, ids = ax-{backendDOMNodeId} (stable).
291
+ export async function axRead(dbg) {
292
+ const raw = await axRaw(dbg);
293
+ if (!raw.length) return { tree: null, context: { modals: [] } };
294
+ const tree = normalizeAxTree(raw, { idFor: axId, readValue: () => '' });
295
+ const context = { modals: modalsFromAx(raw), session: {}, stats: { layer: 'ax' } };
296
+ return { tree, context };
297
+ }
298
+
299
+ // Backward-compat alias: axRead is the current read path (previously stamping).
300
+ export async function extractFromDebugger(dbg) { return axRead(dbg); }
301
+
302
+ // C: IDEMPOTENT materialize. Adds data-ai-id to interactive backing elements.
303
+ // Never overwrites an existing handle (keeps default extractor's `e:...` ids
304
+ // intact). Net effect: read stays side-effect-free; act/peek get handles on demand.
305
+ export async function ensureHandles(dbg) {
306
+ const raw = await axRaw(dbg);
307
+ const byId = new Map(raw.filter((n) => !n.ignored).map((n) => [n.nodeId, n]));
308
+ const stampOne = (objId, ai) => dbg.sendCommand('Runtime.callFunctionOn', {
309
+ objectId: objId,
310
+ functionDeclaration: `function(v){ var e=this,t=e&&e.nodeType||0; if(t!==1) return 'nonel'; var c=e.getAttribute('data-ai-id'); if(c) return c===v?'same':'keep'; e.setAttribute('data-ai-id',v); return 'set'; }`,
311
+ arguments: [{ value: ai }],
312
+ returnByValue: true,
313
+ }).then((r) => r && r.result && r.result.value);
314
+ const out = { set: 0, same: 0, kept: 0, skipped: 0 };
315
+ for (const n of raw) {
316
+ if (n.ignored || n.backendDOMNodeId == null) continue;
317
+ if (!isInteractiveNode(n, byId)) continue;
318
+ const ai = axId(n);
319
+ let rd;
320
+ try { rd = await dbg.sendCommand('DOM.resolveNode', { backendNodeId: n.backendDOMNodeId }); }
321
+ catch (e) { out.skipped++; continue; } // cross-origin/OOPIF node — not reachable here
322
+ const objId = rd && rd.object && rd.object.objectId;
323
+ if (!objId) { out.skipped++; continue; }
324
+ const s = await stampOne(objId, ai);
325
+ if (s === 'set') out.set++;
326
+ else if (s === 'same') out.same++;
327
+ else out.kept++; // 'keep' or 'nonel'
328
+ }
329
+ out.handled = out.set + out.same;
330
+ return out;
331
+ }
332
+
333
+ // A: PURE diff of two AX snapshots (pre/post a reveal) by nodeId. Returns only
334
+ // interactive nodes that newly appeared (revealed) or disappeared (hidden).
335
+ export function diffAx(beforeRaw, afterRaw) {
336
+ const bm = new Map((beforeRaw || []).map((n) => [n.nodeId, n]));
337
+ const am = new Map((afterRaw || []).map((n) => [n.nodeId, n]));
338
+ const bi = interactiveIdSet(beforeRaw || []);
339
+ const ai = interactiveIdSet(afterRaw || []);
340
+ const toLite = (n) => ({
341
+ id: axId(n),
342
+ role: (n.role && n.role.value) || 'generic',
343
+ label: (n.name && n.name.value) || '',
344
+ backendDOMNodeId: n.backendDOMNodeId != null ? n.backendDOMNodeId : undefined,
345
+ });
346
+ const revealed = [], hidden = [];
347
+ for (const id of ai) if (!bi.has(id)) { const n = am.get(id); if (n && !n.ignored) revealed.push(toLite(n)); }
348
+ for (const id of bi) if (!ai.has(id)) { const n = bm.get(id); if (n && !n.ignored) hidden.push(toLite(n)); }
349
+ return { revealed, hidden };
350
+ }
351
+
352
+ // C: read-only href resolution for actionable nodes. The AX read layer only knows
353
+ // "this is clickable", not "where it goes" (AX has no href). We lazily decorate
354
+ // link/button nodes with their absolute target `url` via DOM.resolveNode +
355
+ // Runtime.callFunctionOn (a read, no DOM mutation). Generic across sites — gives
356
+ // agents "where does clicking this take me" instead of guessing from the label.
357
+ const HREF_FN = `function(){var e=this,h=null;if(e.href&&typeof e.href==='string')h=e.href;if(!h&&e.closest){var a=e.closest('a[href]');if(a)h=a.href;}if(!h&&typeof e.getAttribute==='function'){var r=e.getAttribute('href');if(r)try{h=new URL(r, location.href).href;}catch(_){}}return h||null;}`;
358
+ const MAX_HREFS = 500;
359
+ export async function attachHrefs(dbg, tree) {
360
+ if (!tree || !dbg) return tree;
361
+ const targets = [];
362
+ (function w(t) {
363
+ if (!t) return;
364
+ if ((t.role === 'link' || t.role === 'button') && t.backendDOMNodeId != null && !t.url) {
365
+ if (targets.length < MAX_HREFS) targets.push(t);
366
+ }
367
+ for (const c of t.children || []) w(c);
368
+ })(tree);
369
+ if (!targets.length) return tree;
370
+ const resolved = await Promise.all(targets.map(async (n) => {
371
+ try {
372
+ const rd = await dbg.sendCommand('DOM.resolveNode', { backendNodeId: n.backendDOMNodeId });
373
+ const objId = rd && rd.object && rd.object.objectId;
374
+ if (!objId) return null;
375
+ const c = await dbg.sendCommand('Runtime.callFunctionOn', { objectId: objId, functionDeclaration: HREF_FN, returnByValue: true });
376
+ const v = c && c.result && c.result.value;
377
+ return typeof v === 'string' && v ? v : null;
378
+ } catch (e) { return null; } // cross-origin/OOPIF node — leave url unset
379
+ }));
380
+ for (let i = 0; i < targets.length; i++) if (resolved[i]) targets[i].url = resolved[i];
381
+ return tree;
382
+ }
383
+
384
+ // Prune to the interactive/editable subtree. Keeps the root container as a
385
+ // wrapper plus every node that is actionable/editable (or has actionable
386
+ // descendants); drops layout-only branches. Pure and generic (role/actions
387
+ // based, no site heuristics) — the read-lowering mode for long feeds.
388
+ function isInteractive(tree) { return !!tree.editor_type || (tree.actions && tree.actions.length > 0) || !!tree.url; }
389
+ export function filterInteractive(tree) {
390
+ if (!tree) return tree;
391
+ const keep = (t) => {
392
+ if (!t) return null;
393
+ const kids = (t.children || []).map(keep).filter(Boolean);
394
+ if (kids.length || isInteractive(t)) {
395
+ if (!kids.length) return t;
396
+ return { ...t, children: kids };
397
+ }
398
+ return null;
399
+ };
400
+ const out = { ...tree, children: (tree.children || []).map(keep).filter(Boolean) };
401
+ if (!out.children.length && !isInteractive(out)) return null;
402
+ return out;
403
+ }
404
+
405
+ // Small memory-safe snapshot of interactive nodes (backendDOMNodeId -> lite),
406
+ // for incremental get_tree {ax:true, mode:'diff'} across reads without caching
407
+ // the full raw AX payload per tab. Pure.
408
+ export function liteSnapshot(rawNodes) {
409
+ const valid = (rawNodes || []).filter((n) => n && !n.ignored);
410
+ const byNodeId = new Map(valid.map((n) => [n.nodeId, n]));
411
+ const out = new Map();
412
+ for (const n of valid) {
413
+ if (n.backendDOMNodeId == null) continue;
414
+ if (!isInteractiveNode(n, byNodeId)) continue;
415
+ out.set(n.backendDOMNodeId, {
416
+ id: axId(n),
417
+ role: (n.role && n.role.value) || 'generic',
418
+ label: (n.name && n.name.value) || '',
419
+ });
420
+ }
421
+ return out;
422
+ }
423
+
424
+ // Diff two lite snaccapshots by backendDOMNodeId -> { revealed, hidden }. Pure.
425
+ export function diffLite(prevLite, newLite) {
426
+ const revealed = [], hidden = [];
427
+ for (const [bid, n] of (newLite || new Map()).entries()) {
428
+ if (!prevLite || !prevLite.has(bid)) revealed.push(n);
429
+ }
430
+ for (const [bid, n] of (prevLite || new Map()).entries()) {
431
+ if (!newLite || !newLite.has(bid)) hidden.push(n);
432
+ }
433
+ return { revealed, hidden };
434
+ }
435
+
436
+ export default { normalizeAxTree, mergeFrameTrees, modalsFromAx, extractFromDebugger, axRead, axRaw, ensureHandles, diffAx, interactiveIdSet, attachHrefs, filterInteractive, liteSnapshot, diffLite, VERSION_TAG };