dsh-browser-view 0.3.4 → 0.4.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/lib/index.js CHANGED
@@ -191,6 +191,198 @@ function genericCard(kind, title, rawInput) {
191
191
  };
192
192
  }
193
193
  //#endregion
194
+ //#region lib/types/browser/walker.js
195
+ /** 页内 walker 核心(自包含;勿引用模块级变量、勿导入任何模块)。 */
196
+ function __walkerCore() {
197
+ const INTERACTIVE = /* @__PURE__ */ new Set([
198
+ "link",
199
+ "button",
200
+ "textbox",
201
+ "checkbox",
202
+ "radio",
203
+ "combobox",
204
+ "listbox",
205
+ "searchbox",
206
+ "slider",
207
+ "spinbutton",
208
+ "switch",
209
+ "tab",
210
+ "menuitem",
211
+ "option",
212
+ "treeitem"
213
+ ]);
214
+ const STRUCTURAL = /* @__PURE__ */ new Set([
215
+ "navigation",
216
+ "main",
217
+ "banner",
218
+ "contentinfo",
219
+ "form",
220
+ "search",
221
+ "complementary",
222
+ "region",
223
+ "dialog",
224
+ "alert",
225
+ "table",
226
+ "list",
227
+ "heading",
228
+ "toolbar",
229
+ "menu",
230
+ "menubar",
231
+ "tablist",
232
+ "tree",
233
+ "grid"
234
+ ]);
235
+ const SKIP_TAGS = /* @__PURE__ */ new Set([
236
+ "script",
237
+ "style",
238
+ "template",
239
+ "noscript",
240
+ "svg",
241
+ "path",
242
+ "link",
243
+ "meta",
244
+ "br"
245
+ ]);
246
+ function roleOf(el) {
247
+ const explicit = el.getAttribute("role");
248
+ if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
249
+ switch (el.tagName.toLowerCase()) {
250
+ case "a": return el.hasAttribute("href") ? "link" : "";
251
+ case "button": return "button";
252
+ case "input": {
253
+ const t = (el.getAttribute("type") ?? "text").toLowerCase();
254
+ if (t === "checkbox") return "checkbox";
255
+ if (t === "radio") return "radio";
256
+ if (t === "range") return "slider";
257
+ if (t === "number") return "spinbutton";
258
+ if (t === "search") return "searchbox";
259
+ if ([
260
+ "button",
261
+ "submit",
262
+ "reset",
263
+ "image"
264
+ ].includes(t)) return "button";
265
+ if (["hidden", "file"].includes(t)) return "";
266
+ return "textbox";
267
+ }
268
+ case "textarea": return "textbox";
269
+ case "select": return "combobox";
270
+ case "nav": return "navigation";
271
+ case "main": return "main";
272
+ case "header": return "banner";
273
+ case "footer": return "contentinfo";
274
+ case "form": return "form";
275
+ case "h1":
276
+ case "h2":
277
+ case "h3":
278
+ case "h4":
279
+ case "h5":
280
+ case "h6": return "heading";
281
+ case "ul":
282
+ case "ol": return "list";
283
+ case "li": return "listitem";
284
+ case "table": return "table";
285
+ case "dialog": return "dialog";
286
+ case "summary": return "button";
287
+ default: return "";
288
+ }
289
+ }
290
+ function nameOf(el, role) {
291
+ const aria = el.getAttribute("aria-label");
292
+ if (aria && aria.trim()) return aria.trim().slice(0, 60);
293
+ const labelledby = el.getAttribute("aria-labelledby");
294
+ if (labelledby) {
295
+ const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
296
+ if (t) return t.slice(0, 60);
297
+ }
298
+ if (el.id) {
299
+ const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
300
+ if (t) return t.slice(0, 60);
301
+ }
302
+ if ([
303
+ "textbox",
304
+ "searchbox",
305
+ "combobox",
306
+ "spinbutton"
307
+ ].includes(role)) {
308
+ const ph = el.getAttribute("placeholder");
309
+ if (ph && ph.trim()) return ph.trim().slice(0, 60);
310
+ }
311
+ const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
312
+ if (text) return text.slice(0, 60);
313
+ const title = el.getAttribute("title");
314
+ if (title && title.trim()) return title.trim().slice(0, 60);
315
+ return "";
316
+ }
317
+ /** 稳定 CSS 路径:#id → [data-testid] → nth-of-type 链(≤6 层)。 */
318
+ function cssPath(el) {
319
+ if (el.id) return `#${CSS.escape(el.id)}`;
320
+ const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
321
+ if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
322
+ const parts = [];
323
+ let cur = el;
324
+ while (cur && cur !== document.body && parts.length < 6) {
325
+ const tag = cur.tagName.toLowerCase();
326
+ const parent = cur.parentElement;
327
+ if (!parent) {
328
+ parts.unshift(tag);
329
+ break;
330
+ }
331
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
332
+ const idx = sameTag.indexOf(cur) + 1;
333
+ parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
334
+ cur = parent;
335
+ }
336
+ return parts.join(" > ");
337
+ }
338
+ function isVisible(el) {
339
+ if (el.getAttribute("aria-hidden") === "true") return false;
340
+ const rect = el.getBoundingClientRect();
341
+ if (rect.width === 0 && rect.height === 0) return false;
342
+ const style = getComputedStyle(el);
343
+ return style.visibility !== "hidden" && style.display !== "none";
344
+ }
345
+ /**
346
+ * 统一元素描述结构(F5 顺带统一):aria 快照 ref、find 匹配、
347
+ * 拾取指纹(pick hint)、录制回声(recorder payload)共用同一份字段语义。
348
+ */
349
+ function describeElement(el) {
350
+ const role = roleOf(el);
351
+ const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? "";
352
+ return {
353
+ role,
354
+ name: nameOf(el, role),
355
+ selector: cssPath(el),
356
+ testid,
357
+ id: el.id ?? "",
358
+ tag: el.tagName.toLowerCase(),
359
+ text: (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60),
360
+ visible: isVisible(el)
361
+ };
362
+ }
363
+ return {
364
+ INTERACTIVE,
365
+ STRUCTURAL,
366
+ SKIP_TAGS,
367
+ roleOf,
368
+ nameOf,
369
+ cssPath,
370
+ isVisible,
371
+ describeElement
372
+ };
373
+ }
374
+ /** walker 核心的序列化源码(模块加载期取一次;tsdown/vitest 均原样保留)。 */
375
+ const CORE_SRC = __walkerCore.toString();
376
+ /**
377
+ * 把消费方 entry(自包含函数,签名 (payload, w: WalkerApi) => R)拼装成
378
+ * 可被 page.evaluate 序列化的独立函数 (payload: P) => R。
379
+ * 拼装发生在模块加载期(new Function 一次性),调用期零开销。
380
+ */
381
+ function injectWalker(entry) {
382
+ const src = `"use strict"; return function (payload) { const w = (${CORE_SRC})(); return (${entry.toString()})(payload, w); };`;
383
+ return new Function(src)();
384
+ }
385
+ //#endregion
194
386
  //#region lib/types/browser/session.js
195
387
  /** 当前活动 profile 名(默认 "default")。 */
196
388
  let currentProfile = "default";
