dsh-browser-view 0.2.0 → 0.3.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
@@ -1,5 +1,5 @@
1
1
  import { dirname, isAbsolute, join, resolve } from "node:path";
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  //#region lib/types/lib/config.js
5
5
  function typeOf(value) {
@@ -217,6 +217,17 @@ function pageRef(page, ref) {
217
217
  function clearPageRefs(page) {
218
218
  refRegistry.delete(page);
219
219
  }
220
+ /** 取页面当前完整 ref 表(browser_find 增量续号合并用;无则空 Map)。 */
221
+ function pageRefsOf(page) {
222
+ return refRegistry.get(page) ?? /* @__PURE__ */ new Map();
223
+ }
224
+ const snapshotCaches = /* @__PURE__ */ new WeakMap();
225
+ function snapshotCacheOf(page) {
226
+ return snapshotCaches.get(page);
227
+ }
228
+ function setSnapshotCache(page, cache) {
229
+ snapshotCaches.set(page, cache);
230
+ }
220
231
  /** 已保存的录制脚本:name → 步骤数组。 */
221
232
  const savedRecordings = /* @__PURE__ */ new Map();
222
233
  /** 当前录制中的 buffer(非空 = 录制中)。 */
@@ -256,6 +267,195 @@ function recordCall(call) {
256
267
  function testLogOf() {
257
268
  return [...testLog].reverse();
258
269
  }
270
+ /**
271
+ * 当前人工接管等待状态(browser_pause_for_human 等待期间非空),
272
+ * 供实时画面面板展示「等待人工操作」提示。
273
+ */
274
+ let pauseState = null;
275
+ function setPauseInstruction(instruction) {
276
+ pauseState = {
277
+ instruction,
278
+ startedAt: Date.now()
279
+ };
280
+ }
281
+ function clearPauseInstruction() {
282
+ pauseState = null;
283
+ }
284
+ function currentPause() {
285
+ return pauseState;
286
+ }
287
+ let pendingConfirm = null;
288
+ function setPendingConfirm(entry) {
289
+ pendingConfirm = entry;
290
+ }
291
+ function getPendingConfirm() {
292
+ return pendingConfirm;
293
+ }
294
+ function clearPendingConfirm() {
295
+ pendingConfirm = null;
296
+ }
297
+ /** 面板提交审批决策;id 不匹配(已被新审批取代/超时清理)时返回 false。 */
298
+ function resolveConfirm(id, decision) {
299
+ if (pendingConfirm === null || pendingConfirm.id !== id) return false;
300
+ pendingConfirm.decision = decision;
301
+ return true;
302
+ }
303
+ /** window 上的回传 binding 名(须为合法 JS 标识符)。 */
304
+ const HUMAN_BINDING = "__dshBrowserHumanEvent";
305
+ /** 最近一次 agent DOM 操作(点击/输入/选择)的时间戳;用于区分人手与工具回声。 */
306
+ let lastAgentActionAt = 0;
307
+ /** agent 即将/刚刚执行了 DOM 操作(register 包装层调用);1.5s 内的页内事件视为工具回声。 */
308
+ function noteAgentAction() {
309
+ lastAgentActionAt = Date.now();
310
+ }
311
+ /** 导出供测试:距上次 agent 操作是否在去重窗口内。 */
312
+ function agentActionRecent(now = Date.now(), windowMs = 1500) {
313
+ return now - lastAgentActionAt < windowMs;
314
+ }
315
+ /**
316
+ * 页内人工操作事件 → 录制步骤(纯函数,导出供测试)。
317
+ * payload 由 installHumanRecorder 生成:{ kind, selector, value?, testid?, role?, name?, id?, text?, tag? }。
318
+ * 无法映射(缺 selector / 未知 kind / 空输入)返回 null。
319
+ */
320
+ function humanEventToStep(payload) {
321
+ if (payload === null || typeof payload !== "object") return null;
322
+ const p = payload;
323
+ const selector = typeof p.selector === "string" ? p.selector : "";
324
+ if (!selector) return null;
325
+ const hint = {
326
+ ...p.testid !== void 0 && p.testid !== "" ? { testid: String(p.testid) } : {},
327
+ ...p.role !== void 0 && p.role !== "" ? { role: String(p.role) } : {},
328
+ ...p.name !== void 0 && p.name !== "" ? { name: String(p.name) } : {},
329
+ ...p.id !== void 0 && p.id !== "" ? { id: String(p.id) } : {},
330
+ ...p.text !== void 0 && p.text !== "" ? { text: String(p.text) } : {},
331
+ ...p.tag !== void 0 && p.tag !== "" ? { tag: String(p.tag) } : {}
332
+ };
333
+ if (p.kind === "click") return {
334
+ tool: "browser_click",
335
+ args: { selector },
336
+ meta: {
337
+ hint,
338
+ source: "human"
339
+ }
340
+ };
341
+ if (p.kind === "input") {
342
+ const text = typeof p.value === "string" ? p.value : "";
343
+ if (text === "") return null;
344
+ return {
345
+ tool: "browser_type",
346
+ args: {
347
+ selector,
348
+ text
349
+ },
350
+ meta: {
351
+ hint,
352
+ source: "human"
353
+ }
354
+ };
355
+ }
356
+ if (p.kind === "select") {
357
+ const option = typeof p.value === "string" ? p.value.trim() : "";
358
+ if (!option) return null;
359
+ return {
360
+ tool: "browser_select",
361
+ args: {
362
+ selector,
363
+ option
364
+ },
365
+ meta: {
366
+ hint,
367
+ source: "human"
368
+ }
369
+ };
370
+ }
371
+ return null;
372
+ }
373
+ /** binding 回调入站口:仅录制中生效;去重窗口内(agent 工具回声)丢弃。 */
374
+ function handleHumanEvent(payload) {
375
+ if (!isRecording()) return;
376
+ if (agentActionRecent()) return;
377
+ const step = humanEventToStep(payload);
378
+ if (step === null) return;
379
+ recordCall({
380
+ tool: step.tool,
381
+ args: step.args,
382
+ ok: true,
383
+ ms: 0,
384
+ ts: Date.now(),
385
+ meta: step.meta,
386
+ source: "human"
387
+ });
388
+ }
389
+ /**
390
+ * 页内注入的人工操作监听(addInitScript 序列化;每次导航自动重装)。
391
+ * 监听常驻但 node 侧只在录制中生效,关闭录制等价于"移除"。
392
+ * 只上报可交互元素上的 click 与表单控件的 change,避免逐键噪音。
393
+ */
394
+ function installHumanRecorder() {
395
+ const w = window;
396
+ if (w.__dshHumanRecorder === true) return;
397
+ w.__dshHumanRecorder = true;
398
+ const BINDING = "__dshBrowserHumanEvent";
399
+ 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
+ function payloadOf(el, kind, value) {
421
+ const aria = el.getAttribute("aria-label");
422
+ return {
423
+ kind,
424
+ selector: cssPath(el),
425
+ ...value !== void 0 ? { value } : {},
426
+ testid: el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? "",
427
+ role: el.getAttribute("role") ?? "",
428
+ name: aria && aria.trim() ? aria.trim().slice(0, 40) : "",
429
+ id: el.id ?? "",
430
+ text: (el.innerText || el.textContent || "").trim().slice(0, 40),
431
+ tag: el.tagName.toLowerCase()
432
+ };
433
+ }
434
+ function send(payload) {
435
+ try {
436
+ const fn = w[BINDING];
437
+ if (typeof fn === "function") Promise.resolve(fn(payload)).catch(() => {});
438
+ } catch {}
439
+ }
440
+ document.addEventListener("click", (e) => {
441
+ const target = e.target instanceof Element ? e.target.closest(INTERACTIVE) : null;
442
+ if (target === null) return;
443
+ send(payloadOf(target, "click"));
444
+ }, true);
445
+ document.addEventListener("change", (e) => {
446
+ const t = e.target instanceof Element ? e.target : null;
447
+ if (t === null) return;
448
+ if (t.tagName === "SELECT") {
449
+ const opt = t.selectedOptions[0];
450
+ send(payloadOf(t, "select", (opt ? opt.textContent : "").trim()));
451
+ } else if (t.tagName === "INPUT" || t.tagName === "TEXTAREA") {
452
+ const type = (t.getAttribute("type") ?? "text").toLowerCase();
453
+ if (type === "checkbox" || type === "radio") return;
454
+ if (type === "file") return;
455
+ send(payloadOf(t, "input", t.value));
456
+ }
457
+ }, true);
458
+ }
259
459
  const networkByProfile = /* @__PURE__ */ new Map();
260
460
  function networkOf(name) {
261
461
  let list = networkByProfile.get(name);
@@ -419,6 +619,10 @@ function attachPage(page, config) {
419
619
  durationMs: Date.now() - info.startedAt
420
620
  });
421
621
  });
622
+ page.exposeBinding(HUMAN_BINDING, (_source, payload) => {
623
+ handleHumanEvent(payload);
624
+ return null;
625
+ }).then(() => page.addInitScript(installHumanRecorder)).then(() => page.evaluate(installHumanRecorder).catch(() => {})).catch(() => {});
422
626
  }
