@angelky7/dsh-browser-control 1.6.0 → 1.7.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.en.md +8 -7
- package/README.md +8 -7
- package/lib/index.js +163 -17
- package/lib/types/errors.js +3 -0
- package/lib/types/index.js +146 -13
- package/lib/types/policy.js +3 -0
- package/llms.txt +1 -1
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -64,8 +64,8 @@ In one line: **for "use MY browser" tasks (logged-in Bilibili, Zhihu, admin pane
|
|
|
64
64
|
|
|
65
65
|
| File | Purpose |
|
|
66
66
|
|---|---|
|
|
67
|
-
| [DSH-Browser-Control-v1.
|
|
68
|
-
| [dsh-browser-control-plugin-v1.
|
|
67
|
+
| [DSH-Browser-Control-v1.7.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/DSH-Browser-Control-v1.7.1.zip) | Chrome extension (unzip and load; this release adds the metrics command and emulate throttling — reinstall required) |
|
|
68
|
+
| [dsh-browser-control-plugin-v1.7.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/dsh-browser-control-plugin-v1.7.1.zip) | dsh plugin (offline fallback; online installs use Option A/B) |
|
|
69
69
|
|
|
70
70
|
## Install the Chrome extension (30 seconds)
|
|
71
71
|
|
|
@@ -104,7 +104,7 @@ npm install @angelky7/dsh-browser-control
|
|
|
104
104
|
|
|
105
105
|
```bash
|
|
106
106
|
# Straight from GitHub
|
|
107
|
-
dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.
|
|
107
|
+
dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.1"
|
|
108
108
|
|
|
109
109
|
# Local checkout for debugging (note: the explicit file: prefix is required)
|
|
110
110
|
dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
|
|
@@ -256,20 +256,21 @@ curl -X POST http://127.0.0.1:9777/api/command \
|
|
|
256
256
|
| `browser_dialog` | Read or set the native-dialog policy accept/dismiss/manual, plus the recent dialog log (v1.0.9+) |
|
|
257
257
|
| `browser_hold` | Put or remove a cancellable take-over overlay on a tab (tells the user, stops mis-clicks); `covered:false` says why it could not cover the page. A soft layer, not a security boundary |
|
|
258
258
|
| `browser_cookies` | Read/set/remove cookies visible to a tab (`action: get/set/remove`; get takes a `domain` filter, set/remove need a `url` or `domain` anchor, remove re-checks and reports `remaining`). **Cookie values are credentials** — a get puts them fully into the model-visible text; see SECURITY.md |
|
|
259
|
-
| `browser_tabs` | Tab management (list/open/close/activate/group/ungroup;
|
|
259
|
+
| `browser_tabs` | Tab management (list/open/close/activate/group/ungroup; v1.7.0+ history back/forward — same arrival judging as navigate, reports unchanged at the history edge) |
|
|
260
260
|
| `browser_evaluate` | Run arbitrary JS |
|
|
261
|
-
| `browser_screenshot` | Capture page screenshot |
|
|
261
|
+
| `browser_screenshot` | Capture page screenshot (v1.7.0+ receipt carries the real pixel size; fullPage captures the whole scrollable page, selector captures one element) |
|
|
262
262
|
| `browser_console_log` | Captured console entries, listed with their text (v1.0.7+) |
|
|
263
263
|
| `browser_network_log` | Captured HTTP requests, listed with method/status/URL (v1.0.7+) |
|
|
264
264
|
| `browser_network_clear` | Clear the network log buffer on its own (v1.0.7+) |
|
|
265
265
|
| `browser_pdf` | Export the current page as PDF (v1.0.7+) |
|
|
266
266
|
| `browser_archive` | Save the whole page as a single-file MHTML (text/images/styles inline; a greppable offline evidence snapshot — embedded scripts do not run, v1.5.0+) |
|
|
267
267
|
| `browser_windows` | Window control: list / open / update state & geometry / close a whole window / move tabs across windows (the only tool that may touch window geometry, v1.6.0+) |
|
|
268
|
-
| `browser_emulate` | Switch to a device viewport (mobile / desktop / custom, v1.0.7+) |
|
|
268
|
+
| `browser_emulate` | Switch to a device viewport (mobile / desktop / custom, v1.0.7+); v1.7.1+ adds throttling: `network` slow3g/fast3g/offline/reset + `cpuThrottlingRate` (1–20), the receipt states the applied numbers, `device:"reset"` clears them too |
|
|
269
|
+
| `browser_metrics` | Read performance counters (DOM nodes / JS heap / layout & script durations, Performance.getMetrics, read-only; puts numbers behind "this page is heavy", v1.7.1+) |
|
|
269
270
|
| `browser_cleanup` | Clean up temp files |
|
|
270
271
|
| `browser_info` | Which browser and extension build is attached (run it first when debugging, v1.0.9+) |
|
|
271
272
|
|
|
272
|
-
|
|
273
|
+
25 tools in total. `browser_wait`, `browser_find`, `browser_dialog` and `browser_info` existed inside the extension up to v1.0.8 but were never registered as tools, so the agent could not reach them; `browser_network_clear` existed in code and was simply missing from this table. `verify/verify_surface.mjs` now reconciles the extension command table, the commands the plugin calls, this table and the built bundle, so any drift fails `npm run verify`.
|
|
273
274
|
|
|
274
275
|
## Architecture
|
|
275
276
|
|
package/README.md
CHANGED
|
@@ -64,8 +64,8 @@ Chrome 扩展(CDP 驱动)
|
|
|
64
64
|
|
|
65
65
|
| 文件 | 说明 |
|
|
66
66
|
|---|---|
|
|
67
|
-
| [DSH-Browser-Control-v1.
|
|
68
|
-
| [dsh-browser-control-plugin-v1.
|
|
67
|
+
| [DSH-Browser-Control-v1.7.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/DSH-Browser-Control-v1.7.1.zip) | Chrome 扩展(解压后加载;这一版扩展新增 metrics 命令与 emulate 节流参数,**必须重装**) |
|
|
68
|
+
| [dsh-browser-control-plugin-v1.7.1.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.1/dsh-browser-control-plugin-v1.7.1.zip) | dsh 插件(离线兜底,在线装直接用方式 A/B) |
|
|
69
69
|
|
|
70
70
|
## 安装 Chrome 扩展(30 秒)
|
|
71
71
|
|
|
@@ -104,7 +104,7 @@ npm install @angelky7/dsh-browser-control
|
|
|
104
104
|
|
|
105
105
|
```bash
|
|
106
106
|
# 直接从 GitHub 安装
|
|
107
|
-
dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.
|
|
107
|
+
dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.1"
|
|
108
108
|
|
|
109
109
|
# 本地目录调试(注意:必须显式 file: 前缀)
|
|
110
110
|
dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
|
|
@@ -250,20 +250,21 @@ curl -X POST http://127.0.0.1:9777/api/command \
|
|
|
250
250
|
| `browser_dialog` | 读改原生弹窗应答策略 accept/dismiss/manual,并回最近弹窗记录(v1.0.9+) |
|
|
251
251
|
| `browser_hold` | 在某个标签页上加/撤一层可取消的接管浮层(告知用户 + 防误触);`covered:false` 时说明盖不住的原因。软层,不是安全边界 |
|
|
252
252
|
| `browser_cookies` | 读/设/删某标签页可见的 cookie(`action: get/set/remove`;get 带 `domain` 过滤,set/remove 需要 `url` 或 `domain` 锚点,remove 复查并回报 `remaining`)。**cookie 的值是凭据**,get 会把值完整放进模型可见文本——见 SECURITY.md |
|
|
253
|
-
| `browser_tabs` |
|
|
253
|
+
| `browser_tabs` | 标签页管理(列表/新建/关闭/切换/归组/拆组;v1.7.0 起支持历史 back/forward——落地判据与 navigate 同一套,历史尽头如实报 unchanged) |
|
|
254
254
|
| `browser_evaluate` | 执行任意 JS |
|
|
255
|
-
| `browser_screenshot` |
|
|
255
|
+
| `browser_screenshot` | 截取页面截图(v1.7.0 起回执带**真实像素尺寸**;fullPage 一张截全页、selector 只截一个元素) |
|
|
256
256
|
| `browser_console_log` | 抓取页面 console 日志并逐条给出正文(v1.0.7+) |
|
|
257
257
|
| `browser_network_log` | 抓取 HTTP 请求/响应并逐条给出方法/状态/URL(v1.0.7+) |
|
|
258
258
|
| `browser_network_clear` | 单独清空网络日志缓冲(v1.0.7+) |
|
|
259
259
|
| `browser_pdf` | 当前页导出 PDF(v1.0.7+) |
|
|
260
260
|
| `browser_archive` | 整页存成单文件 MHTML(正文/图片/样式全内联,可当文本 grep 的离线证据;是快照,内嵌脚本不会跑,v1.5.0+) |
|
|
261
|
-
| `browser_emulate` | 切设备视口(移动 / 桌面 / 自定义,v1.0.7
|
|
261
|
+
| `browser_emulate` | 切设备视口(移动 / 桌面 / 自定义,v1.0.7+);v1.7.1 起还能限速:`network` slow3g/fast3g/offline/reset + `cpuThrottlingRate`(1–20),回执写明生效数值,`device:"reset"` 一并还原 |
|
|
262
|
+
| `browser_metrics` | 读性能计数器(DOM 节点数 / JS 堆 / 布局与脚本耗时,Performance.getMetrics,只读;让「这页面很重」有数字说话,v1.7.1+) |
|
|
262
263
|
| `browser_windows` | 窗口控制:清单/开窗/改状态与几何/关整窗/跨窗挪标签(唯一能碰窗口几何的工具,v1.6.0+) |
|
|
263
264
|
| `browser_cleanup` | 清理临时文件 |
|
|
264
265
|
| `browser_info` | 报告实际连上的浏览器与扩展版本(排障先跑它,v1.0.9+) |
|
|
265
266
|
|
|
266
|
-
共
|
|
267
|
+
共 25 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
|
|
267
268
|
|
|
268
269
|
## 架构
|
|
269
270
|
|
package/lib/index.js
CHANGED
|
@@ -117,6 +117,8 @@ const WRITE_COMMANDS = /* @__PURE__ */ new Set([
|
|
|
117
117
|
"tabs.closeMany",
|
|
118
118
|
"tabs.activate",
|
|
119
119
|
"dialog",
|
|
120
|
+
"tabs.back",
|
|
121
|
+
"tabs.forward",
|
|
120
122
|
"cleanup",
|
|
121
123
|
"hold",
|
|
122
124
|
"windows.open"
|
|
@@ -876,7 +878,8 @@ const READ_ONLY_ALLOWED = /* @__PURE__ */ new Set([
|
|
|
876
878
|
"console.log",
|
|
877
879
|
"network.log",
|
|
878
880
|
"windows.list",
|
|
879
|
-
"cookies.get"
|
|
881
|
+
"cookies.get",
|
|
882
|
+
"metrics"
|
|
880
883
|
]);
|
|
881
884
|
/**
|
|
882
885
|
* 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
|
|
@@ -1200,6 +1203,17 @@ function closeTabIds(args) {
|
|
|
1200
1203
|
if (ids.length === 0) throw new Error("close requires tabId or tabIds");
|
|
1201
1204
|
return ids;
|
|
1202
1205
|
}
|
|
1206
|
+
/** Actual pixel dimensions straight from the PNG's IHDR chunk (width/height are
|
|
1207
|
+
* big-endian at offsets 16/20 after the 8-byte signature + 4-byte length + 4-byte
|
|
1208
|
+
* type). The receipt should say how big the picture IS — fullPage captures are
|
|
1209
|
+
* often 3-5 viewports tall and the model cannot tell from the byte count. */
|
|
1210
|
+
function pngDimensions(buffer) {
|
|
1211
|
+
if (buffer.length < 24 || buffer.toString("latin1", 12, 16) !== "IHDR") return {};
|
|
1212
|
+
return {
|
|
1213
|
+
width: buffer.readUInt32BE(16),
|
|
1214
|
+
height: buffer.readUInt32BE(20)
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1203
1217
|
/** Write one screenshot payload to the shots directory and return its durable location. */
|
|
1204
1218
|
async function saveScreenshot(controller, payload) {
|
|
1205
1219
|
const dir = controller.shotsDir;
|
|
@@ -1210,12 +1224,15 @@ async function saveScreenshot(controller, payload) {
|
|
|
1210
1224
|
const file = path.join(dir, `${stamp}-${random}.${payload.format === "jpeg" ? "jpg" : "png"}`);
|
|
1211
1225
|
const buffer = Buffer.from(payload.base64, "base64");
|
|
1212
1226
|
await writeFile(file, buffer);
|
|
1227
|
+
const dims = payload.format === "png" ? pngDimensions(buffer) : {};
|
|
1213
1228
|
return {
|
|
1214
1229
|
file,
|
|
1215
1230
|
bytes: buffer.length,
|
|
1216
1231
|
tabId: payload.tabId,
|
|
1217
1232
|
title: payload.tabTitle,
|
|
1218
|
-
url: payload.tabUrl
|
|
1233
|
+
url: payload.tabUrl,
|
|
1234
|
+
...dims,
|
|
1235
|
+
...payload.elementRect === void 0 ? {} : { elementRect: payload.elementRect }
|
|
1219
1236
|
};
|
|
1220
1237
|
}
|
|
1221
1238
|
/** Write one PDF payload to `path` (absolute or relative to `controller.shotsDir`)
|
|
@@ -2041,12 +2058,12 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2041
2058
|
}));
|
|
2042
2059
|
ctx.tools.register(defineTool({
|
|
2043
2060
|
name: "browser_tabs",
|
|
2044
|
-
description: "List tabs, or open/close/activate/group/ungroup. `close` takes one tab (`tabId`) or several (`tabIds`); the result accounts for every id, so a retry is safe.",
|
|
2061
|
+
description: "List tabs, or open/close/activate/group/ungroup, or go back/forward in the tab's history. `close` takes one tab (`tabId`) or several (`tabIds`); the result accounts for every id, so a retry is safe.",
|
|
2045
2062
|
parameters: {
|
|
2046
2063
|
action: {
|
|
2047
2064
|
type: "string",
|
|
2048
2065
|
required: true,
|
|
2049
|
-
description: "One of: list, open, close, activate, group, ungroup."
|
|
2066
|
+
description: "One of: list, open, close, activate, group, ungroup, back, forward."
|
|
2050
2067
|
},
|
|
2051
2068
|
url: {
|
|
2052
2069
|
type: "string",
|
|
@@ -2054,7 +2071,7 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2054
2071
|
},
|
|
2055
2072
|
tabId: {
|
|
2056
2073
|
type: "number",
|
|
2057
|
-
description: "Target tab for close/activate/group."
|
|
2074
|
+
description: "Target tab for close/activate/group/back/forward; defaults to the active tab for back/forward."
|
|
2058
2075
|
},
|
|
2059
2076
|
tabIds: {
|
|
2060
2077
|
type: "array",
|
|
@@ -2071,11 +2088,11 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2071
2088
|
},
|
|
2072
2089
|
waitUntil: {
|
|
2073
2090
|
type: "string",
|
|
2074
|
-
description: "For open: when the
|
|
2091
|
+
description: "For open/back/forward: when the navigation counts as arrived — same tiers as browser_navigate (load default / domcontentloaded / commit / none). back/forward report unchanged:true + a reason when the tab is already at the oldest/newest history entry."
|
|
2075
2092
|
},
|
|
2076
2093
|
timeoutMs: {
|
|
2077
2094
|
type: "number",
|
|
2078
|
-
description: "For open: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000)."
|
|
2095
|
+
description: "For open/back/forward: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000)."
|
|
2079
2096
|
},
|
|
2080
2097
|
windowId: {
|
|
2081
2098
|
type: "number",
|
|
@@ -2121,6 +2138,18 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2121
2138
|
return ids.length === 1 ? await controller.execute("tabs.close", { tabId: ids[0] }, exec.signal) : await controller.execute("tabs.closeMany", { tabIds: ids }, exec.signal);
|
|
2122
2139
|
}
|
|
2123
2140
|
case "activate": return await controller.execute("tabs.activate", { tabId: requireTabId(args, "activate") }, exec.signal);
|
|
2141
|
+
case "back":
|
|
2142
|
+
case "forward": {
|
|
2143
|
+
const waitUntil = args.waitUntil === void 0 ? "load" : String(args.waitUntil);
|
|
2144
|
+
if (!(waitUntil in NAV_TIERS)) throw new Error(`browser_tabs ${args.action}: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
|
|
2145
|
+
const params = { waitUntil };
|
|
2146
|
+
if (args.tabId !== void 0) params.tabId = args.tabId;
|
|
2147
|
+
let budgetMs = NAV_TIERS[waitUntil];
|
|
2148
|
+
if (typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs)) budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
|
|
2149
|
+
params.timeoutMs = budgetMs;
|
|
2150
|
+
if (args.action === "back") return await controller.execute("tabs.back", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
|
|
2151
|
+
return await controller.execute("tabs.forward", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
|
|
2152
|
+
}
|
|
2124
2153
|
case "group": return await controller.execute("tabs.group", { tabId: requireTabId(args, "group") }, exec.signal);
|
|
2125
2154
|
case "ungroup": {
|
|
2126
2155
|
const params = {};
|
|
@@ -2326,7 +2355,7 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2326
2355
|
}));
|
|
2327
2356
|
ctx.tools.register(defineTool({
|
|
2328
2357
|
name: "browser_screenshot",
|
|
2329
|
-
description: "Capture the tab as PNG/JPEG, save it under the configured shots directory, and return the absolute file path.",
|
|
2358
|
+
description: "Capture the tab as PNG/JPEG, save it under the configured shots directory, and return the absolute file path plus the image's real pixel size. fullPage captures the whole scrollable page in one shot (sticky/fixed headers may repeat); selector captures one element.",
|
|
2330
2359
|
parameters: {
|
|
2331
2360
|
tabId: {
|
|
2332
2361
|
type: "number",
|
|
@@ -2334,7 +2363,11 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2334
2363
|
},
|
|
2335
2364
|
fullPage: {
|
|
2336
2365
|
type: "boolean",
|
|
2337
|
-
description: "Capture
|
|
2366
|
+
description: "Capture the entire scrollable page instead of just the viewport. The receipt's width/height show the real size."
|
|
2367
|
+
},
|
|
2368
|
+
selector: {
|
|
2369
|
+
type: "string",
|
|
2370
|
+
description: "CSS selector: capture just that element (scrolled into view); the receipt carries its rect."
|
|
2338
2371
|
},
|
|
2339
2372
|
format: {
|
|
2340
2373
|
type: "string",
|
|
@@ -2359,12 +2392,36 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2359
2392
|
required: true
|
|
2360
2393
|
},
|
|
2361
2394
|
title: { type: "string" },
|
|
2362
|
-
url: { type: "string" }
|
|
2395
|
+
url: { type: "string" },
|
|
2396
|
+
width: { type: "number" },
|
|
2397
|
+
height: { type: "number" },
|
|
2398
|
+
elementRect: {
|
|
2399
|
+
type: "object",
|
|
2400
|
+
additionalProperties: false,
|
|
2401
|
+
properties: {
|
|
2402
|
+
x: {
|
|
2403
|
+
type: "number",
|
|
2404
|
+
required: true
|
|
2405
|
+
},
|
|
2406
|
+
y: {
|
|
2407
|
+
type: "number",
|
|
2408
|
+
required: true
|
|
2409
|
+
},
|
|
2410
|
+
w: {
|
|
2411
|
+
type: "number",
|
|
2412
|
+
required: true
|
|
2413
|
+
},
|
|
2414
|
+
h: {
|
|
2415
|
+
type: "number",
|
|
2416
|
+
required: true
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2363
2420
|
}
|
|
2364
2421
|
},
|
|
2365
2422
|
render: (_args, value) => [{
|
|
2366
2423
|
type: "text",
|
|
2367
|
-
text: `Saved ${value.file} (${value.bytes} bytes)`
|
|
2424
|
+
text: `Saved ${value.file} (${value.bytes} bytes${value.width !== void 0 && value.height !== void 0 ? `, ${value.width}×${value.height}` : ""}${value.elementRect !== void 0 ? `, element at ${value.elementRect.x},${value.elementRect.y} size ${value.elementRect.w}×${value.elementRect.h}` : ""})`
|
|
2368
2425
|
}]
|
|
2369
2426
|
},
|
|
2370
2427
|
presentCall: () => ({
|
|
@@ -2376,6 +2433,7 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2376
2433
|
const params = { format: args.format === "jpeg" ? "jpeg" : "png" };
|
|
2377
2434
|
if (args.tabId !== void 0) params.tabId = args.tabId;
|
|
2378
2435
|
if (args.fullPage !== void 0) params.fullPage = args.fullPage;
|
|
2436
|
+
if (args.selector !== void 0) params.selector = args.selector;
|
|
2379
2437
|
return saveScreenshot(controller, await controller.execute("screenshot", params, exec.signal));
|
|
2380
2438
|
}
|
|
2381
2439
|
}));
|
|
@@ -2796,7 +2854,7 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2796
2854
|
}));
|
|
2797
2855
|
ctx.tools.register(defineTool({
|
|
2798
2856
|
name: "browser_emulate",
|
|
2799
|
-
description: "Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing. `device:\"reset\"` restores the user's actual viewport. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.",
|
|
2857
|
+
description: "Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing, and throttle the network or CPU to test slow conditions. `device:\"reset\"` restores the user's actual viewport and clears all throttling. `network` presets (this tool's own numbers): slow3g = 400ms RTT + ~50KB/s both ways, fast3g = 150ms + ~200KB/s down / ~94KB/s up, offline, reset to clear. `cpuThrottlingRate` multiplies CPU cost (1 = off, up to 20). Throttling lives in the debugger session — it is gone when the extension detaches, and device:\"reset\" clears it too. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.",
|
|
2800
2858
|
parameters: {
|
|
2801
2859
|
tabId: {
|
|
2802
2860
|
type: "number",
|
|
@@ -2806,6 +2864,14 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2806
2864
|
type: "string",
|
|
2807
2865
|
description: "Preset: desktop | mobile-iphone-13 | mobile-pixel-7 | tablet-ipad | reset. Or pass custom width/height below."
|
|
2808
2866
|
},
|
|
2867
|
+
network: {
|
|
2868
|
+
type: "string",
|
|
2869
|
+
description: "slow3g | fast3g | offline | reset. The receipt reports the applied latency/throughput in bytes per second."
|
|
2870
|
+
},
|
|
2871
|
+
cpuThrottlingRate: {
|
|
2872
|
+
type: "number",
|
|
2873
|
+
description: "CPU slowdown multiplier: 1 = none (default), 4 = mid-range phone, up to 20."
|
|
2874
|
+
},
|
|
2809
2875
|
width: {
|
|
2810
2876
|
type: "number",
|
|
2811
2877
|
description: "Custom viewport width in CSS px."
|
|
@@ -2846,30 +2912,39 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2846
2912
|
deviceScaleFactor: { type: "number" },
|
|
2847
2913
|
isMobile: { type: "boolean" },
|
|
2848
2914
|
hasTouch: { type: "boolean" },
|
|
2849
|
-
userAgent: { type: "string" }
|
|
2915
|
+
userAgent: { type: "string" },
|
|
2916
|
+
network: { type: "string" },
|
|
2917
|
+
latency: { type: "number" },
|
|
2918
|
+
downloadThroughput: { type: "number" },
|
|
2919
|
+
uploadThroughput: { type: "number" },
|
|
2920
|
+
cpuThrottlingRate: { type: "number" }
|
|
2850
2921
|
}
|
|
2851
2922
|
},
|
|
2852
2923
|
render: (_args, value) => {
|
|
2853
2924
|
if (value.reset) return [{
|
|
2854
2925
|
type: "text",
|
|
2855
|
-
text: `Tab ${value.tabId}: emulation reset to default`
|
|
2926
|
+
text: `Tab ${value.tabId}: emulation reset to default (network unthrottled, CPU rate 1)`
|
|
2856
2927
|
}];
|
|
2857
2928
|
const ua = value.userAgent === void 0 ? "" : ` UA="${flattenOneLine(value.userAgent, 80)}"`;
|
|
2929
|
+
const net = value.network === void 0 ? "" : ` network=${value.network}(${value.latency}ms↓${value.downloadThroughput}B/s↑${value.uploadThroughput}B/s)`;
|
|
2930
|
+
const cpu = value.cpuThrottlingRate === void 0 ? "" : ` cpu×${value.cpuThrottlingRate}`;
|
|
2858
2931
|
return [{
|
|
2859
2932
|
type: "text",
|
|
2860
|
-
text: `Tab ${value.tabId}: ${value.width || "?"}×${value.height || "?"} DPR=${value.deviceScaleFactor ?? "?"} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}`
|
|
2933
|
+
text: `Tab ${value.tabId}: ${value.width || "?"}×${value.height || "?"} DPR=${value.deviceScaleFactor ?? "?"} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}${net}${cpu}`
|
|
2861
2934
|
}];
|
|
2862
2935
|
}
|
|
2863
2936
|
},
|
|
2864
2937
|
presentCall: (args) => ({
|
|
2865
2938
|
card: "generic",
|
|
2866
|
-
title: `Emulate${args.device ? " " + args.device : "
|
|
2939
|
+
title: `Emulate${args.device ? " " + args.device : ""}${args.network ? " " + args.network : ""}${args.cpuThrottlingRate ? " cpu×" + args.cpuThrottlingRate : ""}`,
|
|
2867
2940
|
kind: "other"
|
|
2868
2941
|
}),
|
|
2869
2942
|
async execute(args, exec) {
|
|
2870
2943
|
const params = {};
|
|
2871
2944
|
if (args.tabId !== void 0) params.tabId = args.tabId;
|
|
2872
2945
|
if (typeof args.device === "string") params.device = args.device;
|
|
2946
|
+
if (typeof args.network === "string") params.network = args.network;
|
|
2947
|
+
if (typeof args.cpuThrottlingRate === "number") params.cpuThrottlingRate = args.cpuThrottlingRate;
|
|
2873
2948
|
if (typeof args.width === "number") params.width = args.width;
|
|
2874
2949
|
if (typeof args.height === "number") params.height = args.height;
|
|
2875
2950
|
if (typeof args.deviceScaleFactor === "number") params.deviceScaleFactor = args.deviceScaleFactor;
|
|
@@ -2885,10 +2960,81 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2885
2960
|
...typeof raw.deviceScaleFactor === "number" ? { deviceScaleFactor: raw.deviceScaleFactor } : {},
|
|
2886
2961
|
...typeof raw.isMobile === "boolean" ? { isMobile: raw.isMobile } : {},
|
|
2887
2962
|
...typeof raw.hasTouch === "boolean" ? { hasTouch: raw.hasTouch } : {},
|
|
2888
|
-
...typeof raw.userAgent === "string" && raw.userAgent.length > 0 ? { userAgent: raw.userAgent } : {}
|
|
2963
|
+
...typeof raw.userAgent === "string" && raw.userAgent.length > 0 ? { userAgent: raw.userAgent } : {},
|
|
2964
|
+
...typeof raw.network === "string" ? { network: raw.network } : {},
|
|
2965
|
+
...typeof raw.latency === "number" ? { latency: raw.latency } : {},
|
|
2966
|
+
...typeof raw.downloadThroughput === "number" ? { downloadThroughput: raw.downloadThroughput } : {},
|
|
2967
|
+
...typeof raw.uploadThroughput === "number" ? { uploadThroughput: raw.uploadThroughput } : {},
|
|
2968
|
+
...typeof raw.cpuThrottlingRate === "number" ? { cpuThrottlingRate: raw.cpuThrottlingRate } : {}
|
|
2889
2969
|
};
|
|
2890
2970
|
}
|
|
2891
2971
|
}));
|
|
2972
|
+
ctx.tools.register(defineTool({
|
|
2973
|
+
name: "browser_metrics",
|
|
2974
|
+
description: "Read the tab's performance counters (Performance.getMetrics): DOM node count, JS heap usage, layout/style recalc counts, script/task durations. Use it to back \"this page is heavy\" with numbers, before and after an action. Read-only.",
|
|
2975
|
+
parameters: { tabId: {
|
|
2976
|
+
type: "number",
|
|
2977
|
+
description: "Target tab; defaults to the active tab."
|
|
2978
|
+
} },
|
|
2979
|
+
output: {
|
|
2980
|
+
schema: {
|
|
2981
|
+
type: "object",
|
|
2982
|
+
additionalProperties: false,
|
|
2983
|
+
properties: {
|
|
2984
|
+
tabId: {
|
|
2985
|
+
type: "number",
|
|
2986
|
+
required: true
|
|
2987
|
+
},
|
|
2988
|
+
metrics: {
|
|
2989
|
+
type: "object",
|
|
2990
|
+
required: true,
|
|
2991
|
+
additionalProperties: false,
|
|
2992
|
+
properties: {
|
|
2993
|
+
Timestamp: { type: "number" },
|
|
2994
|
+
Documents: { type: "number" },
|
|
2995
|
+
Frames: { type: "number" },
|
|
2996
|
+
JSEventListeners: { type: "number" },
|
|
2997
|
+
Nodes: { type: "number" },
|
|
2998
|
+
LayoutCount: { type: "number" },
|
|
2999
|
+
RecalcStyleCount: { type: "number" },
|
|
3000
|
+
LayoutDuration: { type: "number" },
|
|
3001
|
+
RecalcStyleDuration: { type: "number" },
|
|
3002
|
+
ScriptDuration: { type: "number" },
|
|
3003
|
+
TaskDuration: { type: "number" },
|
|
3004
|
+
JSHeapUsedSize: { type: "number" },
|
|
3005
|
+
JSHeapTotalSize: { type: "number" }
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
},
|
|
3010
|
+
render: (_args, value) => {
|
|
3011
|
+
const m = value.metrics ?? {};
|
|
3012
|
+
const parts = [];
|
|
3013
|
+
if (m.Nodes !== void 0) parts.push(`nodes=${m.Nodes}`);
|
|
3014
|
+
if (m.JSHeapUsedSize !== void 0) {
|
|
3015
|
+
const total = m.JSHeapTotalSize !== void 0 ? `/${(m.JSHeapTotalSize / 1048576).toFixed(1)}MB` : "";
|
|
3016
|
+
parts.push(`heap=${(m.JSHeapUsedSize / 1048576).toFixed(1)}${total}`);
|
|
3017
|
+
}
|
|
3018
|
+
if (m.LayoutCount !== void 0) parts.push(`layouts=${m.LayoutCount}`);
|
|
3019
|
+
if (m.ScriptDuration !== void 0) parts.push(`script=${Number(m.ScriptDuration).toFixed(2)}s`);
|
|
3020
|
+
if (m.TaskDuration !== void 0) parts.push(`tasks=${Number(m.TaskDuration).toFixed(2)}s`);
|
|
3021
|
+
return [{
|
|
3022
|
+
type: "text",
|
|
3023
|
+
text: `Tab ${value.tabId}: ${parts.join(" ") || JSON.stringify(value.metrics)}`
|
|
3024
|
+
}];
|
|
3025
|
+
}
|
|
3026
|
+
},
|
|
3027
|
+
presentCall: () => ({
|
|
3028
|
+
card: "generic",
|
|
3029
|
+
title: "Browser metrics",
|
|
3030
|
+
kind: "other"
|
|
3031
|
+
}),
|
|
3032
|
+
async execute(args, exec) {
|
|
3033
|
+
const params = {};
|
|
3034
|
+
if (args.tabId !== void 0) params.tabId = args.tabId;
|
|
3035
|
+
return await controller.execute("metrics", params, exec.signal);
|
|
3036
|
+
}
|
|
3037
|
+
}));
|
|
2892
3038
|
}
|
|
2893
3039
|
/** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
|
|
2894
3040
|
function apply(ctx, config) {
|
package/lib/types/errors.js
CHANGED
|
@@ -41,6 +41,9 @@ export const HOLD_ABORTED_MARKER = 'browser-hold-aborted:';
|
|
|
41
41
|
const WRITE_COMMANDS = new Set([
|
|
42
42
|
'nav', 'click', 'input', 'press', 'scroll', 'eval', 'emulate',
|
|
43
43
|
'tabs.open', 'tabs.close', 'tabs.closeMany', 'tabs.activate', 'dialog',
|
|
44
|
+
// 历史导航与 nav 同族:超时/掉线后重试可能**多跳一条**历史(第一次 back 其实
|
|
45
|
+
// 已经落了地),报 unknown 才诚实。
|
|
46
|
+
'tabs.back', 'tabs.forward',
|
|
44
47
|
// 这条不是扩展命令,而是插件本地命令 `browser_cleanup`:它逐条 rm,中途失败
|
|
45
48
|
// (Windows 上被占用的截图会 EPERM)时前面的文件已经没了,报 not_started
|
|
46
49
|
// 就是告诉模型「什么都没发生,重试安全」,而那正好是假的。
|
package/lib/types/index.js
CHANGED
|
@@ -309,6 +309,15 @@ function closeTabIds(args) {
|
|
|
309
309
|
throw new Error('close requires tabId or tabIds');
|
|
310
310
|
return ids;
|
|
311
311
|
}
|
|
312
|
+
/** Actual pixel dimensions straight from the PNG's IHDR chunk (width/height are
|
|
313
|
+
* big-endian at offsets 16/20 after the 8-byte signature + 4-byte length + 4-byte
|
|
314
|
+
* type). The receipt should say how big the picture IS — fullPage captures are
|
|
315
|
+
* often 3-5 viewports tall and the model cannot tell from the byte count. */
|
|
316
|
+
function pngDimensions(buffer) {
|
|
317
|
+
if (buffer.length < 24 || buffer.toString('latin1', 12, 16) !== 'IHDR')
|
|
318
|
+
return {};
|
|
319
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
320
|
+
}
|
|
312
321
|
/** Write one screenshot payload to the shots directory and return its durable location. */
|
|
313
322
|
async function saveScreenshot(controller, payload) {
|
|
314
323
|
const dir = controller.shotsDir;
|
|
@@ -320,12 +329,15 @@ async function saveScreenshot(controller, payload) {
|
|
|
320
329
|
const file = path.join(dir, `${stamp}-${random}.${payload.format === 'jpeg' ? 'jpg' : 'png'}`);
|
|
321
330
|
const buffer = Buffer.from(payload.base64, 'base64');
|
|
322
331
|
await writeFile(file, buffer);
|
|
332
|
+
const dims = payload.format === 'png' ? pngDimensions(buffer) : {};
|
|
323
333
|
return {
|
|
324
334
|
file,
|
|
325
335
|
bytes: buffer.length,
|
|
326
336
|
tabId: payload.tabId,
|
|
327
337
|
title: payload.tabTitle,
|
|
328
338
|
url: payload.tabUrl,
|
|
339
|
+
...dims,
|
|
340
|
+
...(payload.elementRect === undefined ? {} : { elementRect: payload.elementRect }),
|
|
329
341
|
};
|
|
330
342
|
}
|
|
331
343
|
/** Write one PDF payload to `path` (absolute or relative to `controller.shotsDir`)
|
|
@@ -881,16 +893,16 @@ function applyBrowserTools(ctx, controller) {
|
|
|
881
893
|
}));
|
|
882
894
|
ctx.tools.register(defineTool({
|
|
883
895
|
name: 'browser_tabs',
|
|
884
|
-
description: 'List tabs, or open/close/activate/group/ungroup. `close` takes one tab (`tabId`) or several (`tabIds`); the result accounts for every id, so a retry is safe.',
|
|
896
|
+
description: 'List tabs, or open/close/activate/group/ungroup, or go back/forward in the tab\'s history. `close` takes one tab (`tabId`) or several (`tabIds`); the result accounts for every id, so a retry is safe.',
|
|
885
897
|
parameters: {
|
|
886
|
-
action: { type: 'string', required: true, description: 'One of: list, open, close, activate, group, ungroup.' },
|
|
898
|
+
action: { type: 'string', required: true, description: 'One of: list, open, close, activate, group, ungroup, back, forward.' },
|
|
887
899
|
url: { type: 'string', description: 'URL for the open action.' },
|
|
888
|
-
tabId: { type: 'number', description: 'Target tab for close/activate/group.' },
|
|
900
|
+
tabId: { type: 'number', description: 'Target tab for close/activate/group/back/forward; defaults to the active tab for back/forward.' },
|
|
889
901
|
tabIds: { type: 'array', items: { type: 'number' }, description: 'For close: every tab to close in one call. The result reports each id (closed / alreadyGone / failed) instead of stopping at the first failure.' },
|
|
890
902
|
active: { type: 'boolean', description: 'Whether a newly opened tab becomes active; defaults to true.' },
|
|
891
903
|
group: { type: 'boolean', description: 'For open: put the new tab in the DSH tab group. Defaults to true; pass false to keep it loose.' },
|
|
892
|
-
waitUntil: { type: 'string', description: 'For open: when the
|
|
893
|
-
timeoutMs: { type: 'number', description: 'For open: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000).' },
|
|
904
|
+
waitUntil: { type: 'string', description: 'For open/back/forward: when the navigation counts as arrived — same tiers as browser_navigate (load default / domcontentloaded / commit / none). back/forward report unchanged:true + a reason when the tab is already at the oldest/newest history entry.' },
|
|
905
|
+
timeoutMs: { type: 'number', description: 'For open/back/forward: wait budget, overriding the waitUntil tier (load 15000, commit/domcontentloaded 4000).' },
|
|
894
906
|
windowId: { type: 'number', description: 'For ungroup: only dissolve that window’s DSH group; omit to clear every window.' },
|
|
895
907
|
},
|
|
896
908
|
output: {
|
|
@@ -934,6 +946,28 @@ function applyBrowserTools(ctx, controller) {
|
|
|
934
946
|
}
|
|
935
947
|
case 'activate':
|
|
936
948
|
return await controller.execute('tabs.activate', { tabId: requireTabId(args, 'activate') }, exec.signal);
|
|
949
|
+
case 'back':
|
|
950
|
+
case 'forward': {
|
|
951
|
+
// Same wait-shape as open: tier default load, explicit budget, bridge
|
|
952
|
+
// margin so the reply can outlive the tier without the bridge timing out.
|
|
953
|
+
// 两条各写一个字面量调用点(surface 台对账 execute 字面量,拼名字会瞎)。
|
|
954
|
+
const waitUntil = args.waitUntil === undefined ? 'load' : String(args.waitUntil);
|
|
955
|
+
if (!(waitUntil in NAV_TIERS)) {
|
|
956
|
+
throw new Error(`browser_tabs ${args.action}: waitUntil must be one of none, commit, domcontentloaded, load — got "${waitUntil}"`);
|
|
957
|
+
}
|
|
958
|
+
const params = { waitUntil };
|
|
959
|
+
if (args.tabId !== undefined)
|
|
960
|
+
params.tabId = args.tabId;
|
|
961
|
+
let budgetMs = NAV_TIERS[waitUntil];
|
|
962
|
+
if (typeof args.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)) {
|
|
963
|
+
budgetMs = Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
|
|
964
|
+
}
|
|
965
|
+
params.timeoutMs = budgetMs;
|
|
966
|
+
if (args.action === 'back') {
|
|
967
|
+
return await controller.execute('tabs.back', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
|
|
968
|
+
}
|
|
969
|
+
return await controller.execute('tabs.forward', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
|
|
970
|
+
}
|
|
937
971
|
case 'group':
|
|
938
972
|
return await controller.execute('tabs.group', { tabId: requireTabId(args, 'group') }, exec.signal);
|
|
939
973
|
case 'ungroup': {
|
|
@@ -1078,10 +1112,11 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1078
1112
|
}));
|
|
1079
1113
|
ctx.tools.register(defineTool({
|
|
1080
1114
|
name: 'browser_screenshot',
|
|
1081
|
-
description: 'Capture the tab as PNG/JPEG, save it under the configured shots directory, and return the absolute file path.',
|
|
1115
|
+
description: 'Capture the tab as PNG/JPEG, save it under the configured shots directory, and return the absolute file path plus the image\'s real pixel size. fullPage captures the whole scrollable page in one shot (sticky/fixed headers may repeat); selector captures one element.',
|
|
1082
1116
|
parameters: {
|
|
1083
1117
|
tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
|
|
1084
|
-
fullPage: { type: 'boolean', description: 'Capture
|
|
1118
|
+
fullPage: { type: 'boolean', description: 'Capture the entire scrollable page instead of just the viewport. The receipt\'s width/height show the real size.' },
|
|
1119
|
+
selector: { type: 'string', description: 'CSS selector: capture just that element (scrolled into view); the receipt carries its rect.' },
|
|
1085
1120
|
format: { type: 'string', description: '"png" (default) or "jpeg".' },
|
|
1086
1121
|
},
|
|
1087
1122
|
output: {
|
|
@@ -1094,9 +1129,24 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1094
1129
|
tabId: { type: 'number', required: true },
|
|
1095
1130
|
title: { type: 'string' },
|
|
1096
1131
|
url: { type: 'string' },
|
|
1132
|
+
width: { type: 'number' },
|
|
1133
|
+
height: { type: 'number' },
|
|
1134
|
+
elementRect: {
|
|
1135
|
+
type: 'object',
|
|
1136
|
+
additionalProperties: false,
|
|
1137
|
+
properties: {
|
|
1138
|
+
x: { type: 'number', required: true },
|
|
1139
|
+
y: { type: 'number', required: true },
|
|
1140
|
+
w: { type: 'number', required: true },
|
|
1141
|
+
h: { type: 'number', required: true },
|
|
1142
|
+
},
|
|
1143
|
+
},
|
|
1097
1144
|
},
|
|
1098
1145
|
},
|
|
1099
|
-
render: (_args, value) => [{
|
|
1146
|
+
render: (_args, value) => [{
|
|
1147
|
+
type: 'text',
|
|
1148
|
+
text: `Saved ${value.file} (${value.bytes} bytes${value.width !== undefined && value.height !== undefined ? `, ${value.width}×${value.height}` : ''}${value.elementRect !== undefined ? `, element at ${value.elementRect.x},${value.elementRect.y} size ${value.elementRect.w}×${value.elementRect.h}` : ''})`,
|
|
1149
|
+
}],
|
|
1100
1150
|
},
|
|
1101
1151
|
presentCall: () => ({ card: 'generic', title: 'Browser screenshot', kind: 'other' }),
|
|
1102
1152
|
async execute(args, exec) {
|
|
@@ -1106,6 +1156,8 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1106
1156
|
params.tabId = args.tabId;
|
|
1107
1157
|
if (args.fullPage !== undefined)
|
|
1108
1158
|
params.fullPage = args.fullPage;
|
|
1159
|
+
if (args.selector !== undefined)
|
|
1160
|
+
params.selector = args.selector;
|
|
1109
1161
|
const payload = await controller.execute('screenshot', params, exec.signal);
|
|
1110
1162
|
return saveScreenshot(controller, payload);
|
|
1111
1163
|
},
|
|
@@ -1394,10 +1446,12 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1394
1446
|
}));
|
|
1395
1447
|
ctx.tools.register(defineTool({
|
|
1396
1448
|
name: 'browser_emulate',
|
|
1397
|
-
description: 'Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing. `device:"reset"` restores the user\'s actual viewport. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.',
|
|
1449
|
+
description: 'Switch the tab into a device viewport (mobile / tablet / desktop) for responsive-UI testing, and throttle the network or CPU to test slow conditions. `device:"reset"` restores the user\'s actual viewport and clears all throttling. `network` presets (this tool\'s own numbers): slow3g = 400ms RTT + ~50KB/s both ways, fast3g = 150ms + ~200KB/s down / ~94KB/s up, offline, reset to clear. `cpuThrottlingRate` multiplies CPU cost (1 = off, up to 20). Throttling lives in the debugger session — it is gone when the extension detaches, and device:"reset" clears it too. Custom `width`/`height`/`deviceScaleFactor`/`isMobile`/`hasTouch` override any preset field.',
|
|
1398
1450
|
parameters: {
|
|
1399
1451
|
tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
|
|
1400
1452
|
device: { type: 'string', description: 'Preset: desktop | mobile-iphone-13 | mobile-pixel-7 | tablet-ipad | reset. Or pass custom width/height below.' },
|
|
1453
|
+
network: { type: 'string', description: 'slow3g | fast3g | offline | reset. The receipt reports the applied latency/throughput in bytes per second.' },
|
|
1454
|
+
cpuThrottlingRate: { type: 'number', description: 'CPU slowdown multiplier: 1 = none (default), 4 = mid-range phone, up to 20.' },
|
|
1401
1455
|
width: { type: 'number', description: 'Custom viewport width in CSS px.' },
|
|
1402
1456
|
height: { type: 'number', description: 'Custom viewport height in CSS px.' },
|
|
1403
1457
|
deviceScaleFactor: { type: 'number', description: 'Custom DPR (1 = standard, 2 = retina, 3 = super-retina).' },
|
|
@@ -1418,26 +1472,37 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1418
1472
|
isMobile: { type: 'boolean' },
|
|
1419
1473
|
hasTouch: { type: 'boolean' },
|
|
1420
1474
|
userAgent: { type: 'string' },
|
|
1475
|
+
network: { type: 'string' },
|
|
1476
|
+
latency: { type: 'number' },
|
|
1477
|
+
downloadThroughput: { type: 'number' },
|
|
1478
|
+
uploadThroughput: { type: 'number' },
|
|
1479
|
+
cpuThrottlingRate: { type: 'number' },
|
|
1421
1480
|
},
|
|
1422
1481
|
},
|
|
1423
1482
|
render: (_args, value) => {
|
|
1424
1483
|
if (value.reset)
|
|
1425
|
-
return [{ type: 'text', text: `Tab ${value.tabId}: emulation reset to default` }];
|
|
1426
|
-
// 覆盖生效的是哪个 UA
|
|
1484
|
+
return [{ type: 'text', text: `Tab ${value.tabId}: emulation reset to default (network unthrottled, CPU rate 1)` }];
|
|
1485
|
+
// 覆盖生效的是哪个 UA / 哪档节流必须写出来:模型只看这句,看不见 value 里的字段。
|
|
1427
1486
|
const ua = value.userAgent === undefined ? '' : ` UA="${flattenOneLine(value.userAgent, 80)}"`;
|
|
1487
|
+
const net = value.network === undefined ? '' : ` network=${value.network}(${value.latency}ms↓${value.downloadThroughput}B/s↑${value.uploadThroughput}B/s)`;
|
|
1488
|
+
const cpu = value.cpuThrottlingRate === undefined ? '' : ` cpu×${value.cpuThrottlingRate}`;
|
|
1428
1489
|
return [{
|
|
1429
1490
|
type: 'text',
|
|
1430
|
-
text: `Tab ${value.tabId}: ${value.width || '?'}×${value.height || '?'} DPR=${value.deviceScaleFactor ?? '?'} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}`,
|
|
1491
|
+
text: `Tab ${value.tabId}: ${value.width || '?'}×${value.height || '?'} DPR=${value.deviceScaleFactor ?? '?'} mobile=${value.isMobile ?? false} touch=${value.hasTouch ?? false}${ua}${net}${cpu}`,
|
|
1431
1492
|
}];
|
|
1432
1493
|
},
|
|
1433
1494
|
},
|
|
1434
|
-
presentCall: args => ({ card: 'generic', title: `Emulate${args.device ? ' ' + args.device : '
|
|
1495
|
+
presentCall: args => ({ card: 'generic', title: `Emulate${args.device ? ' ' + args.device : ''}${args.network ? ' ' + args.network : ''}${args.cpuThrottlingRate ? ' cpu×' + args.cpuThrottlingRate : ''}`, kind: 'other' }),
|
|
1435
1496
|
async execute(args, exec) {
|
|
1436
1497
|
const params = {};
|
|
1437
1498
|
if (args.tabId !== undefined)
|
|
1438
1499
|
params.tabId = args.tabId;
|
|
1439
1500
|
if (typeof args.device === 'string')
|
|
1440
1501
|
params.device = args.device;
|
|
1502
|
+
if (typeof args.network === 'string')
|
|
1503
|
+
params.network = args.network;
|
|
1504
|
+
if (typeof args.cpuThrottlingRate === 'number')
|
|
1505
|
+
params.cpuThrottlingRate = args.cpuThrottlingRate;
|
|
1441
1506
|
if (typeof args.width === 'number')
|
|
1442
1507
|
params.width = args.width;
|
|
1443
1508
|
if (typeof args.height === 'number')
|
|
@@ -1463,9 +1528,77 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1463
1528
|
...(typeof raw.isMobile === 'boolean' ? { isMobile: raw.isMobile } : {}),
|
|
1464
1529
|
...(typeof raw.hasTouch === 'boolean' ? { hasTouch: raw.hasTouch } : {}),
|
|
1465
1530
|
...(typeof raw.userAgent === 'string' && raw.userAgent.length > 0 ? { userAgent: raw.userAgent } : {}),
|
|
1531
|
+
...(typeof raw.network === 'string' ? { network: raw.network } : {}),
|
|
1532
|
+
...(typeof raw.latency === 'number' ? { latency: raw.latency } : {}),
|
|
1533
|
+
...(typeof raw.downloadThroughput === 'number' ? { downloadThroughput: raw.downloadThroughput } : {}),
|
|
1534
|
+
...(typeof raw.uploadThroughput === 'number' ? { uploadThroughput: raw.uploadThroughput } : {}),
|
|
1535
|
+
...(typeof raw.cpuThrottlingRate === 'number' ? { cpuThrottlingRate: raw.cpuThrottlingRate } : {}),
|
|
1466
1536
|
};
|
|
1467
1537
|
},
|
|
1468
1538
|
}));
|
|
1539
|
+
// Performance metrics: a pure read of CDP counters — whitelisted under
|
|
1540
|
+
// readOnly, no effect on the page, and the numbers ARE the content.
|
|
1541
|
+
ctx.tools.register(defineTool({
|
|
1542
|
+
name: 'browser_metrics',
|
|
1543
|
+
description: 'Read the tab\'s performance counters (Performance.getMetrics): DOM node count, JS heap usage, layout/style recalc counts, script/task durations. Use it to back "this page is heavy" with numbers, before and after an action. Read-only.',
|
|
1544
|
+
parameters: {
|
|
1545
|
+
tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
|
|
1546
|
+
},
|
|
1547
|
+
output: {
|
|
1548
|
+
schema: {
|
|
1549
|
+
type: 'object',
|
|
1550
|
+
additionalProperties: false,
|
|
1551
|
+
properties: {
|
|
1552
|
+
tabId: { type: 'number', required: true },
|
|
1553
|
+
metrics: {
|
|
1554
|
+
type: 'object',
|
|
1555
|
+
required: true,
|
|
1556
|
+
additionalProperties: false,
|
|
1557
|
+
properties: {
|
|
1558
|
+
Timestamp: { type: 'number' },
|
|
1559
|
+
Documents: { type: 'number' },
|
|
1560
|
+
Frames: { type: 'number' },
|
|
1561
|
+
JSEventListeners: { type: 'number' },
|
|
1562
|
+
Nodes: { type: 'number' },
|
|
1563
|
+
LayoutCount: { type: 'number' },
|
|
1564
|
+
RecalcStyleCount: { type: 'number' },
|
|
1565
|
+
LayoutDuration: { type: 'number' },
|
|
1566
|
+
RecalcStyleDuration: { type: 'number' },
|
|
1567
|
+
ScriptDuration: { type: 'number' },
|
|
1568
|
+
TaskDuration: { type: 'number' },
|
|
1569
|
+
JSHeapUsedSize: { type: 'number' },
|
|
1570
|
+
JSHeapTotalSize: { type: 'number' },
|
|
1571
|
+
},
|
|
1572
|
+
},
|
|
1573
|
+
},
|
|
1574
|
+
},
|
|
1575
|
+
render: (_args, value) => {
|
|
1576
|
+
const m = (value.metrics ?? {});
|
|
1577
|
+
// 数字本身就是产出:关键几项原样进模型可见文本,heap 顺手换算成 MB。
|
|
1578
|
+
const parts = [];
|
|
1579
|
+
if (m.Nodes !== undefined)
|
|
1580
|
+
parts.push(`nodes=${m.Nodes}`);
|
|
1581
|
+
if (m.JSHeapUsedSize !== undefined) {
|
|
1582
|
+
const total = m.JSHeapTotalSize !== undefined ? `/${(m.JSHeapTotalSize / 1_048_576).toFixed(1)}MB` : '';
|
|
1583
|
+
parts.push(`heap=${(m.JSHeapUsedSize / 1_048_576).toFixed(1)}${total}`);
|
|
1584
|
+
}
|
|
1585
|
+
if (m.LayoutCount !== undefined)
|
|
1586
|
+
parts.push(`layouts=${m.LayoutCount}`);
|
|
1587
|
+
if (m.ScriptDuration !== undefined)
|
|
1588
|
+
parts.push(`script=${Number(m.ScriptDuration).toFixed(2)}s`);
|
|
1589
|
+
if (m.TaskDuration !== undefined)
|
|
1590
|
+
parts.push(`tasks=${Number(m.TaskDuration).toFixed(2)}s`);
|
|
1591
|
+
return [{ type: 'text', text: `Tab ${value.tabId}: ${parts.join(' ') || JSON.stringify(value.metrics)}` }];
|
|
1592
|
+
},
|
|
1593
|
+
},
|
|
1594
|
+
presentCall: () => ({ card: 'generic', title: 'Browser metrics', kind: 'other' }),
|
|
1595
|
+
async execute(args, exec) {
|
|
1596
|
+
const params = {};
|
|
1597
|
+
if (args.tabId !== undefined)
|
|
1598
|
+
params.tabId = args.tabId;
|
|
1599
|
+
return await controller.execute('metrics', params, exec.signal);
|
|
1600
|
+
},
|
|
1601
|
+
}));
|
|
1469
1602
|
}
|
|
1470
1603
|
/** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
|
|
1471
1604
|
export function apply(ctx, config) {
|
package/lib/types/policy.js
CHANGED
|
@@ -28,6 +28,9 @@ export const READ_ONLY_ALLOWED = new Set([
|
|
|
28
28
|
'windows.list',
|
|
29
29
|
// 读浏览器自己的状态(cookie 是凭据,但 get 不改任何东西;set/remove 不在此列)
|
|
30
30
|
'cookies.get',
|
|
31
|
+
// 读性能计数器(Performance.getMetrics):纯粹读,重试无损害;节流(emulate
|
|
32
|
+
// 的 network/cpu 参数)改页面加载行为,emulate 整体仍是写命令,不在此列。
|
|
33
|
+
'metrics',
|
|
31
34
|
]);
|
|
32
35
|
/**
|
|
33
36
|
* 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
|
package/llms.txt
CHANGED
|
@@ -35,7 +35,7 @@ Chrome 浏览器扩展 + DeepSeek Harness 插件,让 AI Agent 通过 CDP 驱
|
|
|
35
35
|
|
|
36
36
|
## 关键文件
|
|
37
37
|
|
|
38
|
-
- `src/index.ts` -
|
|
38
|
+
- `src/index.ts` - 25 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
|
|
39
39
|
- `src/policy.ts` - 三张互不相同的表:只读白名单、脚本命令、插件本地破坏性命令(`browser_cleanup` 不经过 `execute()`,必须自己站闸门)
|
|
40
40
|
- `src/server.ts` - WebSocket 服务 + HTTP 面(鉴权、来源校验、状态页)
|
|
41
41
|
- `src/ws.ts` - RFC 6455 帧编解码
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@angelky7/dsh-browser-control",
|
|
3
3
|
"description": "Local WebSocket bridge between dsh and the DSH Browser Control browser extension, with model-facing browser_* tools and a Settings-managed on/off switch",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.7.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"build": "tsc -p tsconfig.json && tsdown",
|
|
38
38
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
39
39
|
"check": "npm run typecheck && npm run build && publint",
|
|
40
|
-
"verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_cookies.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs && node verify/verify_nav_timeout.mjs && node verify/verify_nav_waituntil.mjs && node verify/verify_archive.mjs && node verify/verify_windows.mjs"
|
|
40
|
+
"verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_cookies.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs && node verify/verify_nav_timeout.mjs && node verify/verify_nav_waituntil.mjs && node verify/verify_archive.mjs && node verify/verify_windows.mjs && node verify/verify_history_nav.mjs && node verify/verify_metrics_throttle.mjs"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public",
|