@@ -228,6 +420,35 @@ function snapshotCacheOf(page) {
228
420
  function setSnapshotCache(page, cache) {
229
421
  snapshotCaches.set(page, cache);
230
422
  }
423
+ /** 环形容量钳制(1..20,非法值回落默认 5)。导出供测试。 */
424
+ function snapshotCapacity(k) {
425
+ const n = Math.floor(Number(k));
426
+ if (!Number.isFinite(n)) return 5;
427
+ return Math.min(Math.max(n, 1), 20);
428
+ }
429
+ /**
430
+ * 追加一个新版本并淘汰超限的最旧版本(纯逻辑,导出供测试)。
431
+ * cache 为 undefined 时从 version 1 开始;返回新缓存(不修改入参)。
432
+ */
433
+ function pushSnapshotVersion(cache, content, capacity) {
434
+ const version = (cache?.version ?? 0) + 1;
435
+ const k = snapshotCapacity(capacity);
436
+ const history = [...cache?.history ?? [], {
437
+ version,
438
+ text: content.text,
439
+ aria: content.aria
440
+ }];
441
+ if (history.length > k) history.splice(0, history.length - k);
442
+ return {
443
+ version,
444
+ history
445
+ };
446
+ }
447
+ /** 按版本号取历史条目;不在环形窗口内(更旧/未知)返回 undefined。 */
448
+ function snapshotVersionEntry(cache, version) {
449
+ if (cache === void 0) return void 0;
450
+ return cache.history[cache.history.length - 1]?.version === version ? cache.history[cache.history.length - 1] : cache.history.find((e) => e.version === version);
451
+ }
231
452
  /** 已保存的录制脚本:name → 步骤数组。 */
232
453
  const savedRecordings = /* @__PURE__ */ new Map();
233
454
  /** 当前录制中的 buffer(非空 = 录制中)。 */
@@ -300,6 +521,19 @@ function resolveConfirm(id, decision) {
300
521
  pendingConfirm.decision = decision;
301
522
  return true;
302
523
  }
524
+ let toolInFlight = 0;
525
+ /** live 工具调用开始(register 包装层调用)。 */
526
+ function noteToolStart() {
527
+ toolInFlight++;
528
+ }
529
+ /** live 工具调用结束(成功/失败都算结束)。 */
530
+ function noteToolEnd() {
531
+ if (toolInFlight > 0) toolInFlight--;
532
+ }
533
+ /** 当前在途的 live 工具调用数(0 = agent 空闲)。 */
534
+ function activeToolCalls() {
535
+ return toolInFlight;
536
+ }
303
537
  /** window 上的回传 binding 名(须为合法 JS 标识符)。 */
304
538
  const HUMAN_BINDING = "__dshBrowserHumanEvent";
305
539
  /** 最近一次 agent DOM 操作(点击/输入/选择)的时间戳;用于区分人手与工具回声。 */
@@ -390,33 +624,16 @@ function handleHumanEvent(payload) {
390
624
  * 页内注入的人工操作监听(addInitScript 序列化;每次导航自动重装)。
391
625
  * 监听常驻但 node 侧只在录制中生效,关闭录制等价于"移除"。
392
626
  * 只上报可交互元素上的 click 与表单控件的 change,避免逐键噪音。
627
+ * F5 收敛:cssPath 等元素描述来自 walker.ts 单一来源(构建期拼装注入),
628
+ * 不再持有内联副本;payload 字段语义与 v0.3 完全一致。
393
629
  */
394
- function installHumanRecorder() {
395
- const w = window;
396
- if (w.__dshHumanRecorder === true) return;
397
- w.__dshHumanRecorder = true;
630
+ const installHumanRecorder = injectWalker((_payload, w) => {
631
+ const cssPath = w.cssPath;
632
+ const w2 = window;
633
+ if (w2.__dshHumanRecorder === true) return;
634
+ w2.__dshHumanRecorder = true;
398
635
  const BINDING = "__dshBrowserHumanEvent";
399
636
  const INTERACTIVE = "a,button,input,select,textarea,summary,[role='button'],[role='tab'],[role='menuitem'],[role='option'],[role='switch'],[role='checkbox']";
400
- function cssPath(el) {
401
- if (el.id) return `#${CSS.escape(el.id)}`;
402
- const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
403
- if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
404
- const parts = [];
405
- let cur = el;
406
- while (cur && cur !== document.body && parts.length < 6) {
407
- const tag = cur.tagName.toLowerCase();
408
- const parent = cur.parentElement;
409
- if (!parent) {
410
- parts.unshift(tag);
411
- break;
412
- }
413
- const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
414
- const idx = sameTag.indexOf(cur) + 1;
415
- parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
416
- cur = parent;
417
- }
418
- return parts.join(" > ");
419
- }
420
637
  function payloadOf(el, kind, value) {
421
638
  const aria = el.getAttribute("aria-label");
422
639
  return {
@@ -433,7 +650,7 @@ function installHumanRecorder() {
433
650
  }
434
651
  function send(payload) {
435
652
  try {
436
- const fn = w[BINDING];
653
+ const fn = w2[BINDING];
437
654
  if (typeof fn === "function") Promise.resolve(fn(payload)).catch(() => {});
438
655
  } catch {}
439
656
  }
@@ -455,7 +672,7 @@ function installHumanRecorder() {
455
672
  send(payloadOf(t, "input", t.value));
456
673
  }
457
674
  }, true);
458
- }
675
+ });
459
676
  const networkByProfile = /* @__PURE__ */ new Map();
460
677
  function networkOf(name) {
461
678
  let list = networkByProfile.get(name);
@@ -772,12 +989,16 @@ async function closeBrowser() {
772
989
  profileStates.clear();
773
990
  downloadsByProfile.clear();
774
991
  }
775
- /** Best-effort current page identity, safe when nothing is open. */
992
+ /** Best-effort current page identity, safe when nothing is open.title 有界防页面忙时挂起。 */
776
993
  async function pageIdentity(page) {
777
994
  try {
995
+ const title = await new Promise((resolve, reject) => {
996
+ const t = setTimeout(() => reject(/* @__PURE__ */ new Error("title timeout")), 2e3);
997
+ page.title().then(resolve, reject).finally(() => clearTimeout(t));
998
+ }).catch(() => "");
778
999
  return {
779
1000
  url: page.url(),
780
- title: await page.title()
1001
+ title
781
1002
  };
782
1003
  } catch {
783
1004
  return {
@@ -1189,7 +1410,7 @@ function renderMarkdownReport(runs, source) {
1189
1410
  "| 时间 | 来源 | 通过/总数 | 失败 | 自愈 | 耗时 |",
1190
1411
  "| --- | --- | --- | --- | --- | --- |"
1191
1412
  ];
1192
- for (const r of runs) lines.push(`| ${fmtTs(r.ts)} | ${r.source}${r.row !== void 0 ? ` (行${r.row})` : ""} | ${r.passed}/${r.total} | ${r.failed} | ${r.healedCount} | ${(r.durationMs / 1e3).toFixed(1)}s |`);
1413
+ for (const r of runs) lines.push(`| ${fmtTs(r.ts)} | ${r.source}${r.row !== void 0 ? ` (行${r.row})` : ""}${r.retries !== void 0 ? ` (重试挽回 ${r.retries.recovered}/${r.retries.attempted})` : ""}${r.retryOf !== void 0 ? ` ↻${r.retryOf}` : ""} | ${r.passed}/${r.total} | ${r.failed} | ${r.healedCount} | ${(r.durationMs / 1e3).toFixed(1)}s |`);
1193
1414
  const { failures, healed } = analyze(runs);
1194
1415
  lines.push("", "## 失败步骤 Top", "");
1195
1416
  if (failures.length === 0) lines.push("全部通过 🎉");
@@ -1210,7 +1431,7 @@ function renderHtmlReport(runs, source) {
1210
1431
  const { failures, healed } = analyze(runs);
1211
1432
  const rowHtml = runs.map((r) => {
1212
1433
  const ok = r.failed === 0;
1213
- return `<tr><td>${esc(fmtTs(r.ts))}</td><td>${esc(r.source)}${r.row !== void 0 ? ` (行${r.row})` : ""}</td><td class="${ok ? "ok" : "bad"}">${r.passed}/${r.total}</td><td>${r.failed}</td><td>${r.healedCount}</td><td>${(r.durationMs / 1e3).toFixed(1)}s</td></tr>`;
1434
+ return `<tr><td>${esc(fmtTs(r.ts))}</td><td>${esc(r.source)}${r.row !== void 0 ? ` (行${r.row})` : ""}${r.retries !== void 0 ? ` <span class="meta">(重试挽回 ${r.retries.recovered}/${r.retries.attempted})</span>` : ""}${r.retryOf !== void 0 ? ` <span class="meta" title="重跑自 ${esc(r.retryOf)}">↻</span>` : ""}</td><td class="${ok ? "ok" : "bad"}">${r.passed}/${r.total}</td><td>${r.failed}</td><td>${r.healedCount}</td><td>${(r.durationMs / 1e3).toFixed(1)}s</td></tr>`;
1214
1435
  }).join("\n");
1215
1436
  const failRows = failures.length === 0 ? `<tr><td colspan="4" class="ok">全部通过 🎉</td></tr>` : failures.map((f) => `<tr><td>${f.count}</td><td>${esc(f.source)}</td><td>${esc(f.tool)}</td><td>${esc(f.error.slice(0, 200))}</td></tr>`).join("\n");
1216
1437
  const healRows = healed.slice(0, 20).map((h) => `<tr><td>${esc(fmtTs(h.ts))}</td><td>${esc(h.source)}</td><td>${esc(h.tool)}</td><td>${esc(h.via)}</td><td><code>${esc(h.resolved)}</code></td></tr>`).join("\n");
@@ -1291,363 +1512,87 @@ function compressText(text) {
1291
1512
  blankRun = 0;
1292
1513
  let dup = 0;
1293
1514
  while (i + dup + 1 < lines.length && lines[i + dup + 1].trim() === trimmed) dup++;
1294
- out.push(lines[i]);
1295
- if (dup > 0) {
1296
- out.push(`…(重复 ${dup} 行)`);
1297
- i += dup;
1298
- }
1299
- }
1300
- while (out.length > 0 && out[out.length - 1] === "") out.pop();
1301
- return out.join("\n");
1302
- }
1303
- /**
1304
- * 页内 ARIA 树构建(自包含函数,page.evaluate 序列化注入;禁止引用模块级变量)。
1305
- * 遍历 DOM:结构角色(navigation/main/form/table/heading…)直接打印;
1306
- * 可交互元素分配 ref=e1..eN 并附稳定 CSS 路径;无语义容器(div/span)穿透。
1307
- * 输出形如 `- button "登录" [ref=e3]` 的缩进树,供纯文本模型定位操作目标。
1308
- */
1309
- function buildAriaTree(payload) {
1310
- const INTERACTIVE = /* @__PURE__ */ new Set([
1311
- "link",
1312
- "button",
1313
- "textbox",
1314
- "checkbox",
1315
- "radio",
1316
- "combobox",
1317
- "listbox",
1318
- "searchbox",
1319
- "slider",
1320
- "spinbutton",
1321
- "switch",
1322
- "tab",
1323
- "menuitem",
1324
- "option",
1325
- "treeitem"
1326
- ]);
1327
- const STRUCTURAL = /* @__PURE__ */ new Set([
1328
- "navigation",
1329
- "main",
1330
- "banner",
1331
- "contentinfo",
1332
- "form",
1333
- "search",
1334
- "complementary",
1335
- "region",
1336
- "dialog",
1337
- "alert",
1338
- "table",
1339
- "list",
1340
- "heading",
1341
- "toolbar",
1342
- "menu",
1343
- "menubar",
1344
- "tablist",
1345
- "tree",
1346
- "grid"
1347
- ]);
1348
- const SKIP_TAGS = /* @__PURE__ */ new Set([
1349
- "script",
1350
- "style",
1351
- "template",
1352
- "noscript",
1353
- "svg",
1354
- "path",
1355
- "link",
1356
- "meta",
1357
- "br"
1358
- ]);
1359
- function roleOf(el) {
1360
- const explicit = el.getAttribute("role");
1361
- if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
1362
- switch (el.tagName.toLowerCase()) {
1363
- case "a": return el.hasAttribute("href") ? "link" : "";
1364
- case "button": return "button";
1365
- case "input": {
1366
- const t = (el.getAttribute("type") ?? "text").toLowerCase();
1367
- if (t === "checkbox") return "checkbox";
1368
- if (t === "radio") return "radio";
1369
- if (t === "range") return "slider";
1370
- if (t === "number") return "spinbutton";
1371
- if (t === "search") return "searchbox";
1372
- if ([
1373
- "button",
1374
- "submit",
1375
- "reset",
1376
- "image"
1377
- ].includes(t)) return "button";
1378
- if (["hidden", "file"].includes(t)) return "";
1379
- return "textbox";
1380
- }
1381
- case "textarea": return "textbox";
1382
- case "select": return "combobox";
1383
- case "nav": return "navigation";
1384
- case "main": return "main";
1385
- case "header": return "banner";
1386
- case "footer": return "contentinfo";
1387
- case "form": return "form";
1388
- case "h1":
1389
- case "h2":
1390
- case "h3":
1391
- case "h4":
1392
- case "h5":
1393
- case "h6": return "heading";
1394
- case "ul":
1395
- case "ol": return "list";
1396
- case "li": return "listitem";
1397
- case "table": return "table";
1398
- case "dialog": return "dialog";
1399
- case "summary": return "button";
1400
- default: return "";
1401
- }
1402
- }
1403
- function nameOf(el, role) {
1404
- const aria = el.getAttribute("aria-label");
1405
- if (aria && aria.trim()) return aria.trim().slice(0, 60);
1406
- const labelledby = el.getAttribute("aria-labelledby");
1407
- if (labelledby) {
1408
- const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
1409
- if (t) return t.slice(0, 60);
1410
- }
1411
- if (el.id) {
1412
- const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
1413
- if (t) return t.slice(0, 60);
1414
- }
1415
- if ([
1416
- "textbox",
1417
- "searchbox",
1418
- "combobox",
1419
- "spinbutton"
1420
- ].includes(role)) {
1421
- const ph = el.getAttribute("placeholder");
1422
- if (ph && ph.trim()) return ph.trim().slice(0, 60);
1423
- }
1424
- const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
1425
- if (text) return text.slice(0, 60);
1426
- const title = el.getAttribute("title");
1427
- if (title && title.trim()) return title.trim().slice(0, 60);
1428
- return "";
1429
- }
1430
- /** 稳定 CSS 路径:#id → [data-testid] → nth-of-type 链(≤6 层)。 */
1431
- function cssPath(el) {
1432
- if (el.id) return `#${CSS.escape(el.id)}`;
1433
- const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
1434
- if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
1435
- const parts = [];
1436
- let cur = el;
1437
- while (cur && cur !== document.body && parts.length < 6) {
1438
- const tag = cur.tagName.toLowerCase();
1439
- const parent = cur.parentElement;
1440
- if (!parent) {
1441
- parts.unshift(tag);
1442
- break;
1443
- }
1444
- const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
1445
- const idx = sameTag.indexOf(cur) + 1;
1446
- parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
1447
- cur = parent;
1448
- }
1449
- return parts.join(" > ");
1450
- }
1451
- function isVisible(el) {
1452
- if (el.getAttribute("aria-hidden") === "true") return false;
1453
- const rect = el.getBoundingClientRect();
1454
- if (rect.width === 0 && rect.height === 0) return false;
1455
- const style = getComputedStyle(el);
1456
- return style.visibility !== "hidden" && style.display !== "none";
1457
- }
1458
- const lines = [];
1459
- const refs = [];
1460
- let truncated = false;
1461
- function walk(el, depth) {
1462
- if (truncated) return;
1463
- if (lines.length >= 400 || depth > 10) {
1464
- truncated = true;
1465
- return;
1466
- }
1467
- const role = roleOf(el);
1468
- if (INTERACTIVE.has(role)) {
1469
- if (!isVisible(el)) return;
1470
- const name = nameOf(el, role);
1471
- const ref = refs.length < payload.maxRefs ? `e${refs.length + 1}` : "";
1472
- if (ref !== "") refs.push({
1473
- ref,
1474
- role,
1475
- name,
1476
- selector: cssPath(el)
1477
- });
1478
- const expanded = el.getAttribute("aria-expanded");
1479
- const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true";
1480
- lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${ref !== "" ? ` [ref=${ref}]` : ""}${expanded !== null ? ` [expanded=${expanded}]` : ""}${disabled ? " [disabled]" : ""}`);
1481
- return;
1482
- }
1483
- if (STRUCTURAL.has(role)) {
1484
- if (!isVisible(el)) return;
1485
- const name = nameOf(el, role);
1486
- const level = role === "heading" ? ` [level=${el.tagName[1] ?? "1"}]` : "";
1487
- lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${level}`);
1488
- for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth + 1);
1489
- return;
1490
- }
1491
- for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth);
1492
- }
1493
- if (document.body === null) return {
1494
- tree: "",
1495
- refs,
1496
- truncated: false,
1497
- scopeMissing: false
1498
- };
1499
- const root = payload.scope ? document.querySelector(payload.scope) : document.body;
1500
- if (root === null) return {
1501
- tree: "",
1502
- refs,
1503
- truncated: false,
1504
- scopeMissing: true
1505
- };
1506
- walk(root, 0);
1507
- return {
1508
- tree: lines.join("\n"),
1509
- refs,
1510
- truncated,
1511
- scopeMissing: false
1512
- };
1513
- }
1514
- /**
1515
- * 页内语义查找:只收集匹配 query 的可交互元素(不构建全量树)。
1516
- * role/name/cssPath/isVisible 与 buildAriaTree 保持同一套语义(此处内联副本,
1517
- * evaluate 序列化不允许闭包引用);改动任一处时同步另一处。
1518
- */
1519
- function findInteractive(payload) {
1520
- const INTERACTIVE = /* @__PURE__ */ new Set([
1521
- "link",
1522
- "button",
1523
- "textbox",
1524
- "checkbox",
1525
- "radio",
1526
- "combobox",
1527
- "listbox",
1528
- "searchbox",
1529
- "slider",
1530
- "spinbutton",
1531
- "switch",
1532
- "tab",
1533
- "menuitem",
1534
- "option",
1535
- "treeitem"
1536
- ]);
1537
- const STRUCTURAL = /* @__PURE__ */ new Set([
1538
- "navigation",
1539
- "main",
1540
- "banner",
1541
- "contentinfo",
1542
- "form",
1543
- "search",
1544
- "complementary",
1545
- "region",
1546
- "dialog",
1547
- "alert",
1548
- "table",
1549
- "list",
1550
- "heading",
1551
- "toolbar",
1552
- "menu",
1553
- "menubar",
1554
- "tablist",
1555
- "tree",
1556
- "grid"
1557
- ]);
1558
- const SKIP_TAGS = /* @__PURE__ */ new Set([
1559
- "script",
1560
- "style",
1561
- "template",
1562
- "noscript",
1563
- "svg",
1564
- "path",
1565
- "link",
1566
- "meta",
1567
- "br"
1568
- ]);
1569
- function roleOf(el) {
1570
- const explicit = el.getAttribute("role");
1571
- if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
1572
- switch (el.tagName.toLowerCase()) {
1573
- case "a": return el.hasAttribute("href") ? "link" : "";
1574
- case "button": return "button";
1575
- case "input": {
1576
- const t = (el.getAttribute("type") ?? "text").toLowerCase();
1577
- if (t === "checkbox") return "checkbox";
1578
- if (t === "radio") return "radio";
1579
- if (t === "range") return "slider";
1580
- if (t === "number") return "spinbutton";
1581
- if (t === "search") return "searchbox";
1582
- if ([
1583
- "button",
1584
- "submit",
1585
- "reset",
1586
- "image"
1587
- ].includes(t)) return "button";
1588
- if (["hidden", "file"].includes(t)) return "";
1589
- return "textbox";
1590
- }
1591
- case "textarea": return "textbox";
1592
- case "select": return "combobox";
1593
- case "summary": return "button";
1594
- default: return "";
1595
- }
1596
- }
1597
- function nameOf(el, role) {
1598
- const aria = el.getAttribute("aria-label");
1599
- if (aria && aria.trim()) return aria.trim().slice(0, 60);
1600
- const labelledby = el.getAttribute("aria-labelledby");
1601
- if (labelledby) {
1602
- const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
1603
- if (t) return t.slice(0, 60);
1604
- }
1605
- if (el.id) {
1606
- const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
1607
- if (t) return t.slice(0, 60);
1608
- }
1609
- if ([
1610
- "textbox",
1611
- "searchbox",
1612
- "combobox",
1613
- "spinbutton"
1614
- ].includes(role)) {
1615
- const ph = el.getAttribute("placeholder");
1616
- if (ph && ph.trim()) return ph.trim().slice(0, 60);
1617
- }
1618
- const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
1619
- if (text) return text.slice(0, 60);
1620
- const title = el.getAttribute("title");
1621
- if (title && title.trim()) return title.trim().slice(0, 60);
1622
- return "";
1623
- }
1624
- function cssPath(el) {
1625
- if (el.id) return `#${CSS.escape(el.id)}`;
1626
- const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
1627
- if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
1628
- const parts = [];
1629
- let cur = el;
1630
- while (cur && cur !== document.body && parts.length < 6) {
1631
- const tag = cur.tagName.toLowerCase();
1632
- const parent = cur.parentElement;
1633
- if (!parent) {
1634
- parts.unshift(tag);
1635
- break;
1636
- }
1637
- const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
1638
- const idx = sameTag.indexOf(cur) + 1;
1639
- parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
1640
- cur = parent;
1515
+ out.push(lines[i]);
1516
+ if (dup > 0) {
1517
+ out.push(`…(重复 ${dup} 行)`);
1518
+ i += dup;
1641
1519
  }
1642
- return parts.join(" > ");
1643
1520
  }
1644
- function isVisible(el) {
1645
- if (el.getAttribute("aria-hidden") === "true") return false;
1646
- const rect = el.getBoundingClientRect();
1647
- if (rect.width === 0 && rect.height === 0) return false;
1648
- const style = getComputedStyle(el);
1649
- return style.visibility !== "hidden" && style.display !== "none";
1521
+ while (out.length > 0 && out[out.length - 1] === "") out.pop();
1522
+ return out.join("\n");
1523
+ }
1524
+ /**
1525
+ * 页内 ARIA 树构建(walker 单一来源:roleOf/nameOf/cssPath/isVisible 与
1526
+ * INTERACTIVE/STRUCTURAL 集合来自 walker.ts,构建期拼装注入)。
1527
+ * 遍历 DOM:结构角色(navigation/main/form/table/heading…)直接打印;
1528
+ * 可交互元素分配 ref=e1..eN 并附稳定 CSS 路径;无语义容器(div/span)穿透。
1529
+ * 输出形如 `- button "登录" [ref=e3]` 的缩进树,供纯文本模型定位操作目标。
1530
+ */
1531
+ const buildAriaTree = injectWalker((payload, w) => {
1532
+ const { INTERACTIVE, STRUCTURAL, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible } = w;
1533
+ const lines = [];
1534
+ const refs = [];
1535
+ let truncated = false;
1536
+ function walk(el, depth) {
1537
+ if (truncated) return;
1538
+ if (lines.length >= 400 || depth > 10) {
1539
+ truncated = true;
1540
+ return;
1541
+ }
1542
+ const role = roleOf(el);
1543
+ if (INTERACTIVE.has(role)) {
1544
+ if (!isVisible(el)) return;
1545
+ const name = nameOf(el, role);
1546
+ const ref = refs.length < payload.maxRefs ? `e${refs.length + 1}` : "";
1547
+ if (ref !== "") refs.push({
1548
+ ref,
1549
+ role,
1550
+ name,
1551
+ selector: cssPath(el)
1552
+ });
1553
+ const expanded = el.getAttribute("aria-expanded");
1554
+ const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true";
1555
+ lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${ref !== "" ? ` [ref=${ref}]` : ""}${expanded !== null ? ` [expanded=${expanded}]` : ""}${disabled ? " [disabled]" : ""}`);
1556
+ return;
1557
+ }
1558
+ if (STRUCTURAL.has(role)) {
1559
+ if (!isVisible(el)) return;
1560
+ const name = nameOf(el, role);
1561
+ const level = role === "heading" ? ` [level=${el.tagName[1] ?? "1"}]` : "";
1562
+ lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${level}`);
1563
+ for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth + 1);
1564
+ return;
1565
+ }
1566
+ for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth);
1650
1567
  }
1568
+ if (document.body === null) return {
1569
+ tree: "",
1570
+ refs,
1571
+ truncated: false,
1572
+ scopeMissing: false
1573
+ };
1574
+ const root = payload.scope ? document.querySelector(payload.scope) : document.body;
1575
+ if (root === null) return {
1576
+ tree: "",
1577
+ refs,
1578
+ truncated: false,
1579
+ scopeMissing: true
1580
+ };
1581
+ walk(root, 0);
1582
+ return {
1583
+ tree: lines.join("\n"),
1584
+ refs,
1585
+ truncated,
1586
+ scopeMissing: false
1587
+ };
1588
+ });
1589
+ /**
1590
+ * 页内语义查找:只收集匹配 query 的可交互元素(不构建全量树)。
1591
+ * role/name/cssPath/isVisible 与 buildAriaTree 同一来源(walker.ts 单一来源,
1592
+ * 构建期拼装注入),语义天然一致,不再需要手工同步副本。
1593
+ */
1594
+ const findInteractive = injectWalker((payload, w) => {
1595
+ const { INTERACTIVE, STRUCTURAL, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible } = w;
1651
1596
  /** 最近的结构化祖先(区域上下文,帮助区分多命中)。 */
1652
1597
  function ancestorsOf(el) {
1653
1598
  const out = [];
@@ -1706,7 +1651,7 @@ function findInteractive(payload) {
1706
1651
  candidates,
1707
1652
  scanned
1708
1653
  };
1709
- }
1654
+ });
1710
1655
  /** 最长公共子串长度(小写比较;零依赖,导出供测试)。 */
1711
1656
  function lcsScore(a, b) {
1712
1657
  const x = a.toLowerCase();
@@ -1839,7 +1784,9 @@ async function snapshotTool(config, args) {
1839
1784
  title: await page.title()
1840
1785
  };
1841
1786
  const cache = snapshotCacheOf(page);
1842
- const canDiff = since !== void 0 && cache !== void 0 && cache.version === since;
1787
+ const capacity = config.snapshotHistory ?? 5;
1788
+ const base = since !== void 0 ? snapshotVersionEntry(cache, since) : void 0;
1789
+ const canDiff = base !== void 0;
1843
1790
  let content;
1844
1791
  let extra = {};
1845
1792
  let walkerTruncated = false;
@@ -1885,7 +1832,7 @@ async function snapshotTool(config, args) {
1885
1832
  };
1886
1833
  }
1887
1834
  const key = ariaMode ? "aria" : "text";
1888
- if (canDiff && cache[key] === content) return {
1835
+ if (canDiff && base[key] === content) return {
1889
1836
  unchanged: true,
1890
1837
  contentVersion: cache.version,
1891
1838
  ...scope !== "" ? { scope } : {},
@@ -1893,22 +1840,23 @@ async function snapshotTool(config, args) {
1893
1840
  ...extra
1894
1841
  };
1895
1842
  if (canDiff) {
1896
- const d = diffLines(cache[key], content);
1897
- setSnapshotCache(page, {
1898
- version: cache.version + 1,
1899
- text: ariaMode ? cache.text : content,
1900
- aria: ariaMode ? content : cache.aria
1901
- });
1843
+ const d = diffLines(base[key], content);
1844
+ const latest = cache.history[cache.history.length - 1];
1845
+ const next = pushSnapshotVersion(cache, {
1846
+ text: ariaMode ? latest.text : content,
1847
+ aria: ariaMode ? content : latest.aria
1848
+ }, capacity);
1849
+ setSnapshotCache(page, next);
1902
1850
  if (d.unchanged) return {
1903
1851
  unchanged: true,
1904
- contentVersion: cache.version + 1,
1852
+ contentVersion: next.version,
1905
1853
  ...scope !== "" ? { scope } : {},
1906
1854
  ...identity,
1907
1855
  ...extra
1908
1856
  };
1909
1857
  return {
1910
1858
  diff: true,
1911
- contentVersion: cache.version + 1,
1859
+ contentVersion: next.version,
1912
1860
  changedFrom: d.changedFrom,
1913
1861
  removed: d.removed,
1914
1862
  ...scope !== "" ? { scope } : {},
@@ -1918,13 +1866,14 @@ async function snapshotTool(config, args) {
1918
1866
  ...extra
1919
1867
  };
1920
1868
  }
1921
- setSnapshotCache(page, {
1922
- version: (cache?.version ?? 0) + 1,
1923
- text: ariaMode ? cache?.text ?? "" : content,
1924
- aria: ariaMode ? content : cache?.aria ?? ""
1925
- });
1869
+ const latestEntry = cache?.history[cache.history.length - 1];
1870
+ const next = pushSnapshotVersion(cache, {
1871
+ text: ariaMode ? latestEntry?.text ?? "" : content,
1872
+ aria: ariaMode ? content : latestEntry?.aria ?? ""
1873
+ }, capacity);
1874
+ setSnapshotCache(page, next);
1926
1875
  return {
1927
- contentVersion: (cache?.version ?? 0) + 1,
1876
+ contentVersion: next.version,
1928
1877
  ...since !== void 0 ? { stale: true } : {},
1929
1878
  ...scope !== "" ? { scope } : {},
1930
1879
  [ariaMode ? "tree" : "text"]: content.slice(0, maxText),
@@ -1944,8 +1893,8 @@ async function targetLocator(page, args) {
1944
1893
  const entry = pageRef(page, ref);
1945
1894
  if (entry === void 0) throw new Error(`未知 ref: ${ref}(页面导航或重新快照后旧 ref 失效;请先 browser_snapshot { mode: "aria" })`);
1946
1895
  const byPath = page.locator(entry.selector).first();
1947
- if (await byPath.count().catch(() => 0) > 0) {
1948
- if (await byPath.evaluate((el, want) => {
1896
+ if (await withTimeout(byPath.count(), 3e3, "解析 ref 路径").catch(() => 0) > 0) {
1897
+ if (await withTimeout(byPath.evaluate((el, want) => {
1949
1898
  const tag = el.tagName.toLowerCase();
1950
1899
  const type = (el.getAttribute("type") ?? "").toLowerCase();
1951
1900
  const roleAttr = (el.getAttribute("role") ?? "").trim().split(/\s+/)[0] ?? "";
@@ -1967,10 +1916,10 @@ async function targetLocator(page, args) {
1967
1916
  }, {
1968
1917
  role: entry.role,
1969
1918
  name: entry.name
1970
- }).catch(() => false)) return byPath;
1919
+ }), 3e3, "校验 ref 元素").catch(() => false)) return byPath;
1971
1920
  }
1972
1921
  const byRole = entry.name !== "" ? page.getByRole(entry.role, { name: entry.name }).first() : page.getByRole(entry.role).first();
1973
- if (await byRole.count().catch(() => 0) > 0) return byRole;
1922
+ if (await withTimeout(byRole.count(), 3e3, "解析 ref role 降级").catch(() => 0) > 0) return byRole;
1974
1923
  throw new Error(`ref ${ref} 已失效(元素不存在或内容已变化);请重新 browser_snapshot { mode: "aria" }`);
1975
1924
  }
1976
1925
  if (args.selector === void 0) throw new Error("需要 selector 或 ref 之一");
@@ -2032,6 +1981,18 @@ const DROPDOWN_OPTION_SELECTOR = [
2032
1981
  ".el-select-dropdown__item",
2033
1982
  "[role='listbox'] [role='option']"
2034
1983
  ].join(", ");
1984
+ /** 给本身无超时的 Playwright 调用(evaluate/allTextContents)加有界保护,防页面主线程忙时无限挂起。 */
1985
+ async function withTimeout(p, ms, what) {
1986
+ let timer;
1987
+ const guard = new Promise((_, reject) => {
1988
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${what} 超时(${ms}ms;页面主线程可能正忙)`)), ms);
1989
+ });
1990
+ try {
1991
+ return await Promise.race([p, guard]);
1992
+ } finally {
1993
+ if (timer !== void 0) clearTimeout(timer);
1994
+ }
1995
+ }
2035
1996
  /**
2036
1997
  * 下拉选择原语:原生 <select> 走 selectOption;组件库下拉(antd/element/listbox)
2037
1998
  * 走「点击触发框 → 等待可见选项 → 按文本点击」。找不到选项时抛出错误并附当前可见
@@ -2045,7 +2006,8 @@ async function selectTool(config, args) {
2045
2006
  if (!want) throw new Error("select 需要 option(选项文本)");
2046
2007
  const exact = args.exact === true;
2047
2008
  const timeout = args.timeoutMs ?? config.timeoutMs;
2048
- if (await target.evaluate((el) => el.tagName.toLowerCase()).catch(() => "") === "select") try {
2009
+ const probe = Math.min(timeout, 5e3);
2010
+ if (await withTimeout(target.evaluate((el) => el.tagName.toLowerCase()), probe, "读取目标元素").catch(() => "") === "select") try {
2049
2011
  await target.selectOption({ label: want }, { timeout });
2050
2012
  return {
2051
2013
  selected: want,
@@ -2053,7 +2015,7 @@ async function selectTool(config, args) {
2053
2015
  ...await pageIdentity(page)
2054
2016
  };
2055
2017
  } catch {
2056
- const options = await target.locator("option").allTextContents();
2018
+ const options = await withTimeout(target.locator("option").allTextContents(), probe, "读取原生下拉选项").catch(() => []);
2057
2019
  throw new Error(`原生下拉无选项 "${want}";可用选项: ${options.map((t) => t.trim()).join(" / ")}`);
2058
2020
  }
2059
2021
  const clickTimeout = Math.min(timeout, 1e4);
@@ -2074,14 +2036,14 @@ async function selectTool(config, args) {
2074
2036
  state: "visible",
2075
2037
  timeout: Math.min(timeout, 5e3)
2076
2038
  }).catch(() => {});
2077
- const texts = (await options.allTextContents()).map((t) => t.trim()).filter(Boolean);
2039
+ const texts = (await withTimeout(options.allTextContents(), probe, "读取下拉选项")).map((t) => t.trim()).filter(Boolean);
2078
2040
  if (texts.length === 0) throw new Error(`点击 ${label} 后未出现下拉选项(非原生 select,也不是已知的组件库下拉)`);
2079
2041
  const index = texts.findIndex((t) => exact ? t === want : t.includes(want));
2080
2042
  if (index === -1) {
2081
2043
  await page.keyboard.press("Escape").catch(() => {});
2082
2044
  throw new Error(`下拉选项中无 "${want}";可见选项: ${texts.join(" / ")}`);
2083
2045
  }
2084
- await options.nth(index).click();
2046
+ await options.nth(index).click({ timeout: probe });
2085
2047
  return {
2086
2048
  selected: texts[index],
2087
2049
  selector: label,
@@ -3081,6 +3043,69 @@ async function confirmTool(config, args) {
3081
3043
  detail
3082
3044
  };
3083
3045
  }
3046
+ /** auto 模式默认敏感文本模式(提交/删除/支付类)。 */
3047
+ const DEFAULT_SENSITIVE_PATTERNS = [
3048
+ "提交",
3049
+ "删除",
3050
+ "支付"
3051
+ ];
3052
+ /** auto 模式默认参与判定的工具(已决:只拦 browser_click,type 误报率高)。 */
3053
+ const DEFAULT_SENSITIVE_TOOLS = ["browser_click"];
3054
+ /** 敏感判定的页内目标描述提取(walker 统一元素描述,构建期拼装注入)。 */
3055
+ const describeTarget = injectWalker((sel, w) => {
3056
+ const el = document.querySelector(sel);
3057
+ return el === null ? null : w.describeElement(el);
3058
+ });
3059
+ /**
3060
+ * 纯逻辑:元素描述命中了哪些敏感模式(role/name/text/id/testid/tag 拼接后
3061
+ * 子串匹配,大小写不敏感;空白模式忽略)。返回命中的模式列表(空 = 放行)。
3062
+ * 导出供测试。
3063
+ */
3064
+ function sensitiveHits(desc, patterns) {
3065
+ if (desc === null) return [];
3066
+ const hay = [
3067
+ desc.role,
3068
+ desc.name,
3069
+ desc.text,
3070
+ desc.id,
3071
+ desc.testid,
3072
+ desc.tag
3073
+ ].filter((s) => typeof s === "string").join(" ").toLowerCase();
3074
+ if (hay === "") return [];
3075
+ return patterns.filter((p) => typeof p === "string" && p.trim() !== "" && hay.includes(p.trim().toLowerCase()));
3076
+ }
3077
+ /**
3078
+ * auto 模式审批闸:命中敏感模式时经 confirmTool 弹审批等待用户决策,
3079
+ * 拒绝/超时抛错(调用方该步失败),允许则返回继续执行原工具。
3080
+ * 不满足前置条件(非 auto / 工具不在 sensitiveTools / 无模式 / 无活动页 /
3081
+ * 无目标 selector / 页内取描述失败)一律直接放行,由原工具自行处理错误。
3082
+ * page 参数仅供测试注入;缺省取当前活动页。
3083
+ */
3084
+ async function confirmGate(config, tool, args, page) {
3085
+ if (config.confirmMode !== "auto") return;
3086
+ if (tool === "browser_confirm") return;
3087
+ if (!(config.sensitiveTools ?? DEFAULT_SENSITIVE_TOOLS).includes(tool)) return;
3088
+ const patterns = config.sensitivePatterns ?? DEFAULT_SENSITIVE_PATTERNS;
3089
+ if (patterns.length === 0) return;
3090
+ const active = page !== void 0 ? page : activePage();
3091
+ if (active === null || active === void 0) return;
3092
+ const sel = typeof args?.selector === "string" && args.selector ? args.selector : typeof args?.ref === "string" && args.ref ? pageRef(active, args.ref)?.selector ?? "" : "";
3093
+ if (sel === "") return;
3094
+ let desc = null;
3095
+ try {
3096
+ desc = await active.evaluate(describeTarget, sel);
3097
+ } catch {
3098
+ return;
3099
+ }
3100
+ const hits = sensitiveHits(desc, patterns);
3101
+ if (hits.length === 0) return;
3102
+ const label = desc !== null && (desc.name || desc.text) ? `(${desc.role !== "" ? `${desc.role} ` : ""}"${desc.name || desc.text}")` : "";
3103
+ await confirmTool(config, {
3104
+ reason: `confirmMode:auto 硬拦截:${tool} 目标元素命中敏感模式 [${hits.join(", ")}] ${label}`.trim(),
3105
+ tool,
3106
+ args
3107
+ });
3108
+ }
3084
3109
  /** 断言:条件在 timeoutMs 内成立则通过,否则失败并自动截图存证据。visual 断言走像素比对。 */
3085
3110
  async function assertTool(config, args, exec) {
3086
3111
  const page = await getPage(config);
@@ -3336,37 +3361,29 @@ const SELECTOR_STEP_TOOLS = /* @__PURE__ */ new Set([
3336
3361
  "browser_select",
3337
3362
  "browser_upload"
3338
3363
  ]);
3364
+ /** 拾取指纹的页内提取(walker 统一元素描述结构;构建期拼装注入)。 */
3365
+ const pickElementInjector = injectWalker((el, w) => {
3366
+ const d = w.describeElement(el);
3367
+ return {
3368
+ testid: d.testid !== "" ? d.testid : void 0,
3369
+ id: d.id !== "" ? d.id : void 0,
3370
+ tag: d.tag,
3371
+ role: d.role !== "" ? d.role : void 0,
3372
+ name: d.name !== "" ? d.name : void 0,
3373
+ text: d.text !== "" ? d.text : void 0
3374
+ };
3375
+ });
3339
3376
  /**
3340
3377
  * 采集选择器指向元素的指纹(页内 evaluate;找不到元素返回 null)。
3341
3378
  * 指纹是回放自愈的降级候选来源:testid / role+name / text / id / tag+text。
3379
+ * F5 收敛:元素描述(role/name/text)改用 walker 单一来源
3380
+ * (describeElement),与 aria 快照/find/录制回声共用同一套语义;相比旧
3381
+ * 内联副本,role 推断更完整(slider/spinbutton/searchbox/heading 等),
3382
+ * name 补齐 labelledby/label[for]/title 兜底 —— 指纹只作自愈降级候选,
3383
+ * 语义增强只会让自愈命中率更高,不改变任何主链路行为。
3342
3384
  */
3343
3385
  async function captureSelectorHint(page, selector) {
3344
- return page.locator(selector).first().evaluate((el) => {
3345
- const tag = el.tagName.toLowerCase();
3346
- const type = (el.getAttribute("type") ?? "").toLowerCase();
3347
- const roleAttr = (el.getAttribute("role") ?? "").trim().split(/\s+/)[0] ?? "";
3348
- const implicit = tag === "a" && el.hasAttribute("href") ? "link" : tag === "button" || tag === "summary" || tag === "input" && [
3349
- "button",
3350
- "submit",
3351
- "reset",
3352
- "image"
3353
- ].includes(type) ? "button" : tag === "textarea" || tag === "input" && ![
3354
- "hidden",
3355
- "checkbox",
3356
- "radio",
3357
- "file"
3358
- ].includes(type) ? "textbox" : tag === "select" ? "combobox" : tag === "input" && type === "checkbox" ? "checkbox" : tag === "input" && type === "radio" ? "radio" : "";
3359
- const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60);
3360
- const name = (el.getAttribute("aria-label") ?? "").trim() || (el.getAttribute("placeholder") ?? "").trim() || text;
3361
- return {
3362
- testid: el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? void 0,
3363
- id: el.id !== "" ? el.id : void 0,
3364
- tag,
3365
- role: (roleAttr || implicit) !== "" ? roleAttr || implicit : void 0,
3366
- name: name !== "" ? name : void 0,
3367
- text: text !== "" ? text : void 0
3368
- };
3369
- }).catch(() => null);
3386
+ return page.locator(selector).first().evaluate(pickElementInjector).catch(() => null);
3370
3387
  }
3371
3388
  /** 由指纹生成自愈降级候选(按优先级排序;导出供测试)。 */
3372
3389
  function healCandidates(hint) {
@@ -3489,6 +3506,7 @@ async function runReplay(steps, impls, failFast, opts = {}) {
3489
3506
  let stoppedAt = null;
3490
3507
  let passed = 0;
3491
3508
  let healedCount = 0;
3509
+ let retryAttempted = 0;
3492
3510
  for (let i = 0; i < steps.length; i++) {
3493
3511
  const step = steps[i];
3494
3512
  const impl = impls[step.tool];
@@ -3506,81 +3524,107 @@ async function runReplay(steps, impls, failFast, opts = {}) {
3506
3524
  continue;
3507
3525
  }
3508
3526
  const hint = step.meta?.hint;
3509
- if (!(opts.healing === true && hint !== void 0 && SELECTOR_STEP_TOOLS.has(step.tool) && typeof step.args?.selector === "string")) {
3510
- try {
3527
+ const canHeal = opts.healing === true && hint !== void 0 && SELECTOR_STEP_TOOLS.has(step.tool) && typeof step.args?.selector === "string";
3528
+ /** 单次完整尝试(含自愈链);返回该次尝试的 result 行(带内部 healed 标记)。 */
3529
+ const attempt = async () => {
3530
+ if (!canHeal) try {
3511
3531
  await impl(step.args ?? {});
3512
- passed++;
3513
- results.push({
3514
- index: i,
3515
- tool: step.tool,
3516
- ok: true
3517
- });
3532
+ return {
3533
+ ok: true,
3534
+ result: {
3535
+ index: i,
3536
+ tool: step.tool,
3537
+ ok: true
3538
+ }
3539
+ };
3518
3540
  } catch (e) {
3519
- results.push({
3520
- index: i,
3521
- tool: step.tool,
3541
+ return {
3522
3542
  ok: false,
3523
- error: e instanceof Error ? e.message : String(e)
3524
- });
3525
- if (failFast) {
3526
- stoppedAt = i;
3527
- break;
3528
- }
3529
- }
3530
- continue;
3531
- }
3532
- const healTimeout = opts.healTimeoutMs ?? 3e3;
3533
- const quickArgs = {
3534
- ...step.args,
3535
- timeoutMs: Math.min(Number(step.args.timeoutMs ?? Number.POSITIVE_INFINITY) || Number.POSITIVE_INFINITY, healTimeout)
3536
- };
3537
- try {
3538
- await impl(quickArgs);
3539
- passed++;
3540
- results.push({
3541
- index: i,
3542
- tool: step.tool,
3543
- ok: true
3544
- });
3545
- continue;
3546
- } catch (e0) {
3547
- const tried = [];
3548
- let healed = null;
3549
- for (const candidate of healCandidates(hint)) {
3550
- tried.push(`${candidate.via}: ${candidate.selector}`);
3551
- try {
3552
- await impl({
3553
- ...step.args,
3554
- selector: candidate.selector,
3555
- timeoutMs: healTimeout
3556
- });
3557
- healed = candidate;
3558
- break;
3559
- } catch {}
3543
+ result: {
3544
+ index: i,
3545
+ tool: step.tool,
3546
+ ok: false,
3547
+ error: e instanceof Error ? e.message : String(e)
3548
+ }
3549
+ };
3560
3550
  }
3561
- if (healed !== null) {
3562
- passed++;
3563
- healedCount++;
3564
- results.push({
3565
- index: i,
3566
- tool: step.tool,
3551
+ const healTimeout = opts.healTimeoutMs ?? 3e3;
3552
+ const quickArgs = {
3553
+ ...step.args,
3554
+ timeoutMs: Math.min(Number(step.args.timeoutMs ?? Number.POSITIVE_INFINITY) || Number.POSITIVE_INFINITY, healTimeout)
3555
+ };
3556
+ try {
3557
+ await impl(quickArgs);
3558
+ return {
3567
3559
  ok: true,
3568
- healed: true,
3569
- via: healed.via,
3570
- resolved: healed.selector
3571
- });
3572
- } else {
3560
+ result: {
3561
+ index: i,
3562
+ tool: step.tool,
3563
+ ok: true
3564
+ }
3565
+ };
3566
+ } catch (e0) {
3567
+ const tried = [];
3568
+ let healed = null;
3569
+ for (const candidate of healCandidates(hint)) {
3570
+ tried.push(`${candidate.via}: ${candidate.selector}`);
3571
+ try {
3572
+ await impl({
3573
+ ...step.args,
3574
+ selector: candidate.selector,
3575
+ timeoutMs: healTimeout
3576
+ });
3577
+ healed = candidate;
3578
+ break;
3579
+ } catch {}
3580
+ }
3581
+ if (healed !== null) return {
3582
+ ok: true,
3583
+ result: {
3584
+ index: i,
3585
+ tool: step.tool,
3586
+ ok: true,
3587
+ healed: true,
3588
+ via: healed.via,
3589
+ resolved: healed.selector
3590
+ }
3591
+ };
3573
3592
  const base = e0 instanceof Error ? e0.message : String(e0);
3574
- results.push({
3575
- index: i,
3576
- tool: step.tool,
3593
+ return {
3577
3594
  ok: false,
3578
- error: `${base}${tried.length > 0 ? `; 自愈已尝试: ${tried.join(" | ")}` : ""}`
3579
- });
3580
- if (failFast) {
3581
- stoppedAt = i;
3582
- break;
3583
- }
3595
+ result: {
3596
+ index: i,
3597
+ tool: step.tool,
3598
+ ok: false,
3599
+ error: `${base}${tried.length > 0 ? `; 自愈已尝试: ${tried.join(" | ")}` : ""}`
3600
+ }
3601
+ };
3602
+ }
3603
+ };
3604
+ let outcome = await attempt();
3605
+ let recovered = false;
3606
+ if (!outcome.ok && (opts.retryFailed ?? 0) > 0) for (let n = 1; n <= (opts.retryFailed ?? 0); n++) {
3607
+ await sleep(500 * n);
3608
+ retryAttempted++;
3609
+ const again = await attempt();
3610
+ outcome = again;
3611
+ if (again.ok) {
3612
+ recovered = true;
3613
+ break;
3614
+ }
3615
+ }
3616
+ if (outcome.ok) {
3617
+ passed++;
3618
+ if (outcome.result.healed === true) healedCount++;
3619
+ results.push(recovered ? {
3620
+ ...outcome.result,
3621
+ recovered: true
3622
+ } : outcome.result);
3623
+ } else {
3624
+ results.push(outcome.result);
3625
+ if (failFast) {
3626
+ stoppedAt = i;
3627
+ break;
3584
3628
  }
3585
3629
  }
3586
3630
  }
@@ -3589,6 +3633,10 @@ async function runReplay(steps, impls, failFast, opts = {}) {
3589
3633
  passed,
3590
3634
  failed: results.length - passed,
3591
3635
  healedCount,
3636
+ ...retryAttempted > 0 ? { retries: {
3637
+ attempted: retryAttempted,
3638
+ recovered: results.filter((r) => r.recovered === true).length
3639
+ } } : {},
3592
3640
  ...stoppedAt !== null ? { stoppedAt } : {},
3593
3641
  results
3594
3642
  };
@@ -3640,14 +3688,176 @@ function workspaceFilePath(cwd, file) {
3640
3688
  if (p !== root && !p.startsWith(root + "/") && !p.startsWith(root + "\\")) throw new Error(`文件路径越出工作区: ${file}`);
3641
3689
  return p;
3642
3690
  }
3691
+ /** suggest 的页内结构探测(injectWalker 注入;walker 提供 cssPath)。 */
3692
+ const suggestFieldsInPage = injectWalker((payload, w) => {
3693
+ const root = payload.scope !== "" ? document.querySelector(payload.scope) : document.body;
3694
+ if (root === null) return {
3695
+ ok: false,
3696
+ reason: `scope 未命中: 页面中没有匹配 "${payload.scope}" 的元素`
3697
+ };
3698
+ const cellOf = (el) => {
3699
+ const a = el.querySelector("a[href]");
3700
+ const img = el.querySelector("img[src]");
3701
+ return {
3702
+ text: (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60),
3703
+ ...a !== null ? { href: a.getAttribute("href") ?? "" } : {},
3704
+ ...img !== null ? { src: img.getAttribute("src") ?? "" } : {}
3705
+ };
3706
+ };
3707
+ const rows = Array.from(root.querySelectorAll("table tr"));
3708
+ if (rows.length >= 2) {
3709
+ const first = rows[0];
3710
+ let headerTexts = [];
3711
+ let record = null;
3712
+ if (first.querySelector("th") !== null) {
3713
+ headerTexts = Array.from(first.querySelectorAll("th, td")).map((c) => (c.innerText || c.textContent || "").trim());
3714
+ record = rows[1] ?? null;
3715
+ } else record = first;
3716
+ if (record !== null) {
3717
+ const cells = Array.from(record.querySelectorAll("td, th")).map(cellOf);
3718
+ if (cells.length > 0) return {
3719
+ ok: true,
3720
+ kind: "table",
3721
+ rowSelector: "table tr",
3722
+ headerTexts,
3723
+ cells
3724
+ };
3725
+ }
3726
+ }
3727
+ const list = root.querySelector("ul, ol");
3728
+ if (list !== null) {
3729
+ const lis = Array.from(list.children).filter((c) => c.tagName === "LI");
3730
+ if (lis.length >= 2) {
3731
+ const li = lis[0];
3732
+ return {
3733
+ ok: true,
3734
+ kind: "list",
3735
+ rowSelector: `${w.cssPath(list)} > li`,
3736
+ item: cellOf(li)
3737
+ };
3738
+ }
3739
+ }
3740
+ return {
3741
+ ok: false,
3742
+ reason: "未发现规律结构(无 ≥2 行的表格,也无 ≥2 项的列表);请用 browser_elements/browser_snapshot 查看结构后手写 fields",
3743
+ overview: {
3744
+ links: root.querySelectorAll("a").length,
3745
+ images: root.querySelectorAll("img").length,
3746
+ buttons: root.querySelectorAll("button, [role='button']").length,
3747
+ headings: root.querySelectorAll("h1, h2, h3, h4").length,
3748
+ rows: rows.length,
3749
+ textChars: (root.innerText || "").length
3750
+ }
3751
+ };
3752
+ });
3753
+ /** 字段名净化:空白→下划线,限长 30,空回落 col_N。导出供测试。 */
3754
+ function suggestFieldName(header, index) {
3755
+ const h = (header ?? "").trim().replace(/\s+/g, "_");
3756
+ return h !== "" ? h.slice(0, 30) : `col_${index + 1}`;
3757
+ }
3758
+ /**
3759
+ * 纯逻辑:由表头与首行单元格特征推断表格 fields(单元格文本/链接 href/图片 src)。
3760
+ * 返回可直接放进 rows.fields 的嵌套字段数组。导出供测试。
3761
+ */
3762
+ function fieldsFromCells(headerTexts, cells) {
3763
+ const out = [];
3764
+ cells.forEach((c, i) => {
3765
+ const name = suggestFieldName(headerTexts[i], i);
3766
+ const sel = `td:nth-of-type(${i + 1})`;
3767
+ if (c.src !== void 0) out.push({
3768
+ name: `${name}_src`,
3769
+ selector: `${sel} img`,
3770
+ attr: "src"
3771
+ });
3772
+ if (c.href !== void 0) out.push({
3773
+ name: `${name}_href`,
3774
+ selector: `${sel} a`,
3775
+ attr: "href"
3776
+ });
3777
+ out.push({
3778
+ name,
3779
+ selector: sel
3780
+ });
3781
+ });
3782
+ return out;
3783
+ }
3784
+ /**
3785
+ * 纯逻辑:由首条卡片信息推断列表 fields(项文本/链接/图片)。
3786
+ * 文本取第一个子元素(项自身无法用 querySelector 自选)。导出供测试。
3787
+ */
3788
+ function fieldsFromCard(item) {
3789
+ const out = [];
3790
+ if (item.src !== void 0) out.push({
3791
+ name: "image",
3792
+ selector: "img",
3793
+ attr: "src"
3794
+ });
3795
+ if (item.href !== void 0) out.push({
3796
+ name: "link",
3797
+ selector: "a",
3798
+ attr: "href"
3799
+ });
3800
+ out.push({
3801
+ name: "text",
3802
+ selector: ":scope > *:nth-child(1)"
3803
+ });
3804
+ return out;
3805
+ }
3806
+ /** 纯逻辑:由 fields 与单元格样本拼出首行样例(与抽取输出同构;按 td 序号对位)。导出供测试。 */
3807
+ function sampleOfFields(fields, cells) {
3808
+ const out = {};
3809
+ for (const f of fields) {
3810
+ const m = /td:nth-of-type\((\d+)\)/.exec(f.selector);
3811
+ const c = cells[m !== null ? Number(m[1]) - 1 : 0];
3812
+ if (f.attr === "href") out[f.name] = c?.href ?? null;
3813
+ else if (f.attr === "src") out[f.name] = c?.src ?? null;
3814
+ else out[f.name] = c?.text ?? null;
3815
+ }
3816
+ return out;
3817
+ }
3643
3818
  /**
3644
- * 规则化抽取:fields 定义抽取结构,next 选择器驱动翻页(maxPages 上限),
3645
- * 行数超 maxRows 截断;format/file 控制序列化与落盘(json/csv/jsonl/markdown)。
3819
+ * suggest 汇总(纯逻辑):由页内分析结果组装「可直接交给抽取模式」的顶层 fields
3820
+ * (单个 multiple rows 字段 + 行内嵌套字段)与样本。推断失败原样返回。导出供测试。
3646
3821
  */
3822
+ function suggestWrap(analysis) {
3823
+ if (!analysis.ok || analysis.kind === void 0 || analysis.rowSelector === void 0) return {
3824
+ suggested: false,
3825
+ reason: analysis.reason ?? "结构不足以推断字段",
3826
+ ...analysis.overview !== void 0 ? { overview: analysis.overview } : {}
3827
+ };
3828
+ const rowFields = analysis.kind === "table" ? fieldsFromCells(analysis.headerTexts ?? [], analysis.cells ?? []) : fieldsFromCard(analysis.item ?? { text: "" });
3829
+ if (rowFields.length === 0) return {
3830
+ suggested: false,
3831
+ reason: "首条记录内未发现可抽取内容(文本/链接/图片)",
3832
+ ...analysis.overview !== void 0 ? { overview: analysis.overview } : {}
3833
+ };
3834
+ const sample = sampleOfFields(rowFields, analysis.kind === "table" ? analysis.cells ?? [] : [analysis.item ?? { text: "" }]);
3835
+ return {
3836
+ suggested: true,
3837
+ kind: analysis.kind,
3838
+ rowSelector: analysis.rowSelector,
3839
+ ...analysis.headerTexts !== void 0 ? { headerTexts: analysis.headerTexts } : {},
3840
+ fields: [{
3841
+ name: "rows",
3842
+ selector: analysis.rowSelector,
3843
+ multiple: true,
3844
+ fields: rowFields
3845
+ }],
3846
+ sample
3847
+ };
3848
+ }
3647
3849
  async function extractTool(config, args, exec) {
3648
3850
  const page = await getPage(config);
3851
+ if (args.action === "suggest") {
3852
+ const scope = typeof args.scope === "string" && args.scope !== "" ? args.scope : "";
3853
+ return {
3854
+ action: "suggest",
3855
+ ...suggestWrap(await page.evaluate(suggestFieldsInPage, { scope })),
3856
+ ...await pageIdentity(page)
3857
+ };
3858
+ }
3649
3859
  const fields = args.fields;
3650
- if (!Array.isArray(fields) || fields.length === 0) throw new Error("extract 需要 fields:[{ name, selector, attr?, multiple?, fields? }]");
3860
+ if (!Array.isArray(fields) || fields.length === 0) throw new Error("extract 需要 fields:[{ name, selector, attr?, multiple?, fields? }] 或 action: \"suggest\" 推断字段");
3651
3861
  for (const f of fields) if (typeof f?.name !== "string" || f.name === "" || typeof f?.selector !== "string" || f.selector === "") throw new Error("fields 每项需要非空 name 与 selector");
3652
3862
  const maxPages = Math.min(Math.max(Number(args.maxPages ?? 1) || 1, 1), 100);
3653
3863
  const maxRows = Math.min(Math.max(Number(args.maxRows ?? config.maxRows ?? 1e3) || 1e3, 1), 1e5);
@@ -3858,9 +4068,19 @@ async function replayTool(args, impls, config, exec) {
3858
4068
  else throw new Error("replay 需要 name(已保存录制)或 steps(步骤数组)");
3859
4069
  if (steps.length === 0) throw new Error("步骤为空");
3860
4070
  const failFast = args.failFast !== false;
4071
+ const retryFailed = Math.min(Math.max(Math.floor(Number(args.retryFailed ?? 0)) || 0, 0), 10);
4072
+ const retryOf = typeof args.retryOf === "string" && args.retryOf !== "" ? args.retryOf : void 0;
4073
+ const runMeta = { ...retryFailed > 0 || retryOf !== void 0 ? {
4074
+ ...retryFailed > 0 ? { retries: {
4075
+ attempted: 0,
4076
+ recovered: 0
4077
+ } } : {},
4078
+ ...retryOf !== void 0 ? { retryOf } : {}
4079
+ } : {} };
3861
4080
  const opts = {
3862
4081
  healing: config?.selectorHealing !== false,
3863
- healTimeoutMs: config?.healTimeoutMs ?? 3e3
4082
+ healTimeoutMs: config?.healTimeoutMs ?? 3e3,
4083
+ ...retryFailed > 0 ? { retryFailed } : {}
3864
4084
  };
3865
4085
  let rows = null;
3866
4086
  if (Array.isArray(args.data)) rows = args.data;
@@ -3900,7 +4120,8 @@ async function replayTool(args, impls, config, exec) {
3900
4120
  healedCount: 0,
3901
4121
  durationMs: Date.now() - t0,
3902
4122
  row: i,
3903
- results: []
4123
+ results: [],
4124
+ ...runMeta
3904
4125
  });
3905
4126
  continue;
3906
4127
  }
@@ -3920,7 +4141,9 @@ async function replayTool(args, impls, config, exec) {
3920
4141
  healedCount: result.healedCount,
3921
4142
  durationMs: Date.now() - t0,
3922
4143
  row: i,
3923
- results: result.results
4144
+ results: result.results,
4145
+ ...result.retries ?? runMeta.retries,
4146
+ ...runMeta.retryOf !== void 0 ? { retryOf: runMeta.retryOf } : {}
3924
4147
  });
3925
4148
  }
3926
4149
  return {
@@ -3938,6 +4161,7 @@ async function replayTool(args, impls, config, exec) {
3938
4161
  const t0 = Date.now();
3939
4162
  const result = await runReplay(substituteVars(steps, vars), impls, failFast, opts);
3940
4163
  const stoppedAt = result.stoppedAt;
4164
+ const retries = result.retries;
3941
4165
  noteRun({
3942
4166
  kind: "replay",
3943
4167
  source,
@@ -3946,14 +4170,42 @@ async function replayTool(args, impls, config, exec) {
3946
4170
  failed: result.failed,
3947
4171
  healedCount: result.healedCount,
3948
4172
  durationMs: Date.now() - t0,
3949
- results: result.results
4173
+ results: result.results,
4174
+ ...retries !== void 0 ? { retries } : {},
4175
+ ...retryOf !== void 0 ? { retryOf } : {}
3950
4176
  });
3951
4177
  return {
3952
4178
  source,
3953
4179
  ...result,
3954
- summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${stoppedAt !== void 0 ? `(停在步骤 ${stoppedAt})` : ""}` : ""}${result.healedCount > 0 ? `,${result.healedCount} 步自愈(建议修复录制选择器)` : ""}`
4180
+ summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${stoppedAt !== void 0 ? `(停在步骤 ${stoppedAt})` : ""}` : ""}${result.healedCount > 0 ? `,${result.healedCount} 步自愈(建议修复录制选择器)` : ""}${retries !== void 0 && retries.recovered > 0 ? `,重试挽回 ${retries.recovered}/${retries.attempted}` : ""}`
3955
4181
  };
3956
4182
  }
4183
+ let rerunHandler = null;
4184
+ let rerunInFlight = false;
4185
+ /** registerTools 注册重跑入口(stepImpls 同路径);web-only 进程保持 null。 */
4186
+ function setRerunHandler(fn) {
4187
+ rerunHandler = fn;
4188
+ }
4189
+ /** 当前可用的重跑入口(null = 本进程没有 tools 服务,无法重跑)。 */
4190
+ function getRerunHandler() {
4191
+ return rerunHandler;
4192
+ }
4193
+ /** 重跑是否正在执行(端点互斥;导出供测试)。 */
4194
+ function rerunBusy() {
4195
+ return rerunInFlight;
4196
+ }
4197
+ /** 执行一次面板重跑(busy 检查 + 互斥标记;返回 replayTool 结果)。 */
4198
+ async function runPanelRerun(req) {
4199
+ if (rerunInFlight) throw Object.assign(/* @__PURE__ */ new Error("重跑进行中"), { status: 409 });
4200
+ rerunInFlight = true;
4201
+ try {
4202
+ const handler = rerunHandler;
4203
+ if (handler === null) throw Object.assign(/* @__PURE__ */ new Error("本进程无回放能力(tools 服务未注册)"), { status: 503 });
4204
+ return await handler(req);
4205
+ } finally {
4206
+ rerunInFlight = false;
4207
+ }
4208
+ }
3957
4209
  //#endregion
3958
4210
  //#region lib/types/browser/register.js
3959
4211
  function registerTools(ctx, config) {
@@ -3984,6 +4236,15 @@ function registerTools(ctx, config) {
3984
4236
  "browser_press"
3985
4237
  ]);
3986
4238
  /**
4239
+ * auto 审批闸(confirmMode:"auto" 硬约束):live 调用在 register 包装层拦,
4240
+ * replay/batch 在 stepImpls 外再包一层(默认同样拦截;confirmInReplay: false
4241
+ * 可关,README 注明风险)。未命中模式时闸内只花一次页内 evaluate。
4242
+ */
4243
+ const replayGate = (tool, fn) => async (a) => {
4244
+ if (config.confirmMode === "auto" && config.confirmInReplay !== false) await confirmGate(config, tool, a);
4245
+ return fn(a);
4246
+ };
4247
+ /**
3987
4248
  * toolset: "core" 的高频核心集:浏览/定位/操作/等待/断言/人机协作。
3988
4249
  * 低频的录制/回放/报告/抽取/表单回放/profile/context 等只在 "all" 注册。
3989
4250
  */
@@ -4019,8 +4280,10 @@ function registerTools(ctx, config) {
4019
4280
  const original = def.execute;
4020
4281
  def.execute = async (args, exec) => {
4021
4282
  const t0 = Date.now();
4283
+ noteToolStart();
4022
4284
  const domTool = AGENT_DOM_TOOLS.has(def.name);
4023
4285
  if (domTool) noteAgentAction();
4286
+ if (config.confirmMode === "auto") await confirmGate(config, def.name, args);
4024
4287
  if (PREVIEW_TOOLS.has(def.name)) {
4025
4288
  const page = activePage();
4026
4289
  previewHighlight(page, typeof args?.selector === "string" && args.selector ? args.selector : typeof args?.ref === "string" && args.ref && page !== null ? pageRef(page, args.ref)?.selector ?? "" : "", def.name.replace("browser_", ""));
@@ -4053,6 +4316,8 @@ function registerTools(ctx, config) {
4053
4316
  error: error instanceof Error ? error.message : String(error)
4054
4317
  });
4055
4318
  throw error;
4319
+ } finally {
4320
+ noteToolEnd();
4056
4321
  }
4057
4322
  };
4058
4323
  tools.register(def);
@@ -4095,6 +4360,14 @@ function registerTools(ctx, config) {
4095
4360
  browser_batch: (a) => batchTool(a, stepImpls, config),
4096
4361
  browser_extract: (a) => extractTool(config, a)
4097
4362
  };
4363
+ for (const name of Object.keys(stepImpls)) {
4364
+ const fn = stepImpls[name];
4365
+ stepImpls[name] = replayGate(name, fn);
4366
+ }
4367
+ setRerunHandler((req) => replayTool({
4368
+ name: req.source,
4369
+ ...req.retryOf !== void 0 ? { retryOf: req.retryOf } : {}
4370
+ }, stepImpls, config));
4098
4371
  register(definePlainTool({
4099
4372
  name: "browser_open",
4100
4373
  description: "在共享浏览器中打开一个 URL。之后可用 browser_snapshot 读取页面内容,用 browser_click / browser_type / browser_eval 操作页面,用 browser_screenshot 截图。",
@@ -4143,7 +4416,7 @@ function registerTools(ctx, config) {
4143
4416
  }));
4144
4417
  register(definePlainTool({
4145
4418
  name: "browser_snapshot",
4146
- description: "读取当前页面快照(不截图)。mode:'text'(默认)返回压缩后的可见文本+链接清单,适合读内容;'aria' 返回可访问性树,可交互元素带 [ref=eN] 短引用,后续 browser_click/browser_type/browser_select/browser_press 可直接传 ref 代替 CSS 选择器(导航或重新快照后旧 ref 失效)。maxTextChars 默认 20000。scope:可选 CSS 选择器,只对命中的容器区域提取(text/aria 两种模式都生效,scope 未命中报错)。since:增量模式,传上次快照返回的 contentVersion——内容未变返回 { unchanged: true },变了只返回变化的行(diff: true, changedFrom/removed 标注变化区),表格翻页/局部刷新后重读 token 开销可降一个量级;since 过期则回落全量并标 stale。",
4419
+ description: "读取当前页面快照(不截图)。mode:'text'(默认)返回压缩后的可见文本+链接清单,适合读内容;'aria' 返回可访问性树,可交互元素带 [ref=eN] 短引用,后续 browser_click/browser_type/browser_select/browser_press 可直接传 ref 代替 CSS 选择器(导航或重新快照后旧 ref 失效)。maxTextChars 默认 20000。scope:可选 CSS 选择器,只对命中的容器区域提取(text/aria 两种模式都生效,scope 未命中报错)。since:增量模式,传之前任一次快照返回的 contentVersion——历史版本环形缓存(snapshotHistory 默认 5,可配 1-20)窗口内的任意版本都能 diff:内容未变返回 { unchanged: true },变了只返回相对该版本的变化行(diff: true, changedFrom/removed 标注变化区),表格翻页/局部刷新/轮询抓取后重读 token 开销可降一个量级;since 超出窗口(过旧/未知)则回落全量并标 stale。",
4147
4420
  parameters: {
4148
4421
  type: "object",
4149
4422
  properties: {
@@ -4158,7 +4431,7 @@ function registerTools(ctx, config) {
4158
4431
  },
4159
4432
  since: {
4160
4433
  type: "integer",
4161
- description: "可选:上次快照的 contentVersion,只返回变化内容。"
4434
+ description: "可选:之前任一次快照的 contentVersion(历史窗口内任意版本),只返回相对该版本的变化内容。"
4162
4435
  },
4163
4436
  maxTextChars: {
4164
4437
  type: "integer",
@@ -5562,7 +5835,7 @@ function registerTools(ctx, config) {
5562
5835
  }));
5563
5836
  register(definePlainTool({
5564
5837
  name: "browser_replay",
5565
- description: "回放录制或步骤序列(回归测试):name 指定已保存录制(browser_record save),或直接传 steps 数组(每步 {tool, args})。failFast 默认 true(遇错停止);返回每步结果与汇总。选择器自愈:录制时保存了元素指纹的步骤,原 selector 失败时自动按 testid→role+name→text→id 降级重试并在结果中标 healed(建议事后修复录制选择器)。参数化:录制含 {{占位符}} 时用 vars 注入;data 传对象数组或 dataFile 传 CSV 路径(首行为变量名)可逐行数据驱动回放。运行结果自动记入回归历史(browser_report 可导出报告)。",
5838
+ description: "回放录制或步骤序列(回归测试):name 指定已保存录制(browser_record save),或直接传 steps 数组(每步 {tool, args})。failFast 默认 true(遇错停止);返回每步结果与汇总。选择器自愈:录制时保存了元素指纹的步骤,原 selector 失败时自动按 testid→role+name→text→id 降级重试并在结果中标 healed(建议事后修复录制选择器)。参数化:录制含 {{占位符}} 时用 vars 注入;data 传对象数组或 dataFile 传 CSV 路径(首行为变量名)可逐行数据驱动回放。retryFailed: N 使失败步骤(自愈链耗尽后)自动重试 N 次(500ms×n 线性退避,重试耗尽才停),返回值 retries 标注 { attempted, recovered }。运行结果自动记入回归历史(browser_report 可导出报告)。",
5566
5839
  parameters: {
5567
5840
  type: "object",
5568
5841
  properties: {
@@ -5591,6 +5864,10 @@ function registerTools(ctx, config) {
5591
5864
  dataFile: {
5592
5865
  type: "string",
5593
5866
  description: "数据集 CSV 文件(工作区相对路径,首行为变量名)。"
5867
+ },
5868
+ retryFailed: {
5869
+ type: "integer",
5870
+ description: "失败步骤自动重试次数(0-10,默认 0;500ms×n 线性退避,重试耗尽才停)。偶发超时/网络抖动类失败的兜底。"
5594
5871
  }
5595
5872
  },
5596
5873
  additionalProperties: false
@@ -5604,6 +5881,10 @@ function registerTools(ctx, config) {
5604
5881
  passed: { type: "integer" },
5605
5882
  failed: { type: "integer" },
5606
5883
  healedCount: { type: "integer" },
5884
+ retries: {
5885
+ type: "object",
5886
+ description: "自动重试统计 { attempted, recovered }(仅 retryFailed>0 时返回)。"
5887
+ },
5607
5888
  stoppedAt: { type: "integer" },
5608
5889
  results: {
5609
5890
  type: "array",
@@ -5629,7 +5910,7 @@ function registerTools(ctx, config) {
5629
5910
  ]
5630
5911
  },
5631
5912
  render: (_args, value) => {
5632
- return value.runs !== void 0 ? `${value.summary}\n${value.runs.map((r) => `${(r.failed ?? 0) === 0 ? "✓" : "✗"} 行${r.row}${r.error ? ` — ${String(r.error).slice(0, 100)}` : ` ${r.passed}/${r.total}`}`).join("\n")}` : `${value.summary}\n${(value.results ?? []).map((r) => `${r.ok ? "✓" : "✗"} #${r.index} ${r.tool}${r.healed ? ` [自愈:${r.via}]` : ""}${r.error ? ` — ${String(r.error).slice(0, 120)}` : ""}`).join("\n")}`;
5913
+ return value.runs !== void 0 ? `${value.summary}\n${value.runs.map((r) => `${(r.failed ?? 0) === 0 ? "✓" : "✗"} 行${r.row}${r.error ? ` — ${String(r.error).slice(0, 100)}` : ` ${r.passed}/${r.total}`}`).join("\n")}` : `${value.summary}\n${(value.results ?? []).map((r) => `${r.ok ? "✓" : "✗"} #${r.index} ${r.tool}${r.healed ? ` [自愈:${r.via}]` : ""}${r.recovered ? " [重试挽回]" : ""}${r.error ? ` — ${String(r.error).slice(0, 120)}` : ""}`).join("\n")}`;
5633
5914
  },
5634
5915
  timeoutMs: timeout(6e5),
5635
5916
  execute: (args, exec) => replayTool(args, stepImpls, config, exec)
@@ -5683,14 +5964,23 @@ function registerTools(ctx, config) {
5683
5964
  }));
5684
5965
  register(definePlainTool({
5685
5966
  name: "browser_extract",
5686
- description: "规则化结构化抽取(网站信息获取):fields 定义抽取结构 [{name, selector, attr?, multiple?, fields?}](attr 缺省取可见文本,可指定 href/src 等属性;multiple=true 收集所有命中;fields 嵌套可在行内抽子字段);next 传翻页选择器自动翻页聚合(maxPages 上限,next 消失/禁用即停);format 支持 json/csv/jsonl/markdown,file 落盘到工作区(自动按扩展名推断格式,拒绝路径穿越)。语义化抽取(如「总结要点」)不在此工具范围——用 browser_snapshot 读文本后交 jev_extract。",
5967
+ description: "规则化结构化抽取(网站信息获取):fields 定义抽取结构 [{name, selector, attr?, multiple?, fields?}](attr 缺省取可见文本,可指定 href/src 等属性;multiple=true 收集所有命中;fields 嵌套可在行内抽子字段);next 传翻页选择器自动翻页聚合(maxPages 上限,next 消失/禁用即停);format 支持 json/csv/jsonl/markdown,file 落盘到工作区(自动按扩展名推断格式,拒绝路径穿越)。action:\"suggest\"(不传 fields)自动推断字段:从命中容器首条记录(表格首行/列表卡片)推断单元格文本/链接 href/图片 src 结构,返回建议 fields + 首行样本,确认/微调后直接交给抽取模式(往返 3-4 次降到 2 次);推断不出规律结构时返回原因与容器元素概览,不猜。语义化抽取(如「总结要点」)不在此工具范围——用 browser_snapshot 读文本后交 jev_extract。",
5687
5968
  parameters: {
5688
5969
  type: "object",
5689
5970
  properties: {
5971
+ action: {
5972
+ type: "string",
5973
+ enum: ["suggest"],
5974
+ description: "suggest:自动推断字段结构,返回建议 fields + 样本(此模式不传 fields)。"
5975
+ },
5976
+ scope: {
5977
+ type: "string",
5978
+ description: "suggest 模式的容器选择器(缺省整页;表格/列表探测在此范围内进行)。"
5979
+ },
5690
5980
  fields: {
5691
5981
  type: "array",
5692
5982
  items: { type: "object" },
5693
- description: "抽取字段定义 [{name, selector, attr?, multiple?, fields?}]。"
5983
+ description: "抽取字段定义 [{name, selector, attr?, multiple?, fields?}](action:suggest 时不传)。"
5694
5984
  },
5695
5985
  next: {
5696
5986
  type: "string",
@@ -5723,13 +6013,28 @@ function registerTools(ctx, config) {
5723
6013
  description: "落盘路径(工作区相对或绝对,限工作区内)。"
5724
6014
  }
5725
6015
  },
5726
- required: ["fields"],
6016
+ required: [],
5727
6017
  additionalProperties: false
5728
6018
  },
5729
6019
  outputSchema: {
5730
6020
  type: "object",
5731
6021
  additionalProperties: false,
5732
6022
  properties: {
6023
+ action: { type: "string" },
6024
+ suggested: { type: "boolean" },
6025
+ kind: { type: "string" },
6026
+ rowSelector: { type: "string" },
6027
+ headerTexts: {
6028
+ type: "array",
6029
+ items: { type: "string" }
6030
+ },
6031
+ fields: {
6032
+ type: "array",
6033
+ items: { type: "object" }
6034
+ },
6035
+ sample: { type: "object" },
6036
+ reason: { type: "string" },
6037
+ overview: { type: "object" },
5733
6038
  rows: {
5734
6039
  type: "array",
5735
6040
  items: { type: "object" }
@@ -5743,17 +6048,9 @@ function registerTools(ctx, config) {
5743
6048
  url: { type: "string" },
5744
6049
  title: { type: "string" }
5745
6050
  },
5746
- required: [
5747
- "rows",
5748
- "count",
5749
- "pages",
5750
- "format",
5751
- "truncated",
5752
- "url",
5753
- "title"
5754
- ]
6051
+ required: ["url", "title"]
5755
6052
  },
5756
- render: (_args, value) => `抽取 ${value.count} 行 / ${value.pages} 页${value.truncated ? "(已截断)" : ""}${value.file ? ` → ${value.file} (${value.bytes} bytes)` : ""}`,
6053
+ render: (_args, value) => value.action === "suggest" ? value.suggested ? `字段建议(${value.kind}):行选择器 ${value.rowSelector},${value.fields[0].fields.length} 个字段;样例 ${JSON.stringify(value.sample).slice(0, 120)}` : `字段建议失败: ${String(value.reason).slice(0, 120)}` : `抽取 ${value.count} 行 / ${value.pages} 页${value.truncated ? "(已截断)" : ""}${value.file ? ` → ${value.file} (${value.bytes} bytes)` : ""}`,
5757
6054
  timeoutMs: timeout(),
5758
6055
  execute: (args, exec) => extractTool(config, args, exec)
5759
6056
  }));
@@ -5866,7 +6163,42 @@ function registerBrowserApi(ctx, config, base) {
5866
6163
  return;
5867
6164
  }
5868
6165
  if (path === `${base}/runs/latest`) {
5869
- sendJson(res, 200, { run: latestRun() });
6166
+ sendJson(res, 200, {
6167
+ run: latestRun(),
6168
+ busy: activeToolCalls() > 0 || rerunBusy()
6169
+ });
6170
+ return;
6171
+ }
6172
+ if (path === `${base}/runs/rerun`) {
6173
+ if ((req.method ?? "GET") !== "POST") {
6174
+ sendText(res, 405, "POST only");
6175
+ return;
6176
+ }
6177
+ if (activeToolCalls() > 0 || rerunBusy()) {
6178
+ sendJson(res, 409, { error: "agent 忙碌(有工具调用或回放在进行),稍后重试" });
6179
+ return;
6180
+ }
6181
+ if (getRerunHandler() === null) {
6182
+ sendJson(res, 503, { error: "本进程无回放能力(tools 服务未注册)" });
6183
+ return;
6184
+ }
6185
+ const body = await readJsonBody(req).catch(() => void 0);
6186
+ const source = typeof body?.source === "string" ? body.source.trim() : "";
6187
+ if (!source) {
6188
+ sendJson(res, 400, "source required");
6189
+ return;
6190
+ }
6191
+ try {
6192
+ sendJson(res, 200, {
6193
+ ok: true,
6194
+ result: await runPanelRerun({
6195
+ source,
6196
+ ...typeof body?.retryOf === "string" && body.retryOf !== "" ? { retryOf: body.retryOf } : {}
6197
+ })
6198
+ });
6199
+ } catch (e) {
6200
+ sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
6201
+ }
5870
6202
  return;
5871
6203
  }
5872
6204
  if (path === `${base}/runs/report`) {
@@ -6266,14 +6598,26 @@ const SCHEMA = {
6266
6598
  type: "number",
6267
6599
  min: 1
6268
6600
  },
6601
+ snapshotHistory: {
6602
+ type: "number",
6603
+ min: 1,
6604
+ max: 20
6605
+ },
6269
6606
  confirmMode: {
6270
6607
  type: "string",
6271
- enum: ["off", "manual"]
6608
+ enum: [
6609
+ "off",
6610
+ "manual",
6611
+ "auto"
6612
+ ]
6272
6613
  },
6273
6614
  confirmTimeoutMs: {
6274
6615
  type: "number",
6275
6616
  min: 1e3
6276
6617
  },
6618
+ sensitivePatterns: { type: "string[]" },
6619
+ sensitiveTools: { type: "string[]" },
6620
+ confirmInReplay: { type: "boolean" },
6277
6621
  toolset: {
6278
6622
  type: "string",
6279
6623
  enum: ["core", "all"]
@@ -6300,8 +6644,16 @@ const DEFAULT_CONFIG = {
6300
6644
  healTimeoutMs: 3e3,
6301
6645
  maxRefs: 200,
6302
6646
  maxRows: 1e3,
6647
+ snapshotHistory: 5,
6303
6648
  confirmMode: "off",
6304
6649
  confirmTimeoutMs: 6e4,
6650
+ sensitivePatterns: [
6651
+ "提交",
6652
+ "删除",
6653
+ "支付"
6654
+ ],
6655
+ sensitiveTools: ["browser_click"],
6656
+ confirmInReplay: true,
6305
6657
  toolset: "all"
6306
6658
  };
6307
6659
  function apply(ctx, rawConfig) {
@@ -6313,7 +6665,7 @@ function apply(ctx, rawConfig) {
6313
6665
  if (loadedRecordings > 0) ctx.logger?.info?.(`dsh-browser-view: loaded ${loadedRecordings} saved recording(s)`);
6314
6666
  registerTools(ctx, config);
6315
6667
  const systemPrompt = ctx.get("systemPrompt");
6316
- const confirmGuidance = config.confirmMode === "manual" ? "本环境 confirmMode 为 manual:凡提交/删除/支付等不可逆或影响线上数据的敏感动作,执行前必须先调用 browser_confirm(reason 说明操作与影响,等待用户在实时画面面板点允许/拒绝;拒绝或超时即放弃该动作,不得换路径绕过)。" : "";
6668
+ const confirmGuidance = config.confirmMode === "manual" ? "本环境 confirmMode 为 manual:凡提交/删除/支付等不可逆或影响线上数据的敏感动作,执行前必须先调用 browser_confirm(reason 说明操作与影响,等待用户在实时画面面板点允许/拒绝;拒绝或超时即放弃该动作,不得换路径绕过)。" : config.confirmMode === "auto" ? "本环境 confirmMode 为 auto:宿主层已在敏感动作(命中敏感模式的目标元素)前强制弹审批卡,agent 无法绕过;审批被拒绝或超时的动作不可换路径重试,应向用户说明并等待指示。" : "";
6317
6669
  const sectionDispose = systemPrompt?.section?.({
6318
6670
  name: "tool:browser",
6319
6671
  order: 200,