423
627
  async function loadPlaywright() {
424
628
  try {
@@ -485,6 +689,17 @@ async function launchProfile(name, config) {
485
689
  function stateOf() {
486
690
  return getProfileState(currentProfile);
487
691
  }
692
+ const namedContexts = /* @__PURE__ */ new Map();
693
+ function namedContextsOf() {
694
+ return namedContexts;
695
+ }
696
+ /** 把一个已 attach 的页面注册进 profile 标签表并激活,返回分配的 tab id。 */
697
+ function registerTabPage(state, page) {
698
+ const id = state.counter++;
699
+ state.pages.set(id, page);
700
+ state.activeId = id;
701
+ return id;
702
+ }
488
703
  /** 新建一个页面(当前 profile 会话未启动时先启动)。 */
489
704
  async function newPage(config) {
490
705
  const state = stateOf();
@@ -541,6 +756,7 @@ async function tabList() {
541
756
  return tabs;
542
757
  }
543
758
  async function closeBrowser() {
759
+ namedContexts.clear();
544
760
  for (const [name, state] of profileStates) {
545
761
  const { browser, context } = state;
546
762
  state.browser = null;
@@ -1063,7 +1279,7 @@ function compressText(text) {
1063
1279
  * 可交互元素分配 ref=e1..eN 并附稳定 CSS 路径;无语义容器(div/span)穿透。
1064
1280
  * 输出形如 `- button "登录" [ref=e3]` 的缩进树,供纯文本模型定位操作目标。
1065
1281
  */
1066
- function buildAriaTree(maxRefs) {
1282
+ function buildAriaTree(payload) {
1067
1283
  const INTERACTIVE = /* @__PURE__ */ new Set([
1068
1284
  "link",
1069
1285
  "button",
@@ -1225,7 +1441,7 @@ function buildAriaTree(maxRefs) {
1225
1441
  if (INTERACTIVE.has(role)) {
1226
1442
  if (!isVisible(el)) return;
1227
1443
  const name = nameOf(el, role);
1228
- const ref = refs.length < maxRefs ? `e${refs.length + 1}` : "";
1444
+ const ref = refs.length < payload.maxRefs ? `e${refs.length + 1}` : "";
1229
1445
  if (ref !== "") refs.push({
1230
1446
  ref,
1231
1447
  role,
@@ -1247,51 +1463,447 @@ function buildAriaTree(maxRefs) {
1247
1463
  }
1248
1464
  for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth);
1249
1465
  }
1250
- if (document.body) walk(document.body, 0);
1466
+ if (document.body === null) return {
1467
+ tree: "",
1468
+ refs,
1469
+ truncated: false,
1470
+ scopeMissing: false
1471
+ };
1472
+ const root = payload.scope ? document.querySelector(payload.scope) : document.body;
1473
+ if (root === null) return {
1474
+ tree: "",
1475
+ refs,
1476
+ truncated: false,
1477
+ scopeMissing: true
1478
+ };
1479
+ walk(root, 0);
1251
1480
  return {
1252
1481
  tree: lines.join("\n"),
1253
1482
  refs,
1483
+ truncated,
1484
+ scopeMissing: false
1485
+ };
1486
+ }
1487
+ /**
1488
+ * 页内语义查找:只收集匹配 query 的可交互元素(不构建全量树)。
1489
+ * role/name/cssPath/isVisible 与 buildAriaTree 保持同一套语义(此处内联副本,
1490
+ * evaluate 序列化不允许闭包引用);改动任一处时同步另一处。
1491
+ */
1492
+ function findInteractive(payload) {
1493
+ const INTERACTIVE = /* @__PURE__ */ new Set([
1494
+ "link",
1495
+ "button",
1496
+ "textbox",
1497
+ "checkbox",
1498
+ "radio",
1499
+ "combobox",
1500
+ "listbox",
1501
+ "searchbox",
1502
+ "slider",
1503
+ "spinbutton",
1504
+ "switch",
1505
+ "tab",
1506
+ "menuitem",
1507
+ "option",
1508
+ "treeitem"
1509
+ ]);
1510
+ const STRUCTURAL = /* @__PURE__ */ new Set([
1511
+ "navigation",
1512
+ "main",
1513
+ "banner",
1514
+ "contentinfo",
1515
+ "form",
1516
+ "search",
1517
+ "complementary",
1518
+ "region",
1519
+ "dialog",
1520
+ "alert",
1521
+ "table",
1522
+ "list",
1523
+ "heading",
1524
+ "toolbar",
1525
+ "menu",
1526
+ "menubar",
1527
+ "tablist",
1528
+ "tree",
1529
+ "grid"
1530
+ ]);
1531
+ const SKIP_TAGS = /* @__PURE__ */ new Set([
1532
+ "script",
1533
+ "style",
1534
+ "template",
1535
+ "noscript",
1536
+ "svg",
1537
+ "path",
1538
+ "link",
1539
+ "meta",
1540
+ "br"
1541
+ ]);
1542
+ function roleOf(el) {
1543
+ const explicit = el.getAttribute("role");
1544
+ if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
1545
+ switch (el.tagName.toLowerCase()) {
1546
+ case "a": return el.hasAttribute("href") ? "link" : "";
1547
+ case "button": return "button";
1548
+ case "input": {
1549
+ const t = (el.getAttribute("type") ?? "text").toLowerCase();
1550
+ if (t === "checkbox") return "checkbox";
1551
+ if (t === "radio") return "radio";
1552
+ if (t === "range") return "slider";
1553
+ if (t === "number") return "spinbutton";
1554
+ if (t === "search") return "searchbox";
1555
+ if ([
1556
+ "button",
1557
+ "submit",
1558
+ "reset",
1559
+ "image"
1560
+ ].includes(t)) return "button";
1561
+ if (["hidden", "file"].includes(t)) return "";
1562
+ return "textbox";
1563
+ }
1564
+ case "textarea": return "textbox";
1565
+ case "select": return "combobox";
1566
+ case "summary": return "button";
1567
+ default: return "";
1568
+ }
1569
+ }
1570
+ function nameOf(el, role) {
1571
+ const aria = el.getAttribute("aria-label");
1572
+ if (aria && aria.trim()) return aria.trim().slice(0, 60);
1573
+ const labelledby = el.getAttribute("aria-labelledby");
1574
+ if (labelledby) {
1575
+ const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
1576
+ if (t) return t.slice(0, 60);
1577
+ }
1578
+ if (el.id) {
1579
+ const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
1580
+ if (t) return t.slice(0, 60);
1581
+ }
1582
+ if ([
1583
+ "textbox",
1584
+ "searchbox",
1585
+ "combobox",
1586
+ "spinbutton"
1587
+ ].includes(role)) {
1588
+ const ph = el.getAttribute("placeholder");
1589
+ if (ph && ph.trim()) return ph.trim().slice(0, 60);
1590
+ }
1591
+ const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
1592
+ if (text) return text.slice(0, 60);
1593
+ const title = el.getAttribute("title");
1594
+ if (title && title.trim()) return title.trim().slice(0, 60);
1595
+ return "";
1596
+ }
1597
+ function cssPath(el) {
1598
+ if (el.id) return `#${CSS.escape(el.id)}`;
1599
+ const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
1600
+ if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
1601
+ const parts = [];
1602
+ let cur = el;
1603
+ while (cur && cur !== document.body && parts.length < 6) {
1604
+ const tag = cur.tagName.toLowerCase();
1605
+ const parent = cur.parentElement;
1606
+ if (!parent) {
1607
+ parts.unshift(tag);
1608
+ break;
1609
+ }
1610
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
1611
+ const idx = sameTag.indexOf(cur) + 1;
1612
+ parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
1613
+ cur = parent;
1614
+ }
1615
+ return parts.join(" > ");
1616
+ }
1617
+ function isVisible(el) {
1618
+ if (el.getAttribute("aria-hidden") === "true") return false;
1619
+ const rect = el.getBoundingClientRect();
1620
+ if (rect.width === 0 && rect.height === 0) return false;
1621
+ const style = getComputedStyle(el);
1622
+ return style.visibility !== "hidden" && style.display !== "none";
1623
+ }
1624
+ /** 最近的结构化祖先(区域上下文,帮助区分多命中)。 */
1625
+ function ancestorsOf(el) {
1626
+ const out = [];
1627
+ let cur = el.parentElement;
1628
+ while (cur && cur !== document.body && out.length < 3) {
1629
+ const role = roleOf(cur);
1630
+ if (STRUCTURAL.has(role)) {
1631
+ const name = nameOf(cur, role);
1632
+ out.push(name !== "" ? `${role} "${name.replace(/"/g, "'")}"` : role);
1633
+ }
1634
+ cur = cur.parentElement;
1635
+ }
1636
+ return out;
1637
+ }
1638
+ const q = payload.query.trim().toLowerCase();
1639
+ const matches = [];
1640
+ const candidates = [];
1641
+ let scanned = 0;
1642
+ const root = payload.scope ? document.querySelector(payload.scope) : document.body;
1643
+ if (root === null) return {
1644
+ matches: [],
1645
+ candidates,
1646
+ scanned: 0
1647
+ };
1648
+ const stack = [root];
1649
+ while (stack.length > 0) {
1650
+ const el = stack.pop();
1651
+ const role = roleOf(el);
1652
+ if (INTERACTIVE.has(role)) {
1653
+ if (!isVisible(el)) continue;
1654
+ const name = nameOf(el, role);
1655
+ const idOrTestid = el.id || el.getAttribute("data-testid") || "";
1656
+ let hit = false;
1657
+ if (name !== "") {
1658
+ const n = name.toLowerCase();
1659
+ hit = payload.exact ? n === q : n.includes(q);
1660
+ }
1661
+ if (!hit && idOrTestid !== "" && idOrTestid.toLowerCase() === q) hit = true;
1662
+ scanned += 1;
1663
+ if (name !== "" && candidates.length < 50) candidates.push({
1664
+ role,
1665
+ name
1666
+ });
1667
+ if (hit && matches.length < payload.limit) matches.push({
1668
+ role,
1669
+ name,
1670
+ selector: cssPath(el),
1671
+ ancestors: ancestorsOf(el)
1672
+ });
1673
+ continue;
1674
+ }
1675
+ for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) stack.push(c);
1676
+ }
1677
+ return {
1678
+ matches,
1679
+ candidates,
1680
+ scanned
1681
+ };
1682
+ }
1683
+ /** 最长公共子串长度(小写比较;零依赖,导出供测试)。 */
1684
+ function lcsScore(a, b) {
1685
+ const x = a.toLowerCase();
1686
+ const y = b.toLowerCase();
1687
+ if (x === "" || y === "") return 0;
1688
+ let best = 0;
1689
+ const prev = new Array(y.length + 1).fill(0);
1690
+ for (let i = 1; i <= x.length; i++) {
1691
+ let diag = 0;
1692
+ for (let j = 1; j <= y.length; j++) {
1693
+ const tmp = prev[j];
1694
+ prev[j] = x[i - 1] === y[j - 1] ? diag + 1 : 0;
1695
+ if (prev[j] > best) best = prev[j];
1696
+ diag = tmp;
1697
+ }
1698
+ }
1699
+ return best;
1700
+ }
1701
+ /** 零命中时按相似度挑 top5 候选(导出供测试)。 */
1702
+ function topSuggestions(query, candidates, count = 5) {
1703
+ const min = query.trim().length <= 3 ? 1 : 2;
1704
+ return candidates.map((c) => ({
1705
+ ...c,
1706
+ score: lcsScore(query, c.name)
1707
+ })).filter((c) => c.score >= min).sort((a, b) => b.score - a.score).slice(0, count).map(({ role, name, score }) => ({
1708
+ role,
1709
+ name,
1710
+ score
1711
+ }));
1712
+ }
1713
+ /**
1714
+ * 为命中元素增量分配 ref:沿用既有注册表的编号继续(不与上次快照冲突),
1715
+ * 合并写回;返回带 ref 的命中列表(上限 maxRefs,超出标 truncated)。
1716
+ * 导出供测试(纯逻辑,不依赖浏览器)。
1717
+ */
1718
+ function assignRefs(existing, matches, maxRefs) {
1719
+ let next = 0;
1720
+ for (const key of existing.keys()) {
1721
+ const n = Number(key.replace(/^e/, ""));
1722
+ if (Number.isFinite(n) && n > next) next = n;
1723
+ }
1724
+ const merged = new Map(existing);
1725
+ const withRefs = [];
1726
+ let truncated = false;
1727
+ for (const m of matches) {
1728
+ if (merged.size >= maxRefs) {
1729
+ truncated = true;
1730
+ break;
1731
+ }
1732
+ next += 1;
1733
+ const ref = `e${next}`;
1734
+ merged.set(ref, {
1735
+ role: m.role,
1736
+ name: m.name,
1737
+ selector: m.selector
1738
+ });
1739
+ withRefs.push({
1740
+ ref,
1741
+ ...m
1742
+ });
1743
+ }
1744
+ return {
1745
+ withRefs,
1746
+ merged,
1254
1747
  truncated
1255
1748
  };
1256
1749
  }
1750
+ /**
1751
+ * 语义元素定位:按名称(或 id/data-testid)在可交互元素中查找,只返回命中的
1752
+ * ref + 区域上下文,不输出全量 aria 树 —— 大页面定位单个目标时 token 开销
1753
+ * 远小于 browser_snapshot { mode: "aria" }。返回的 ref 可直接用于
1754
+ * click/type/select/press。零命中不抛错,附最相近候选供二次定位。
1755
+ */
1756
+ async function findTool(config, args) {
1757
+ const query = typeof args.query === "string" ? args.query.trim() : "";
1758
+ if (!query) throw new Error("缺少 query: 要找的元素名称(如「提交」「登录」,或 id/data-testid)");
1759
+ const page = await getPage(config);
1760
+ const maxRefs = Math.min(Math.max(Number(args.maxRefs ?? config.maxRefs ?? 200) || 200, 1), 500);
1761
+ const data = await page.evaluate(findInteractive, {
1762
+ query,
1763
+ exact: args.exact === true,
1764
+ scope: typeof args.scope === "string" && args.scope ? args.scope : "",
1765
+ limit: 20
1766
+ });
1767
+ const { withRefs, merged, truncated } = assignRefs(pageRefsOf(page), data.matches, maxRefs);
1768
+ setPageRefs(page, merged);
1769
+ const suggestions = withRefs.length === 0 ? topSuggestions(query, data.candidates) : [];
1770
+ return {
1771
+ found: withRefs.length,
1772
+ matches: withRefs.slice(0, 10).map(({ ref, role, name, ancestors }) => ({
1773
+ ref,
1774
+ role,
1775
+ name,
1776
+ ancestors
1777
+ })),
1778
+ ...suggestions.length > 0 ? { suggestions } : {},
1779
+ ...truncated ? { truncated } : {},
1780
+ scanned: data.scanned,
1781
+ url: page.url(),
1782
+ title: await page.title()
1783
+ };
1784
+ }
1785
+ /**
1786
+ * 行级 diff:掐掉公共前缀/后缀,返回变化区。导出供测试。
1787
+ * 全等返回 { unchanged: true };否则 changedFrom 为新文本中变化区首行(1 起)。
1788
+ */
1789
+ function diffLines(oldText, newText) {
1790
+ const a = oldText === "" ? [] : oldText.split("\n");
1791
+ const b = newText === "" ? [] : newText.split("\n");
1792
+ let i = 0;
1793
+ while (i < a.length && i < b.length && a[i] === b[i]) i += 1;
1794
+ if (i === a.length && i === b.length) return { unchanged: true };
1795
+ let j = 0;
1796
+ while (j < a.length - i && j < b.length - i && a[a.length - 1 - j] === b[b.length - 1 - j]) j += 1;
1797
+ return {
1798
+ unchanged: false,
1799
+ changedFrom: i + 1,
1800
+ lines: b.slice(i, b.length - j),
1801
+ removed: a.length - i - j
1802
+ };
1803
+ }
1257
1804
  async function snapshotTool(config, args) {
1258
1805
  const page = await getPage(config);
1259
1806
  const maxText = args.maxTextChars ?? config.maxTextChars;
1260
- if (args.mode === "aria") {
1807
+ const scope = typeof args.scope === "string" && args.scope ? args.scope : "";
1808
+ const since = args.since !== void 0 ? Number(args.since) : void 0;
1809
+ const ariaMode = args.mode === "aria";
1810
+ const identity = {
1811
+ url: page.url(),
1812
+ title: await page.title()
1813
+ };
1814
+ const cache = snapshotCacheOf(page);
1815
+ const canDiff = since !== void 0 && cache !== void 0 && cache.version === since;
1816
+ let content;
1817
+ let extra = {};
1818
+ let walkerTruncated = false;
1819
+ if (ariaMode) {
1261
1820
  const maxRefs = Math.min(Math.max(Number(args.maxRefs ?? config.maxRefs ?? 200) || 200, 1), 500);
1262
- const data = await page.evaluate(buildAriaTree, maxRefs);
1821
+ const data = await page.evaluate(buildAriaTree, {
1822
+ maxRefs,
1823
+ scope
1824
+ });
1825
+ if (data.scopeMissing) throw new Error(`scope 未命中: 页面中没有匹配 "${scope}" 的元素`);
1263
1826
  setPageRefs(page, new Map(data.refs.map((r) => [r.ref, {
1264
1827
  role: r.role,
1265
1828
  name: r.name,
1266
1829
  selector: r.selector
1267
1830
  }])));
1268
- return {
1269
- url: page.url(),
1270
- title: await page.title(),
1271
- tree: data.tree.slice(0, maxText),
1272
- truncated: data.truncated || data.tree.length > maxText,
1273
- refs: data.refs.length
1831
+ content = data.tree;
1832
+ walkerTruncated = data.truncated;
1833
+ extra = { refs: data.refs.length };
1834
+ } else {
1835
+ const maxLinks = args.maxLinks ?? config.maxLinks;
1836
+ const data = await page.evaluate((payload) => {
1837
+ const root = payload.sc ? document.querySelector(payload.sc) : document.body;
1838
+ const text = root instanceof HTMLElement ? root.innerText : "";
1839
+ const scopeEl = root ?? document;
1840
+ return {
1841
+ text,
1842
+ links: Array.from(scopeEl.querySelectorAll("a")).slice(0, payload.maxL).map((a) => ({
1843
+ text: (a.innerText || a.textContent || "").trim().slice(0, 200),
1844
+ href: a.href
1845
+ })),
1846
+ inputs: scopeEl.querySelectorAll("input, textarea, select").length,
1847
+ scopeMissing: payload.sc !== "" && root === null
1848
+ };
1849
+ }, {
1850
+ maxL: maxLinks,
1851
+ sc: scope
1852
+ });
1853
+ if (data.scopeMissing) throw new Error(`scope 未命中: 页面中没有匹配 "${scope}" 的元素`);
1854
+ content = compressText(data.text);
1855
+ extra = {
1856
+ links: data.links,
1857
+ inputs: data.inputs
1274
1858
  };
1275
1859
  }
1276
- const maxLinks = args.maxLinks ?? config.maxLinks;
1277
- const data = await page.evaluate(([maxT, maxL]) => {
1860
+ const key = ariaMode ? "aria" : "text";
1861
+ if (canDiff && cache[key] === content) return {
1862
+ unchanged: true,
1863
+ contentVersion: cache.version,
1864
+ ...scope !== "" ? { scope } : {},
1865
+ ...identity,
1866
+ ...extra
1867
+ };
1868
+ if (canDiff) {
1869
+ const d = diffLines(cache[key], content);
1870
+ setSnapshotCache(page, {
1871
+ version: cache.version + 1,
1872
+ text: ariaMode ? cache.text : content,
1873
+ aria: ariaMode ? content : cache.aria
1874
+ });
1875
+ if (d.unchanged) return {
1876
+ unchanged: true,
1877
+ contentVersion: cache.version + 1,
1878
+ ...scope !== "" ? { scope } : {},
1879
+ ...identity,
1880
+ ...extra
1881
+ };
1278
1882
  return {
1279
- text: document.body ? document.body.innerText : "",
1280
- links: Array.from(document.querySelectorAll("a")).slice(0, maxL).map((a) => ({
1281
- text: (a.innerText || a.textContent || "").trim().slice(0, 200),
1282
- href: a.href
1283
- })),
1284
- inputs: document.querySelectorAll("input, textarea, select").length
1883
+ diff: true,
1884
+ contentVersion: cache.version + 1,
1885
+ changedFrom: d.changedFrom,
1886
+ removed: d.removed,
1887
+ ...scope !== "" ? { scope } : {},
1888
+ [ariaMode ? "tree" : "text"]: (d.lines ?? []).join("\n").slice(0, maxText),
1889
+ truncated: false,
1890
+ ...identity,
1891
+ ...extra
1285
1892
  };
1286
- }, [maxText, maxLinks]);
1287
- const compressed = compressText(data.text);
1893
+ }
1894
+ setSnapshotCache(page, {
1895
+ version: (cache?.version ?? 0) + 1,
1896
+ text: ariaMode ? cache?.text ?? "" : content,
1897
+ aria: ariaMode ? content : cache?.aria ?? ""
1898
+ });
1288
1899
  return {
1289
- url: page.url(),
1290
- title: await page.title(),
1291
- text: compressed.slice(0, maxText),
1292
- truncated: compressed.length > maxText,
1293
- links: data.links,
1294
- inputs: data.inputs
1900
+ contentVersion: (cache?.version ?? 0) + 1,
1901
+ ...since !== void 0 ? { stale: true } : {},
1902
+ ...scope !== "" ? { scope } : {},
1903
+ [ariaMode ? "tree" : "text"]: content.slice(0, maxText),
1904
+ truncated: ariaMode ? walkerTruncated || content.length > maxText : content.length > maxText,
1905
+ ...identity,
1906
+ ...extra
1295
1907
  };
1296
1908
  }
1297
1909
  /**
@@ -1499,6 +2111,368 @@ async function reloadTool(config) {
1499
2111
  await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
1500
2112
  return { ...await pageIdentity(page) };
1501
2113
  }
2114
+ /** 面板自检探针(由 index.ts 在注册 API 后注入;fetch 本机 /status)。 */
2115
+ let doctorProbe = null;
2116
+ function setDoctorProbe(fn) {
2117
+ doctorProbe = fn;
2118
+ }
2119
+ /** 目录可写性检查(mkdir + 写探针文件 + 删除)。导出供测试。 */
2120
+ function checkWritableDir(dir) {
2121
+ try {
2122
+ mkdirSync(dir, { recursive: true });
2123
+ const probe = join(dir, `.doctor-probe-${Date.now()}`);
2124
+ writeFileSync(probe, "ok");
2125
+ unlinkSync(probe);
2126
+ return {
2127
+ ok: true,
2128
+ detail: dir
2129
+ };
2130
+ } catch (e) {
2131
+ return {
2132
+ ok: false,
2133
+ detail: `${dir} — ${e instanceof Error ? e.message : String(e)}`
2134
+ };
2135
+ }
2136
+ }
2137
+ /** 可执行文件存在性检查(executablePath 显式配置用)。导出供测试。 */
2138
+ function checkExecutable(path) {
2139
+ if (!existsSync(path)) return {
2140
+ name: "executable",
2141
+ ok: false,
2142
+ detail: `executablePath 指向的文件不存在: ${path}`,
2143
+ advice: "修正 cordis.patch.yml 的 browser.executablePath,或清空该字段改用 channel 自动探测"
2144
+ };
2145
+ try {
2146
+ accessSync(path, constants.X_OK);
2147
+ } catch {
2148
+ return {
2149
+ name: "executable",
2150
+ ok: false,
2151
+ detail: `文件存在但不可执行: ${path}`,
2152
+ advice: "chmod +x 或确认路径指向浏览器主程序"
2153
+ };
2154
+ }
2155
+ const st = statSync(path);
2156
+ return {
2157
+ name: "executable",
2158
+ ok: st.isFile(),
2159
+ detail: st.isFile() ? path : `不是普通文件: ${path}`,
2160
+ ...st.isFile() ? {} : { advice: "确认路径指向浏览器可执行文件而非目录" }
2161
+ };
2162
+ }
2163
+ /**
2164
+ * 命名隔离上下文:同一浏览器进程内独立 cookies/storage 的"标签组"。
2165
+ * 页注册进 profile 标签表(所有既有工具直接可用),context 只做归属记录:
2166
+ * list / new(name?, url?) / switch(激活该组的一页,没有则新开) / close(整组关闭)。
2167
+ * 持久模式(userDataDir)浏览器只有单一 context,不支持本工具(用 profiles 替代)。
2168
+ */
2169
+ async function contextTool(config, args) {
2170
+ const action = args.action ?? "list";
2171
+ const state = stateOf();
2172
+ const contexts = namedContextsOf();
2173
+ if (action === "list") return {
2174
+ contexts: Array.from(contexts.entries()).map(([name, c]) => {
2175
+ return {
2176
+ name,
2177
+ pages: Array.from(c.pageIds).filter((id) => state.pages.has(id)).length,
2178
+ active: state.activeId !== null && c.pageIds.has(state.activeId),
2179
+ createdAt: c.createdAt
2180
+ };
2181
+ }),
2182
+ profile: currentProfile
2183
+ };
2184
+ if (action === "new") {
2185
+ const name = String(args.name ?? "").trim() || `ctx-${contexts.size + 1}`;
2186
+ if (contexts.has(name)) throw new Error(`context "${name}" 已存在;如需重建先 browser_context { action: "close", name }`);
2187
+ if (state.browser === null && state.context === null) await launchProfile(currentProfile, config);
2188
+ if (state.browser === null) throw new Error("持久模式(userDataDir)的浏览器只有单一 context,不支持多 context;需要多账号隔离请配置 profiles 多套 userDataDir");
2189
+ const context = await state.browser.newContext();
2190
+ const page = await context.newPage();
2191
+ attachPage(page, profileConfig(config, currentProfile));
2192
+ const id = registerTabPage(state, page);
2193
+ const entry = {
2194
+ context,
2195
+ pageIds: /* @__PURE__ */ new Set([id]),
2196
+ createdAt: Date.now()
2197
+ };
2198
+ contexts.set(name, entry);
2199
+ page.on("close", () => {
2200
+ entry.pageIds.delete(id);
2201
+ });
2202
+ let navigated;
2203
+ if (typeof args.url === "string" && args.url) {
2204
+ await page.goto(args.url, { waitUntil: "domcontentloaded" });
2205
+ navigated = page.url();
2206
+ }
2207
+ return {
2208
+ created: name,
2209
+ ...navigated !== void 0 ? { navigated } : {},
2210
+ ...await pageIdentity(page)
2211
+ };
2212
+ }
2213
+ if (action === "switch") {
2214
+ const name = String(args.name ?? "").trim();
2215
+ if (!name) throw new Error("switch 需要 name(用 list 查看已有 context)");
2216
+ const entry = contexts.get(name);
2217
+ if (entry === void 0) throw new Error(`context "${name}" 不存在;已有: ${Array.from(contexts.keys()).join("、") || "(无)"}`);
2218
+ for (const id of entry.pageIds) {
2219
+ const page = state.pages.get(id);
2220
+ if (page !== void 0) {
2221
+ state.activeId = id;
2222
+ return {
2223
+ switched: name,
2224
+ ...await pageIdentity(page)
2225
+ };
2226
+ }
2227
+ }
2228
+ const page = await entry.context.newPage();
2229
+ attachPage(page, profileConfig(config, currentProfile));
2230
+ const id = registerTabPage(state, page);
2231
+ entry.pageIds.add(id);
2232
+ page.on("close", () => {
2233
+ entry.pageIds.delete(id);
2234
+ });
2235
+ return {
2236
+ switched: name,
2237
+ pages: 1,
2238
+ ...await pageIdentity(page)
2239
+ };
2240
+ }
2241
+ if (action === "close") {
2242
+ const name = String(args.name ?? "").trim();
2243
+ if (!name) throw new Error("close 需要 name");
2244
+ const entry = contexts.get(name);
2245
+ if (entry === void 0) throw new Error(`context "${name}" 不存在`);
2246
+ for (const id of entry.pageIds) {
2247
+ state.pages.delete(id);
2248
+ if (state.activeId === id) state.activeId = Array.from(state.pages.keys()).pop() ?? null;
2249
+ }
2250
+ entry.pageIds.clear();
2251
+ contexts.delete(name);
2252
+ try {
2253
+ await entry.context.close();
2254
+ } catch {}
2255
+ return {
2256
+ closed: name,
2257
+ remainingTabs: state.pages.size
2258
+ };
2259
+ }
2260
+ throw new Error(`未知 action: ${action}(list | new | switch | close)`);
2261
+ }
2262
+ /**
2263
+ * 页内拾取器(evaluate 注入):一次性 click 捕获 —— 点击任意元素时阻止默认行为,
2264
+ * 计算稳定 CSS 路径 + 元素信息存 window.__dshPickResult(面板轮询读取后自清)。
2265
+ * 与 aria walker 的 cssPath 同一套语义(#id → [data-testid] → nth-of-type 链)。
2266
+ */
2267
+ function pickElementInjector() {
2268
+ const w = window;
2269
+ if (w.__dshPicking === true) return;
2270
+ w.__dshPicking = true;
2271
+ function cssPath(el) {
2272
+ if (el.id) return `#${CSS.escape(el.id)}`;
2273
+ const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
2274
+ if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
2275
+ const parts = [];
2276
+ let cur = el;
2277
+ while (cur && cur !== document.body && parts.length < 6) {
2278
+ const tag = cur.tagName.toLowerCase();
2279
+ const parent = cur.parentElement;
2280
+ if (!parent) {
2281
+ parts.unshift(tag);
2282
+ break;
2283
+ }
2284
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
2285
+ const idx = sameTag.indexOf(cur) + 1;
2286
+ parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
2287
+ cur = parent;
2288
+ }
2289
+ return parts.join(" > ");
2290
+ }
2291
+ const handler = (e) => {
2292
+ const el = e.target instanceof Element ? e.target : null;
2293
+ if (el === null) return;
2294
+ e.preventDefault();
2295
+ e.stopPropagation();
2296
+ const aria = el.getAttribute("aria-label");
2297
+ w.__dshPickResult = {
2298
+ selector: cssPath(el),
2299
+ tag: el.tagName.toLowerCase(),
2300
+ id: el.id ?? "",
2301
+ testid: el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? "",
2302
+ role: el.getAttribute("role") ?? "",
2303
+ name: aria && aria.trim() ? aria.trim().slice(0, 60) : (el.innerText || el.textContent || "").trim().slice(0, 60),
2304
+ url: location.href
2305
+ };
2306
+ w.__dshPicking = false;
2307
+ document.removeEventListener("click", handler, true);
2308
+ };
2309
+ document.addEventListener("click", handler, true);
2310
+ }
2311
+ /** 读取并清空拾取结果(ui.ts /pick GET 调用;无结果返回 null)。 */
2312
+ async function takePickResult(page) {
2313
+ try {
2314
+ return await page.evaluate(() => {
2315
+ const w = window;
2316
+ const out = w.__dshPickResult ?? null;
2317
+ w.__dshPickResult = null;
2318
+ w.__dshPicking = false;
2319
+ return out;
2320
+ });
2321
+ } catch {
2322
+ return null;
2323
+ }
2324
+ }
2325
+ /**
2326
+ * 操作预告高亮:agent 即将 click/type/select/press 的元素在页面上琥珀色描边
2327
+ * ~1.5 秒。异步不阻塞(fire-and-forget,任何失败静默),不影响操作时序。
2328
+ */
2329
+ function previewHighlight(page, selector, label) {
2330
+ if (page === null || selector === "") return;
2331
+ page.evaluate((arg) => {
2332
+ const el = document.querySelector(arg.selector);
2333
+ if (el === null) return;
2334
+ const rect = el.getBoundingClientRect();
2335
+ const box = document.createElement("div");
2336
+ box.setAttribute("data-dsh-preview", "");
2337
+ Object.assign(box.style, {
2338
+ position: "fixed",
2339
+ left: `${rect.left - 2}px`,
2340
+ top: `${rect.top - 2}px`,
2341
+ width: `${rect.width + 4}px`,
2342
+ height: `${rect.height + 4}px`,
2343
+ border: "2px solid #f59e0b",
2344
+ borderRadius: "4px",
2345
+ background: "rgba(245, 158, 11, 0.08)",
2346
+ pointerEvents: "none",
2347
+ zIndex: "2147483646",
2348
+ boxSizing: "border-box",
2349
+ transition: "opacity 0.3s"
2350
+ });
2351
+ const tip = document.createElement("div");
2352
+ Object.assign(tip.style, {
2353
+ position: "absolute",
2354
+ left: "0",
2355
+ top: "-20px",
2356
+ font: "12px/1.4 -apple-system,'PingFang SC',sans-serif",
2357
+ color: "#fff",
2358
+ background: "#f59e0b",
2359
+ padding: "1px 6px",
2360
+ borderRadius: "4px",
2361
+ whiteSpace: "nowrap"
2362
+ });
2363
+ tip.textContent = `agent: ${arg.label}`;
2364
+ box.appendChild(tip);
2365
+ document.documentElement.appendChild(box);
2366
+ setTimeout(() => {
2367
+ box.style.opacity = "0";
2368
+ setTimeout(() => box.remove(), 400);
2369
+ }, 1200);
2370
+ }, {
2371
+ selector,
2372
+ label
2373
+ }).catch(() => {});
2374
+ }
2375
+ /** 一次性健康诊断:不启动浏览器,只做静态/本地检查。 */
2376
+ async function doctorTool(config, _args, exec) {
2377
+ const checks = [];
2378
+ if (config.executablePath) checks.push(checkExecutable(config.executablePath));
2379
+ else {
2380
+ const channel = config.channel ?? "auto";
2381
+ checks.push({
2382
+ name: "channel",
2383
+ ok: true,
2384
+ detail: channel === "auto" || channel === "" ? "auto:按平台序探测已装浏览器(chrome → msedge → chromium;Windows 为 msedge 优先),启动失败会提示安装" : `channel = ${channel}(由 Playwright 在首次启动时解析)`
2385
+ });
2386
+ try {
2387
+ const bundled = (await loadPlaywright()).chromium.executablePath();
2388
+ checks.push({
2389
+ name: "bundled-chromium",
2390
+ ok: existsSync(bundled),
2391
+ detail: existsSync(bundled) ? bundled : `playwright 捆绑 chromium 未安装(${bundled})`,
2392
+ ...existsSync(bundled) ? {} : { advice: "可选:npx playwright install chromium 安装兜底浏览器,或依赖系统已装的 Edge/Chrome" }
2393
+ });
2394
+ } catch {
2395
+ checks.push({
2396
+ name: "playwright-core",
2397
+ ok: false,
2398
+ detail: "playwright-core 不可用",
2399
+ advice: "在 profile 中安装 playwright-core(dsh plugin --profile web add playwright-core)"
2400
+ });
2401
+ }
2402
+ }
2403
+ if (config.userDataDir) {
2404
+ const w = checkWritableDir(config.userDataDir);
2405
+ checks.push({
2406
+ name: "userDataDir",
2407
+ ok: w.ok,
2408
+ detail: w.ok ? `${config.userDataDir}(登录态将持久化)` : w.detail,
2409
+ ...!w.ok ? { advice: "确认目录存在且当前用户可写" } : {}
2410
+ });
2411
+ } else checks.push({
2412
+ name: "userDataDir",
2413
+ ok: true,
2414
+ detail: "未配置(登录态随会话,DSH 重启后失效)"
2415
+ });
2416
+ const baseDir = workspaceBaseDir(config, exec);
2417
+ const baseWritable = checkWritableDir(baseDir);
2418
+ checks.push({
2419
+ name: "workspaceDir",
2420
+ ok: baseWritable.ok,
2421
+ detail: baseWritable.ok ? `${baseDir}(来源: ${config.workspaceDir ? "config.workspaceDir" : "会话 cwd 探测"})` : baseWritable.detail,
2422
+ ...!baseWritable.ok ? { advice: "设置 config.workspaceDir 钉死基准目录,或修正会话工作区权限" } : {}
2423
+ });
2424
+ for (const [name, dir] of [
2425
+ ["screenshotDir", config.screenshotDir ?? ".dsh-browser/screenshots"],
2426
+ ["downloadDir", config.downloadDir ?? ".dsh-browser/downloads"],
2427
+ ["baselineDir", config.baselineDir ?? ".dsh-browser/baselines"]
2428
+ ]) {
2429
+ const abs = isAbsolute(dir) ? dir : resolve(baseDir, dir);
2430
+ const w = checkWritableDir(abs);
2431
+ checks.push({
2432
+ name,
2433
+ ok: w.ok,
2434
+ detail: w.ok ? abs : w.detail,
2435
+ ...!w.ok ? { advice: `检查 ${abs} 的创建/写权限(父目录需可写)` } : {}
2436
+ });
2437
+ }
2438
+ const state = stateOf();
2439
+ checks.push({
2440
+ name: "session",
2441
+ ok: true,
2442
+ detail: `当前 profile "${currentProfile}"${state.browser !== null ? " 已启动浏览器" : " 浏览器未启动(首次调用工具时自动启动)"} · ${state.pages.size} 个标签 · 共 ${profileStates.size} 个 profile`
2443
+ });
2444
+ const basePath = config.basePath ?? "/browser";
2445
+ if (doctorProbe !== null) try {
2446
+ const r = await doctorProbe(basePath);
2447
+ checks.push({
2448
+ name: "panel",
2449
+ ok: r.ok,
2450
+ detail: r.detail,
2451
+ ...r.advice ? { advice: r.advice } : {}
2452
+ });
2453
+ } catch (e) {
2454
+ checks.push({
2455
+ name: "panel",
2456
+ ok: false,
2457
+ detail: `探针执行失败: ${e instanceof Error ? e.message : String(e)}`,
2458
+ advice: `在浏览器打开 http://<DSH地址>${basePath}/status 手动验证`
2459
+ });
2460
+ }
2461
+ else checks.push({
2462
+ name: "panel",
2463
+ ok: true,
2464
+ detail: `本进程无 webServer(工具半),面板 API 基路径 ${basePath}`
2465
+ });
2466
+ const failed = checks.filter((c) => !c.ok).length;
2467
+ return {
2468
+ checks,
2469
+ summary: {
2470
+ ok: failed === 0,
2471
+ passed: checks.length - failed,
2472
+ failed
2473
+ }
2474
+ };
2475
+ }
1502
2476
  async function statusTool() {
1503
2477
  const page = activePage();
1504
2478
  const state = stateOf();
@@ -1964,14 +2938,137 @@ async function waitForLoginTool(config, args) {
1964
2938
  selector: String(args.successSelector),
1965
2939
  state: "visible"
1966
2940
  };
1967
- else if (args.successUrl) condition = { url: String(args.successUrl) };
1968
- else condition = { eval: `location.href !== ${JSON.stringify(startUrl)} && location.href !== "about:blank"` };
1969
- const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 12e4) || 12e4, 5e3), 6e5);
2941
+ else if (args.successUrl) condition = { url: String(args.successUrl) };
2942
+ else condition = { eval: `location.href !== ${JSON.stringify(startUrl)} && location.href !== "about:blank"` };
2943
+ const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 12e4) || 12e4, 5e3), 6e5);
2944
+ return {
2945
+ loggedIn: (await pollCondition(page, condition, timeoutMs, 2e3)).ok,
2946
+ ...await pageIdentity(page)
2947
+ };
2948
+ }
2949
+ /**
2950
+ * 人工接管等待(通用版 wait_for_login):agent 把控制权交还给用户(验证码 /
2951
+ * 二次确认 / 人工复核等)。instruction 会显示在实时画面面板;完成条件三选一:
2952
+ * successSelector(元素出现)/ successUrl(URL 正则)/ 两者都不给则以
2953
+ * URL 变化或页面内容签名变化视为完成。轮询间隔 2 秒,超时返回 completed:false
2954
+ * (不抛错)。导出 runPauseOnPage 供测试注入 fake page。
2955
+ */
2956
+ async function runPauseOnPage(page, args) {
2957
+ const instruction = typeof args.instruction === "string" ? args.instruction.trim() : "";
2958
+ if (!instruction) throw new Error("缺少 instruction: 请说明需要人工完成什么操作(如「请输入短信验证码」)");
2959
+ const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 3e5) || 3e5, 1e3), 6e5);
2960
+ let startUrl = "";
2961
+ try {
2962
+ startUrl = page.url();
2963
+ } catch {}
2964
+ const startedAt = Date.now();
2965
+ const pageSignature = async () => {
2966
+ try {
2967
+ return await page.evaluate(() => {
2968
+ const body = document.body;
2969
+ return `${location.href}|${body ? body.innerText.length : 0}|${document.querySelectorAll("input,button,a").length}`;
2970
+ });
2971
+ } catch {
2972
+ return page.url();
2973
+ }
2974
+ };
2975
+ const startSig = args.successSelector || args.successUrl ? "" : await pageSignature();
2976
+ let completed = false;
2977
+ let detail = `等待人工操作超时(${timeoutMs}ms)`;
2978
+ setPauseInstruction(instruction);
2979
+ try {
2980
+ const deadline = startedAt + timeoutMs;
2981
+ while (Date.now() < deadline) {
2982
+ try {
2983
+ if (args.successSelector) completed = await page.locator(String(args.successSelector)).first().isVisible();
2984
+ else if (args.successUrl) completed = new RegExp(String(args.successUrl)).test(page.url());
2985
+ else completed = page.url() !== startUrl || await pageSignature() !== startSig;
2986
+ } catch (e) {
2987
+ completed = false;
2988
+ detail = `检查完成条件出错: ${e instanceof Error ? e.message : String(e)}`;
2989
+ }
2990
+ if (completed) {
2991
+ detail = "人工操作完成";
2992
+ break;
2993
+ }
2994
+ await page.waitForTimeout(2e3);
2995
+ }
2996
+ } finally {
2997
+ clearPauseInstruction();
2998
+ }
2999
+ return {
3000
+ completed,
3001
+ waitedMs: Date.now() - startedAt,
3002
+ detail
3003
+ };
3004
+ }
3005
+ /** 人工接管等待工具入口(缺 instruction 在启动浏览器前即校验,失败更快)。 */
3006
+ async function pauseForHumanTool(config, args) {
3007
+ const instruction = typeof args.instruction === "string" ? args.instruction.trim() : "";
3008
+ if (!instruction) throw new Error("缺少 instruction: 请说明需要人工完成什么操作(如「请输入短信验证码」)");
3009
+ const page = await getPage(config);
3010
+ const result = await runPauseOnPage(page, args);
1970
3011
  return {
1971
- loggedIn: (await pollCondition(page, condition, timeoutMs, 2e3)).ok,
3012
+ completed: result.completed,
3013
+ status: result.completed ? "completed" : "timedOut",
3014
+ instruction,
3015
+ detail: result.detail,
3016
+ waitedMs: result.waitedMs,
1972
3017
  ...await pageIdentity(page)
1973
3018
  };
1974
3019
  }
3020
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3021
+ /**
3022
+ * 敏感操作审批:agent 在执行提交/删除/支付类动作前调用,把审批请求推到实时
3023
+ * 画面面板,挂起等待用户点「允许/拒绝」。超时视为拒绝(fail-closed)。
3024
+ * 用户允许时返回 { decision: "allowed", ... };拒绝/超时**抛错**(消息含原因),
3025
+ * 使 live 调用与 replay/batch 步骤统一按失败处理,agent 不应继续敏感动作。
3026
+ * 决策通过 session.resolveConfirm(面板 POST)写入。
3027
+ */
3028
+ async function confirmTool(config, args) {
3029
+ const reason = typeof args.reason === "string" ? args.reason.trim() : "";
3030
+ if (!reason) throw new Error("缺少 reason: 请说明要执行什么敏感操作、为什么需要审批(如「提交白名单表单,会产生线上数据变更」)");
3031
+ const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? config.confirmTimeoutMs ?? 6e4) || 6e4, 1e3), 6e5);
3032
+ const id = `cfm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
3033
+ setPendingConfirm({
3034
+ id,
3035
+ reason,
3036
+ tool: typeof args.tool === "string" && args.tool ? args.tool : void 0,
3037
+ args: args.args && typeof args.args === "object" ? args.args : void 0,
3038
+ startedAt: Date.now()
3039
+ });
3040
+ const startedAt = Date.now();
3041
+ let decision = "timeout";
3042
+ let detail = `等待审批超时(${timeoutMs}ms),按拒绝处理`;
3043
+ try {
3044
+ const deadline = startedAt + timeoutMs;
3045
+ while (Date.now() < deadline) {
3046
+ await sleep(500);
3047
+ const pending = getPendingConfirm();
3048
+ if (pending === null || pending.id !== id) {
3049
+ decision = "denied";
3050
+ detail = "审批请求已被新的审批取代,按拒绝处理";
3051
+ break;
3052
+ }
3053
+ if (pending.decision !== void 0) {
3054
+ decision = pending.decision;
3055
+ detail = decision === "allowed" ? "用户允许执行" : "用户拒绝执行";
3056
+ break;
3057
+ }
3058
+ }
3059
+ } finally {
3060
+ const pending = getPendingConfirm();
3061
+ if (pending !== null && pending.id === id) clearPendingConfirm();
3062
+ }
3063
+ if (decision !== "allowed") throw new Error(`敏感操作被拒绝: ${reason} — ${detail}`);
3064
+ return {
3065
+ decision,
3066
+ id,
3067
+ reason,
3068
+ waitedMs: Date.now() - startedAt,
3069
+ detail
3070
+ };
3071
+ }
1975
3072
  /** 断言:条件在 timeoutMs 内成立则通过,否则失败并自动截图存证据。visual 断言走像素比对。 */
1976
3073
  async function assertTool(config, args, exec) {
1977
3074
  const page = await getPage(config);
@@ -2847,14 +3944,69 @@ function registerTools(ctx, config) {
2847
3944
  }
2848
3945
  return out;
2849
3946
  }
3947
+ /** 会触发真实 DOM 事件的 agent 工具:执行前后打点,混合录制时用于区分人手与工具回声。 */
3948
+ const AGENT_DOM_TOOLS = /* @__PURE__ */ new Set([
3949
+ "browser_click",
3950
+ "browser_type",
3951
+ "browser_select",
3952
+ "browser_press",
3953
+ "browser_upload",
3954
+ "browser_form",
3955
+ "browser_eval"
3956
+ ]);
3957
+ /** 操作预告:这些工具执行前把目标元素在实时画面上高亮(异步不阻塞)。 */
3958
+ const PREVIEW_TOOLS = /* @__PURE__ */ new Set([
3959
+ "browser_click",
3960
+ "browser_type",
3961
+ "browser_select",
3962
+ "browser_press"
3963
+ ]);
3964
+ /**
3965
+ * toolset: "core" 的高频核心集:浏览/定位/操作/等待/断言/人机协作。
3966
+ * 低频的录制/回放/报告/抽取/表单回放/profile/context 等只在 "all" 注册。
3967
+ */
3968
+ const CORE_TOOLS = /* @__PURE__ */ new Set([
3969
+ "browser_open",
3970
+ "browser_snapshot",
3971
+ "browser_find",
3972
+ "browser_elements",
3973
+ "browser_click",
3974
+ "browser_type",
3975
+ "browser_press",
3976
+ "browser_select",
3977
+ "browser_eval",
3978
+ "browser_batch",
3979
+ "browser_wait",
3980
+ "browser_wait_for",
3981
+ "browser_assert",
3982
+ "browser_screenshot",
3983
+ "browser_wait_for_login",
3984
+ "browser_pause_for_human",
3985
+ "browser_confirm",
3986
+ "browser_network",
3987
+ "browser_tabs",
3988
+ "browser_download",
3989
+ "browser_upload",
3990
+ "browser_status",
3991
+ "browser_doctor",
3992
+ "browser_close"
3993
+ ]);
2850
3994
  /** 打点包装:每次工具调用记录操作轨迹(测试报告 / 录制数据源)。 */
2851
3995
  function register(def) {
3996
+ if (config.toolset === "core" && !CORE_TOOLS.has(def.name)) return;
2852
3997
  const original = def.execute;
2853
3998
  def.execute = async (args, exec) => {
2854
3999
  const t0 = Date.now();
2855
4000
  const sanitized = sanitizeArgs(args);
4001
+ const domTool = AGENT_DOM_TOOLS.has(def.name);
4002
+ if (domTool) noteAgentAction();
4003
+ if (PREVIEW_TOOLS.has(def.name)) {
4004
+ const page = activePage();
4005
+ 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_", ""));
4006
+ }
2856
4007
  try {
2857
4008
  const value = await original(args, exec);
4009
+ if (domTool) noteAgentAction();
2858
4010
  const call = recordCall({
2859
4011
  tool: def.name,
2860
4012
  args: sanitized,
@@ -2909,6 +4061,11 @@ function registerTools(ctx, config) {
2909
4061
  browser_profile: (a) => profileTool(config, a),
2910
4062
  browser_elements: (a) => elementsTool(config, a),
2911
4063
  browser_wait_for_login: (a) => waitForLoginTool(config, a),
4064
+ browser_pause_for_human: (a) => pauseForHumanTool(config, a),
4065
+ browser_confirm: (a) => confirmTool(config, a),
4066
+ browser_find: (a) => findTool(config, a),
4067
+ browser_doctor: (a) => doctorTool(config, a, void 0),
4068
+ browser_context: (a) => contextTool(config, a),
2912
4069
  browser_wait_for: (a) => waitForTool(config, a),
2913
4070
  browser_assert: (a) => assertTool(config, a, void 0),
2914
4071
  browser_network: (a) => networkTool(a),
@@ -2965,7 +4122,7 @@ function registerTools(ctx, config) {
2965
4122
  }));
2966
4123
  register(definePlainTool({
2967
4124
  name: "browser_snapshot",
2968
- description: "读取当前页面快照(不截图)。mode:'text'(默认)返回压缩后的可见文本+链接清单,适合读内容;'aria' 返回可访问性树,可交互元素带 [ref=eN] 短引用,后续 browser_click/browser_type/browser_select/browser_press 可直接传 ref 代替 CSS 选择器(导航或重新快照后旧 ref 失效)。maxTextChars 默认 20000。",
4125
+ 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。",
2969
4126
  parameters: {
2970
4127
  type: "object",
2971
4128
  properties: {
@@ -2974,6 +4131,14 @@ function registerTools(ctx, config) {
2974
4131
  enum: ["text", "aria"],
2975
4132
  description: "快照模式,默认 text。"
2976
4133
  },
4134
+ scope: {
4135
+ type: "string",
4136
+ description: "可选:限定提取区域的 CSS 选择器(如 '#main')。"
4137
+ },
4138
+ since: {
4139
+ type: "integer",
4140
+ description: "可选:上次快照的 contentVersion,只返回变化内容。"
4141
+ },
2977
4142
  maxTextChars: {
2978
4143
  type: "integer",
2979
4144
  description: "文本截断上限。"
@@ -2996,14 +4161,21 @@ function registerTools(ctx, config) {
2996
4161
  url: { type: "string" },
2997
4162
  title: { type: "string" },
2998
4163
  text: { type: "string" },
4164
+ tree: { type: "string" },
2999
4165
  truncated: { type: "boolean" },
3000
4166
  links: {
3001
4167
  type: "array",
3002
4168
  items: { type: "object" }
3003
4169
  },
3004
4170
  inputs: { type: "integer" },
3005
- tree: { type: "string" },
3006
- refs: { type: "integer" }
4171
+ refs: { type: "integer" },
4172
+ scope: { type: "string" },
4173
+ contentVersion: { type: "integer" },
4174
+ unchanged: { type: "boolean" },
4175
+ diff: { type: "boolean" },
4176
+ changedFrom: { type: "integer" },
4177
+ removed: { type: "integer" },
4178
+ stale: { type: "boolean" }
3007
4179
  },
3008
4180
  required: [
3009
4181
  "url",
@@ -3012,8 +4184,13 @@ function registerTools(ctx, config) {
3012
4184
  ]
3013
4185
  },
3014
4186
  render: (_args, value) => {
3015
- if (value.tree !== void 0) return `URL: ${value.url}\nTitle: ${value.title}\nRefs: ${value.refs}\n--- aria tree ---\n${value.tree}`;
3016
- return `URL: ${value.url}\nTitle: ${value.title}\nInputs: ${value.inputs}\n--- text ---\n${value.text}`;
4187
+ if (value.unchanged === true) return `页面未变化(版本 v${value.contentVersion}): ${value.url}`;
4188
+ if (value.diff === true) {
4189
+ const body = value.tree !== void 0 ? value.tree : value.text;
4190
+ return `内容变化(v${value.contentVersion},第 ${value.changedFrom} 行起,删除 ${value.removed} 行):\n${body}`;
4191
+ }
4192
+ if (value.tree !== void 0) return `URL: ${value.url}\nTitle: ${value.title}\nRefs: ${value.refs}\nVersion: v${value.contentVersion}${value.stale === true ? "(stale)" : ""}\n--- aria tree ---\n${value.tree}`;
4193
+ return `URL: ${value.url}\nTitle: ${value.title}\nInputs: ${value.inputs}\nVersion: v${value.contentVersion}${value.stale === true ? "(stale)" : ""}\n--- text ---\n${value.text}`;
3017
4194
  },
3018
4195
  timeoutMs: timeout(),
3019
4196
  execute: (args) => snapshotTool(config, args)
@@ -3755,6 +4932,325 @@ function registerTools(ctx, config) {
3755
4932
  timeoutMs: timeout(),
3756
4933
  execute: (args) => waitForLoginTool(config, args)
3757
4934
  }));
4935
+ register(definePlainTool({
4936
+ name: "browser_pause_for_human",
4937
+ description: "人工接管等待:agent 把控制权暂时交还给用户,适用于验证码/短信确认/二次复核/敏感步骤人工兜底等场景。调用后提示(instruction)会显示在界面右下角实时画面面板,等待用户在浏览器窗口中完成操作。完成条件三选一:successSelector(完成后出现的元素)、successUrl(完成后 URL 匹配的正则),都不给则 URL 或页面内容发生变化即视为完成。轮询间隔 2 秒;超时不抛错,返回 completed:false,agent 可再次调用或改走其他路径。",
4938
+ parameters: {
4939
+ type: "object",
4940
+ properties: {
4941
+ instruction: {
4942
+ type: "string",
4943
+ description: "给用户看的提示语,如「请在浏览器中输入短信验证码」。"
4944
+ },
4945
+ timeoutMs: {
4946
+ type: "integer",
4947
+ description: "等待上限(毫秒,默认 300000,下限 1000,上限 600000)。"
4948
+ },
4949
+ successSelector: {
4950
+ type: "string",
4951
+ description: "完成后应出现的 CSS 选择器。"
4952
+ },
4953
+ successUrl: {
4954
+ type: "string",
4955
+ description: "完成后 URL 应匹配的正则。"
4956
+ }
4957
+ },
4958
+ required: ["instruction"],
4959
+ additionalProperties: false
4960
+ },
4961
+ outputSchema: {
4962
+ type: "object",
4963
+ additionalProperties: false,
4964
+ properties: {
4965
+ completed: { type: "boolean" },
4966
+ status: {
4967
+ type: "string",
4968
+ enum: ["completed", "timedOut"]
4969
+ },
4970
+ instruction: { type: "string" },
4971
+ detail: { type: "string" },
4972
+ waitedMs: { type: "integer" },
4973
+ url: { type: "string" },
4974
+ title: { type: "string" }
4975
+ },
4976
+ required: [
4977
+ "completed",
4978
+ "status",
4979
+ "instruction",
4980
+ "detail",
4981
+ "waitedMs",
4982
+ "url",
4983
+ "title"
4984
+ ]
4985
+ },
4986
+ render: (_args, value) => value.completed ? `人工操作完成(${value.waitedMs}ms): ${value.url}` : `等待人工操作超时(${value.waitedMs}ms): ${value.instruction} — ${value.detail}`,
4987
+ timeoutMs: timeout(6e5),
4988
+ execute: (args) => pauseForHumanTool(config, args)
4989
+ }));
4990
+ register(definePlainTool({
4991
+ name: "browser_confirm",
4992
+ description: "敏感操作审批:在执行提交/删除/支付等不可逆或影响线上数据的动作前调用,审批请求(含 reason 与将执行的操作)会推送到界面右下角实时画面面板,等待用户点「允许/拒绝」。用户允许时返回 decision:'allowed';用户拒绝或等待超时(默认 60s,fail-closed)**抛错**,live 调用与 replay/batch 步骤统一按失败处理。confirmMode:'manual' 时 systemPrompt 会要求所有敏感动作前必须先过本工具。",
4993
+ parameters: {
4994
+ type: "object",
4995
+ properties: {
4996
+ reason: {
4997
+ type: "string",
4998
+ description: "给用户看的审批理由:要执行什么操作、影响是什么。"
4999
+ },
5000
+ tool: {
5001
+ type: "string",
5002
+ description: "可选:即将执行的工具名,如 'browser_click'。"
5003
+ },
5004
+ args: {
5005
+ type: "object",
5006
+ description: "可选:即将执行的工具参数(如 { selector: '#submit' }),供面板展示。"
5007
+ },
5008
+ timeoutMs: {
5009
+ type: "integer",
5010
+ description: "等待决策上限(毫秒,默认取配置 confirmTimeoutMs=60000,下限 1000,上限 600000)。"
5011
+ }
5012
+ },
5013
+ required: ["reason"],
5014
+ additionalProperties: false
5015
+ },
5016
+ outputSchema: {
5017
+ type: "object",
5018
+ additionalProperties: false,
5019
+ properties: {
5020
+ decision: {
5021
+ type: "string",
5022
+ enum: ["allowed"]
5023
+ },
5024
+ id: { type: "string" },
5025
+ reason: { type: "string" },
5026
+ waitedMs: { type: "integer" },
5027
+ detail: { type: "string" }
5028
+ },
5029
+ required: [
5030
+ "decision",
5031
+ "id",
5032
+ "reason",
5033
+ "waitedMs",
5034
+ "detail"
5035
+ ]
5036
+ },
5037
+ render: (_args, value) => `用户允许执行(${value.waitedMs}ms): ${value.reason}`,
5038
+ timeoutMs: timeout(6e5),
5039
+ execute: (args) => confirmTool(config, args)
5040
+ }));
5041
+ register(definePlainTool({
5042
+ name: "browser_find",
5043
+ description: "按名称/id/data-testid 语义定位页面元素,只返回命中的 ref + 区域上下文,不输出全量 aria 树 —— 在大页面定位单个操作目标时比 browser_snapshot { mode: \"aria\" } 省 95% 以上 token。返回的 ref 可直接传给 browser_click / browser_type / browser_select / browser_press。默认包含匹配(query 是名称子串,忽略大小写;exact:true 精确匹配);scope 限定查找容器;多命中全部返回并附 ancestors 区域链供二次定位;零命中不抛错,返回最相近的 suggestions 候选。",
5044
+ parameters: {
5045
+ type: "object",
5046
+ properties: {
5047
+ query: {
5048
+ type: "string",
5049
+ description: "要找的元素名称(如「提交」「登录」),或元素 id / data-testid。"
5050
+ },
5051
+ exact: {
5052
+ type: "boolean",
5053
+ description: "true 时名称需完全相等(默认 false 包含匹配)。"
5054
+ },
5055
+ scope: {
5056
+ type: "string",
5057
+ description: "可选:限定查找的容器 CSS 选择器(如 '#login-form')。"
5058
+ }
5059
+ },
5060
+ required: ["query"],
5061
+ additionalProperties: false
5062
+ },
5063
+ outputSchema: {
5064
+ type: "object",
5065
+ additionalProperties: false,
5066
+ properties: {
5067
+ found: { type: "integer" },
5068
+ matches: {
5069
+ type: "array",
5070
+ items: {
5071
+ type: "object",
5072
+ properties: {
5073
+ ref: { type: "string" },
5074
+ role: { type: "string" },
5075
+ name: { type: "string" },
5076
+ ancestors: {
5077
+ type: "array",
5078
+ items: { type: "string" }
5079
+ }
5080
+ },
5081
+ required: [
5082
+ "ref",
5083
+ "role",
5084
+ "name",
5085
+ "ancestors"
5086
+ ],
5087
+ additionalProperties: false
5088
+ }
5089
+ },
5090
+ suggestions: {
5091
+ type: "array",
5092
+ items: {
5093
+ type: "object",
5094
+ properties: {
5095
+ role: { type: "string" },
5096
+ name: { type: "string" },
5097
+ score: { type: "integer" }
5098
+ },
5099
+ required: [
5100
+ "role",
5101
+ "name",
5102
+ "score"
5103
+ ],
5104
+ additionalProperties: false
5105
+ }
5106
+ },
5107
+ truncated: { type: "boolean" },
5108
+ scanned: { type: "integer" },
5109
+ url: { type: "string" },
5110
+ title: { type: "string" }
5111
+ },
5112
+ required: [
5113
+ "found",
5114
+ "matches",
5115
+ "scanned",
5116
+ "url",
5117
+ "title"
5118
+ ]
5119
+ },
5120
+ render: (args, value) => {
5121
+ const hits = value.matches.map((m) => `${m.ref}=${m.role} "${m.name}"`).join(", ");
5122
+ if (value.found > 0) return `找到 ${value.found} 个「${args.query}」: ${hits}${value.found > 10 ? " …" : ""}`;
5123
+ const sugg = (value.suggestions ?? []).map((s) => `${s.role} "${s.name}"`).join("、");
5124
+ return `未找到「${args.query}」(扫描 ${value.scanned} 个元素)${sugg ? `;相近候选: ${sugg}` : ""}`;
5125
+ },
5126
+ timeoutMs: timeout(),
5127
+ execute: (args) => findTool(config, args)
5128
+ }));
5129
+ register(definePlainTool({
5130
+ name: "browser_doctor",
5131
+ description: "浏览器环境健康诊断(不启动浏览器):检查可执行文件/channel、playwright-core 可用性、userDataDir 与截图/下载/基线目录写权限、工作区基准目录解析来源、当前会话状态、面板 API 可达性。环境异常(文件落错位置/浏览器起不来)时先跑本工具定位,每项失败检查附修复建议。",
5132
+ parameters: {
5133
+ type: "object",
5134
+ properties: {},
5135
+ additionalProperties: false
5136
+ },
5137
+ outputSchema: {
5138
+ type: "object",
5139
+ additionalProperties: false,
5140
+ properties: {
5141
+ checks: {
5142
+ type: "array",
5143
+ items: {
5144
+ type: "object",
5145
+ properties: {
5146
+ name: { type: "string" },
5147
+ ok: { type: "boolean" },
5148
+ detail: { type: "string" },
5149
+ advice: { type: "string" }
5150
+ },
5151
+ required: [
5152
+ "name",
5153
+ "ok",
5154
+ "detail"
5155
+ ],
5156
+ additionalProperties: false
5157
+ }
5158
+ },
5159
+ summary: {
5160
+ type: "object",
5161
+ properties: {
5162
+ ok: { type: "boolean" },
5163
+ passed: { type: "integer" },
5164
+ failed: { type: "integer" }
5165
+ },
5166
+ required: [
5167
+ "ok",
5168
+ "passed",
5169
+ "failed"
5170
+ ],
5171
+ additionalProperties: false
5172
+ }
5173
+ },
5174
+ required: ["checks", "summary"]
5175
+ },
5176
+ render: (_args, value) => {
5177
+ const lines = value.checks.map((c) => `${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}${c.advice ? `\n 建议: ${c.advice}` : ""}`);
5178
+ return `诊断 ${value.summary.ok ? "通过" : "发现问题"}(${value.summary.passed}/${value.checks.length})\n${lines.join("\n")}`;
5179
+ },
5180
+ timeoutMs: timeout(15e3),
5181
+ execute: (args, exec) => doctorTool(config, args, exec)
5182
+ }));
5183
+ register(definePlainTool({
5184
+ name: "browser_context",
5185
+ description: "命名隔离上下文:同一浏览器进程内 cookies/storage 完全隔离的\"标签组\"。action:new(name 可省略自动编号,可选 url 直接打开)/ list / switch(name,激活该组页面,组内页全关则在原 context 新开,登录态仍在)/ close(name,整组关闭)。适合多账号并行验证、需要干净会话的抓取。页注册进普通标签表,所有浏览器工具直接可用;browser_tabs 也能看到/切换这些页。持久模式(userDataDir)不支持,请改用 profiles。",
5186
+ parameters: {
5187
+ type: "object",
5188
+ properties: {
5189
+ action: {
5190
+ type: "string",
5191
+ enum: [
5192
+ "list",
5193
+ "new",
5194
+ "switch",
5195
+ "close"
5196
+ ],
5197
+ description: "默认 list。"
5198
+ },
5199
+ name: {
5200
+ type: "string",
5201
+ description: "上下文名(new 自动编号时可省略;switch/close 必填)。"
5202
+ },
5203
+ url: {
5204
+ type: "string",
5205
+ description: "new 时可选:新页直接打开的 URL。"
5206
+ }
5207
+ },
5208
+ additionalProperties: false
5209
+ },
5210
+ outputSchema: {
5211
+ type: "object",
5212
+ additionalProperties: false,
5213
+ properties: {
5214
+ contexts: {
5215
+ type: "array",
5216
+ items: {
5217
+ type: "object",
5218
+ properties: {
5219
+ name: { type: "string" },
5220
+ pages: { type: "integer" },
5221
+ active: { type: "boolean" },
5222
+ createdAt: { type: "integer" }
5223
+ },
5224
+ required: [
5225
+ "name",
5226
+ "pages",
5227
+ "active",
5228
+ "createdAt"
5229
+ ],
5230
+ additionalProperties: false
5231
+ }
5232
+ },
5233
+ profile: { type: "string" },
5234
+ created: { type: "string" },
5235
+ navigated: { type: "string" },
5236
+ switched: { type: "string" },
5237
+ closed: { type: "string" },
5238
+ remainingTabs: { type: "integer" },
5239
+ pages: { type: "integer" },
5240
+ url: { type: "string" },
5241
+ title: { type: "string" }
5242
+ },
5243
+ required: []
5244
+ },
5245
+ render: (args, value) => {
5246
+ if (args.action === "new" || value.created) return `新建隔离上下文 "${value.created}"${value.navigated ? ` → ${value.navigated}` : ""}`;
5247
+ if (args.action === "switch" || value.switched) return `切换到上下文 "${value.switched}": ${value.url}`;
5248
+ if (args.action === "close" || value.closed) return `关闭上下文 "${value.closed}"(剩余 ${value.remainingTabs} 标签)`;
5249
+ return value.contexts.map((c) => `${c.active ? "▶" : " "} ${c.name}(${c.pages} 页)`).join("\n") || "(无隔离上下文;browser_context { action: \"new\" } 创建)";
5250
+ },
5251
+ timeoutMs: timeout(),
5252
+ execute: (args) => contextTool(config, args)
5253
+ }));
3758
5254
  register(definePlainTool({
3759
5255
  name: "browser_elements",
3760
5256
  description: "列出当前页面可交互元素(input/textarea/select/button/链接)的结构化清单:类型、name/id、关联 label(aria-label/label[for]/form-item)、placeholder、可见文本、可直接用于 browser_click/browser_type/browser_select 的 CSS 选择器。页面有展开的下拉时,dropdownOptions 会列出可见选项。比 browser_snapshot 更适合定位表单。",
@@ -4301,7 +5797,10 @@ function registerBrowserApi(ctx, config, base) {
4301
5797
  handler: async (req, res) => {
4302
5798
  const path = urlPath(req);
4303
5799
  if (path === `${base}/status`) {
4304
- sendJson(res, 200, await statusTool());
5800
+ sendJson(res, 200, {
5801
+ ...await statusTool(),
5802
+ pause: currentPause()
5803
+ });
4305
5804
  return;
4306
5805
  }
4307
5806
  if (path === `${base}/history`) {
@@ -4322,6 +5821,44 @@ function registerBrowserApi(ctx, config, base) {
4322
5821
  sendJson(res, 200, { run: latestRun() });
4323
5822
  return;
4324
5823
  }
5824
+ if (path === `${base}/runs/report`) {
5825
+ let source = "";
5826
+ try {
5827
+ source = new URL(req.url ?? "/", "http://x").searchParams.get("source") ?? "";
5828
+ } catch {}
5829
+ if (source === "") source = latestRun()?.source ?? "";
5830
+ const html = renderHtmlReport(listRuns(50).filter((r) => source === "" ? true : r.source === source), source === "" ? void 0 : source);
5831
+ res.writeHead(200, {
5832
+ "content-type": "text/html; charset=utf-8",
5833
+ "cache-control": "no-store",
5834
+ "content-length": Buffer.byteLength(html)
5835
+ });
5836
+ res.end(html);
5837
+ return;
5838
+ }
5839
+ if (path === `${base}/pick`) {
5840
+ if ((req.method ?? "GET") === "POST") {
5841
+ const page = activePage();
5842
+ if (page === null || page.isClosed()) {
5843
+ sendText(res, 409, "browser not open");
5844
+ return;
5845
+ }
5846
+ try {
5847
+ await page.evaluate(pickElementInjector);
5848
+ sendJson(res, 200, { started: true });
5849
+ } catch (error) {
5850
+ sendText(res, 500, `pick start failed: ${error instanceof Error ? error.message : String(error)}`);
5851
+ }
5852
+ return;
5853
+ }
5854
+ const page = activePage();
5855
+ if (page === null || page.isClosed()) {
5856
+ sendJson(res, 200, { pick: null });
5857
+ return;
5858
+ }
5859
+ sendJson(res, 200, { pick: await takePickResult(page) });
5860
+ return;
5861
+ }
4325
5862
  if (path === `${base}/recordings`) {
4326
5863
  if ((req.method ?? "GET") === "POST") {
4327
5864
  const body = await readJsonBody(req).catch(() => void 0);
@@ -4352,6 +5889,25 @@ function registerBrowserApi(ctx, config, base) {
4352
5889
  sendJson(res, 200, detail);
4353
5890
  return;
4354
5891
  }
5892
+ if (path === `${base}/confirm`) {
5893
+ if ((req.method ?? "GET") === "POST") {
5894
+ const body = await readJsonBody(req).catch(() => void 0);
5895
+ const id = typeof body?.id === "string" ? body.id : "";
5896
+ const decision = body?.decision === "allowed" || body?.decision === "denied" ? body.decision : void 0;
5897
+ if (!id || decision === void 0) {
5898
+ sendText(res, 400, "id and decision (allowed|denied) required");
5899
+ return;
5900
+ }
5901
+ sendJson(res, 200, {
5902
+ resolved: resolveConfirm(id, decision),
5903
+ id,
5904
+ decision
5905
+ });
5906
+ return;
5907
+ }
5908
+ sendJson(res, 200, { confirm: getPendingConfirm() });
5909
+ return;
5910
+ }
4355
5911
  if (path === `${base}/screenshot`) {
4356
5912
  const page = activePage();
4357
5913
  if (page === null || page.isClosed()) {
@@ -4684,6 +6240,18 @@ const SCHEMA = {
4684
6240
  maxRows: {
4685
6241
  type: "number",
4686
6242
  min: 1
6243
+ },
6244
+ confirmMode: {
6245
+ type: "string",
6246
+ enum: ["off", "manual"]
6247
+ },
6248
+ confirmTimeoutMs: {
6249
+ type: "number",
6250
+ min: 1e3
6251
+ },
6252
+ toolset: {
6253
+ type: "string",
6254
+ enum: ["core", "all"]
4687
6255
  }
4688
6256
  };
4689
6257
  const DEFAULT_CONFIG = {
@@ -4706,7 +6274,10 @@ const DEFAULT_CONFIG = {
4706
6274
  selectorHealing: true,
4707
6275
  healTimeoutMs: 3e3,
4708
6276
  maxRefs: 200,
4709
- maxRows: 1e3
6277
+ maxRows: 1e3,
6278
+ confirmMode: "off",
6279
+ confirmTimeoutMs: 6e4,
6280
+ toolset: "all"
4710
6281
  };
4711
6282
  function apply(ctx, rawConfig) {
4712
6283
  const resolved = resolveConfig("dsh-browser-view", SCHEMA, DEFAULT_CONFIG, rawConfig);
@@ -4716,16 +6287,41 @@ function apply(ctx, rawConfig) {
4716
6287
  const loadedRecordings = loadRecordings();
4717
6288
  if (loadedRecordings > 0) ctx.logger?.info?.(`dsh-browser-view: loaded ${loadedRecordings} saved recording(s)`);
4718
6289
  registerTools(ctx, config);
4719
- const sectionDispose = ctx.get("systemPrompt")?.section?.({
6290
+ const systemPrompt = ctx.get("systemPrompt");
6291
+ const confirmGuidance = config.confirmMode === "manual" ? "本环境 confirmMode 为 manual:凡提交/删除/支付等不可逆或影响线上数据的敏感动作,执行前必须先调用 browser_confirm(reason 说明操作与影响,等待用户在实时画面面板点允许/拒绝;拒绝或超时即放弃该动作,不得换路径绕过)。" : "";
6292
+ const sectionDispose = systemPrompt?.section?.({
4720
6293
  name: "tool:browser",
4721
6294
  order: 200,
4722
- text: "浏览器工具操作一个共享浏览器会话,支持多标签页与多配置文件。打开页面后先用 browser_snapshot 读文本与链接、browser_elements 读表单结构(含关联 label 与展开的下拉选项),再决定 browser_click / browser_type / browser_form;定位操作元素优先用 browser_snapshot { mode: \"aria\" }(可交互元素带 ref=eN,click/type/select/press 直接传 ref 代替 CSS 选择器);下拉选择(原生 select 与 antd/element 组件库)用 browser_select 传 selector/ref + option 文本一步完成;固定小序列(点击→等待→输入→提交)用 browser_batch 一次执行减少往返;标签管理用 browser_tabs;下载用 browser_download(wait=true 可等新下载);登录态用 browser_cookies。做自动化测试时:用 browser_wait_for 显式等待页面稳定/元素出现,用 browser_assert 断言(失败自动截图;visual={baseline} 可做视觉回归,先用 browser_screenshot { baseline } 建基线),用 browser_network 检查接口调用与 4xx/5xx,跑完一轮可用 browser_record save 保存操作序列、browser_replay 回放做回归(选择器失效自动按指纹自愈;vars/data 支持参数化与数据驱动)。每次 replay/batch 都记入运行历史,browser_report list/export 可查看与导出回归报告。抓取结构化数据用 browser_extract(fields 定义 CSS 字段,可翻页,可导出 CSV/JSONL)。遇到需要登录的网站:先 browser_open 打开登录页,告知用户请在浏览器窗口中完成登录,再调用 browser_wait_for_login 等待(可给 successSelector/successUrl);配置 userDataDir 时登录态会持久化。用户可通过界面右下角面板的实时画面查看浏览器。"
6295
+ text: (config.toolset === "core" ? "浏览器工具操作一个共享浏览器会话(toolset=core:已精简工具集)。打开页面后用 browser_snapshot 读内容(scope 限定区域、since 增量重读省 token),定位操作元素优先 browser_find(query 传名称,返回 ref);点击/输入/按键/下拉用 browser_click/browser_type/browser_press/browser_select(selector 或 ref);固定小序列用 browser_batch;等待用 browser_wait/browser_wait_for,断言用 browser_assert;接口检查用 browser_network;标签 browser_tabs;下载/上传 browser_download/browser_upload。需要人工:登录用 browser_wait_for_login,验证码/二次确认用 browser_pause_for_human,敏感动作(提交/删除/支付)前用 browser_confirm。环境异常先跑 browser_doctor 诊断。录制/回放/报告/抽取/profile/隔离 context 等工具当前未注册(toolset=core),需要时把配置改为 toolset: all 并重启 DSH。用户可通过界面右下角面板的实时画面查看浏览器。" : "浏览器工具操作一个共享浏览器会话,支持多标签页与多配置文件。打开页面后先用 browser_snapshot 读文本与链接、browser_elements 读表单结构(含关联 label 与展开的下拉选项),再决定 browser_click / browser_type / browser_form;定位操作元素优先用 browser_find(query 传名称,只返回命中 ref+区域上下文,大页面最省 token;零命中会给相近候选),需要全页结构时再用 browser_snapshot { mode: \"aria\" }(可交互元素带 ref=eN,click/type/select/press 直接传 ref 代替 CSS 选择器);下拉选择(原生 select 与 antd/element 组件库)用 browser_select 传 selector/ref + option 文本一步完成;固定小序列(点击→等待→输入→提交)用 browser_batch 一次执行减少往返;标签管理用 browser_tabs;下载用 browser_download(wait=true 可等新下载);登录态用 browser_cookies。做自动化测试时:用 browser_wait_for 显式等待页面稳定/元素出现,用 browser_assert 断言(失败自动截图;visual={baseline} 可做视觉回归,先用 browser_screenshot { baseline } 建基线),用 browser_network 检查接口调用与 4xx/5xx,跑完一轮可用 browser_record save 保存操作序列、browser_replay 回放做回归(选择器失效自动按指纹自愈;vars/data 支持参数化与数据驱动)。每次 replay/batch 都记入运行历史,browser_report list/export 可查看与导出回归报告。抓取结构化数据用 browser_extract(fields 定义 CSS 字段,可翻页,可导出 CSV/JSONL)。遇到需要登录的网站:先 browser_open 打开登录页,告知用户请在浏览器窗口中完成登录,再调用 browser_wait_for_login 等待(可给 successSelector/successUrl);验证码/二次确认等更广的「人补一步」场景用 browser_pause_for_human(提示语会显示在实时画面面板);配置 userDataDir 时登录态会持久化。用户可通过界面右下角面板的实时画面查看浏览器。") + confirmGuidance
4723
6296
  });
4724
6297
  if (sectionDispose !== void 0) ctx.effect(() => sectionDispose);
4725
6298
  registerBrowserApi(ctx, config, base);
4726
6299
  const webServer = ctx.get("webServer");
4727
6300
  if (webServer !== void 0) {
4728
6301
  applyEmbed(ctx, webServer, base);
6302
+ const port = webServer.port;
6303
+ setDoctorProbe(port !== void 0 ? async (basePath) => {
6304
+ try {
6305
+ const res = await fetch(`http://127.0.0.1:${port}${basePath}/status`, {
6306
+ signal: AbortSignal.timeout(2e3),
6307
+ cache: "no-store"
6308
+ });
6309
+ return res.ok ? {
6310
+ ok: true,
6311
+ detail: `面板 API 可达(/status → 200,端口 ${port})`
6312
+ } : {
6313
+ ok: false,
6314
+ detail: `面板 API 异常(/status → ${res.status})`,
6315
+ advice: "查看 DSH 日志中 webServer 的注册错误"
6316
+ };
6317
+ } catch (e) {
6318
+ return {
6319
+ ok: false,
6320
+ detail: `本机 127.0.0.1:${port} 请求失败: ${e instanceof Error ? e.message : String(e)}`,
6321
+ advice: "确认 DSH web 端口未被防火墙/代理拦截"
6322
+ };
6323
+ }
6324
+ } : null);
4729
6325
  const disposeConfig = webServer.register({
4730
6326
  kind: "exact",
4731
6327
  path: "/__dsh-browser-view__/config",