@glittering0717/ai-browser 1.3.0 → 2.0.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.
@@ -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
- var rec = { index: i, src: (f.getAttribute('src') || '').slice(0, 80),
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
- preferred: !graphObj && rich,
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
- : `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`,
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
- const origSetActive = pageManager.setActive.bind(pageManager);
496
- pageManager.setActive = function (tabId) {
497
- origSetActive(tabId);
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
- if (isQuitting) return;
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
- app.on('before-quit', cleanupAndQuit);
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
+ }
@@ -18,6 +18,7 @@ import { fileURLToPath } from 'node:url';
18
18
  // self-heals by running electron's bundled installer on first launch.
19
19
  import electronResolve from '../../scripts/electron_resolve.cjs';
20
20
  import { MCP_TOOLS } from './mcp_tools.js';
21
+ import { actParams, networkParams } from './mcp_args.js';
21
22
  import { evaluateGuardError } from '../shared/guards.js';
22
23
  // 截图体积上限(与 WS 侧共用同一把尺):超过就不把图塞进上下文。
23
24
  import screenshotOpts from '../shared/screenshot_opts.cjs';
@@ -178,39 +179,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
178
179
  }
179
180
 
180
181
  case 'browse_act': {
182
+ // 参数映射收在 mcp_args.js 单点完成:schema 对 agent 暴露分组,
183
+ // page_manager 只认扁平。旧扁平参数继续被接受(向后兼容)。
181
184
  const result = await wsClient.call('ui.act', {
182
185
  action: args.action,
183
186
  target: args.target,
184
- params: {
185
- text: args.text,
186
- value: args.value,
187
- url: args.url,
188
- keep_tab: args.keep_tab,
189
- file: args.file,
190
- offset: args.offset,
191
- limit: args.limit,
192
- // drag:终点与端点锚点。to_x/to_y 是绝对视口坐标(用于拖到空白处,
193
- // 例如平移画布);to_target 是落到另一个元素上(例如连线)。
194
- // 这些名字与 raw WS 层完全一致 —— 归一由 page_manager._cdpDrag 单点负责,
195
- // 这里不再做第二套映射(两套名字 = 迟早有一层不认识)。
196
- to_target: args.to_target,
197
- dx: args.dx,
198
- dy: args.dy,
199
- to_x: args.to_x,
200
- to_y: args.to_y,
201
- from_anchor: args.from_anchor,
202
- from: (args.from_x !== undefined && args.from_y !== undefined)
203
- ? { x: args.from_x, y: args.from_y }
204
- : undefined,
205
- to_anchor: args.to_anchor,
206
- hold: args.hold,
207
- button: args.button,
208
- // press:单个键或按键序列
209
- key: args.key,
210
- keys: args.keys,
211
- dx: args.delta_x,
212
- dy: args.delta_y,
213
- },
187
+ params: actParams(args),
214
188
  tab: args.tab
215
189
  });
216
190
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
@@ -276,54 +250,63 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
276
250
  return { content: [{ type: 'text', text: JSON.stringify(slim) }] };
277
251
  }
278
252
 
279
- case 'browse_list_tabs': {
280
- const result = await wsClient.call('ui.list_tabs', {});
281
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
282
- }
283
-
284
- case 'browse_new_tab': {
285
- const result = await wsClient.call('ui.new_tab', { url: args.url || null });
286
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
287
- }
288
-
289
- case 'browse_close_tab': {
290
- const result = await wsClient.call('ui.close_tab', { tab: args.tab });
291
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
292
- }
293
-
294
- case 'browse_set_active_tab': {
295
- const result = await wsClient.call('ui.set_active_tab', { tab: args.tab });
296
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
253
+ case 'browse_tabs': {
254
+ // 4 个单动词工具(list/new/close/set_active)合并为一个。
255
+ // 理由:它们操作同一个实体(标签页),每个只承载一个动词,是工具数量
256
+ // 膨胀里最没有道理的一种。合并后参数只有 op/tab/url 三个。
257
+ const op = args.op;
258
+ if (op === 'list') {
259
+ const result = await wsClient.call('ui.list_tabs', {});
260
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
261
+ }
262
+ if (op === 'new') {
263
+ const result = await wsClient.call('ui.new_tab', { url: args.url || null });
264
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
265
+ }
266
+ if (op === 'close') {
267
+ const result = await wsClient.call('ui.close_tab', { tab: args.tab });
268
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
269
+ }
270
+ if (op === 'switch') {
271
+ const result = await wsClient.call('ui.set_active_tab', { tab: args.tab });
272
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
273
+ }
274
+ return {
275
+ content: [{ type: 'text', text: `Error: unknown op "${op}" — use list / new / close / switch` }],
276
+ isError: true
277
+ };
297
278
  }
298
279
 
299
280
  case 'browse_network': {
300
- const result = args.operation === 'list' || args.operation === 'configure' || args.operation === 'clear'
281
+ const p = networkParams(args);
282
+ const isListLike =
283
+ args.operation === 'list' || args.operation === 'configure' || args.operation === 'clear';
284
+ const result = isListLike
301
285
  ? await wsClient.call('ui.network_' + args.operation, {
302
- tab: args.tab,
303
- method: args.method,
304
- url_contains: args.url_contains,
305
- status: (args.status_min !== undefined || args.status_max !== undefined)
306
- ? { min: args.status_min, max: args.status_max } : undefined,
307
- resource_type: args.resource_type,
308
- state: args.state,
309
- started_after: args.started_after,
310
- started_before: args.started_before,
311
- limit: args.limit,
312
- before_seq: args.before_seq,
313
- enabled: args.enabled,
314
- capture_bodies: args.capture_bodies,
286
+ tab: p.tab,
287
+ method: p.method,
288
+ url_contains: p.url_contains,
289
+ status: p.status,
290
+ resource_type: p.resource_type,
291
+ state: p.state,
292
+ started_after: p.started_after,
293
+ started_before: p.started_before,
294
+ limit: p.limit,
295
+ before_seq: p.before_seq,
296
+ enabled: p.enabled,
297
+ capture_bodies: p.capture_bodies
315
298
  })
316
299
  : await wsClient.call('ui.network_get', {
317
- tab: args.tab,
318
- network_id: args.network_id,
319
- include_request_headers: args.include_request_headers,
320
- include_request_body: args.include_request_body,
321
- include_response_headers: args.include_response_headers,
322
- include_response_body: args.include_response_body,
323
- include_sensitive_headers: args.include_sensitive_headers,
324
- request_body_offset: args.request_body_offset,
325
- response_body_offset: args.response_body_offset,
326
- body_limit: args.body_limit,
300
+ tab: p.tab,
301
+ network_id: p.network_id,
302
+ include_request_headers: p.include_request_headers,
303
+ include_request_body: p.include_request_body,
304
+ include_response_headers: p.include_response_headers,
305
+ include_response_body: p.include_response_body,
306
+ include_sensitive_headers: p.include_sensitive_headers,
307
+ request_body_offset: p.request_body_offset,
308
+ response_body_offset: p.response_body_offset,
309
+ body_limit: p.body_limit
327
310
  });
328
311
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
329
312
  }