@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.
- package/LICENSE +21 -0
- package/README.md +784 -2
- package/bin/ai-browser.cjs +78 -0
- package/examples/AGENT_INSTRUCTIONS.md +166 -0
- package/examples/mcp-config.json +12 -0
- package/llms-install.md +158 -0
- package/package.json +78 -4
- package/scripts/electron_resolve.cjs +241 -0
- package/scripts/start.cjs +60 -0
- package/src/main/axExtractor.js +436 -0
- package/src/main/canvas_hook_source.js +654 -0
- package/src/main/canvas_monitor.js +269 -0
- package/src/main/capability_probe.js +415 -0
- package/src/main/download_monitor.js +137 -0
- package/src/main/index.js +359 -0
- package/src/main/mcp_server.js +408 -0
- package/src/main/mcp_tools.js +289 -0
- package/src/main/mcp_ws.js +178 -0
- package/src/main/network_monitor.js +805 -0
- package/src/main/page_manager.js +2743 -0
- package/src/main/snapshot_diff.js +371 -0
- package/src/main/ws_server.js +440 -0
- package/src/preload/actions.cjs +190 -0
- package/src/preload/bridge.cjs +75 -0
- package/src/preload/extractor.cjs +936 -0
- package/src/preload/watcher.cjs +241 -0
- package/src/renderer/tab_bar.html +115 -0
- package/src/shared/config.js +32 -0
- package/src/shared/deadline.js +89 -0
- package/src/shared/guards.js +17 -0
- package/src/shared/protocol.js +116 -0
- package/src/shared/value_contract.cjs +132 -0
- package/src/shared/version.js +5 -0
- package/src/shared/watchdog.js +57 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// main/canvas_monitor.js — canvas 绘制调用的有界存储与聚合(纯逻辑,不依赖 Electron)
|
|
2
|
+
//
|
|
3
|
+
// 数据流:页面 main world 的 hook(canvas_hook_source.js)通过
|
|
4
|
+
// window[bindingName](payload) 回传 { v, nav, frame, calls: [...] },
|
|
5
|
+
// 主进程在 CDP 的 Runtime.bindingCalled 里收到后调用 ingest()。
|
|
6
|
+
//
|
|
7
|
+
// 有界是硬要求:canvas 密集的页面(游戏、图表)每帧几十次调用,不设上限
|
|
8
|
+
// 会直接把内存吃光。超出时淘汰最旧的记录,并在 dropped_calls 里如实累计。
|
|
9
|
+
|
|
10
|
+
const MAX_CALLS_PER_TAB = 5000;
|
|
11
|
+
const MAX_BYTES_PER_TAB = 4 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
// kind → 可读性。2D 能给语义;WebGL 拿不到业务语义,必须诚实标记。
|
|
14
|
+
// 出现这些 kind 就说明这个 canvas 走的是 2D 上下文
|
|
15
|
+
const r2dKinds = new Set(['text', 'rect', 'clear', 'image', 'path', 'pixel']);
|
|
16
|
+
|
|
17
|
+
function readabilityFor(kind) {
|
|
18
|
+
if (kind === '2d') return 'semantic';
|
|
19
|
+
if (kind === 'webgl' || kind === 'webgl2') return 'opaque';
|
|
20
|
+
return 'partial';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function approxBytes(rec) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.stringify(rec).length;
|
|
26
|
+
} catch {
|
|
27
|
+
return 64;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class TabCanvasState {
|
|
32
|
+
constructor(mode) {
|
|
33
|
+
this.mode = mode || 'semantic';
|
|
34
|
+
this.hookInstalled = false;
|
|
35
|
+
this.warnings = [];
|
|
36
|
+
this.seq = 0;
|
|
37
|
+
this.calls = []; // { seq, rec }
|
|
38
|
+
this.bytes = 0;
|
|
39
|
+
this.droppedCalls = 0;
|
|
40
|
+
this.canvases = new Map(); // id -> { id, kind, firstSeq, lastSeq, calls, kindLocked }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class CanvasMonitor {
|
|
45
|
+
constructor(opts = {}) {
|
|
46
|
+
this.maxCalls = Number(opts.maxCalls) || MAX_CALLS_PER_TAB;
|
|
47
|
+
this.maxBytes = Number(opts.maxBytes) || MAX_BYTES_PER_TAB;
|
|
48
|
+
this.tabs = new Map();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_tab(tabId) {
|
|
52
|
+
let st = this.tabs.get(tabId);
|
|
53
|
+
if (!st) {
|
|
54
|
+
st = new TabCanvasState(this.defaultMode || 'semantic');
|
|
55
|
+
this.tabs.set(tabId, st);
|
|
56
|
+
}
|
|
57
|
+
return st;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setDefaultMode(mode) {
|
|
61
|
+
if (mode === 'off' || mode === 'semantic' || mode === 'trace') this.defaultMode = mode;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
markHook(tabId, installed, warnings) {
|
|
65
|
+
const st = this._tab(tabId);
|
|
66
|
+
st.hookInstalled = !!installed;
|
|
67
|
+
if (Array.isArray(warnings) && warnings.length) {
|
|
68
|
+
for (const w of warnings) if (!st.warnings.includes(w)) st.warnings.push(w);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setMode(tabId, mode) {
|
|
73
|
+
const st = this._tab(tabId);
|
|
74
|
+
if (mode === 'off' || mode === 'semantic' || mode === 'trace') st.mode = mode;
|
|
75
|
+
return st.mode;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getMode(tabId) {
|
|
79
|
+
return this.tabs.get(tabId) ? this.tabs.get(tabId).mode : (this.defaultMode || 'semantic');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 接收一次 flush 的上报。
|
|
84
|
+
* @returns {{accepted:number, dropped:number}} 便于调用方观测
|
|
85
|
+
*/
|
|
86
|
+
ingest(tabId, payload) {
|
|
87
|
+
const st = this._tab(tabId);
|
|
88
|
+
if (!payload || !Array.isArray(payload.calls)) return { accepted: 0, dropped: 0 };
|
|
89
|
+
let accepted = 0;
|
|
90
|
+
let dropped = 0;
|
|
91
|
+
for (const rec of payload.calls) {
|
|
92
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
93
|
+
const canvasId = typeof rec.c === 'string' ? rec.c : 'canvas:unknown';
|
|
94
|
+
const kind = typeof rec.k === 'string' && (rec.k === 'text' || rec.k === 'image') ? rec.k : null;
|
|
95
|
+
// kind 由 hook 在 wrapGetContext 时上报(m:getContext, k:'2d'/'webgl')
|
|
96
|
+
let cv = st.canvases.get(canvasId);
|
|
97
|
+
if (!cv) {
|
|
98
|
+
cv = { id: canvasId, kind: 'unknown', firstSeq: st.seq + 1, lastSeq: st.seq + 1, calls: 0 };
|
|
99
|
+
st.canvases.set(canvasId, cv);
|
|
100
|
+
}
|
|
101
|
+
// kind 推断:优先认 getContext 记录;但那条不一定录到(实测首帧就可能没有),
|
|
102
|
+
// 因此再从调用本身反推 —— 出现过 2D 绘制类调用就一定是 2D canvas。
|
|
103
|
+
if (rec.m === 'getContext' && (rec.k === '2d' || rec.k === 'webgl' || rec.k === 'webgl2')) {
|
|
104
|
+
cv.kind = rec.k;
|
|
105
|
+
} else if (cv.kind === 'unknown') {
|
|
106
|
+
if (r2dKinds.has(rec.k)) cv.kind = '2d';
|
|
107
|
+
else if (rec.k === 'gl' || (typeof rec.m === 'string' && rec.m.indexOf('gl') === 0)) cv.kind = 'webgl';
|
|
108
|
+
}
|
|
109
|
+
cv.lastSeq = st.seq + 1;
|
|
110
|
+
cv.calls += 1;
|
|
111
|
+
|
|
112
|
+
st.seq += 1;
|
|
113
|
+
st.calls.push({ seq: st.seq, rec });
|
|
114
|
+
st.bytes += approxBytes(rec);
|
|
115
|
+
accepted += 1;
|
|
116
|
+
void kind;
|
|
117
|
+
}
|
|
118
|
+
// 有界:先按条数,再按字节
|
|
119
|
+
while (st.calls.length > this.maxCalls) {
|
|
120
|
+
const gone = st.calls.shift();
|
|
121
|
+
st.bytes -= approxBytes(gone.rec);
|
|
122
|
+
st.droppedCalls += 1;
|
|
123
|
+
dropped += 1;
|
|
124
|
+
}
|
|
125
|
+
while (st.bytes > this.maxBytes && st.calls.length) {
|
|
126
|
+
const gone = st.calls.shift();
|
|
127
|
+
st.bytes -= approxBytes(gone.rec);
|
|
128
|
+
st.droppedCalls += 1;
|
|
129
|
+
dropped += 1;
|
|
130
|
+
}
|
|
131
|
+
return { accepted, dropped };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
clear(tabId) {
|
|
135
|
+
const st = this.tabs.get(tabId);
|
|
136
|
+
if (!st) return { cleared_calls: 0 };
|
|
137
|
+
const n = st.calls.length;
|
|
138
|
+
st.calls = [];
|
|
139
|
+
st.bytes = 0;
|
|
140
|
+
st.droppedCalls = 0;
|
|
141
|
+
st.canvases = new Map();
|
|
142
|
+
return { cleared_calls: n };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 列出该 tab 的 canvas。 */
|
|
146
|
+
list(tabId) {
|
|
147
|
+
const st = this._tab(tabId);
|
|
148
|
+
const canvases = [];
|
|
149
|
+
for (const cv of st.canvases.values()) {
|
|
150
|
+
canvases.push({
|
|
151
|
+
canvas_id: cv.id,
|
|
152
|
+
kind: cv.kind,
|
|
153
|
+
readability: readabilityFor(cv.kind),
|
|
154
|
+
calls_retained: cv.calls,
|
|
155
|
+
first_retained_seq: cv.firstSeq,
|
|
156
|
+
last_seq: cv.lastSeq,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
canvases.sort((a, b) => a.canvas_id.localeCompare(b.canvas_id));
|
|
160
|
+
return {
|
|
161
|
+
canvases,
|
|
162
|
+
retention: {
|
|
163
|
+
calls: st.calls.length,
|
|
164
|
+
bytes: st.bytes,
|
|
165
|
+
max_calls: this.maxCalls,
|
|
166
|
+
max_bytes: this.maxBytes,
|
|
167
|
+
dropped_calls: st.droppedCalls,
|
|
168
|
+
},
|
|
169
|
+
mode: st.mode,
|
|
170
|
+
hook_installed: st.hookInstalled,
|
|
171
|
+
warnings: st.warnings.slice(),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 读取某个 canvas 的内容。
|
|
177
|
+
* view='summary' 给出 texts[] / regions[];view='calls' 给原始调用明细。
|
|
178
|
+
*/
|
|
179
|
+
read(tabId, opts = {}) {
|
|
180
|
+
const st = this._tab(tabId);
|
|
181
|
+
const canvasId = opts.canvasId;
|
|
182
|
+
const view = opts.view === 'calls' ? 'calls' : 'summary';
|
|
183
|
+
const sinceSeq = Number.isFinite(opts.sinceSeq) ? opts.sinceSeq : 0;
|
|
184
|
+
const limit = Math.min(Math.max(Number(opts.limit) || 200, 1), 1000);
|
|
185
|
+
|
|
186
|
+
let rows = st.calls.filter((e) => e.seq > sinceSeq);
|
|
187
|
+
if (canvasId) rows = rows.filter((e) => e.rec && e.rec.c === canvasId);
|
|
188
|
+
const raw = rows.slice(0, limit);
|
|
189
|
+
|
|
190
|
+
const cv = canvasId ? st.canvases.get(canvasId) : null;
|
|
191
|
+
const kind = cv ? cv.kind : (rows.length && rows[0].rec.k === 'text' ? '2d' : 'unknown');
|
|
192
|
+
|
|
193
|
+
const out = {
|
|
194
|
+
canvas_id: canvasId || null,
|
|
195
|
+
kind,
|
|
196
|
+
readability: readabilityFor(kind),
|
|
197
|
+
mode: st.mode,
|
|
198
|
+
hook_installed: st.hookInstalled,
|
|
199
|
+
pagination: {
|
|
200
|
+
returned: raw.length,
|
|
201
|
+
next_seq: raw.length ? raw[raw.length - 1].seq : null,
|
|
202
|
+
has_more: rows.length > raw.length,
|
|
203
|
+
},
|
|
204
|
+
warnings: st.warnings.slice(),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (view === 'calls') {
|
|
208
|
+
out.calls = raw.map((e) => ({ seq: e.seq, ...e.rec }));
|
|
209
|
+
out.texts = null;
|
|
210
|
+
out.regions = null;
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const texts = [];
|
|
215
|
+
const regions = [];
|
|
216
|
+
for (const e of raw) {
|
|
217
|
+
const r = e.rec;
|
|
218
|
+
if (r.k === 'text') {
|
|
219
|
+
texts.push({
|
|
220
|
+
seq: e.seq,
|
|
221
|
+
method: r.m,
|
|
222
|
+
text: r.t == null ? '' : String(r.t),
|
|
223
|
+
truncated: !!r.tt,
|
|
224
|
+
full_length: typeof r.tl === 'number' ? r.tl : null,
|
|
225
|
+
bounds_canvas: r.b || null,
|
|
226
|
+
style: r.st || null,
|
|
227
|
+
});
|
|
228
|
+
} else if (r.k === 'rect' || r.k === 'clear' || r.k === 'image' || r.k === 'path' || r.k === 'pixel') {
|
|
229
|
+
regions.push({
|
|
230
|
+
seq: e.seq,
|
|
231
|
+
kind: r.k,
|
|
232
|
+
method: r.m,
|
|
233
|
+
bounds_canvas: r.b || null,
|
|
234
|
+
source: r.s || null,
|
|
235
|
+
pixels_captured: false,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
out.texts = texts;
|
|
240
|
+
out.regions = regions;
|
|
241
|
+
out.calls = null;
|
|
242
|
+
if (kind === 'webgl' || kind === 'webgl2') {
|
|
243
|
+
out.warnings = out.warnings.concat([
|
|
244
|
+
'WebGL canvas: 只有绘制次数/shader/buffer 等结构信息,无法还原业务语义(文字常是 glyph atlas)',
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
if (regions.length) {
|
|
248
|
+
out.warnings = out.warnings.concat([
|
|
249
|
+
'regions are draw bounds, not proven interactive hit regions',
|
|
250
|
+
]);
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 环境变量开关,默认**开启**。关闭:AI_BROWSER_CANVAS_HOOK=0(或 off/false/no)。
|
|
258
|
+
*
|
|
259
|
+
* 注意:hook 只能在页面首次加载**完成之后**安装(CDP 的 await 命令在刚创建的
|
|
260
|
+
* tab 上会挂死),所以首屏已经画完的内容拿不回来 —— 需要完整历史就用
|
|
261
|
+
* ui.canvas_configure({ reload: true })。
|
|
262
|
+
*/
|
|
263
|
+
export function isCanvasHookEnabled(envValue) {
|
|
264
|
+
if (typeof envValue !== 'string') return true;
|
|
265
|
+
const v = envValue.trim().toLowerCase();
|
|
266
|
+
return !(v === '0' || v === 'off' || v === 'false' || v === 'no');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export default CanvasMonitor;
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// main/capability_probe.js — answer "how should an agent operate THIS page?"
|
|
2
|
+
//
|
|
3
|
+
// Promoted from tools/_oneoff/page_capability_probe.cjs after it reproduced,
|
|
4
|
+
// on two very different real pages, conclusions that had taken hours of manual
|
|
5
|
+
// investigation to reach. What transfers between sites is not site knowledge
|
|
6
|
+
// ("how do you connect nodes in React Flow") — it is the *decision procedure*:
|
|
7
|
+
//
|
|
8
|
+
// 1. Is the graph DOM or <canvas>? → decides whether the semantic tree
|
|
9
|
+
// can see it at all
|
|
10
|
+
// 2. Is the body inside an iframe? Same
|
|
11
|
+
// origin? → decides whether frame routing is
|
|
12
|
+
// needed to act on it
|
|
13
|
+
// 3. Does the app hang its own object off
|
|
14
|
+
// window (graph/_nodes/serialize/…)? → decides whether a structured-data
|
|
15
|
+
// path exists (by far the best one)
|
|
16
|
+
// 4. Which JSON endpoints did it call? → decides whether read/verify can go
|
|
17
|
+
// through an API
|
|
18
|
+
//
|
|
19
|
+
// and one action discipline that is not site-specific: every write must be
|
|
20
|
+
// proven by reading state back (see snapshot_diff.js / ui.diff).
|
|
21
|
+
//
|
|
22
|
+
// The route recommendation is a *recommendation*, not an oracle: it names
|
|
23
|
+
// candidates and the evidence for each, so the caller can judge semantics.
|
|
24
|
+
|
|
25
|
+
export const APP_KEY_RE = /^(app|editor|graph|store|canvas|workspace|comfy|litegraph|monaco|codemirror|diagram|flow|rh|state|editorState|doc|documentModel)$/i;
|
|
26
|
+
|
|
27
|
+
// Globals worth reporting when they look like a graph/document model.
|
|
28
|
+
// Kept as substrings so `myGraphStore` / `canvasApp` also match — exact
|
|
29
|
+
// allow-listing missed real ones before.
|
|
30
|
+
//
|
|
31
|
+
// [probe name, accessor path]. A path ending in 'typeof' tests for a METHOD
|
|
32
|
+
// and never calls it; anything else reads a property. Order is significant:
|
|
33
|
+
// first match wins, and GRAPH_RANK below scores in the same spirit.
|
|
34
|
+
export const MODEL_PROBES = [
|
|
35
|
+
['graph', ['graph']],
|
|
36
|
+
['_nodes', ['_nodes']],
|
|
37
|
+
['nodes', ['nodes']],
|
|
38
|
+
['getState', ['getState', 'typeof']],
|
|
39
|
+
['serialize', ['serialize', 'typeof']],
|
|
40
|
+
['loadGraphData', ['loadGraphData', 'typeof']],
|
|
41
|
+
['queuePrompt', ['queuePrompt', 'typeof']],
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Shared in-page scan for the capability probe. Lives here rather than being
|
|
45
|
+
// re-derived per call site: the first version of rankAppGlobals matched the
|
|
46
|
+
// hint prefix wrong, ranked every candidate -1, and silently downgraded a real
|
|
47
|
+
// page. The tables are injected as data so there is exactly one definition.
|
|
48
|
+
const APP_MODEL_SCAN_JS = `
|
|
49
|
+
var amRe = new RegExp(${JSON.stringify(APP_KEY_RE.source)}, 'i');
|
|
50
|
+
var amP = ${JSON.stringify(MODEL_PROBES)};
|
|
51
|
+
function amProbe(v, p) {
|
|
52
|
+
var c = v;
|
|
53
|
+
for (var i = 0; i < p.length; i++) {
|
|
54
|
+
if (p[i] === 'typeof') return typeof c === 'function' ? 'fn' : undefined;
|
|
55
|
+
try { c = c[p[i]]; } catch (e) { return undefined; }
|
|
56
|
+
if (c === null || c === undefined) return undefined;
|
|
57
|
+
}
|
|
58
|
+
return c;
|
|
59
|
+
}
|
|
60
|
+
function amMatch(v) {
|
|
61
|
+
for (var i = 0; i < amP.length; i++) {
|
|
62
|
+
var r = amProbe(v, amP[i][1]);
|
|
63
|
+
if (r === undefined || r === null || r === false) continue;
|
|
64
|
+
var n = amP[i][0];
|
|
65
|
+
return {
|
|
66
|
+
name: n,
|
|
67
|
+
hint: r === 'fn' ? n : n + (Array.isArray(r) ? ' (' + r.length + ' items)'
|
|
68
|
+
: typeof r === 'object' ? ' (obj)' : '')
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
// Exported so tests can execute the real probe: the table tests below assert
|
|
76
|
+
// against the hint strings this emits, and until now nothing verified that the
|
|
77
|
+
// emitter and the table agreed — the format could have drifted silently.
|
|
78
|
+
export const PROBE_JS = `(function(){
|
|
79
|
+
try {
|
|
80
|
+
${APP_MODEL_SCAN_JS}
|
|
81
|
+
var out = { url: location.href, title: document.title, topDom: 0,
|
|
82
|
+
topCanvas: 0, iframes: [], globals: [], canvases: [] };
|
|
83
|
+
|
|
84
|
+
try { out.topDom = document.querySelectorAll('*').length; } catch(e){}
|
|
85
|
+
try { out.topCanvas = document.querySelectorAll('canvas').length; } catch(e){}
|
|
86
|
+
try {
|
|
87
|
+
Array.prototype.forEach.call(document.querySelectorAll('canvas'), function(c, i){
|
|
88
|
+
var r = c.getBoundingClientRect();
|
|
89
|
+
if (r.width < 1 && r.height < 1) return;
|
|
90
|
+
out.canvases.push({ index: i, w: Math.round(r.width), h: Math.round(r.height),
|
|
91
|
+
cls: String(c.className || '').slice(0, 40) });
|
|
92
|
+
});
|
|
93
|
+
} catch(e){}
|
|
94
|
+
|
|
95
|
+
function scan(W, tag) {
|
|
96
|
+
if (!W) return;
|
|
97
|
+
var keys;
|
|
98
|
+
try { keys = Object.keys(W); } catch(e) { return; }
|
|
99
|
+
keys.forEach(function(k) {
|
|
100
|
+
if (!amRe.test(k)) return;
|
|
101
|
+
var v;
|
|
102
|
+
try { v = W[k]; } catch(e) { return; }
|
|
103
|
+
var t = (v === null ? 'null' : typeof v);
|
|
104
|
+
var hint = null;
|
|
105
|
+
try {
|
|
106
|
+
if (v && (typeof v === 'object' || typeof v === 'function')) {
|
|
107
|
+
var m = amMatch(v);
|
|
108
|
+
if (m) hint = m.hint;
|
|
109
|
+
if (!hint) hint = Object.keys(v).slice(0, 8).join(',');
|
|
110
|
+
}
|
|
111
|
+
} catch(e) {}
|
|
112
|
+
if (out.globals.length < 40) out.globals.push({ where: tag, key: k, type: t, hint: hint });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
scan(window, 'top');
|
|
117
|
+
Array.prototype.forEach.call(document.querySelectorAll('iframe'), function(f, i) {
|
|
118
|
+
var rec = { index: i, src: (f.getAttribute('src') || '').slice(0, 80),
|
|
119
|
+
sameOrigin: false, innerElements: 0, innerCanvas: 0, innerButtons: 0 };
|
|
120
|
+
try {
|
|
121
|
+
var d = f.contentDocument;
|
|
122
|
+
if (d && d.body) {
|
|
123
|
+
rec.sameOrigin = true;
|
|
124
|
+
rec.innerElements = d.querySelectorAll('*').length;
|
|
125
|
+
rec.innerCanvas = d.querySelectorAll('canvas').length;
|
|
126
|
+
rec.innerButtons = d.querySelectorAll('button,[role=button]').length;
|
|
127
|
+
scan(d.defaultView, 'iframe[' + i + ']');
|
|
128
|
+
}
|
|
129
|
+
} catch(e) {}
|
|
130
|
+
try {
|
|
131
|
+
var r = f.getBoundingClientRect();
|
|
132
|
+
rec.box = [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)];
|
|
133
|
+
} catch(e) {}
|
|
134
|
+
out.iframes.push(rec);
|
|
135
|
+
});
|
|
136
|
+
return out;
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return { __error: String(e && e.message || e) };
|
|
139
|
+
}
|
|
140
|
+
})()`;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {PageManager} pageManager
|
|
144
|
+
* @param {object} opts { tab, include_network, network_limit }
|
|
145
|
+
*/
|
|
146
|
+
export async function probeCapabilities(pageManager, opts = {}) {
|
|
147
|
+
const tid = opts.tab !== undefined && opts.tab !== null ? opts.tab : pageManager.activeTab;
|
|
148
|
+
const view = pageManager._getView(tid);
|
|
149
|
+
if (!view) return { ok: false, error: 'no-such-tab', tab: tid };
|
|
150
|
+
|
|
151
|
+
// 1. Semantic tree — the single most informative signal. A page whose whole UI
|
|
152
|
+
// is canvas shows up as a tiny tree; that is the tell.
|
|
153
|
+
let tree = null;
|
|
154
|
+
try {
|
|
155
|
+
const res = await pageManager.getTree(false, tid);
|
|
156
|
+
tree = res && res.tree;
|
|
157
|
+
} catch (e) { /* getTree timeout on a busy page must not kill the probe */ }
|
|
158
|
+
|
|
159
|
+
const flat = [];
|
|
160
|
+
(function walk(n) {
|
|
161
|
+
if (!n) return;
|
|
162
|
+
flat.push(n);
|
|
163
|
+
(n.children || []).forEach(walk);
|
|
164
|
+
})(tree);
|
|
165
|
+
|
|
166
|
+
const roles = {}, actions = {};
|
|
167
|
+
for (const n of flat) {
|
|
168
|
+
const r = n.role || '(none)';
|
|
169
|
+
roles[r] = (roles[r] || 0) + 1;
|
|
170
|
+
for (const a of (n.actions || [])) actions[a] = (actions[a] || 0) + 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 2/3. In-page probe: canvas, iframes, app globals.
|
|
174
|
+
let dom = null;
|
|
175
|
+
try {
|
|
176
|
+
dom = await pageManager.evaluate(PROBE_JS, tid);
|
|
177
|
+
} catch (e) { dom = { __error: e && e.message ? e.message : String(e) }; }
|
|
178
|
+
if (dom && dom.__error) dom = null;
|
|
179
|
+
|
|
180
|
+
// 4. JSON-ish endpoints the page already called.
|
|
181
|
+
let endpoints = [];
|
|
182
|
+
if (opts.include_network !== false) {
|
|
183
|
+
try {
|
|
184
|
+
endpoints = await collectEndpoints(pageManager, tid, opts.network_limit || 200);
|
|
185
|
+
} catch (e) { /* capture disabled — not an error worth failing on */ }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const route = recommend({ flat, dom, endpoints });
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
tab: tid,
|
|
192
|
+
url: (dom && dom.url) || view.webContents.getURL(),
|
|
193
|
+
title: (dom && dom.title) || '',
|
|
194
|
+
tree: {
|
|
195
|
+
elements: flat.length,
|
|
196
|
+
roles,
|
|
197
|
+
actions,
|
|
198
|
+
// The actionable count is the honest signal: a big tree of layout-only
|
|
199
|
+
// nodes tells an agent nothing about what it can *do*.
|
|
200
|
+
actionable: Object.values(actions).reduce((s, n) => s + n, 0),
|
|
201
|
+
},
|
|
202
|
+
canvas: {
|
|
203
|
+
top: (dom && dom.topCanvas) || 0,
|
|
204
|
+
in_frames: ((dom && dom.iframes) || []).reduce((s, f) => s + (f.innerCanvas || 0), 0),
|
|
205
|
+
visible: (dom && dom.canvases) || [],
|
|
206
|
+
},
|
|
207
|
+
iframes: (dom && dom.iframes) || [],
|
|
208
|
+
app_globals: (dom && dom.globals) || [],
|
|
209
|
+
endpoints,
|
|
210
|
+
route,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Pull JSON-ish endpoints out of the network capture.
|
|
216
|
+
* Static assets are filtered hard: media files alone number in the hundreds on
|
|
217
|
+
* canvas sites and would bury the actual API list.
|
|
218
|
+
*/
|
|
219
|
+
async function collectEndpoints(pageManager, tid, limit) {
|
|
220
|
+
const res = await pageManager.networkMonitor.list
|
|
221
|
+
? await pageManager.networkMonitor.list({ tabId: tid, limit })
|
|
222
|
+
: null;
|
|
223
|
+
const reqs = (res && (res.requests || res.items || res)) || [];
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const r of reqs) {
|
|
227
|
+
const u = String(r.url || '');
|
|
228
|
+
if (!u || u.startsWith('data:') || u.startsWith('blob:')) continue;
|
|
229
|
+
if (/\.(css|js|mjs|png|jpg|jpeg|gif|webp|svg|woff2?|ttf|otf|mp3|mp4|webm|mov|avi|wav|ogg|ico)(\?|$)/i.test(u)) continue;
|
|
230
|
+
const short = u.split('?')[0].replace(/^https?:\/\/[^/]+/, '');
|
|
231
|
+
if (seen.has(short)) continue;
|
|
232
|
+
seen.add(short);
|
|
233
|
+
out.push({ method: String(r.method || 'GET'), path: short, status: r.status });
|
|
234
|
+
if (out.length >= 40) break;
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Ranking when several globals look graph-ish. Order matters: measured on
|
|
240
|
+
// ComfyUI, `window.LiteGraph` (a library namespace whose getState() matches)
|
|
241
|
+
// sorted ahead of `window.graph` (the actual model, 13 nodes), so the
|
|
242
|
+
// recommendation pointed the agent at the library instead of the data.
|
|
243
|
+
// "Owns nodes" is what separates them: a model holds nodes, a library exports
|
|
244
|
+
// constructors.
|
|
245
|
+
//
|
|
246
|
+
// Match against the START of the hint. The probe names come from MODEL_PROBES
|
|
247
|
+
// above and come out as `_nodes (13 items)` / `graph (obj)` / `getState` — i.e.
|
|
248
|
+
// the bare property name, NOT `has ._nodes`. Matching the "has ." form silently
|
|
249
|
+
// ranked everything -1 and made the probe fall through to the generic L1_api
|
|
250
|
+
// route, which is how that mistake was caught.
|
|
251
|
+
const GRAPH_RANK = [
|
|
252
|
+
['_nodes', 100], // LiteGraph graph — the model itself
|
|
253
|
+
['nodes', 95],
|
|
254
|
+
['graph', 85],
|
|
255
|
+
['links', 80],
|
|
256
|
+
['loadGraphData', 75],
|
|
257
|
+
['serialize', 60],
|
|
258
|
+
['getState', 30], // library namespace: weaker signal
|
|
259
|
+
['queuePrompt', 25],
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
// Below this score a candidate is NOT fingerprinted into the default snapshot.
|
|
263
|
+
// `getState` (30) and `queuePrompt` (25) are what LIBRARY namespaces expose —
|
|
264
|
+
// ComfyUI's window.LiteGraph scored ahead of the real model on exactly that
|
|
265
|
+
// signal — and their state is far more likely to be volatile, which would turn
|
|
266
|
+
// every diff into a change and destroy the tool's only real job. 60 is
|
|
267
|
+
// `serialize`: an app that offers a serializer is offering its state for
|
|
268
|
+
// round-tripping, which is precisely what a fingerprint wants.
|
|
269
|
+
export const MODEL_MIN_SCORE = 60;
|
|
270
|
+
|
|
271
|
+
// Names that clear MODEL_MIN_SCORE, best first. DERIVED from GRAPH_RANK rather
|
|
272
|
+
// than written out again, so the in-page picker and rankAppGlobals can never
|
|
273
|
+
// disagree about who qualifies. GRAPH_RANK is already sorted by descending
|
|
274
|
+
// score, so array index IS rank.
|
|
275
|
+
export const MODEL_PROBE_ORDER = GRAPH_RANK
|
|
276
|
+
.filter(([, score]) => score >= MODEL_MIN_SCORE)
|
|
277
|
+
.map(([name]) => name);
|
|
278
|
+
|
|
279
|
+
function rankGlobal(g) {
|
|
280
|
+
if (!g || !g.hint) return -1;
|
|
281
|
+
const hint = String(g.hint);
|
|
282
|
+
for (const [needle, score] of GRAPH_RANK) {
|
|
283
|
+
// `startsWith` on the bare name, so "nodes (13 items)" matches "_nodes" first
|
|
284
|
+
// (higher score) rather than being caught by the looser "nodes" rule.
|
|
285
|
+
if (hint === needle || hint.startsWith(needle + ' ')) return score;
|
|
286
|
+
}
|
|
287
|
+
return -1;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Rank app-like globals, best candidate first. Exported so it can be unit
|
|
292
|
+
* tested against the exact hint strings the in-page probe produces — the first
|
|
293
|
+
* version of this got the prefix wrong ("has ._nodes" vs "_nodes"), ranked
|
|
294
|
+
* everything -1, and silently downgraded a page from L1_structured_data to a
|
|
295
|
+
* generic L1_api. Only a real site surfaced it; a table test pins it instantly.
|
|
296
|
+
*/
|
|
297
|
+
export function rankAppGlobals(globals) {
|
|
298
|
+
return (globals || [])
|
|
299
|
+
.map((g) => ({ g, score: rankGlobal(g) }))
|
|
300
|
+
.filter((x) => x.score >= 0)
|
|
301
|
+
.sort((a, b) => b.score - a.score);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The actual deliverable: which route to take, and WHY (with the evidence).
|
|
306
|
+
* Every claim cites the number that produced it, so a caller can disagree.
|
|
307
|
+
*/
|
|
308
|
+
function recommend({ flat, dom, endpoints }) {
|
|
309
|
+
const routes = [];
|
|
310
|
+
const iframes = (dom && dom.iframes) || [];
|
|
311
|
+
const globals = (dom && dom.globals) || [];
|
|
312
|
+
const topCanvas = (dom && dom.topCanvas) || 0;
|
|
313
|
+
const frameCanvas = iframes.reduce((s, f) => s + (f.innerCanvas || 0), 0);
|
|
314
|
+
const totalCanvas = topCanvas + frameCanvas;
|
|
315
|
+
const sameOriginFrame = iframes.find((f) => f.sameOrigin);
|
|
316
|
+
const ranked = rankAppGlobals(globals);
|
|
317
|
+
const graphObj = ranked.length ? ranked[0].g : null;
|
|
318
|
+
// Say so explicitly when the top candidate is a library rather than a model:
|
|
319
|
+
// acting on `LiteGraph` instead of `graph` wastes a round-trip at best.
|
|
320
|
+
const weakTop = graphObj && rankGlobal(graphObj) <= 30;
|
|
321
|
+
|
|
322
|
+
if (graphObj) {
|
|
323
|
+
routes.push({
|
|
324
|
+
route: 'L1_structured_data',
|
|
325
|
+
preferred: true,
|
|
326
|
+
why: `${graphObj.where}.${graphObj.key} looks like the app's own model (${graphObj.hint}) — read/write the whole graph as JSON`,
|
|
327
|
+
evidence: {
|
|
328
|
+
global: `${graphObj.where}.${graphObj.key}`,
|
|
329
|
+
hint: graphObj.hint,
|
|
330
|
+
// All graph-ish candidates, best first. Exposing the runners-up matters:
|
|
331
|
+
// the top pick is a judgement call, and the alternative is usually one
|
|
332
|
+
// level away (ComfyUI: `graph` is the model, `LiteGraph` the library).
|
|
333
|
+
candidates: ranked.slice(0, 4).map((x) => `${x.g.where}.${x.g.key} (${x.g.hint})`),
|
|
334
|
+
},
|
|
335
|
+
how: 'ui.evaluate to reach that object; use ui.snapshot + ui.diff around every write',
|
|
336
|
+
});
|
|
337
|
+
if (weakTop) {
|
|
338
|
+
routes.push({
|
|
339
|
+
route: 'NOTE_weak_graph_candidate',
|
|
340
|
+
preferred: false,
|
|
341
|
+
why: `the best match \`${graphObj.key}\` only proves it exposes ${graphObj.hint}, which libraries also do — check the runners-up in evidence.candidates for the object that actually holds the nodes`,
|
|
342
|
+
evidence: { candidates: ranked.slice(0, 4).map((x) => `${x.g.where}.${x.g.key} (${x.g.hint})`) },
|
|
343
|
+
how: 'inspect each candidate for a node/link collection before writing to it',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
routes.push({
|
|
348
|
+
route: 'L1_api',
|
|
349
|
+
preferred: endpoints.length > 0,
|
|
350
|
+
why: endpoints.length
|
|
351
|
+
? `no app object on window, but ${endpoints.length} JSON-ish endpoints were observed — check whether one returns the whole document`
|
|
352
|
+
: 'no app object on window and no JSON endpoints observed — data access will have to go through the UI',
|
|
353
|
+
evidence: { endpoint_count: endpoints.length, sample: endpoints.slice(0, 5).map((e) => `${e.method} ${e.path}`) },
|
|
354
|
+
how: 'ui.network_get to read one, then ui.evaluate with fetch to call it',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// "Rich tree" = enough elements AND enough role variety AND something the agent
|
|
359
|
+
// can actually act on. Element count alone is misleading — a page can expose
|
|
360
|
+
// 400 <div>s that are all layout scaffolding with nothing clickable.
|
|
361
|
+
const roleSet = new Set(flat.map((n) => n.role).filter(Boolean));
|
|
362
|
+
const actionable = flat.filter((n) => (n.actions || []).some((a) => a === 'click' || a === 'type' || a === 'setContent')).length;
|
|
363
|
+
const rich = flat.length >= 60 && roleSet.size >= 4 && actionable >= 10;
|
|
364
|
+
routes.push({
|
|
365
|
+
route: 'L2_semantic_tree',
|
|
366
|
+
preferred: !graphObj && rich,
|
|
367
|
+
why: rich
|
|
368
|
+
? `${flat.length} elements, ${roleSet.size} distinct roles, ${actionable} with a click/type action — targets can be found by role/label and driven by data-ai-id, no coordinates`
|
|
369
|
+
: `only ${flat.length} elements (${actionable} actionable)${totalCanvas ? ` and ${totalCanvas} canvas element(s)` : ''} — the UI is largely canvas-rendered, so the tree cannot see the content`,
|
|
370
|
+
evidence: {
|
|
371
|
+
elements: flat.length,
|
|
372
|
+
actionable,
|
|
373
|
+
roles: roleSet.size,
|
|
374
|
+
// Split by location: a page with 0 top-level canvases but 7 inside an
|
|
375
|
+
// iframe is a canvas app, and reporting only the total invites the reader
|
|
376
|
+
// to conclude "no canvas, must be DOM" — the opposite of the truth.
|
|
377
|
+
canvas_top: topCanvas,
|
|
378
|
+
canvas_in_frames: frameCanvas,
|
|
379
|
+
},
|
|
380
|
+
how: 'ui.get_tree to read; ui.act with the returned data-ai-id to click/type/setContent',
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
routes.push({
|
|
384
|
+
route: 'L3_pointer_gestures',
|
|
385
|
+
preferred: false,
|
|
386
|
+
why: 'only for what has no structured path (dragging nodes, drawing connections, wheel pan/zoom)',
|
|
387
|
+
evidence: {},
|
|
388
|
+
how: 'ui.act drag/press/wheel — ALWAYS followed by ui.diff against a snapshot, because these get rewritten by the app (thresholds, snapping, auto-pan)',
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
if (sameOriginFrame) {
|
|
392
|
+
routes.push({
|
|
393
|
+
route: 'NOTE_same_origin_iframe',
|
|
394
|
+
preferred: false,
|
|
395
|
+
why: `the body lives in a same-origin iframe (${sameOriginFrame.innerElements} elements, ${sameOriginFrame.innerButtons} buttons) — its content IS in the semantic tree and ui.act routes to it`,
|
|
396
|
+
evidence: { iframe: sameOriginFrame },
|
|
397
|
+
how: 'no special handling needed; act on the returned data-ai-id as usual',
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (totalCanvas > 0 && !rich) {
|
|
401
|
+
routes.push({
|
|
402
|
+
route: 'NOTE_canvas_rendered',
|
|
403
|
+
preferred: false,
|
|
404
|
+
why: `${totalCanvas} canvas element(s) (${topCanvas} top-level, ${frameCanvas} in frames) but only ${flat.length} semantic elements (${actionable} actionable) — do NOT keep trying coordinate gestures to hit canvas-drawn items; go find the app's data model instead`,
|
|
405
|
+
evidence: { canvas_top: topCanvas, canvas_in_frames: frameCanvas, elements: flat.length, actionable },
|
|
406
|
+
how: 'ui.canvas list/read can still extract text drawn via fillText (no OCR), but that is a fallback, not a primary path',
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
recommended: routes.filter((r) => r.preferred).map((r) => r.route),
|
|
412
|
+
routes,
|
|
413
|
+
discipline: 'every write must be proven by ui.diff against a ui.snapshot baseline — a gesture or act returning success is not evidence',
|
|
414
|
+
};
|
|
415
|
+
}
|