@glittering0717/ai-browser 1.3.1 → 2.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/README.md +56 -5
- package/bin/ai-browser.cjs +41 -4
- package/examples/AGENT_INSTRUCTIONS.md +49 -164
- package/llms-install.md +67 -5
- package/package.json +3 -1
- package/scripts/cli_call.cjs +222 -0
- package/scripts/ensure_running.cjs +195 -0
- package/scripts/skill.cjs +133 -0
- package/skills/ai-browser/SKILL.md +56 -0
- package/skills/ai-browser/references/cli.md +81 -0
- package/skills/ai-browser/references/hard-pages.md +52 -0
- package/skills/ai-browser/references/network.md +39 -0
- package/skills/ai-browser/references/tasks.md +44 -0
- package/skills/ai-browser/references/troubleshooting.md +31 -0
- package/skills/content-publish/SKILL.md +61 -0
- package/skills/read-webpage/SKILL.md +52 -0
- package/skills/web-network-monitor/SKILL.md +52 -0
- package/src/main/axExtractor.js +50 -6
- package/src/main/capability_probe.js +93 -5
- package/src/main/index.js +99 -6
- package/src/main/mcp_args.js +122 -0
- package/src/main/mcp_server.js +72 -159
- package/src/main/mcp_tools.js +127 -86
- package/src/main/mcp_ws.js +7 -1
- package/src/main/page_manager.js +481 -71
- package/src/main/window_open_shim_source.js +117 -0
- package/src/preload/bridge.cjs +22 -4
- package/src/preload/extractor.cjs +161 -17
- package/src/shared/doc_coverage.js +264 -0
- package/src/shared/effect_state.js +160 -0
- package/src/shared/frame_probe.cjs +91 -1
- package/src/shared/frame_read_source.js +260 -0
- package/src/shared/opened_tab.js +71 -0
- package/src/shared/ref_epoch.js +99 -0
- package/src/shared/tab_hooks.js +33 -0
- package/src/shared/version.js +1 -1
- package/tools/coverage_probe.cjs +111 -9
|
@@ -115,7 +115,12 @@ export const PROBE_JS = `(function(){
|
|
|
115
115
|
|
|
116
116
|
scan(window, 'top');
|
|
117
117
|
Array.prototype.forEach.call(document.querySelectorAll('iframe'), function(f, i) {
|
|
118
|
-
|
|
118
|
+
// src_attr 是原始属性(可能是相对地址,也可能根本没有),src 是解析后的
|
|
119
|
+
// 绝对地址 —— 后者才能直接喂给 ui.new_tab / ui.navigate。此前只报相对
|
|
120
|
+
// 地址,等于告诉调用方"内容在那",却给了一个不能用的地址。
|
|
121
|
+
var rec = { index: i,
|
|
122
|
+
src: String(f.src || ''),
|
|
123
|
+
src_attr: (f.getAttribute('src') || '').slice(0, 120),
|
|
119
124
|
sameOrigin: false, innerElements: 0, innerCanvas: 0, innerButtons: 0 };
|
|
120
125
|
try {
|
|
121
126
|
var d = f.contentDocument;
|
|
@@ -151,9 +156,13 @@ export async function probeCapabilities(pageManager, opts = {}) {
|
|
|
151
156
|
// 1. Semantic tree — the single most informative signal. A page whose whole UI
|
|
152
157
|
// is canvas shows up as a tiny tree; that is the tell.
|
|
153
158
|
let tree = null;
|
|
159
|
+
let treeCtx = null;
|
|
154
160
|
try {
|
|
155
161
|
const res = await pageManager.getTree(false, tid);
|
|
156
162
|
tree = res && res.tree;
|
|
163
|
+
// 保留 context:frame_hints 里写着"跨进程帧并进去多少、还剩多少没进树",
|
|
164
|
+
// 那是"树薄"最直接的解释,不该只留在 get_tree 的回执里让探针重新猜一遍。
|
|
165
|
+
treeCtx = (res && res.context) || null;
|
|
157
166
|
} catch (e) { /* getTree timeout on a busy page must not kill the probe */ }
|
|
158
167
|
|
|
159
168
|
const flat = [];
|
|
@@ -185,7 +194,7 @@ export async function probeCapabilities(pageManager, opts = {}) {
|
|
|
185
194
|
} catch (e) { /* capture disabled — not an error worth failing on */ }
|
|
186
195
|
}
|
|
187
196
|
|
|
188
|
-
const route = recommend({ flat, dom, endpoints });
|
|
197
|
+
const route = recommend({ flat, dom, endpoints, coverage: (treeCtx && treeCtx.page_coverage) || null });
|
|
189
198
|
return {
|
|
190
199
|
ok: true,
|
|
191
200
|
tab: tid,
|
|
@@ -205,6 +214,12 @@ export async function probeCapabilities(pageManager, opts = {}) {
|
|
|
205
214
|
visible: (dom && dom.canvases) || [],
|
|
206
215
|
},
|
|
207
216
|
iframes: (dom && dom.iframes) || [],
|
|
217
|
+
// 树对跨进程帧的覆盖率(来自 get_tree 的回执,不是探针自己算的)。
|
|
218
|
+
// null = 这个页面没有跨进程帧,或者读树失败。
|
|
219
|
+
frame_hints: (treeCtx && treeCtx.frame_hints) || null,
|
|
220
|
+
// 整页文字覆盖率(树里的正文字数 / 主文档 + 帧里看得见的字数)。
|
|
221
|
+
// "树薄"到底薄到什么程度,由这个数字回答,而不是由探针猜原因。
|
|
222
|
+
page_coverage: (treeCtx && treeCtx.page_coverage) || null,
|
|
208
223
|
app_globals: (dom && dom.globals) || [],
|
|
209
224
|
endpoints,
|
|
210
225
|
route,
|
|
@@ -301,11 +316,48 @@ export function rankAppGlobals(globals) {
|
|
|
301
316
|
.sort((a, b) => b.score - a.score);
|
|
302
317
|
}
|
|
303
318
|
|
|
319
|
+
/**
|
|
320
|
+
* "树为什么这么薄" —— 三种归因必须分开说。
|
|
321
|
+
*
|
|
322
|
+
* 事故(2026-10-19,超星泛雅学习页):树只有 27 个节点、13 个可操作,探针**自己
|
|
323
|
+
* 测到了** 1 个跨进程 iframe(`iframe: 跨进程 1 个`、`canvas 顶层级 0`),却在
|
|
324
|
+
* 结论里写 "the UI is largely canvas-rendered, so the tree cannot see the content"。
|
|
325
|
+
* 归因错了:内容看不见是因为它在 iframe 里,不是因为它画在 canvas 上。这个错误
|
|
326
|
+
* 直接把 agent 推向 L3 指针手势 —— 也就是"在 canvas 上瞎点",而正确动作是去读
|
|
327
|
+
* 那个 frame。
|
|
328
|
+
*
|
|
329
|
+
* 原来的写法是模板串里无条件带上 "largely canvas-rendered",只有前半句
|
|
330
|
+
* `${totalCanvas ? ...}` 受条件控制。**"树薄 = canvas" 曾经是唯一已知原因,
|
|
331
|
+
* 后来 iframe 成了更常见的原因,这句文案却没跟着改。**
|
|
332
|
+
*
|
|
333
|
+
* @param {{elements:number, actionable:number, canvasTop:number, canvasInFrames:number, opaqueFrames:Array}} input
|
|
334
|
+
*/
|
|
335
|
+
export function describeThinTree(input) {
|
|
336
|
+
const o = input && typeof input === 'object' ? input : {};
|
|
337
|
+
const elements = Number(o.elements) || 0;
|
|
338
|
+
const actionable = Number(o.actionable) || 0;
|
|
339
|
+
const canvasTop = Number(o.canvasTop) || 0;
|
|
340
|
+
const canvasInFrames = Number(o.canvasInFrames) || 0;
|
|
341
|
+
const canvas = canvasTop + canvasInFrames;
|
|
342
|
+
const frames = (Array.isArray(o.opaqueFrames) ? o.opaqueFrames : []).filter(Boolean);
|
|
343
|
+
const head = `only ${elements} elements (${actionable} actionable)`;
|
|
344
|
+
|
|
345
|
+
if (canvas > 0) {
|
|
346
|
+
return `${head} and ${canvas} canvas element(s) (${canvasTop} top-level, ${canvasInFrames} in frames) — the UI is largely canvas-rendered, so the tree cannot see the content`;
|
|
347
|
+
}
|
|
348
|
+
if (frames.length) {
|
|
349
|
+
const first = frames.find((f) => f && f.src) || frames[0];
|
|
350
|
+
const where = first && first.src ? ` (first: ${String(first.src).slice(0, 100)})` : '';
|
|
351
|
+
return `${head} and ${frames.length} cross-process iframe(s) the top document cannot read${where} — the content is inside those frames, NOT on a canvas; the tree merges their nodes, and the frame src is directly navigable`;
|
|
352
|
+
}
|
|
353
|
+
return `${head}, no canvas and no cross-process frame — the page is either genuinely sparse (a form, a stub) or still loading; verify with ui.screenshot or retry once it settles before concluding the tree cannot see the content`;
|
|
354
|
+
}
|
|
355
|
+
|
|
304
356
|
/**
|
|
305
357
|
* The actual deliverable: which route to take, and WHY (with the evidence).
|
|
306
358
|
* Every claim cites the number that produced it, so a caller can disagree.
|
|
307
359
|
*/
|
|
308
|
-
function recommend({ flat, dom, endpoints }) {
|
|
360
|
+
export function recommend({ flat, dom, endpoints, coverage }) {
|
|
309
361
|
const routes = [];
|
|
310
362
|
const iframes = (dom && dom.iframes) || [];
|
|
311
363
|
const globals = (dom && dom.globals) || [];
|
|
@@ -313,6 +365,14 @@ function recommend({ flat, dom, endpoints }) {
|
|
|
313
365
|
const frameCanvas = iframes.reduce((s, f) => s + (f.innerCanvas || 0), 0);
|
|
314
366
|
const totalCanvas = topCanvas + frameCanvas;
|
|
315
367
|
const sameOriginFrame = iframes.find((f) => f.sameOrigin);
|
|
368
|
+
// 跨进程、且**有尺寸**的帧:内容在另一个渲染进程里,顶层文档读不到。广告/埋点
|
|
369
|
+
// 帧通常是 0×0,不算。这是"树薄"最常见的真实原因(泛雅、B站图文、掘金都属这类)。
|
|
370
|
+
const opaqueFrames = iframes.filter((f) => {
|
|
371
|
+
if (!f || f.sameOrigin) return false;
|
|
372
|
+
const box = f.box;
|
|
373
|
+
if (!Array.isArray(box)) return true; // 拿不到尺寸时按"可能是内容帧"处理
|
|
374
|
+
return (Number(box[2]) || 0) > 0 && (Number(box[3]) || 0) > 0;
|
|
375
|
+
});
|
|
316
376
|
const ranked = rankAppGlobals(globals);
|
|
317
377
|
const graphObj = ranked.length ? ranked[0].g : null;
|
|
318
378
|
// Say so explicitly when the top candidate is a library rather than a model:
|
|
@@ -363,10 +423,23 @@ const actionable = flat.filter((n) => (n.actions || []).some((a) => a === 'click
|
|
|
363
423
|
const rich = flat.length >= 60 && roleSet.size >= 4 && actionable >= 10;
|
|
364
424
|
routes.push({
|
|
365
425
|
route: 'L2_semantic_tree',
|
|
366
|
-
|
|
426
|
+
// 内容藏在跨进程帧里时,树仍然是**唯一**的结构化通路(默认读法会自动把帧里
|
|
427
|
+
// 的节点并进来),所以它该被推荐 —— 此前这种页面上 recommended 是**空数组**,
|
|
428
|
+
// 调用方问"走哪条路"得到一个空答案。
|
|
429
|
+
preferred: !graphObj && (rich || (opaqueFrames.length > 0 && totalCanvas === 0)),
|
|
367
430
|
why: rich
|
|
368
431
|
? `${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
|
-
|
|
432
|
+
// 树薄时,把**实测的覆盖率**接在归因后面:归因说明"为什么薄",数字说明
|
|
433
|
+
// "薄到什么程度"。没有数字的归因,就是这次事故里的那句 canvas。
|
|
434
|
+
: describeThinTree({
|
|
435
|
+
elements: flat.length,
|
|
436
|
+
actionable,
|
|
437
|
+
canvasTop: topCanvas,
|
|
438
|
+
canvasInFrames: frameCanvas,
|
|
439
|
+
opaqueFrames,
|
|
440
|
+
}) + (coverage && coverage.percent != null
|
|
441
|
+
? ` [tree carries ${coverage.percent}% of the page's visible text: ${coverage.tree_chars}/${coverage.page_chars} chars]`
|
|
442
|
+
: ''),
|
|
370
443
|
evidence: {
|
|
371
444
|
elements: flat.length,
|
|
372
445
|
actionable,
|
|
@@ -376,10 +449,25 @@ const rich = flat.length >= 60 && roleSet.size >= 4 && actionable >= 10;
|
|
|
376
449
|
// to conclude "no canvas, must be DOM" — the opposite of the truth.
|
|
377
450
|
canvas_top: topCanvas,
|
|
378
451
|
canvas_in_frames: frameCanvas,
|
|
452
|
+
// 同样的道理适用于 iframe:说"有几个帧"不够,要说"哪几个帧,地址是什么"。
|
|
453
|
+
cross_process_frames: opaqueFrames.length,
|
|
454
|
+
canvas_rendered: totalCanvas > 0,
|
|
379
455
|
},
|
|
380
456
|
how: 'ui.get_tree to read; ui.act with the returned data-ai-id to click/type/setContent',
|
|
381
457
|
});
|
|
382
458
|
|
|
459
|
+
if (opaqueFrames.length && !rich) {
|
|
460
|
+
routes.push({
|
|
461
|
+
route: 'NOTE_cross_process_iframe',
|
|
462
|
+
preferred: false,
|
|
463
|
+
why: `${opaqueFrames.length} cross-process iframe(s) hold content the top document cannot read — the semantic tree splices their nodes in (context.frame_hints tells you how much actually made it), and .frames[].src is absolute`,
|
|
464
|
+
evidence: {
|
|
465
|
+
frames: opaqueFrames.slice(0, 4).map((f) => ({ src: f.src || null, box: f.box || null })),
|
|
466
|
+
},
|
|
467
|
+
how: 'ui.get_tree merges frame nodes automatically; if context.frame_hints.text_coverage is low, ui.new_tab / ui.navigate to frames[].src and read the frame as a top-level document',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
383
471
|
routes.push({
|
|
384
472
|
route: 'L3_pointer_gestures',
|
|
385
473
|
preferred: false,
|
package/src/main/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { startWSServer } from './ws_server.js';
|
|
|
7
7
|
import { PageManager } from './page_manager.js';
|
|
8
8
|
import { config } from '../shared/config.js';
|
|
9
9
|
import { parseWatchdogTargets, isWatchdogEnabled } from '../shared/watchdog.js';
|
|
10
|
+
import { wrapSetActive } from '../shared/tab_hooks.js';
|
|
10
11
|
import { initErrorLog, recordError, markShutdown, getLogPath } from './error_log.js';
|
|
11
12
|
import errorBanner from '../shared/error_banner.cjs';
|
|
12
13
|
|
|
@@ -299,6 +300,12 @@ let navStateInterval = null;
|
|
|
299
300
|
let navStateWc = null;
|
|
300
301
|
let networkWatchdog = null;
|
|
301
302
|
let isQuitting = false;
|
|
303
|
+
// 退出时固化会话 cookie 的一次性开关:cleanupAndQuit 分流用(见其注释),
|
|
304
|
+
// 固化期间的其他重入调用被忽略,固化完成后经 _finishCleanup 继续退出。
|
|
305
|
+
let cookiePersistDone = false;
|
|
306
|
+
// 退出流程总开关:第一条退出路径(ui.quit / 关窗 / before-quit)置位后,
|
|
307
|
+
// 其余路径全部忽略;固化完成回来时走 _finishCleanup 而不是再进这里。
|
|
308
|
+
let cleanupStarted = false;
|
|
302
309
|
|
|
303
310
|
// === Single instance lock — second launch just focuses the existing window ===
|
|
304
311
|
const gotLock = app.requestSingleInstanceLock();
|
|
@@ -492,12 +499,13 @@ function createWindow() {
|
|
|
492
499
|
wc.on('did-navigate-in-page', push);
|
|
493
500
|
wc.on('page-title-updated', push);
|
|
494
501
|
};
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
502
|
+
// 包一层只为了挂「地址栏跟随」这块副作用,返回值必须原样透传 ——
|
|
503
|
+
// ws_server 的 ui.set_active_tab 靠它回 { ok }(2026-10-19 测到过包装吞掉
|
|
504
|
+
// 返回值导致 agent 收到空回执的事故,见 shared/tab_hooks.js 注释)。
|
|
505
|
+
wrapSetActive(pageManager, (tabId) => {
|
|
498
506
|
wireNavState(tabId);
|
|
499
507
|
pushNavState();
|
|
500
|
-
};
|
|
508
|
+
});
|
|
501
509
|
wireNavState(pageManager.activeTab);
|
|
502
510
|
// 兜底轮询:只覆盖上面事件没抓到的情况(低频,成本可忽略)
|
|
503
511
|
navStateInterval = setInterval(pushNavState, 1500);
|
|
@@ -799,9 +807,86 @@ app.whenReady().then(() => {
|
|
|
799
807
|
}
|
|
800
808
|
});
|
|
801
809
|
|
|
810
|
+
// Chromium 默认在退出时**清除会话 cookie**(无过期时间的那些)。扫码/账密
|
|
811
|
+
// 登录产生的 JWT、SESSION 基本都是会话 cookie —— 于是 ai-browser 每次
|
|
812
|
+
// ui.quit / 关窗重启都掉登录态,agent 侧表现为"重启后站点又让我登录"。
|
|
813
|
+
// 退出前把它们逐个 set 成带远期过期时间的持久 cookie(值不变,只获得
|
|
814
|
+
// 落盘资格;服务端本来就认的是 value,本地过期时间不影响校验)。
|
|
815
|
+
// before-quit / window-all-closed / ui.quit 三条退出路径都经 cleanupAndQuit,
|
|
816
|
+
// 在那里先等这一步做完再真正 app.quit()。
|
|
817
|
+
async function persistSessionCookies() {
|
|
818
|
+
try {
|
|
819
|
+
const ses = session.fromPartition(config.viewPartition);
|
|
820
|
+
const all = await ses.cookies.get({});
|
|
821
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
822
|
+
const horizon = nowSec + 400 * 24 * 3600; // ~13 个月,长于任何服务端会话
|
|
823
|
+
const isDurable = (c) => !!(c.expirationDate && c.expirationDate > nowSec + 7 * 24 * 3600);
|
|
824
|
+
const todo = all.filter((c) => !isDurable(c));
|
|
825
|
+
let n = 0;
|
|
826
|
+
for (let i = 0; i < todo.length; i++) {
|
|
827
|
+
const c = todo[i];
|
|
828
|
+
const host = (c.domain || '').replace(/^\./, '');
|
|
829
|
+
if (!host) continue;
|
|
830
|
+
const details = {
|
|
831
|
+
url: (c.secure ? 'https://' : 'http://') + host + (c.path || '/'),
|
|
832
|
+
name: c.name,
|
|
833
|
+
value: c.value,
|
|
834
|
+
path: c.path || '/',
|
|
835
|
+
secure: !!c.secure,
|
|
836
|
+
httpOnly: !!c.httpOnly,
|
|
837
|
+
expirationDate: horizon,
|
|
838
|
+
};
|
|
839
|
+
// 域 cookie(.example.com)要显式带 domain;host-only 的绝不能带,
|
|
840
|
+
// 带了会从"仅本主机"放宽成"子域共享",是安全隐患。
|
|
841
|
+
if (c.domain && c.domain.startsWith('.')) details.domain = c.domain;
|
|
842
|
+
if (c.sameSite && c.sameSite !== 'unspecified') details.sameSite = c.sameSite;
|
|
843
|
+
try {
|
|
844
|
+
await ses.cookies.set(details);
|
|
845
|
+
n++;
|
|
846
|
+
} catch (e) {
|
|
847
|
+
console.error('[index] persist cookie failed:', c.name, e && e.message);
|
|
848
|
+
}
|
|
849
|
+
// 分批让出:Chromium 的 cookie store 是 grouped commit(几十毫秒一批),
|
|
850
|
+
// 一口气把全部 set 发完,提交永远追不上 —— app.quit() 一到,没提交的
|
|
851
|
+
// 全丢(实测 86 个 set 全部"成功"但磁盘只剩 2 个,2026-10-19)。
|
|
852
|
+
// 每 10 个停 100ms,让每一批都有机会落盘。
|
|
853
|
+
if (i % 10 === 9) await new Promise((r) => setTimeout(r, 100));
|
|
854
|
+
}
|
|
855
|
+
// 最后一组的 grouped commit 也要时间,等 2 秒再让 cleanupAndQuit 继续。
|
|
856
|
+
if (n > 0) {
|
|
857
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
858
|
+
console.log(`[index] persisted ${n} session cookies before quit`);
|
|
859
|
+
}
|
|
860
|
+
} catch (e) {
|
|
861
|
+
console.error('[index] persistSessionCookies failed:', e && e.message);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
802
865
|
function cleanupAndQuit() {
|
|
803
|
-
|
|
866
|
+
// cleanupStarted 防的是"多条退出路径同时触发"(ui.quit + window close +
|
|
867
|
+
// before-quit 可能叠着来),它必须在第一次进入时就置位;isQuitting 只被
|
|
868
|
+
// watchdog 等查询用。
|
|
869
|
+
if (cleanupStarted) return;
|
|
870
|
+
cleanupStarted = true;
|
|
804
871
|
isQuitting = true;
|
|
872
|
+
// 先固化会话 cookie,再走原有收尾。异步等完成(通常几百毫秒到几秒)——
|
|
873
|
+
// 直接 app.quit() 会把还没提交的 cookies.set 全部打断,等于没固化。
|
|
874
|
+
// 注意:finally 回来的这次调用不能被 cleanupStarted 挡掉(第一版就是
|
|
875
|
+
// 这么写成死锁的 —— app.quit() 永远执行不到,app 退不掉),所以分流:
|
|
876
|
+
// 固化中的重入走 _finishCleanup,其他重入直接忽略。
|
|
877
|
+
if (!cookiePersistDone) {
|
|
878
|
+
cookiePersistDone = true;
|
|
879
|
+
persistSessionCookies()
|
|
880
|
+
.catch(() => {})
|
|
881
|
+
.finally(() => { _finishCleanup(); });
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
_finishCleanup();
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// 真正的收尾 + app.quit()。与 cleanupAndQuit 分开,避免防重入标志把
|
|
888
|
+
// "固化完成后回来继续退出"这条必经之路也挡掉。
|
|
889
|
+
function _finishCleanup() {
|
|
805
890
|
// 收尾行:日志里**没有** shutdown 行,就说明上一次是硬退(崩溃 / 被 kill)——
|
|
806
891
|
// 这是事后判断"到底有没有崩过"最直接的依据。
|
|
807
892
|
try { markShutdown('cleanup'); } catch {}
|
|
@@ -827,4 +912,12 @@ function cleanupAndQuit() {
|
|
|
827
912
|
}
|
|
828
913
|
|
|
829
914
|
app.on('window-all-closed', cleanupAndQuit);
|
|
830
|
-
|
|
915
|
+
// before-quit 里 preventDefault:固化会话 cookie 是异步的(要等 cookies.set
|
|
916
|
+
// 分批提交完),不拦住默认退出流程的话窗口/视图先被拆,固化就写一半。
|
|
917
|
+
// cleanupStarted 之后的第二次 before-quit(我们自己 app.quit() 触发的)
|
|
918
|
+
// 不再拦截,直接放行。
|
|
919
|
+
app.on('before-quit', (e) => {
|
|
920
|
+
if (cleanupStarted) return;
|
|
921
|
+
e.preventDefault();
|
|
922
|
+
cleanupAndQuit();
|
|
923
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// main/mcp_args.js — MCP 参数映射:对外分组,对内扁平
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有这一层:
|
|
4
|
+
// 2026-10 之前,工具的专属参数被**拍平**到顶层(browse_act 27 个顶层参数),
|
|
5
|
+
// agent 每次要读完 27 个才能找到自己需要的 3 个,而且平面命名空间必然撞车。
|
|
6
|
+
// 实际撞过:drag 的 dx/dy 与 wheel 的 delta_x/delta_y 都往 params.dx/dy 上写,
|
|
7
|
+
// 对象字面量后者胜出 → `browse_act({action:'drag', dx:100})` 的 dx 被清成
|
|
8
|
+
// undefined,拖拽按偏移量的用法静默失效(无异常、无日志)。
|
|
9
|
+
//
|
|
10
|
+
// 现在分成三层:
|
|
11
|
+
// schema(mcp_tools.js) 只暴露分组,agent 少读
|
|
12
|
+
// 本文件 分组 → 扁平,单一映射点
|
|
13
|
+
// page_manager 只认扁平(保持不动,避免第二套名字)
|
|
14
|
+
//
|
|
15
|
+
// ⚠️ 向后兼容是硬要求:**旧的扁平参数继续被接受**。已经写好的脚本和提示词
|
|
16
|
+
// 不该因为我们的 API 整理而失效。分组只是"读起来更省",不是"旧写法作废"。
|
|
17
|
+
|
|
18
|
+
/** null/undefined 都退回 fallback;0 和 false 是有效值,必须保留。 */
|
|
19
|
+
function pick(v, fallback) {
|
|
20
|
+
return v === undefined || v === null ? fallback : v;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* browse_act 的参数映射。
|
|
25
|
+
* 返回 page_manager `ui.act` 认识的那套扁平 params。
|
|
26
|
+
*/
|
|
27
|
+
export function actParams(args = {}) {
|
|
28
|
+
const action = typeof args.action === 'string' ? args.action : '';
|
|
29
|
+
const click = args.click || {};
|
|
30
|
+
const read = args.read || {};
|
|
31
|
+
const press = args.press || {};
|
|
32
|
+
const wheel = args.wheel || {};
|
|
33
|
+
const drag = args.drag || {};
|
|
34
|
+
|
|
35
|
+
const params = {
|
|
36
|
+
text: args.text,
|
|
37
|
+
html: args.html,
|
|
38
|
+
value: args.value,
|
|
39
|
+
file: args.file,
|
|
40
|
+
timeout_ms: args.timeout_ms,
|
|
41
|
+
offset: pick(read.offset, args.offset),
|
|
42
|
+
limit: pick(read.limit, args.limit),
|
|
43
|
+
url: pick(click.url, args.url),
|
|
44
|
+
keep_tab: pick(click.keep_tab, args.keep_tab),
|
|
45
|
+
// 点完等新标签记录出现的时间(默认 150ms,0 = 不等)。见 page_manager._awaitOpenedTab。
|
|
46
|
+
opened_tab_wait_ms: pick(click.opened_tab_wait_ms, args.opened_tab_wait_ms),
|
|
47
|
+
key: pick(press.key, args.key),
|
|
48
|
+
keys: pick(press.keys, args.keys),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// drag 与 wheel 在 page_manager 里共用 dx/dy 两个字段,由 action 决定语义。
|
|
52
|
+
// 所以这里**必须按 action 二选一**,绝不能两个分支都无条件写 —— 那正是
|
|
53
|
+
// 上面说的覆盖事故的来源。
|
|
54
|
+
if (action === 'drag') {
|
|
55
|
+
params.to_target = pick(drag.target_to, args.to_target);
|
|
56
|
+
params.dx = pick(drag.dx, args.dx);
|
|
57
|
+
params.dy = pick(drag.dy, args.dy);
|
|
58
|
+
params.to_x = pick(drag.to && drag.to.x, args.to_x);
|
|
59
|
+
params.to_y = pick(drag.to && drag.to.y, args.to_y);
|
|
60
|
+
params.from_anchor = pick(drag.from && drag.from.anchor, pick(drag.from_anchor, args.from_anchor));
|
|
61
|
+
params.to_anchor = pick(drag.to && drag.to.anchor, pick(drag.to_anchor, args.to_anchor));
|
|
62
|
+
params.hold = pick(drag.hold, args.hold);
|
|
63
|
+
params.button = pick(drag.button, args.button);
|
|
64
|
+
|
|
65
|
+
// 起点:显式坐标(可无 target,用于在空白画布上起拖)
|
|
66
|
+
const from = drag.from;
|
|
67
|
+
if (from && from.x !== undefined && from.y !== undefined) {
|
|
68
|
+
params.from = { x: from.x, y: from.y };
|
|
69
|
+
} else if (args.from_x !== undefined && args.from_y !== undefined) {
|
|
70
|
+
params.from = { x: args.from_x, y: args.from_y };
|
|
71
|
+
}
|
|
72
|
+
} else if (action === 'wheel') {
|
|
73
|
+
params.dx = pick(wheel.dx, args.delta_x);
|
|
74
|
+
params.dy = pick(wheel.dy, args.delta_y);
|
|
75
|
+
params.hold = pick(wheel.hold, args.hold);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return params;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* browse_network 的参数映射。
|
|
83
|
+
* list/clear/configure 走 ui.network_<op>,get 走 ui.network_get。
|
|
84
|
+
*/
|
|
85
|
+
export function networkParams(args = {}) {
|
|
86
|
+
const f = args.filters || {};
|
|
87
|
+
const inc = args.include || {};
|
|
88
|
+
const off = args.body_offset || {};
|
|
89
|
+
const cfg = args.configure || {};
|
|
90
|
+
|
|
91
|
+
const statusMin = pick(f.status_min, args.status_min);
|
|
92
|
+
const statusMax = pick(f.status_max, args.status_max);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
// list / clear / configure
|
|
96
|
+
tab: args.tab,
|
|
97
|
+
method: pick(f.method, args.method),
|
|
98
|
+
url_contains: pick(f.url_contains, args.url_contains),
|
|
99
|
+
status:
|
|
100
|
+
statusMin !== undefined || statusMax !== undefined
|
|
101
|
+
? { min: statusMin, max: statusMax }
|
|
102
|
+
: undefined,
|
|
103
|
+
resource_type: pick(f.resource_type, args.resource_type),
|
|
104
|
+
state: pick(f.state, args.state),
|
|
105
|
+
started_after: pick(f.started_after, args.started_after),
|
|
106
|
+
started_before: pick(f.started_before, args.started_before),
|
|
107
|
+
limit: pick(f.limit, args.limit),
|
|
108
|
+
before_seq: pick(f.before_seq, args.before_seq),
|
|
109
|
+
enabled: pick(cfg.enabled, args.enabled),
|
|
110
|
+
capture_bodies: pick(cfg.capture_bodies, args.capture_bodies),
|
|
111
|
+
// get
|
|
112
|
+
network_id: args.network_id,
|
|
113
|
+
include_request_headers: pick(inc.request_headers, args.include_request_headers),
|
|
114
|
+
include_request_body: pick(inc.request_body, args.include_request_body),
|
|
115
|
+
include_response_headers: pick(inc.response_headers, args.include_response_headers),
|
|
116
|
+
include_response_body: pick(inc.response_body, args.include_response_body),
|
|
117
|
+
include_sensitive_headers: pick(inc.sensitive_headers, args.include_sensitive_headers),
|
|
118
|
+
body_limit: pick(inc.body_limit, args.body_limit),
|
|
119
|
+
request_body_offset: pick(off.request, args.request_body_offset),
|
|
120
|
+
response_body_offset: pick(off.response, args.response_body_offset),
|
|
121
|
+
};
|
|
122
|
+
}
|