dsh-browser-view 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -4
- package/lib/client.js +1 -1
- package/lib/index.js +1586 -69
- package/lib/types/browser/session.d.ts +66 -1
- package/lib/types/browser/tools.d.ts +198 -14
- package/lib/types/browser/types.d.ts +13 -0
- package/package.json +2 -2
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(
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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
|
|
1277
|
-
|
|
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
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
}
|
|
1284
|
-
|
|
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
|
-
}
|
|
1287
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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,305 @@ 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
|
+
* 操作预告高亮:agent 即将 click/type/select/press 的元素在页面上琥珀色描边
|
|
2264
|
+
* ~1.5 秒。异步不阻塞(fire-and-forget,任何失败静默),不影响操作时序。
|
|
2265
|
+
*/
|
|
2266
|
+
function previewHighlight(page, selector, label) {
|
|
2267
|
+
if (page === null || selector === "") return;
|
|
2268
|
+
page.evaluate((arg) => {
|
|
2269
|
+
const el = document.querySelector(arg.selector);
|
|
2270
|
+
if (el === null) return;
|
|
2271
|
+
const rect = el.getBoundingClientRect();
|
|
2272
|
+
const box = document.createElement("div");
|
|
2273
|
+
box.setAttribute("data-dsh-preview", "");
|
|
2274
|
+
Object.assign(box.style, {
|
|
2275
|
+
position: "fixed",
|
|
2276
|
+
left: `${rect.left - 2}px`,
|
|
2277
|
+
top: `${rect.top - 2}px`,
|
|
2278
|
+
width: `${rect.width + 4}px`,
|
|
2279
|
+
height: `${rect.height + 4}px`,
|
|
2280
|
+
border: "2px solid #f59e0b",
|
|
2281
|
+
borderRadius: "4px",
|
|
2282
|
+
background: "rgba(245, 158, 11, 0.08)",
|
|
2283
|
+
pointerEvents: "none",
|
|
2284
|
+
zIndex: "2147483646",
|
|
2285
|
+
boxSizing: "border-box",
|
|
2286
|
+
transition: "opacity 0.3s"
|
|
2287
|
+
});
|
|
2288
|
+
const tip = document.createElement("div");
|
|
2289
|
+
Object.assign(tip.style, {
|
|
2290
|
+
position: "absolute",
|
|
2291
|
+
left: "0",
|
|
2292
|
+
top: "-20px",
|
|
2293
|
+
font: "12px/1.4 -apple-system,'PingFang SC',sans-serif",
|
|
2294
|
+
color: "#fff",
|
|
2295
|
+
background: "#f59e0b",
|
|
2296
|
+
padding: "1px 6px",
|
|
2297
|
+
borderRadius: "4px",
|
|
2298
|
+
whiteSpace: "nowrap"
|
|
2299
|
+
});
|
|
2300
|
+
tip.textContent = `agent: ${arg.label}`;
|
|
2301
|
+
box.appendChild(tip);
|
|
2302
|
+
document.documentElement.appendChild(box);
|
|
2303
|
+
setTimeout(() => {
|
|
2304
|
+
box.style.opacity = "0";
|
|
2305
|
+
setTimeout(() => box.remove(), 400);
|
|
2306
|
+
}, 1200);
|
|
2307
|
+
}, {
|
|
2308
|
+
selector,
|
|
2309
|
+
label
|
|
2310
|
+
}).catch(() => {});
|
|
2311
|
+
}
|
|
2312
|
+
/** 一次性健康诊断:不启动浏览器,只做静态/本地检查。 */
|
|
2313
|
+
async function doctorTool(config, _args, exec) {
|
|
2314
|
+
const checks = [];
|
|
2315
|
+
if (config.executablePath) checks.push(checkExecutable(config.executablePath));
|
|
2316
|
+
else {
|
|
2317
|
+
const channel = config.channel ?? "auto";
|
|
2318
|
+
checks.push({
|
|
2319
|
+
name: "channel",
|
|
2320
|
+
ok: true,
|
|
2321
|
+
detail: channel === "auto" || channel === "" ? "auto:按平台序探测已装浏览器(chrome → msedge → chromium;Windows 为 msedge 优先),启动失败会提示安装" : `channel = ${channel}(由 Playwright 在首次启动时解析)`
|
|
2322
|
+
});
|
|
2323
|
+
try {
|
|
2324
|
+
const bundled = (await loadPlaywright()).chromium.executablePath();
|
|
2325
|
+
checks.push({
|
|
2326
|
+
name: "bundled-chromium",
|
|
2327
|
+
ok: existsSync(bundled),
|
|
2328
|
+
detail: existsSync(bundled) ? bundled : `playwright 捆绑 chromium 未安装(${bundled})`,
|
|
2329
|
+
...existsSync(bundled) ? {} : { advice: "可选:npx playwright install chromium 安装兜底浏览器,或依赖系统已装的 Edge/Chrome" }
|
|
2330
|
+
});
|
|
2331
|
+
} catch {
|
|
2332
|
+
checks.push({
|
|
2333
|
+
name: "playwright-core",
|
|
2334
|
+
ok: false,
|
|
2335
|
+
detail: "playwright-core 不可用",
|
|
2336
|
+
advice: "在 profile 中安装 playwright-core(dsh plugin --profile web add playwright-core)"
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
if (config.userDataDir) {
|
|
2341
|
+
const w = checkWritableDir(config.userDataDir);
|
|
2342
|
+
checks.push({
|
|
2343
|
+
name: "userDataDir",
|
|
2344
|
+
ok: w.ok,
|
|
2345
|
+
detail: w.ok ? `${config.userDataDir}(登录态将持久化)` : w.detail,
|
|
2346
|
+
...!w.ok ? { advice: "确认目录存在且当前用户可写" } : {}
|
|
2347
|
+
});
|
|
2348
|
+
} else checks.push({
|
|
2349
|
+
name: "userDataDir",
|
|
2350
|
+
ok: true,
|
|
2351
|
+
detail: "未配置(登录态随会话,DSH 重启后失效)"
|
|
2352
|
+
});
|
|
2353
|
+
const baseDir = workspaceBaseDir(config, exec);
|
|
2354
|
+
const baseWritable = checkWritableDir(baseDir);
|
|
2355
|
+
checks.push({
|
|
2356
|
+
name: "workspaceDir",
|
|
2357
|
+
ok: baseWritable.ok,
|
|
2358
|
+
detail: baseWritable.ok ? `${baseDir}(来源: ${config.workspaceDir ? "config.workspaceDir" : "会话 cwd 探测"})` : baseWritable.detail,
|
|
2359
|
+
...!baseWritable.ok ? { advice: "设置 config.workspaceDir 钉死基准目录,或修正会话工作区权限" } : {}
|
|
2360
|
+
});
|
|
2361
|
+
for (const [name, dir] of [
|
|
2362
|
+
["screenshotDir", config.screenshotDir ?? ".dsh-browser/screenshots"],
|
|
2363
|
+
["downloadDir", config.downloadDir ?? ".dsh-browser/downloads"],
|
|
2364
|
+
["baselineDir", config.baselineDir ?? ".dsh-browser/baselines"]
|
|
2365
|
+
]) {
|
|
2366
|
+
const abs = isAbsolute(dir) ? dir : resolve(baseDir, dir);
|
|
2367
|
+
const w = checkWritableDir(abs);
|
|
2368
|
+
checks.push({
|
|
2369
|
+
name,
|
|
2370
|
+
ok: w.ok,
|
|
2371
|
+
detail: w.ok ? abs : w.detail,
|
|
2372
|
+
...!w.ok ? { advice: `检查 ${abs} 的创建/写权限(父目录需可写)` } : {}
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
const state = stateOf();
|
|
2376
|
+
checks.push({
|
|
2377
|
+
name: "session",
|
|
2378
|
+
ok: true,
|
|
2379
|
+
detail: `当前 profile "${currentProfile}"${state.browser !== null ? " 已启动浏览器" : " 浏览器未启动(首次调用工具时自动启动)"} · ${state.pages.size} 个标签 · 共 ${profileStates.size} 个 profile`
|
|
2380
|
+
});
|
|
2381
|
+
const basePath = config.basePath ?? "/browser";
|
|
2382
|
+
if (doctorProbe !== null) try {
|
|
2383
|
+
const r = await doctorProbe(basePath);
|
|
2384
|
+
checks.push({
|
|
2385
|
+
name: "panel",
|
|
2386
|
+
ok: r.ok,
|
|
2387
|
+
detail: r.detail,
|
|
2388
|
+
...r.advice ? { advice: r.advice } : {}
|
|
2389
|
+
});
|
|
2390
|
+
} catch (e) {
|
|
2391
|
+
checks.push({
|
|
2392
|
+
name: "panel",
|
|
2393
|
+
ok: false,
|
|
2394
|
+
detail: `探针执行失败: ${e instanceof Error ? e.message : String(e)}`,
|
|
2395
|
+
advice: `在浏览器打开 http://<DSH地址>${basePath}/status 手动验证`
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
else checks.push({
|
|
2399
|
+
name: "panel",
|
|
2400
|
+
ok: true,
|
|
2401
|
+
detail: `本进程无 webServer(工具半),面板 API 基路径 ${basePath}`
|
|
2402
|
+
});
|
|
2403
|
+
const failed = checks.filter((c) => !c.ok).length;
|
|
2404
|
+
return {
|
|
2405
|
+
checks,
|
|
2406
|
+
summary: {
|
|
2407
|
+
ok: failed === 0,
|
|
2408
|
+
passed: checks.length - failed,
|
|
2409
|
+
failed
|
|
2410
|
+
}
|
|
2411
|
+
};
|
|
2412
|
+
}
|
|
1502
2413
|
async function statusTool() {
|
|
1503
2414
|
const page = activePage();
|
|
1504
2415
|
const state = stateOf();
|
|
@@ -1946,30 +2857,153 @@ async function waitForTool(config, args) {
|
|
|
1946
2857
|
const result = await pollCondition(page, args.condition, timeoutMs, intervalMs);
|
|
1947
2858
|
if (!result.ok) throw new Error(`等待条件超时(${timeoutMs}ms): ${result.detail ?? "条件未满足"}`);
|
|
1948
2859
|
return {
|
|
1949
|
-
met: true,
|
|
2860
|
+
met: true,
|
|
2861
|
+
...await pageIdentity(page)
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* 等待人工登录完成(配合 headless:false 使用):agent 打开登录页后调用本工具,
|
|
2866
|
+
* 提示用户在浏览器窗口中完成登录(输入/扫码/双因素)。基于通用条件引擎:
|
|
2867
|
+
* successSelector / successUrl 指定完成条件,两者都不给则以 URL 跳转视为完成。
|
|
2868
|
+
* 超时返回 loggedIn:false(不抛错)。
|
|
2869
|
+
*/
|
|
2870
|
+
async function waitForLoginTool(config, args) {
|
|
2871
|
+
const page = await getPage(config);
|
|
2872
|
+
const startUrl = page.url();
|
|
2873
|
+
let condition;
|
|
2874
|
+
if (args.successSelector) condition = {
|
|
2875
|
+
selector: String(args.successSelector),
|
|
2876
|
+
state: "visible"
|
|
2877
|
+
};
|
|
2878
|
+
else if (args.successUrl) condition = { url: String(args.successUrl) };
|
|
2879
|
+
else condition = { eval: `location.href !== ${JSON.stringify(startUrl)} && location.href !== "about:blank"` };
|
|
2880
|
+
const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 12e4) || 12e4, 5e3), 6e5);
|
|
2881
|
+
return {
|
|
2882
|
+
loggedIn: (await pollCondition(page, condition, timeoutMs, 2e3)).ok,
|
|
2883
|
+
...await pageIdentity(page)
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
/**
|
|
2887
|
+
* 人工接管等待(通用版 wait_for_login):agent 把控制权交还给用户(验证码 /
|
|
2888
|
+
* 二次确认 / 人工复核等)。instruction 会显示在实时画面面板;完成条件三选一:
|
|
2889
|
+
* successSelector(元素出现)/ successUrl(URL 正则)/ 两者都不给则以
|
|
2890
|
+
* URL 变化或页面内容签名变化视为完成。轮询间隔 2 秒,超时返回 completed:false
|
|
2891
|
+
* (不抛错)。导出 runPauseOnPage 供测试注入 fake page。
|
|
2892
|
+
*/
|
|
2893
|
+
async function runPauseOnPage(page, args) {
|
|
2894
|
+
const instruction = typeof args.instruction === "string" ? args.instruction.trim() : "";
|
|
2895
|
+
if (!instruction) throw new Error("缺少 instruction: 请说明需要人工完成什么操作(如「请输入短信验证码」)");
|
|
2896
|
+
const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 3e5) || 3e5, 1e3), 6e5);
|
|
2897
|
+
let startUrl = "";
|
|
2898
|
+
try {
|
|
2899
|
+
startUrl = page.url();
|
|
2900
|
+
} catch {}
|
|
2901
|
+
const startedAt = Date.now();
|
|
2902
|
+
const pageSignature = async () => {
|
|
2903
|
+
try {
|
|
2904
|
+
return await page.evaluate(() => {
|
|
2905
|
+
const body = document.body;
|
|
2906
|
+
return `${location.href}|${body ? body.innerText.length : 0}|${document.querySelectorAll("input,button,a").length}`;
|
|
2907
|
+
});
|
|
2908
|
+
} catch {
|
|
2909
|
+
return page.url();
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
const startSig = args.successSelector || args.successUrl ? "" : await pageSignature();
|
|
2913
|
+
let completed = false;
|
|
2914
|
+
let detail = `等待人工操作超时(${timeoutMs}ms)`;
|
|
2915
|
+
setPauseInstruction(instruction);
|
|
2916
|
+
try {
|
|
2917
|
+
const deadline = startedAt + timeoutMs;
|
|
2918
|
+
while (Date.now() < deadline) {
|
|
2919
|
+
try {
|
|
2920
|
+
if (args.successSelector) completed = await page.locator(String(args.successSelector)).first().isVisible();
|
|
2921
|
+
else if (args.successUrl) completed = new RegExp(String(args.successUrl)).test(page.url());
|
|
2922
|
+
else completed = page.url() !== startUrl || await pageSignature() !== startSig;
|
|
2923
|
+
} catch (e) {
|
|
2924
|
+
completed = false;
|
|
2925
|
+
detail = `检查完成条件出错: ${e instanceof Error ? e.message : String(e)}`;
|
|
2926
|
+
}
|
|
2927
|
+
if (completed) {
|
|
2928
|
+
detail = "人工操作完成";
|
|
2929
|
+
break;
|
|
2930
|
+
}
|
|
2931
|
+
await page.waitForTimeout(2e3);
|
|
2932
|
+
}
|
|
2933
|
+
} finally {
|
|
2934
|
+
clearPauseInstruction();
|
|
2935
|
+
}
|
|
2936
|
+
return {
|
|
2937
|
+
completed,
|
|
2938
|
+
waitedMs: Date.now() - startedAt,
|
|
2939
|
+
detail
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
/** 人工接管等待工具入口(缺 instruction 在启动浏览器前即校验,失败更快)。 */
|
|
2943
|
+
async function pauseForHumanTool(config, args) {
|
|
2944
|
+
const instruction = typeof args.instruction === "string" ? args.instruction.trim() : "";
|
|
2945
|
+
if (!instruction) throw new Error("缺少 instruction: 请说明需要人工完成什么操作(如「请输入短信验证码」)");
|
|
2946
|
+
const page = await getPage(config);
|
|
2947
|
+
const result = await runPauseOnPage(page, args);
|
|
2948
|
+
return {
|
|
2949
|
+
completed: result.completed,
|
|
2950
|
+
status: result.completed ? "completed" : "timedOut",
|
|
2951
|
+
instruction,
|
|
2952
|
+
detail: result.detail,
|
|
2953
|
+
waitedMs: result.waitedMs,
|
|
1950
2954
|
...await pageIdentity(page)
|
|
1951
2955
|
};
|
|
1952
2956
|
}
|
|
2957
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1953
2958
|
/**
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
*
|
|
1957
|
-
*
|
|
2959
|
+
* 敏感操作审批:agent 在执行提交/删除/支付类动作前调用,把审批请求推到实时
|
|
2960
|
+
* 画面面板,挂起等待用户点「允许/拒绝」。超时视为拒绝(fail-closed)。
|
|
2961
|
+
* 用户允许时返回 { decision: "allowed", ... };拒绝/超时**抛错**(消息含原因),
|
|
2962
|
+
* 使 live 调用与 replay/batch 步骤统一按失败处理,agent 不应继续敏感动作。
|
|
2963
|
+
* 决策通过 session.resolveConfirm(面板 POST)写入。
|
|
1958
2964
|
*/
|
|
1959
|
-
async function
|
|
1960
|
-
const
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2965
|
+
async function confirmTool(config, args) {
|
|
2966
|
+
const reason = typeof args.reason === "string" ? args.reason.trim() : "";
|
|
2967
|
+
if (!reason) throw new Error("缺少 reason: 请说明要执行什么敏感操作、为什么需要审批(如「提交白名单表单,会产生线上数据变更」)");
|
|
2968
|
+
const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? config.confirmTimeoutMs ?? 6e4) || 6e4, 1e3), 6e5);
|
|
2969
|
+
const id = `cfm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
2970
|
+
setPendingConfirm({
|
|
2971
|
+
id,
|
|
2972
|
+
reason,
|
|
2973
|
+
tool: typeof args.tool === "string" && args.tool ? args.tool : void 0,
|
|
2974
|
+
args: args.args && typeof args.args === "object" ? args.args : void 0,
|
|
2975
|
+
startedAt: Date.now()
|
|
2976
|
+
});
|
|
2977
|
+
const startedAt = Date.now();
|
|
2978
|
+
let decision = "timeout";
|
|
2979
|
+
let detail = `等待审批超时(${timeoutMs}ms),按拒绝处理`;
|
|
2980
|
+
try {
|
|
2981
|
+
const deadline = startedAt + timeoutMs;
|
|
2982
|
+
while (Date.now() < deadline) {
|
|
2983
|
+
await sleep(500);
|
|
2984
|
+
const pending = getPendingConfirm();
|
|
2985
|
+
if (pending === null || pending.id !== id) {
|
|
2986
|
+
decision = "denied";
|
|
2987
|
+
detail = "审批请求已被新的审批取代,按拒绝处理";
|
|
2988
|
+
break;
|
|
2989
|
+
}
|
|
2990
|
+
if (pending.decision !== void 0) {
|
|
2991
|
+
decision = pending.decision;
|
|
2992
|
+
detail = decision === "allowed" ? "用户允许执行" : "用户拒绝执行";
|
|
2993
|
+
break;
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
} finally {
|
|
2997
|
+
const pending = getPendingConfirm();
|
|
2998
|
+
if (pending !== null && pending.id === id) clearPendingConfirm();
|
|
2999
|
+
}
|
|
3000
|
+
if (decision !== "allowed") throw new Error(`敏感操作被拒绝: ${reason} — ${detail}`);
|
|
1970
3001
|
return {
|
|
1971
|
-
|
|
1972
|
-
|
|
3002
|
+
decision,
|
|
3003
|
+
id,
|
|
3004
|
+
reason,
|
|
3005
|
+
waitedMs: Date.now() - startedAt,
|
|
3006
|
+
detail
|
|
1973
3007
|
};
|
|
1974
3008
|
}
|
|
1975
3009
|
/** 断言:条件在 timeoutMs 内成立则通过,否则失败并自动截图存证据。visual 断言走像素比对。 */
|
|
@@ -2837,27 +3871,76 @@ function registerTools(ctx, config) {
|
|
|
2837
3871
|
if (tools === void 0) return;
|
|
2838
3872
|
setToolsLogger((m) => ctx.logger?.info?.(m));
|
|
2839
3873
|
const timeout = (ms) => ms ?? config.timeoutMs;
|
|
2840
|
-
/**
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
3874
|
+
/**
|
|
3875
|
+
* 打点包装:每次工具调用记录操作轨迹(测试报告 / 录制数据源)。
|
|
3876
|
+
* 注意:记录必须保留完整原始 args —— 录制/回放依赖脚本、steps 数组等大字段的
|
|
3877
|
+
* 完整性(截断会让回放得到损坏的 script / 空的 steps)。展示层截断在 ui.ts 做。
|
|
3878
|
+
*/
|
|
3879
|
+
/** 会触发真实 DOM 事件的 agent 工具:执行前后打点,混合录制时用于区分人手与工具回声。 */
|
|
3880
|
+
const AGENT_DOM_TOOLS = /* @__PURE__ */ new Set([
|
|
3881
|
+
"browser_click",
|
|
3882
|
+
"browser_type",
|
|
3883
|
+
"browser_select",
|
|
3884
|
+
"browser_press",
|
|
3885
|
+
"browser_upload",
|
|
3886
|
+
"browser_form",
|
|
3887
|
+
"browser_eval"
|
|
3888
|
+
]);
|
|
3889
|
+
/** 操作预告:这些工具执行前把目标元素在实时画面上高亮(异步不阻塞)。 */
|
|
3890
|
+
const PREVIEW_TOOLS = /* @__PURE__ */ new Set([
|
|
3891
|
+
"browser_click",
|
|
3892
|
+
"browser_type",
|
|
3893
|
+
"browser_select",
|
|
3894
|
+
"browser_press"
|
|
3895
|
+
]);
|
|
3896
|
+
/**
|
|
3897
|
+
* toolset: "core" 的高频核心集:浏览/定位/操作/等待/断言/人机协作。
|
|
3898
|
+
* 低频的录制/回放/报告/抽取/表单回放/profile/context 等只在 "all" 注册。
|
|
3899
|
+
*/
|
|
3900
|
+
const CORE_TOOLS = /* @__PURE__ */ new Set([
|
|
3901
|
+
"browser_open",
|
|
3902
|
+
"browser_snapshot",
|
|
3903
|
+
"browser_find",
|
|
3904
|
+
"browser_elements",
|
|
3905
|
+
"browser_click",
|
|
3906
|
+
"browser_type",
|
|
3907
|
+
"browser_press",
|
|
3908
|
+
"browser_select",
|
|
3909
|
+
"browser_eval",
|
|
3910
|
+
"browser_batch",
|
|
3911
|
+
"browser_wait",
|
|
3912
|
+
"browser_wait_for",
|
|
3913
|
+
"browser_assert",
|
|
3914
|
+
"browser_screenshot",
|
|
3915
|
+
"browser_wait_for_login",
|
|
3916
|
+
"browser_pause_for_human",
|
|
3917
|
+
"browser_confirm",
|
|
3918
|
+
"browser_network",
|
|
3919
|
+
"browser_tabs",
|
|
3920
|
+
"browser_download",
|
|
3921
|
+
"browser_upload",
|
|
3922
|
+
"browser_status",
|
|
3923
|
+
"browser_doctor",
|
|
3924
|
+
"browser_close"
|
|
3925
|
+
]);
|
|
2850
3926
|
/** 打点包装:每次工具调用记录操作轨迹(测试报告 / 录制数据源)。 */
|
|
2851
3927
|
function register(def) {
|
|
3928
|
+
if (config.toolset === "core" && !CORE_TOOLS.has(def.name)) return;
|
|
2852
3929
|
const original = def.execute;
|
|
2853
3930
|
def.execute = async (args, exec) => {
|
|
2854
3931
|
const t0 = Date.now();
|
|
2855
|
-
const
|
|
3932
|
+
const domTool = AGENT_DOM_TOOLS.has(def.name);
|
|
3933
|
+
if (domTool) noteAgentAction();
|
|
3934
|
+
if (PREVIEW_TOOLS.has(def.name)) {
|
|
3935
|
+
const page = activePage();
|
|
3936
|
+
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_", ""));
|
|
3937
|
+
}
|
|
2856
3938
|
try {
|
|
2857
3939
|
const value = await original(args, exec);
|
|
3940
|
+
if (domTool) noteAgentAction();
|
|
2858
3941
|
const call = recordCall({
|
|
2859
3942
|
tool: def.name,
|
|
2860
|
-
args
|
|
3943
|
+
args,
|
|
2861
3944
|
ok: true,
|
|
2862
3945
|
ms: Date.now() - t0,
|
|
2863
3946
|
ts: Date.now()
|
|
@@ -2873,7 +3956,7 @@ function registerTools(ctx, config) {
|
|
|
2873
3956
|
} catch (error) {
|
|
2874
3957
|
recordCall({
|
|
2875
3958
|
tool: def.name,
|
|
2876
|
-
args
|
|
3959
|
+
args,
|
|
2877
3960
|
ok: false,
|
|
2878
3961
|
ms: Date.now() - t0,
|
|
2879
3962
|
ts: Date.now(),
|
|
@@ -2909,6 +3992,11 @@ function registerTools(ctx, config) {
|
|
|
2909
3992
|
browser_profile: (a) => profileTool(config, a),
|
|
2910
3993
|
browser_elements: (a) => elementsTool(config, a),
|
|
2911
3994
|
browser_wait_for_login: (a) => waitForLoginTool(config, a),
|
|
3995
|
+
browser_pause_for_human: (a) => pauseForHumanTool(config, a),
|
|
3996
|
+
browser_confirm: (a) => confirmTool(config, a),
|
|
3997
|
+
browser_find: (a) => findTool(config, a),
|
|
3998
|
+
browser_doctor: (a) => doctorTool(config, a, void 0),
|
|
3999
|
+
browser_context: (a) => contextTool(config, a),
|
|
2912
4000
|
browser_wait_for: (a) => waitForTool(config, a),
|
|
2913
4001
|
browser_assert: (a) => assertTool(config, a, void 0),
|
|
2914
4002
|
browser_network: (a) => networkTool(a),
|
|
@@ -2965,7 +4053,7 @@ function registerTools(ctx, config) {
|
|
|
2965
4053
|
}));
|
|
2966
4054
|
register(definePlainTool({
|
|
2967
4055
|
name: "browser_snapshot",
|
|
2968
|
-
description: "读取当前页面快照(不截图)。mode:'text'(默认)返回压缩后的可见文本+链接清单,适合读内容;'aria' 返回可访问性树,可交互元素带 [ref=eN] 短引用,后续 browser_click/browser_type/browser_select/browser_press 可直接传 ref 代替 CSS 选择器(导航或重新快照后旧 ref 失效)。maxTextChars 默认 20000。",
|
|
4056
|
+
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
4057
|
parameters: {
|
|
2970
4058
|
type: "object",
|
|
2971
4059
|
properties: {
|
|
@@ -2974,6 +4062,14 @@ function registerTools(ctx, config) {
|
|
|
2974
4062
|
enum: ["text", "aria"],
|
|
2975
4063
|
description: "快照模式,默认 text。"
|
|
2976
4064
|
},
|
|
4065
|
+
scope: {
|
|
4066
|
+
type: "string",
|
|
4067
|
+
description: "可选:限定提取区域的 CSS 选择器(如 '#main')。"
|
|
4068
|
+
},
|
|
4069
|
+
since: {
|
|
4070
|
+
type: "integer",
|
|
4071
|
+
description: "可选:上次快照的 contentVersion,只返回变化内容。"
|
|
4072
|
+
},
|
|
2977
4073
|
maxTextChars: {
|
|
2978
4074
|
type: "integer",
|
|
2979
4075
|
description: "文本截断上限。"
|
|
@@ -2996,14 +4092,21 @@ function registerTools(ctx, config) {
|
|
|
2996
4092
|
url: { type: "string" },
|
|
2997
4093
|
title: { type: "string" },
|
|
2998
4094
|
text: { type: "string" },
|
|
4095
|
+
tree: { type: "string" },
|
|
2999
4096
|
truncated: { type: "boolean" },
|
|
3000
4097
|
links: {
|
|
3001
4098
|
type: "array",
|
|
3002
4099
|
items: { type: "object" }
|
|
3003
4100
|
},
|
|
3004
4101
|
inputs: { type: "integer" },
|
|
3005
|
-
|
|
3006
|
-
|
|
4102
|
+
refs: { type: "integer" },
|
|
4103
|
+
scope: { type: "string" },
|
|
4104
|
+
contentVersion: { type: "integer" },
|
|
4105
|
+
unchanged: { type: "boolean" },
|
|
4106
|
+
diff: { type: "boolean" },
|
|
4107
|
+
changedFrom: { type: "integer" },
|
|
4108
|
+
removed: { type: "integer" },
|
|
4109
|
+
stale: { type: "boolean" }
|
|
3007
4110
|
},
|
|
3008
4111
|
required: [
|
|
3009
4112
|
"url",
|
|
@@ -3012,8 +4115,13 @@ function registerTools(ctx, config) {
|
|
|
3012
4115
|
]
|
|
3013
4116
|
},
|
|
3014
4117
|
render: (_args, value) => {
|
|
3015
|
-
if (value.
|
|
3016
|
-
|
|
4118
|
+
if (value.unchanged === true) return `页面未变化(版本 v${value.contentVersion}): ${value.url}`;
|
|
4119
|
+
if (value.diff === true) {
|
|
4120
|
+
const body = value.tree !== void 0 ? value.tree : value.text;
|
|
4121
|
+
return `内容变化(v${value.contentVersion},第 ${value.changedFrom} 行起,删除 ${value.removed} 行):\n${body}`;
|
|
4122
|
+
}
|
|
4123
|
+
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}`;
|
|
4124
|
+
return `URL: ${value.url}\nTitle: ${value.title}\nInputs: ${value.inputs}\nVersion: v${value.contentVersion}${value.stale === true ? "(stale)" : ""}\n--- text ---\n${value.text}`;
|
|
3017
4125
|
},
|
|
3018
4126
|
timeoutMs: timeout(),
|
|
3019
4127
|
execute: (args) => snapshotTool(config, args)
|
|
@@ -3755,6 +4863,325 @@ function registerTools(ctx, config) {
|
|
|
3755
4863
|
timeoutMs: timeout(),
|
|
3756
4864
|
execute: (args) => waitForLoginTool(config, args)
|
|
3757
4865
|
}));
|
|
4866
|
+
register(definePlainTool({
|
|
4867
|
+
name: "browser_pause_for_human",
|
|
4868
|
+
description: "人工接管等待:agent 把控制权暂时交还给用户,适用于验证码/短信确认/二次复核/敏感步骤人工兜底等场景。调用后提示(instruction)会显示在界面右下角实时画面面板,等待用户在浏览器窗口中完成操作。完成条件三选一:successSelector(完成后出现的元素)、successUrl(完成后 URL 匹配的正则),都不给则 URL 或页面内容发生变化即视为完成。轮询间隔 2 秒;超时不抛错,返回 completed:false,agent 可再次调用或改走其他路径。",
|
|
4869
|
+
parameters: {
|
|
4870
|
+
type: "object",
|
|
4871
|
+
properties: {
|
|
4872
|
+
instruction: {
|
|
4873
|
+
type: "string",
|
|
4874
|
+
description: "给用户看的提示语,如「请在浏览器中输入短信验证码」。"
|
|
4875
|
+
},
|
|
4876
|
+
timeoutMs: {
|
|
4877
|
+
type: "integer",
|
|
4878
|
+
description: "等待上限(毫秒,默认 300000,下限 1000,上限 600000)。"
|
|
4879
|
+
},
|
|
4880
|
+
successSelector: {
|
|
4881
|
+
type: "string",
|
|
4882
|
+
description: "完成后应出现的 CSS 选择器。"
|
|
4883
|
+
},
|
|
4884
|
+
successUrl: {
|
|
4885
|
+
type: "string",
|
|
4886
|
+
description: "完成后 URL 应匹配的正则。"
|
|
4887
|
+
}
|
|
4888
|
+
},
|
|
4889
|
+
required: ["instruction"],
|
|
4890
|
+
additionalProperties: false
|
|
4891
|
+
},
|
|
4892
|
+
outputSchema: {
|
|
4893
|
+
type: "object",
|
|
4894
|
+
additionalProperties: false,
|
|
4895
|
+
properties: {
|
|
4896
|
+
completed: { type: "boolean" },
|
|
4897
|
+
status: {
|
|
4898
|
+
type: "string",
|
|
4899
|
+
enum: ["completed", "timedOut"]
|
|
4900
|
+
},
|
|
4901
|
+
instruction: { type: "string" },
|
|
4902
|
+
detail: { type: "string" },
|
|
4903
|
+
waitedMs: { type: "integer" },
|
|
4904
|
+
url: { type: "string" },
|
|
4905
|
+
title: { type: "string" }
|
|
4906
|
+
},
|
|
4907
|
+
required: [
|
|
4908
|
+
"completed",
|
|
4909
|
+
"status",
|
|
4910
|
+
"instruction",
|
|
4911
|
+
"detail",
|
|
4912
|
+
"waitedMs",
|
|
4913
|
+
"url",
|
|
4914
|
+
"title"
|
|
4915
|
+
]
|
|
4916
|
+
},
|
|
4917
|
+
render: (_args, value) => value.completed ? `人工操作完成(${value.waitedMs}ms): ${value.url}` : `等待人工操作超时(${value.waitedMs}ms): ${value.instruction} — ${value.detail}`,
|
|
4918
|
+
timeoutMs: timeout(6e5),
|
|
4919
|
+
execute: (args) => pauseForHumanTool(config, args)
|
|
4920
|
+
}));
|
|
4921
|
+
register(definePlainTool({
|
|
4922
|
+
name: "browser_confirm",
|
|
4923
|
+
description: "敏感操作审批:在执行提交/删除/支付等不可逆或影响线上数据的动作前调用,审批请求(含 reason 与将执行的操作)会推送到界面右下角实时画面面板,等待用户点「允许/拒绝」。用户允许时返回 decision:'allowed';用户拒绝或等待超时(默认 60s,fail-closed)**抛错**,live 调用与 replay/batch 步骤统一按失败处理。confirmMode:'manual' 时 systemPrompt 会要求所有敏感动作前必须先过本工具。",
|
|
4924
|
+
parameters: {
|
|
4925
|
+
type: "object",
|
|
4926
|
+
properties: {
|
|
4927
|
+
reason: {
|
|
4928
|
+
type: "string",
|
|
4929
|
+
description: "给用户看的审批理由:要执行什么操作、影响是什么。"
|
|
4930
|
+
},
|
|
4931
|
+
tool: {
|
|
4932
|
+
type: "string",
|
|
4933
|
+
description: "可选:即将执行的工具名,如 'browser_click'。"
|
|
4934
|
+
},
|
|
4935
|
+
args: {
|
|
4936
|
+
type: "object",
|
|
4937
|
+
description: "可选:即将执行的工具参数(如 { selector: '#submit' }),供面板展示。"
|
|
4938
|
+
},
|
|
4939
|
+
timeoutMs: {
|
|
4940
|
+
type: "integer",
|
|
4941
|
+
description: "等待决策上限(毫秒,默认取配置 confirmTimeoutMs=60000,下限 1000,上限 600000)。"
|
|
4942
|
+
}
|
|
4943
|
+
},
|
|
4944
|
+
required: ["reason"],
|
|
4945
|
+
additionalProperties: false
|
|
4946
|
+
},
|
|
4947
|
+
outputSchema: {
|
|
4948
|
+
type: "object",
|
|
4949
|
+
additionalProperties: false,
|
|
4950
|
+
properties: {
|
|
4951
|
+
decision: {
|
|
4952
|
+
type: "string",
|
|
4953
|
+
enum: ["allowed"]
|
|
4954
|
+
},
|
|
4955
|
+
id: { type: "string" },
|
|
4956
|
+
reason: { type: "string" },
|
|
4957
|
+
waitedMs: { type: "integer" },
|
|
4958
|
+
detail: { type: "string" }
|
|
4959
|
+
},
|
|
4960
|
+
required: [
|
|
4961
|
+
"decision",
|
|
4962
|
+
"id",
|
|
4963
|
+
"reason",
|
|
4964
|
+
"waitedMs",
|
|
4965
|
+
"detail"
|
|
4966
|
+
]
|
|
4967
|
+
},
|
|
4968
|
+
render: (_args, value) => `用户允许执行(${value.waitedMs}ms): ${value.reason}`,
|
|
4969
|
+
timeoutMs: timeout(6e5),
|
|
4970
|
+
execute: (args) => confirmTool(config, args)
|
|
4971
|
+
}));
|
|
4972
|
+
register(definePlainTool({
|
|
4973
|
+
name: "browser_find",
|
|
4974
|
+
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 候选。",
|
|
4975
|
+
parameters: {
|
|
4976
|
+
type: "object",
|
|
4977
|
+
properties: {
|
|
4978
|
+
query: {
|
|
4979
|
+
type: "string",
|
|
4980
|
+
description: "要找的元素名称(如「提交」「登录」),或元素 id / data-testid。"
|
|
4981
|
+
},
|
|
4982
|
+
exact: {
|
|
4983
|
+
type: "boolean",
|
|
4984
|
+
description: "true 时名称需完全相等(默认 false 包含匹配)。"
|
|
4985
|
+
},
|
|
4986
|
+
scope: {
|
|
4987
|
+
type: "string",
|
|
4988
|
+
description: "可选:限定查找的容器 CSS 选择器(如 '#login-form')。"
|
|
4989
|
+
}
|
|
4990
|
+
},
|
|
4991
|
+
required: ["query"],
|
|
4992
|
+
additionalProperties: false
|
|
4993
|
+
},
|
|
4994
|
+
outputSchema: {
|
|
4995
|
+
type: "object",
|
|
4996
|
+
additionalProperties: false,
|
|
4997
|
+
properties: {
|
|
4998
|
+
found: { type: "integer" },
|
|
4999
|
+
matches: {
|
|
5000
|
+
type: "array",
|
|
5001
|
+
items: {
|
|
5002
|
+
type: "object",
|
|
5003
|
+
properties: {
|
|
5004
|
+
ref: { type: "string" },
|
|
5005
|
+
role: { type: "string" },
|
|
5006
|
+
name: { type: "string" },
|
|
5007
|
+
ancestors: {
|
|
5008
|
+
type: "array",
|
|
5009
|
+
items: { type: "string" }
|
|
5010
|
+
}
|
|
5011
|
+
},
|
|
5012
|
+
required: [
|
|
5013
|
+
"ref",
|
|
5014
|
+
"role",
|
|
5015
|
+
"name",
|
|
5016
|
+
"ancestors"
|
|
5017
|
+
],
|
|
5018
|
+
additionalProperties: false
|
|
5019
|
+
}
|
|
5020
|
+
},
|
|
5021
|
+
suggestions: {
|
|
5022
|
+
type: "array",
|
|
5023
|
+
items: {
|
|
5024
|
+
type: "object",
|
|
5025
|
+
properties: {
|
|
5026
|
+
role: { type: "string" },
|
|
5027
|
+
name: { type: "string" },
|
|
5028
|
+
score: { type: "integer" }
|
|
5029
|
+
},
|
|
5030
|
+
required: [
|
|
5031
|
+
"role",
|
|
5032
|
+
"name",
|
|
5033
|
+
"score"
|
|
5034
|
+
],
|
|
5035
|
+
additionalProperties: false
|
|
5036
|
+
}
|
|
5037
|
+
},
|
|
5038
|
+
truncated: { type: "boolean" },
|
|
5039
|
+
scanned: { type: "integer" },
|
|
5040
|
+
url: { type: "string" },
|
|
5041
|
+
title: { type: "string" }
|
|
5042
|
+
},
|
|
5043
|
+
required: [
|
|
5044
|
+
"found",
|
|
5045
|
+
"matches",
|
|
5046
|
+
"scanned",
|
|
5047
|
+
"url",
|
|
5048
|
+
"title"
|
|
5049
|
+
]
|
|
5050
|
+
},
|
|
5051
|
+
render: (args, value) => {
|
|
5052
|
+
const hits = value.matches.map((m) => `${m.ref}=${m.role} "${m.name}"`).join(", ");
|
|
5053
|
+
if (value.found > 0) return `找到 ${value.found} 个「${args.query}」: ${hits}${value.found > 10 ? " …" : ""}`;
|
|
5054
|
+
const sugg = (value.suggestions ?? []).map((s) => `${s.role} "${s.name}"`).join("、");
|
|
5055
|
+
return `未找到「${args.query}」(扫描 ${value.scanned} 个元素)${sugg ? `;相近候选: ${sugg}` : ""}`;
|
|
5056
|
+
},
|
|
5057
|
+
timeoutMs: timeout(),
|
|
5058
|
+
execute: (args) => findTool(config, args)
|
|
5059
|
+
}));
|
|
5060
|
+
register(definePlainTool({
|
|
5061
|
+
name: "browser_doctor",
|
|
5062
|
+
description: "浏览器环境健康诊断(不启动浏览器):检查可执行文件/channel、playwright-core 可用性、userDataDir 与截图/下载/基线目录写权限、工作区基准目录解析来源、当前会话状态、面板 API 可达性。环境异常(文件落错位置/浏览器起不来)时先跑本工具定位,每项失败检查附修复建议。",
|
|
5063
|
+
parameters: {
|
|
5064
|
+
type: "object",
|
|
5065
|
+
properties: {},
|
|
5066
|
+
additionalProperties: false
|
|
5067
|
+
},
|
|
5068
|
+
outputSchema: {
|
|
5069
|
+
type: "object",
|
|
5070
|
+
additionalProperties: false,
|
|
5071
|
+
properties: {
|
|
5072
|
+
checks: {
|
|
5073
|
+
type: "array",
|
|
5074
|
+
items: {
|
|
5075
|
+
type: "object",
|
|
5076
|
+
properties: {
|
|
5077
|
+
name: { type: "string" },
|
|
5078
|
+
ok: { type: "boolean" },
|
|
5079
|
+
detail: { type: "string" },
|
|
5080
|
+
advice: { type: "string" }
|
|
5081
|
+
},
|
|
5082
|
+
required: [
|
|
5083
|
+
"name",
|
|
5084
|
+
"ok",
|
|
5085
|
+
"detail"
|
|
5086
|
+
],
|
|
5087
|
+
additionalProperties: false
|
|
5088
|
+
}
|
|
5089
|
+
},
|
|
5090
|
+
summary: {
|
|
5091
|
+
type: "object",
|
|
5092
|
+
properties: {
|
|
5093
|
+
ok: { type: "boolean" },
|
|
5094
|
+
passed: { type: "integer" },
|
|
5095
|
+
failed: { type: "integer" }
|
|
5096
|
+
},
|
|
5097
|
+
required: [
|
|
5098
|
+
"ok",
|
|
5099
|
+
"passed",
|
|
5100
|
+
"failed"
|
|
5101
|
+
],
|
|
5102
|
+
additionalProperties: false
|
|
5103
|
+
}
|
|
5104
|
+
},
|
|
5105
|
+
required: ["checks", "summary"]
|
|
5106
|
+
},
|
|
5107
|
+
render: (_args, value) => {
|
|
5108
|
+
const lines = value.checks.map((c) => `${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}${c.advice ? `\n 建议: ${c.advice}` : ""}`);
|
|
5109
|
+
return `诊断 ${value.summary.ok ? "通过" : "发现问题"}(${value.summary.passed}/${value.checks.length})\n${lines.join("\n")}`;
|
|
5110
|
+
},
|
|
5111
|
+
timeoutMs: timeout(15e3),
|
|
5112
|
+
execute: (args, exec) => doctorTool(config, args, exec)
|
|
5113
|
+
}));
|
|
5114
|
+
register(definePlainTool({
|
|
5115
|
+
name: "browser_context",
|
|
5116
|
+
description: "命名隔离上下文:同一浏览器进程内 cookies/storage 完全隔离的\"标签组\"。action:new(name 可省略自动编号,可选 url 直接打开)/ list / switch(name,激活该组页面,组内页全关则在原 context 新开,登录态仍在)/ close(name,整组关闭)。适合多账号并行验证、需要干净会话的抓取。页注册进普通标签表,所有浏览器工具直接可用;browser_tabs 也能看到/切换这些页。持久模式(userDataDir)不支持,请改用 profiles。",
|
|
5117
|
+
parameters: {
|
|
5118
|
+
type: "object",
|
|
5119
|
+
properties: {
|
|
5120
|
+
action: {
|
|
5121
|
+
type: "string",
|
|
5122
|
+
enum: [
|
|
5123
|
+
"list",
|
|
5124
|
+
"new",
|
|
5125
|
+
"switch",
|
|
5126
|
+
"close"
|
|
5127
|
+
],
|
|
5128
|
+
description: "默认 list。"
|
|
5129
|
+
},
|
|
5130
|
+
name: {
|
|
5131
|
+
type: "string",
|
|
5132
|
+
description: "上下文名(new 自动编号时可省略;switch/close 必填)。"
|
|
5133
|
+
},
|
|
5134
|
+
url: {
|
|
5135
|
+
type: "string",
|
|
5136
|
+
description: "new 时可选:新页直接打开的 URL。"
|
|
5137
|
+
}
|
|
5138
|
+
},
|
|
5139
|
+
additionalProperties: false
|
|
5140
|
+
},
|
|
5141
|
+
outputSchema: {
|
|
5142
|
+
type: "object",
|
|
5143
|
+
additionalProperties: false,
|
|
5144
|
+
properties: {
|
|
5145
|
+
contexts: {
|
|
5146
|
+
type: "array",
|
|
5147
|
+
items: {
|
|
5148
|
+
type: "object",
|
|
5149
|
+
properties: {
|
|
5150
|
+
name: { type: "string" },
|
|
5151
|
+
pages: { type: "integer" },
|
|
5152
|
+
active: { type: "boolean" },
|
|
5153
|
+
createdAt: { type: "integer" }
|
|
5154
|
+
},
|
|
5155
|
+
required: [
|
|
5156
|
+
"name",
|
|
5157
|
+
"pages",
|
|
5158
|
+
"active",
|
|
5159
|
+
"createdAt"
|
|
5160
|
+
],
|
|
5161
|
+
additionalProperties: false
|
|
5162
|
+
}
|
|
5163
|
+
},
|
|
5164
|
+
profile: { type: "string" },
|
|
5165
|
+
created: { type: "string" },
|
|
5166
|
+
navigated: { type: "string" },
|
|
5167
|
+
switched: { type: "string" },
|
|
5168
|
+
closed: { type: "string" },
|
|
5169
|
+
remainingTabs: { type: "integer" },
|
|
5170
|
+
pages: { type: "integer" },
|
|
5171
|
+
url: { type: "string" },
|
|
5172
|
+
title: { type: "string" }
|
|
5173
|
+
},
|
|
5174
|
+
required: []
|
|
5175
|
+
},
|
|
5176
|
+
render: (args, value) => {
|
|
5177
|
+
if (args.action === "new" || value.created) return `新建隔离上下文 "${value.created}"${value.navigated ? ` → ${value.navigated}` : ""}`;
|
|
5178
|
+
if (args.action === "switch" || value.switched) return `切换到上下文 "${value.switched}": ${value.url}`;
|
|
5179
|
+
if (args.action === "close" || value.closed) return `关闭上下文 "${value.closed}"(剩余 ${value.remainingTabs} 标签)`;
|
|
5180
|
+
return value.contexts.map((c) => `${c.active ? "▶" : " "} ${c.name}(${c.pages} 页)`).join("\n") || "(无隔离上下文;browser_context { action: \"new\" } 创建)";
|
|
5181
|
+
},
|
|
5182
|
+
timeoutMs: timeout(),
|
|
5183
|
+
execute: (args) => contextTool(config, args)
|
|
5184
|
+
}));
|
|
3758
5185
|
register(definePlainTool({
|
|
3759
5186
|
name: "browser_elements",
|
|
3760
5187
|
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 更适合定位表单。",
|
|
@@ -4291,6 +5718,16 @@ function registerTools(ctx, config) {
|
|
|
4291
5718
|
}
|
|
4292
5719
|
//#endregion
|
|
4293
5720
|
//#region lib/types/browser/ui.js
|
|
5721
|
+
/** 展示层参数截断:单字段 JSON 超过 300 字符截断(仅用于面板轨迹显示,不进录制)。 */
|
|
5722
|
+
function truncateForDisplay(args) {
|
|
5723
|
+
if (typeof args !== "object" || args === null) return args;
|
|
5724
|
+
const out = {};
|
|
5725
|
+
for (const [k, v] of Object.entries(args)) {
|
|
5726
|
+
const s = JSON.stringify(v);
|
|
5727
|
+
out[k] = s && s.length > 300 ? `${s.slice(0, 300)}…(截断)` : v;
|
|
5728
|
+
}
|
|
5729
|
+
return out;
|
|
5730
|
+
}
|
|
4294
5731
|
function registerBrowserApi(ctx, config, base) {
|
|
4295
5732
|
const webServer = ctx.get("webServer");
|
|
4296
5733
|
if (webServer === void 0) return;
|
|
@@ -4301,7 +5738,10 @@ function registerBrowserApi(ctx, config, base) {
|
|
|
4301
5738
|
handler: async (req, res) => {
|
|
4302
5739
|
const path = urlPath(req);
|
|
4303
5740
|
if (path === `${base}/status`) {
|
|
4304
|
-
sendJson(res, 200,
|
|
5741
|
+
sendJson(res, 200, {
|
|
5742
|
+
...await statusTool(),
|
|
5743
|
+
pause: currentPause()
|
|
5744
|
+
});
|
|
4305
5745
|
return;
|
|
4306
5746
|
}
|
|
4307
5747
|
if (path === `${base}/history`) {
|
|
@@ -4314,7 +5754,10 @@ function registerBrowserApi(ctx, config, base) {
|
|
|
4314
5754
|
if (path === `${base}/log`) {
|
|
4315
5755
|
sendJson(res, 200, {
|
|
4316
5756
|
profile: currentProfile,
|
|
4317
|
-
calls: testLogOf().slice(0, 50)
|
|
5757
|
+
calls: testLogOf().slice(0, 50).map((c) => ({
|
|
5758
|
+
...c,
|
|
5759
|
+
args: truncateForDisplay(c.args)
|
|
5760
|
+
}))
|
|
4318
5761
|
});
|
|
4319
5762
|
return;
|
|
4320
5763
|
}
|
|
@@ -4322,6 +5765,21 @@ function registerBrowserApi(ctx, config, base) {
|
|
|
4322
5765
|
sendJson(res, 200, { run: latestRun() });
|
|
4323
5766
|
return;
|
|
4324
5767
|
}
|
|
5768
|
+
if (path === `${base}/runs/report`) {
|
|
5769
|
+
let source = "";
|
|
5770
|
+
try {
|
|
5771
|
+
source = new URL(req.url ?? "/", "http://x").searchParams.get("source") ?? "";
|
|
5772
|
+
} catch {}
|
|
5773
|
+
if (source === "") source = latestRun()?.source ?? "";
|
|
5774
|
+
const html = renderHtmlReport(listRuns(50).filter((r) => source === "" ? true : r.source === source), source === "" ? void 0 : source);
|
|
5775
|
+
res.writeHead(200, {
|
|
5776
|
+
"content-type": "text/html; charset=utf-8",
|
|
5777
|
+
"cache-control": "no-store",
|
|
5778
|
+
"content-length": Buffer.byteLength(html)
|
|
5779
|
+
});
|
|
5780
|
+
res.end(html);
|
|
5781
|
+
return;
|
|
5782
|
+
}
|
|
4325
5783
|
if (path === `${base}/recordings`) {
|
|
4326
5784
|
if ((req.method ?? "GET") === "POST") {
|
|
4327
5785
|
const body = await readJsonBody(req).catch(() => void 0);
|
|
@@ -4352,6 +5810,25 @@ function registerBrowserApi(ctx, config, base) {
|
|
|
4352
5810
|
sendJson(res, 200, detail);
|
|
4353
5811
|
return;
|
|
4354
5812
|
}
|
|
5813
|
+
if (path === `${base}/confirm`) {
|
|
5814
|
+
if ((req.method ?? "GET") === "POST") {
|
|
5815
|
+
const body = await readJsonBody(req).catch(() => void 0);
|
|
5816
|
+
const id = typeof body?.id === "string" ? body.id : "";
|
|
5817
|
+
const decision = body?.decision === "allowed" || body?.decision === "denied" ? body.decision : void 0;
|
|
5818
|
+
if (!id || decision === void 0) {
|
|
5819
|
+
sendText(res, 400, "id and decision (allowed|denied) required");
|
|
5820
|
+
return;
|
|
5821
|
+
}
|
|
5822
|
+
sendJson(res, 200, {
|
|
5823
|
+
resolved: resolveConfirm(id, decision),
|
|
5824
|
+
id,
|
|
5825
|
+
decision
|
|
5826
|
+
});
|
|
5827
|
+
return;
|
|
5828
|
+
}
|
|
5829
|
+
sendJson(res, 200, { confirm: getPendingConfirm() });
|
|
5830
|
+
return;
|
|
5831
|
+
}
|
|
4355
5832
|
if (path === `${base}/screenshot`) {
|
|
4356
5833
|
const page = activePage();
|
|
4357
5834
|
if (page === null || page.isClosed()) {
|
|
@@ -4684,6 +6161,18 @@ const SCHEMA = {
|
|
|
4684
6161
|
maxRows: {
|
|
4685
6162
|
type: "number",
|
|
4686
6163
|
min: 1
|
|
6164
|
+
},
|
|
6165
|
+
confirmMode: {
|
|
6166
|
+
type: "string",
|
|
6167
|
+
enum: ["off", "manual"]
|
|
6168
|
+
},
|
|
6169
|
+
confirmTimeoutMs: {
|
|
6170
|
+
type: "number",
|
|
6171
|
+
min: 1e3
|
|
6172
|
+
},
|
|
6173
|
+
toolset: {
|
|
6174
|
+
type: "string",
|
|
6175
|
+
enum: ["core", "all"]
|
|
4687
6176
|
}
|
|
4688
6177
|
};
|
|
4689
6178
|
const DEFAULT_CONFIG = {
|
|
@@ -4706,7 +6195,10 @@ const DEFAULT_CONFIG = {
|
|
|
4706
6195
|
selectorHealing: true,
|
|
4707
6196
|
healTimeoutMs: 3e3,
|
|
4708
6197
|
maxRefs: 200,
|
|
4709
|
-
maxRows: 1e3
|
|
6198
|
+
maxRows: 1e3,
|
|
6199
|
+
confirmMode: "off",
|
|
6200
|
+
confirmTimeoutMs: 6e4,
|
|
6201
|
+
toolset: "all"
|
|
4710
6202
|
};
|
|
4711
6203
|
function apply(ctx, rawConfig) {
|
|
4712
6204
|
const resolved = resolveConfig("dsh-browser-view", SCHEMA, DEFAULT_CONFIG, rawConfig);
|
|
@@ -4716,16 +6208,41 @@ function apply(ctx, rawConfig) {
|
|
|
4716
6208
|
const loadedRecordings = loadRecordings();
|
|
4717
6209
|
if (loadedRecordings > 0) ctx.logger?.info?.(`dsh-browser-view: loaded ${loadedRecordings} saved recording(s)`);
|
|
4718
6210
|
registerTools(ctx, config);
|
|
4719
|
-
const
|
|
6211
|
+
const systemPrompt = ctx.get("systemPrompt");
|
|
6212
|
+
const confirmGuidance = config.confirmMode === "manual" ? "本环境 confirmMode 为 manual:凡提交/删除/支付等不可逆或影响线上数据的敏感动作,执行前必须先调用 browser_confirm(reason 说明操作与影响,等待用户在实时画面面板点允许/拒绝;拒绝或超时即放弃该动作,不得换路径绕过)。" : "";
|
|
6213
|
+
const sectionDispose = systemPrompt?.section?.({
|
|
4720
6214
|
name: "tool:browser",
|
|
4721
6215
|
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 时登录态会持久化。用户可通过界面右下角面板的实时画面查看浏览器。"
|
|
6216
|
+
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
6217
|
});
|
|
4724
6218
|
if (sectionDispose !== void 0) ctx.effect(() => sectionDispose);
|
|
4725
6219
|
registerBrowserApi(ctx, config, base);
|
|
4726
6220
|
const webServer = ctx.get("webServer");
|
|
4727
6221
|
if (webServer !== void 0) {
|
|
4728
6222
|
applyEmbed(ctx, webServer, base);
|
|
6223
|
+
const port = webServer.port;
|
|
6224
|
+
setDoctorProbe(port !== void 0 ? async (basePath) => {
|
|
6225
|
+
try {
|
|
6226
|
+
const res = await fetch(`http://127.0.0.1:${port}${basePath}/status`, {
|
|
6227
|
+
signal: AbortSignal.timeout(2e3),
|
|
6228
|
+
cache: "no-store"
|
|
6229
|
+
});
|
|
6230
|
+
return res.ok ? {
|
|
6231
|
+
ok: true,
|
|
6232
|
+
detail: `面板 API 可达(/status → 200,端口 ${port})`
|
|
6233
|
+
} : {
|
|
6234
|
+
ok: false,
|
|
6235
|
+
detail: `面板 API 异常(/status → ${res.status})`,
|
|
6236
|
+
advice: "查看 DSH 日志中 webServer 的注册错误"
|
|
6237
|
+
};
|
|
6238
|
+
} catch (e) {
|
|
6239
|
+
return {
|
|
6240
|
+
ok: false,
|
|
6241
|
+
detail: `本机 127.0.0.1:${port} 请求失败: ${e instanceof Error ? e.message : String(e)}`,
|
|
6242
|
+
advice: "确认 DSH web 端口未被防火墙/代理拦截"
|
|
6243
|
+
};
|
|
6244
|
+
}
|
|
6245
|
+
} : null);
|
|
4729
6246
|
const disposeConfig = webServer.register({
|
|
4730
6247
|
kind: "exact",
|
|
4731
6248
|
path: "/__dsh-browser-view__/config",
|