dsh-browser-view 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +134 -2
- package/cordis.patch.yml +31 -0
- package/lib/client.js +2 -0
- package/lib/index.js +2949 -0
- package/lib/types/browser/recordings.d.ts +31 -0
- package/lib/types/browser/register.d.ts +4 -0
- package/lib/types/browser/session.d.ts +103 -0
- package/lib/types/browser/tools.d.ts +339 -0
- package/lib/types/browser/types.d.ts +55 -0
- package/lib/types/browser/ui.d.ts +4 -0
- package/lib/types/client/index.d.ts +26 -0
- package/lib/types/embed.d.ts +7 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/lib/config.d.ts +17 -0
- package/lib/types/lib/http.d.ts +12 -0
- package/lib/types/lib/tools.d.ts +26 -0
- package/lib/types/lib/types.d.ts +59 -0
- package/package.json +76 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,2949 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
//#region lib/types/lib/config.js
|
|
5
|
+
function typeOf(value) {
|
|
6
|
+
if (value === null) return "null";
|
|
7
|
+
if (Array.isArray(value)) return "array";
|
|
8
|
+
return typeof value;
|
|
9
|
+
}
|
|
10
|
+
function checkField(key, value, schema, errors) {
|
|
11
|
+
if (value === void 0 || value === null) {
|
|
12
|
+
if (!schema.optional) errors.push(`config.${key}: 必填但缺失`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const t = typeOf(value);
|
|
16
|
+
if (schema.type === "any") return;
|
|
17
|
+
if (schema.type === "string[]") {
|
|
18
|
+
if (t !== "array" || !value.every((v) => typeof v === "string")) errors.push(`config.${key}: 期望 string[] 实际 ${t}`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (t !== schema.type) {
|
|
22
|
+
errors.push(`config.${key}: 期望 ${schema.type} 实际 ${t}`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (schema.type === "number") {
|
|
26
|
+
const n = value;
|
|
27
|
+
if (schema.max !== void 0 && n > schema.max) errors.push(`config.${key}: 超过上限 ${schema.max}`);
|
|
28
|
+
if (schema.min !== void 0 && n < schema.min) errors.push(`config.${key}: 低于下限 ${schema.min}`);
|
|
29
|
+
}
|
|
30
|
+
if (schema.type === "string" && schema.enum !== void 0 && !schema.enum.includes(value)) errors.push(`config.${key}: 必须是 ${schema.enum.join(" / ")} 之一`);
|
|
31
|
+
}
|
|
32
|
+
/** 校验配置;返回错误列表(空 = 通过)。 */
|
|
33
|
+
function validateConfig(schema, config) {
|
|
34
|
+
const errors = [];
|
|
35
|
+
for (const [key, field] of Object.entries(schema)) checkField(key, config[key], field, errors);
|
|
36
|
+
return errors;
|
|
37
|
+
}
|
|
38
|
+
/** 合并默认值 + 校验;抛错时带模块名前缀。 */
|
|
39
|
+
function resolveConfig(moduleName, schema, defaults, raw) {
|
|
40
|
+
const config = {
|
|
41
|
+
...defaults,
|
|
42
|
+
...raw ?? {}
|
|
43
|
+
};
|
|
44
|
+
for (const [key, field] of Object.entries(schema)) if (field.default !== void 0 && config[key] === void 0) config[key] = field.default;
|
|
45
|
+
const errors = validateConfig(schema, config);
|
|
46
|
+
if (errors.length > 0) throw new Error(`${moduleName}: 配置无效 — ${errors.join("; ")}`);
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region lib/types/lib/http.js
|
|
51
|
+
/** Parse the request URL pathname (query strings are ignored). */
|
|
52
|
+
function urlPath(req) {
|
|
53
|
+
try {
|
|
54
|
+
return new URL(req.url ?? "/", "http://x").pathname;
|
|
55
|
+
} catch {
|
|
56
|
+
return "/";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Read the request body as a UTF-8 string (bounded). */
|
|
60
|
+
function readRawBody(req, limitBytes = 1e6) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const chunks = [];
|
|
63
|
+
let size = 0;
|
|
64
|
+
req.on("data", (chunk) => {
|
|
65
|
+
size += chunk.length;
|
|
66
|
+
if (size > limitBytes) {
|
|
67
|
+
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
68
|
+
req.destroy();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
});
|
|
73
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
74
|
+
req.on("error", reject);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Read and parse a JSON request body; `undefined` when the body is empty. */
|
|
78
|
+
async function readJsonBody(req, limitBytes) {
|
|
79
|
+
const raw = await readRawBody(req, limitBytes);
|
|
80
|
+
if (!raw) return void 0;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(raw);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw new Error(`invalid JSON body: ${error instanceof Error ? error.message : String(error)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Send a JSON response. */
|
|
88
|
+
function sendJson(res, status, value, extraHeaders = {}) {
|
|
89
|
+
const body = JSON.stringify(value);
|
|
90
|
+
res.writeHead(status, {
|
|
91
|
+
"content-type": "application/json; charset=utf-8",
|
|
92
|
+
"content-length": Buffer.byteLength(body),
|
|
93
|
+
...extraHeaders
|
|
94
|
+
});
|
|
95
|
+
res.end(body);
|
|
96
|
+
}
|
|
97
|
+
/** Send a plain text response. */
|
|
98
|
+
function sendText(res, status, text, headers = {}) {
|
|
99
|
+
res.writeHead(status, {
|
|
100
|
+
"content-type": "text/plain; charset=utf-8",
|
|
101
|
+
"content-length": Buffer.byteLength(text),
|
|
102
|
+
...headers
|
|
103
|
+
});
|
|
104
|
+
res.end(text);
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region lib/types/lib/tools.js
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the workspace working directory for one tool execution.
|
|
110
|
+
* Prefers the calling agent's session cwd; falls back to the host process cwd.
|
|
111
|
+
*/
|
|
112
|
+
function workspaceCwd(exec) {
|
|
113
|
+
try {
|
|
114
|
+
const cwd = exec?.agent?.session?.meta?.cwd;
|
|
115
|
+
if (typeof cwd === "string" && cwd.length > 0) return cwd;
|
|
116
|
+
} catch {}
|
|
117
|
+
return process.cwd();
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Build a plain ToolDefinition for `ctx.tools.register` without depending on
|
|
121
|
+
* any @deepseek-ai package (max version-alignment tolerance).
|
|
122
|
+
*/
|
|
123
|
+
function definePlainTool(options) {
|
|
124
|
+
const render = options.render ?? ((_args, value) => JSON.stringify(value, null, 2));
|
|
125
|
+
const definition = {
|
|
126
|
+
name: options.name,
|
|
127
|
+
description: options.description,
|
|
128
|
+
parameters: options.parameters,
|
|
129
|
+
output: {
|
|
130
|
+
schema: options.outputSchema ?? { type: "object" },
|
|
131
|
+
render: (args, value) => [{
|
|
132
|
+
type: "text",
|
|
133
|
+
text: render(args, value)
|
|
134
|
+
}]
|
|
135
|
+
},
|
|
136
|
+
execute: options.execute
|
|
137
|
+
};
|
|
138
|
+
if (options.presentCall !== void 0) definition.presentCall = options.presentCall;
|
|
139
|
+
if (options.concurrencySafe) definition.isConcurrencySafe = () => true;
|
|
140
|
+
if (options.timeoutMs !== void 0) definition.timeoutMs = options.timeoutMs;
|
|
141
|
+
return definition;
|
|
142
|
+
}
|
|
143
|
+
/** Generic card view used by all dsh-browser tools. */
|
|
144
|
+
function genericCard(kind, title, rawInput) {
|
|
145
|
+
return {
|
|
146
|
+
card: "generic",
|
|
147
|
+
kind,
|
|
148
|
+
title,
|
|
149
|
+
rawInput
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region lib/types/browser/session.js
|
|
154
|
+
/** 当前活动 profile 名(默认 "default")。 */
|
|
155
|
+
let currentProfile = "default";
|
|
156
|
+
/** 多配置文件浏览器会话:name → 会话状态。 */
|
|
157
|
+
const profileStates = /* @__PURE__ */ new Map();
|
|
158
|
+
/** 最近下载记录 [{ download, suggestedFilename, at }](按 profile 名隔离)。 */
|
|
159
|
+
const downloadsByProfile = /* @__PURE__ */ new Map();
|
|
160
|
+
/** 页面访问历史(按 profile 名隔离)。不随 closeBrowser 清空,DSH 重启才重置。 */
|
|
161
|
+
const historyByProfile = /* @__PURE__ */ new Map();
|
|
162
|
+
/** 已保存的表单回放:name → fields 数组。 */
|
|
163
|
+
const savedForms = /* @__PURE__ */ new Map();
|
|
164
|
+
/** 最近一次 browser_form 填充的字段(供 browser_form_save 无参保存)。 */
|
|
165
|
+
let lastFormFields = [];
|
|
166
|
+
/** 已保存的录制脚本:name → 步骤数组。 */
|
|
167
|
+
const savedRecordings = /* @__PURE__ */ new Map();
|
|
168
|
+
/** 当前录制中的 buffer(非空 = 录制中)。 */
|
|
169
|
+
let recordingSteps = null;
|
|
170
|
+
/** 操作轨迹(全局,记录 currentProfile;上限 500 条)。 */
|
|
171
|
+
const testLog = [];
|
|
172
|
+
function isRecording() {
|
|
173
|
+
return recordingSteps !== null;
|
|
174
|
+
}
|
|
175
|
+
function startRecording() {
|
|
176
|
+
recordingSteps = [];
|
|
177
|
+
}
|
|
178
|
+
function stopRecording() {
|
|
179
|
+
const steps = recordingSteps ?? [];
|
|
180
|
+
recordingSteps = null;
|
|
181
|
+
return steps;
|
|
182
|
+
}
|
|
183
|
+
/** 操作轨迹追加(同时写入录制 buffer,排除 record/replay 自身)。 */
|
|
184
|
+
function recordCall(call) {
|
|
185
|
+
testLog.push(call);
|
|
186
|
+
if (testLog.length > 500) testLog.shift();
|
|
187
|
+
if (recordingSteps !== null && call.tool !== "browser_record" && call.tool !== "browser_replay") {
|
|
188
|
+
recordingSteps.push(call);
|
|
189
|
+
if (recordingSteps.length > 500) recordingSteps.shift();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** 操作轨迹(按时间倒序,最新在前)。 */
|
|
193
|
+
function testLogOf() {
|
|
194
|
+
return [...testLog].reverse();
|
|
195
|
+
}
|
|
196
|
+
const networkByProfile = /* @__PURE__ */ new Map();
|
|
197
|
+
function networkOf(name) {
|
|
198
|
+
let list = networkByProfile.get(name);
|
|
199
|
+
if (list === void 0) {
|
|
200
|
+
list = [];
|
|
201
|
+
networkByProfile.set(name, list);
|
|
202
|
+
}
|
|
203
|
+
return list;
|
|
204
|
+
}
|
|
205
|
+
function networkEntries(name) {
|
|
206
|
+
return networkOf(name);
|
|
207
|
+
}
|
|
208
|
+
function addNetworkEntry(profile, entry) {
|
|
209
|
+
const list = networkOf(profile);
|
|
210
|
+
list.push(entry);
|
|
211
|
+
if (list.length > 500) list.shift();
|
|
212
|
+
}
|
|
213
|
+
function clearNetwork(profile) {
|
|
214
|
+
networkByProfile.delete(profile);
|
|
215
|
+
}
|
|
216
|
+
function profileConfig(config, name) {
|
|
217
|
+
const named = config.profiles?.[name];
|
|
218
|
+
if (!named || typeof named !== "object") return { ...config };
|
|
219
|
+
return {
|
|
220
|
+
...config,
|
|
221
|
+
...named,
|
|
222
|
+
profiles: config.profiles
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function getProfileState(name) {
|
|
226
|
+
let state = profileStates.get(name);
|
|
227
|
+
if (state === void 0) {
|
|
228
|
+
state = {
|
|
229
|
+
browser: null,
|
|
230
|
+
context: null,
|
|
231
|
+
persistent: false,
|
|
232
|
+
pages: /* @__PURE__ */ new Map(),
|
|
233
|
+
activeId: null,
|
|
234
|
+
counter: 0
|
|
235
|
+
};
|
|
236
|
+
profileStates.set(name, state);
|
|
237
|
+
}
|
|
238
|
+
return state;
|
|
239
|
+
}
|
|
240
|
+
function downloadsOf(name) {
|
|
241
|
+
let list = downloadsByProfile.get(name);
|
|
242
|
+
if (list === void 0) {
|
|
243
|
+
list = [];
|
|
244
|
+
downloadsByProfile.set(name, list);
|
|
245
|
+
}
|
|
246
|
+
return list;
|
|
247
|
+
}
|
|
248
|
+
/** 某 profile 的访问历史(按时间先后)。 */
|
|
249
|
+
function historyOf(name) {
|
|
250
|
+
let list = historyByProfile.get(name);
|
|
251
|
+
if (list === void 0) {
|
|
252
|
+
list = [];
|
|
253
|
+
historyByProfile.set(name, list);
|
|
254
|
+
}
|
|
255
|
+
return list;
|
|
256
|
+
}
|
|
257
|
+
/** 把一次主 frame 导航记入该 profile 的访问历史(去重 + 上限 50 条)。 */
|
|
258
|
+
function recordNav(profile, url) {
|
|
259
|
+
const list = historyOf(profile);
|
|
260
|
+
const last = list[list.length - 1];
|
|
261
|
+
if (last !== void 0 && last.url === url) {
|
|
262
|
+
last.ts = Date.now();
|
|
263
|
+
return last;
|
|
264
|
+
}
|
|
265
|
+
const entry = {
|
|
266
|
+
url,
|
|
267
|
+
title: "",
|
|
268
|
+
ts: Date.now()
|
|
269
|
+
};
|
|
270
|
+
list.push(entry);
|
|
271
|
+
if (list.length > 50) list.shift();
|
|
272
|
+
return entry;
|
|
273
|
+
}
|
|
274
|
+
function attachPage(page, config) {
|
|
275
|
+
const timeoutMs = config.timeoutMs ?? 3e4;
|
|
276
|
+
page.setDefaultTimeout(timeoutMs);
|
|
277
|
+
page.on("download", (download) => {
|
|
278
|
+
const list = downloadsOf(currentProfile);
|
|
279
|
+
list.push({
|
|
280
|
+
download,
|
|
281
|
+
suggestedFilename: download.suggestedFilename(),
|
|
282
|
+
at: Date.now()
|
|
283
|
+
});
|
|
284
|
+
if (list.length > 20) list.shift();
|
|
285
|
+
});
|
|
286
|
+
const profile = currentProfile;
|
|
287
|
+
page.on("framenavigated", (frame) => {
|
|
288
|
+
if (frame !== page.mainFrame()) return;
|
|
289
|
+
const url = frame.url();
|
|
290
|
+
if (!url || url === "about:blank") return;
|
|
291
|
+
recordNav(profile, url);
|
|
292
|
+
});
|
|
293
|
+
page.on("load", () => {
|
|
294
|
+
const list = historyOf(profile);
|
|
295
|
+
const entry = list[list.length - 1];
|
|
296
|
+
if (entry === void 0) return;
|
|
297
|
+
page.title().then((t) => {
|
|
298
|
+
if (t) entry.title = t;
|
|
299
|
+
}).catch(() => {});
|
|
300
|
+
});
|
|
301
|
+
const recordBodies = config.recordBodies !== false;
|
|
302
|
+
const pendingReqs = /* @__PURE__ */ new WeakMap();
|
|
303
|
+
page.on("request", (req) => {
|
|
304
|
+
if (req.resourceType() !== "xhr" && req.resourceType() !== "fetch") return;
|
|
305
|
+
let postData = null;
|
|
306
|
+
if (recordBodies) try {
|
|
307
|
+
const raw = req.postData();
|
|
308
|
+
if (raw) postData = raw.length > 2e3 ? `${raw.slice(0, 2e3)}…(截断)` : raw;
|
|
309
|
+
} catch {}
|
|
310
|
+
pendingReqs.set(req, {
|
|
311
|
+
url: req.url(),
|
|
312
|
+
method: req.method(),
|
|
313
|
+
startedAt: Date.now(),
|
|
314
|
+
postData
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
page.on("response", async (res) => {
|
|
318
|
+
const req = res.request();
|
|
319
|
+
if (req.resourceType() !== "xhr" && req.resourceType() !== "fetch") return;
|
|
320
|
+
const info = pendingReqs.get(req);
|
|
321
|
+
if (info === void 0) return;
|
|
322
|
+
let body = null;
|
|
323
|
+
if (recordBodies) try {
|
|
324
|
+
const headers = res.headers();
|
|
325
|
+
const contentType = (headers["content-type"] ?? "").toLowerCase();
|
|
326
|
+
const contentLength = Number(headers["content-length"] ?? 0);
|
|
327
|
+
if (!contentType.includes("text/event-stream") && !(contentLength > 5e5)) {
|
|
328
|
+
const text = await res.text();
|
|
329
|
+
if (text) body = text.length > 4e3 ? `${text.slice(0, 4e3)}…(截断)` : text;
|
|
330
|
+
}
|
|
331
|
+
} catch {}
|
|
332
|
+
addNetworkEntry(profile, {
|
|
333
|
+
url: res.url(),
|
|
334
|
+
method: info.method,
|
|
335
|
+
resourceType: req.resourceType(),
|
|
336
|
+
status: res.status(),
|
|
337
|
+
ok: res.ok(),
|
|
338
|
+
startedAt: info.startedAt,
|
|
339
|
+
durationMs: Date.now() - info.startedAt,
|
|
340
|
+
postData: info.postData,
|
|
341
|
+
body
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
page.on("requestfailed", (req) => {
|
|
345
|
+
if (req.resourceType() !== "xhr" && req.resourceType() !== "fetch") return;
|
|
346
|
+
const info = pendingReqs.get(req);
|
|
347
|
+
if (info === void 0) return;
|
|
348
|
+
addNetworkEntry(profile, {
|
|
349
|
+
url: req.url(),
|
|
350
|
+
method: info.method,
|
|
351
|
+
resourceType: req.resourceType(),
|
|
352
|
+
failed: true,
|
|
353
|
+
error: req.failure()?.errorText ?? "failed",
|
|
354
|
+
startedAt: info.startedAt,
|
|
355
|
+
durationMs: Date.now() - info.startedAt
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
async function loadPlaywright() {
|
|
360
|
+
try {
|
|
361
|
+
return await import("playwright-core");
|
|
362
|
+
} catch {
|
|
363
|
+
throw new Error("dsh-browser: playwright-core is not installed. Run `dsh plugin --profile web add playwright-core` or install it in the profile.");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async function launchProfile(name, config) {
|
|
367
|
+
const pw = await loadPlaywright();
|
|
368
|
+
const state = getProfileState(name);
|
|
369
|
+
const resolved = profileConfig(config, name);
|
|
370
|
+
const base = { headless: resolved.headless ?? false };
|
|
371
|
+
const candidates = [];
|
|
372
|
+
if (resolved.executablePath) candidates.push({
|
|
373
|
+
...base,
|
|
374
|
+
executablePath: resolved.executablePath
|
|
375
|
+
});
|
|
376
|
+
else if (resolved.channel && resolved.channel !== "auto") candidates.push({
|
|
377
|
+
...base,
|
|
378
|
+
channel: resolved.channel
|
|
379
|
+
});
|
|
380
|
+
else {
|
|
381
|
+
const order = process.platform === "win32" ? [
|
|
382
|
+
"msedge",
|
|
383
|
+
"chrome",
|
|
384
|
+
"chromium"
|
|
385
|
+
] : [
|
|
386
|
+
"chrome",
|
|
387
|
+
"msedge",
|
|
388
|
+
"chromium"
|
|
389
|
+
];
|
|
390
|
+
for (const channel of order) candidates.push({
|
|
391
|
+
...base,
|
|
392
|
+
channel
|
|
393
|
+
});
|
|
394
|
+
candidates.push(base);
|
|
395
|
+
}
|
|
396
|
+
let lastError;
|
|
397
|
+
for (const options of candidates) try {
|
|
398
|
+
if (resolved.userDataDir) {
|
|
399
|
+
const context = await pw.chromium.launchPersistentContext(resolved.userDataDir, {
|
|
400
|
+
...options,
|
|
401
|
+
headless: resolved.headless ?? false
|
|
402
|
+
});
|
|
403
|
+
for (const existing of context.pages()) existing.close().catch(() => {});
|
|
404
|
+
state.browser = null;
|
|
405
|
+
state.context = context;
|
|
406
|
+
state.persistent = true;
|
|
407
|
+
state.channel = options.channel ?? options.executablePath ?? "bundled";
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
state.browser = await pw.chromium.launch(options);
|
|
411
|
+
state.context = null;
|
|
412
|
+
state.persistent = false;
|
|
413
|
+
state.channel = options.channel ?? options.executablePath ?? "bundled";
|
|
414
|
+
return;
|
|
415
|
+
} catch (error) {
|
|
416
|
+
lastError = error;
|
|
417
|
+
}
|
|
418
|
+
throw new Error(`dsh-browser: could not launch a Chromium-based browser. Install one, set config executablePath, or run \`npx playwright install chromium\`. Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
419
|
+
}
|
|
420
|
+
/** 当前 profile 状态(未启动时创建空状态)。 */
|
|
421
|
+
function stateOf() {
|
|
422
|
+
return getProfileState(currentProfile);
|
|
423
|
+
}
|
|
424
|
+
/** 新建一个页面(当前 profile 会话未启动时先启动)。 */
|
|
425
|
+
async function newPage(config) {
|
|
426
|
+
const state = stateOf();
|
|
427
|
+
const resolved = profileConfig(config, currentProfile);
|
|
428
|
+
if (state.browser === null && state.context === null) await launchProfile(currentProfile, config);
|
|
429
|
+
const context = state.context;
|
|
430
|
+
const browser = state.browser;
|
|
431
|
+
if (context !== null) {
|
|
432
|
+
const page = await context.newPage();
|
|
433
|
+
attachPage(page, resolved);
|
|
434
|
+
return page;
|
|
435
|
+
}
|
|
436
|
+
if (browser !== null) {
|
|
437
|
+
const page = await browser.newPage();
|
|
438
|
+
attachPage(page, resolved);
|
|
439
|
+
return page;
|
|
440
|
+
}
|
|
441
|
+
throw new Error("dsh-browser: browser session failed to start");
|
|
442
|
+
}
|
|
443
|
+
/** 返回当前活动页面(没有则新建),并保证浏览器已启动。 */
|
|
444
|
+
async function getPage(config) {
|
|
445
|
+
const state = stateOf();
|
|
446
|
+
if (state.browser === null && state.context === null) await launchProfile(currentProfile, config);
|
|
447
|
+
if (state.pages.size === 0) {
|
|
448
|
+
const page = await newPage(config);
|
|
449
|
+
const id = state.counter++;
|
|
450
|
+
state.pages.set(id, page);
|
|
451
|
+
state.activeId = id;
|
|
452
|
+
}
|
|
453
|
+
const page = state.pages.get(state.activeId ?? -1);
|
|
454
|
+
if (page === void 0) throw new Error("dsh-browser: no active page");
|
|
455
|
+
return page;
|
|
456
|
+
}
|
|
457
|
+
/** 活动页面(可能为 null,不触发启动)。 */
|
|
458
|
+
function activePage() {
|
|
459
|
+
const state = stateOf();
|
|
460
|
+
return state.pages.get(state.activeId ?? -1) ?? null;
|
|
461
|
+
}
|
|
462
|
+
/** 投影当前 profile 的标签列表。 */
|
|
463
|
+
async function tabList() {
|
|
464
|
+
const state = stateOf();
|
|
465
|
+
const tabs = [];
|
|
466
|
+
for (const [id, page] of state.pages) {
|
|
467
|
+
let title = "";
|
|
468
|
+
try {
|
|
469
|
+
title = await page.title();
|
|
470
|
+
} catch {}
|
|
471
|
+
tabs.push({
|
|
472
|
+
id,
|
|
473
|
+
url: page.url(),
|
|
474
|
+
title
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
return tabs;
|
|
478
|
+
}
|
|
479
|
+
async function closeBrowser() {
|
|
480
|
+
for (const [name, state] of profileStates) {
|
|
481
|
+
const { browser, context } = state;
|
|
482
|
+
state.browser = null;
|
|
483
|
+
state.context = null;
|
|
484
|
+
state.pages = /* @__PURE__ */ new Map();
|
|
485
|
+
state.activeId = null;
|
|
486
|
+
state.counter = 0;
|
|
487
|
+
try {
|
|
488
|
+
if (context !== null) await context.close();
|
|
489
|
+
else if (browser !== null) await browser.close();
|
|
490
|
+
} catch {}
|
|
491
|
+
}
|
|
492
|
+
profileStates.clear();
|
|
493
|
+
downloadsByProfile.clear();
|
|
494
|
+
}
|
|
495
|
+
/** Best-effort current page identity, safe when nothing is open. */
|
|
496
|
+
async function pageIdentity(page) {
|
|
497
|
+
try {
|
|
498
|
+
return {
|
|
499
|
+
url: page.url(),
|
|
500
|
+
title: await page.title()
|
|
501
|
+
};
|
|
502
|
+
} catch {
|
|
503
|
+
return {
|
|
504
|
+
url: "",
|
|
505
|
+
title: ""
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** 设置最近一次表单填充(供 browser_form_save 无参保存)。 */
|
|
510
|
+
function setLastFormFields(fields) {
|
|
511
|
+
lastFormFields = fields;
|
|
512
|
+
}
|
|
513
|
+
/** 切换当前 profile。 */
|
|
514
|
+
function setCurrentProfile(name) {
|
|
515
|
+
currentProfile = name;
|
|
516
|
+
}
|
|
517
|
+
/** 最近一次 browser_screenshot 实际写入的目录(供定时清扫复用,避免相对路径解析漂移)。 */
|
|
518
|
+
let lastScreenshotDir = null;
|
|
519
|
+
/** 记录最近一次截图目录(截图工具调用时写入)。 */
|
|
520
|
+
function noteScreenshotDir(dir) {
|
|
521
|
+
lastScreenshotDir = dir;
|
|
522
|
+
}
|
|
523
|
+
/** 定时清扫入口:若已知截图目录则按当前配置清理。 */
|
|
524
|
+
function sweepScreenshotDir(opts) {
|
|
525
|
+
if (lastScreenshotDir === null) return 0;
|
|
526
|
+
return cleanupScreenshots(lastScreenshotDir, opts);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* 清理截图目录里的 .png 文件:按修改时间保留最新的 maxCount 个,
|
|
530
|
+
* 并删除超过 maxAgeDays 天的旧文件。只处理目录直属文件,不递归;
|
|
531
|
+
* 单个文件删除失败(被占用等)静默跳过。返回删除数量。
|
|
532
|
+
*/
|
|
533
|
+
function cleanupScreenshots(dir, opts) {
|
|
534
|
+
const maxAgeDays = Number(opts.maxAgeDays ?? 0) || 0;
|
|
535
|
+
const maxCount = Math.max(0, Number(opts.maxCount ?? 0) || 0);
|
|
536
|
+
if (maxAgeDays <= 0 && maxCount <= 0) return 0;
|
|
537
|
+
let entries;
|
|
538
|
+
try {
|
|
539
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
540
|
+
} catch {
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
const cutoff = maxAgeDays > 0 ? Date.now() - maxAgeDays * 864e5 : 0;
|
|
544
|
+
const files = [];
|
|
545
|
+
for (const entry of entries) {
|
|
546
|
+
if (!entry.isFile()) continue;
|
|
547
|
+
if (!/\.png$/i.test(entry.name)) continue;
|
|
548
|
+
let mtimeMs = 0;
|
|
549
|
+
try {
|
|
550
|
+
mtimeMs = statSync(join(dir, entry.name)).mtimeMs;
|
|
551
|
+
} catch {
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
files.push({
|
|
555
|
+
name: entry.name,
|
|
556
|
+
mtimeMs
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
560
|
+
let removed = 0;
|
|
561
|
+
for (let i = 0; i < files.length; i++) {
|
|
562
|
+
const file = files[i];
|
|
563
|
+
if (!(cutoff > 0 && file.mtimeMs < cutoff) && !(maxCount > 0 && i >= maxCount)) continue;
|
|
564
|
+
try {
|
|
565
|
+
rmSync(join(dir, file.name), { force: true });
|
|
566
|
+
removed++;
|
|
567
|
+
} catch {}
|
|
568
|
+
}
|
|
569
|
+
return removed;
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region lib/types/browser/recordings.js
|
|
573
|
+
/** 录制存储目录($DSH_HOME/.dsh-browser/recordings)。 */
|
|
574
|
+
function recordingsDir() {
|
|
575
|
+
const home = process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
576
|
+
return join(home, ".dsh-browser", "recordings");
|
|
577
|
+
}
|
|
578
|
+
/** 名字安全化:只保留字母数字 . _ -,连续点压成下划线防路径穿越。 */
|
|
579
|
+
function safeName(name) {
|
|
580
|
+
return name.replace(/\.\./g, "_").replace(/[^A-Za-z0-9._-]/g, "_");
|
|
581
|
+
}
|
|
582
|
+
/** 加载目录下所有 .json 录制进内存表;返回加载数量。坏文件静默跳过。 */
|
|
583
|
+
function loadRecordings() {
|
|
584
|
+
const dir = recordingsDir();
|
|
585
|
+
let loaded = 0;
|
|
586
|
+
try {
|
|
587
|
+
for (const file of readdirSync(dir)) {
|
|
588
|
+
if (!file.endsWith(".json")) continue;
|
|
589
|
+
try {
|
|
590
|
+
const data = JSON.parse(readFileSync(join(dir, file), "utf8"));
|
|
591
|
+
if (data !== null && typeof data === "object" && Array.isArray(data.steps)) {
|
|
592
|
+
savedRecordings.set(file.slice(0, -5), {
|
|
593
|
+
steps: data.steps,
|
|
594
|
+
savedAt: typeof data.savedAt === "number" ? data.savedAt : Date.now()
|
|
595
|
+
});
|
|
596
|
+
loaded++;
|
|
597
|
+
}
|
|
598
|
+
} catch {}
|
|
599
|
+
}
|
|
600
|
+
} catch {}
|
|
601
|
+
return loaded;
|
|
602
|
+
}
|
|
603
|
+
/** 写一个录制到磁盘(原子替换);返回文件路径。 */
|
|
604
|
+
function saveRecordingFile(name, steps) {
|
|
605
|
+
const dir = recordingsDir();
|
|
606
|
+
mkdirSync(dir, { recursive: true });
|
|
607
|
+
const file = join(dir, `${safeName(name)}.json`);
|
|
608
|
+
const tmp = `${file}.tmp`;
|
|
609
|
+
writeFileSync(tmp, JSON.stringify({
|
|
610
|
+
steps,
|
|
611
|
+
savedAt: Date.now()
|
|
612
|
+
}, null, 2));
|
|
613
|
+
renameSync(tmp, file);
|
|
614
|
+
return file;
|
|
615
|
+
}
|
|
616
|
+
/** 删除磁盘上的录制文件;返回是否存在并删除。 */
|
|
617
|
+
function deleteRecordingFile(name) {
|
|
618
|
+
const file = join(recordingsDir(), `${safeName(name)}.json`);
|
|
619
|
+
try {
|
|
620
|
+
rmSync(file, { force: true });
|
|
621
|
+
return true;
|
|
622
|
+
} catch {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/** 录制列表(面板 / browser_record list 共用)。 */
|
|
627
|
+
function listRecordings() {
|
|
628
|
+
return [...savedRecordings.entries()].map(([name, r]) => ({
|
|
629
|
+
name,
|
|
630
|
+
steps: r.steps.length,
|
|
631
|
+
savedAt: r.savedAt,
|
|
632
|
+
preview: r.steps.slice(0, 3).map((s) => s.tool).join(", ")
|
|
633
|
+
}));
|
|
634
|
+
}
|
|
635
|
+
/** 录制详情(面板展开查看步骤)。 */
|
|
636
|
+
function recordingDetail(name) {
|
|
637
|
+
const r = savedRecordings.get(name);
|
|
638
|
+
if (r === void 0) return void 0;
|
|
639
|
+
return {
|
|
640
|
+
name,
|
|
641
|
+
steps: r.steps
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/** 删除录制(内存 + 磁盘同步);返回是否存在并删除。 */
|
|
645
|
+
function deleteRecording(name) {
|
|
646
|
+
const existed = savedRecordings.delete(name);
|
|
647
|
+
if (existed) deleteRecordingFile(name);
|
|
648
|
+
return existed;
|
|
649
|
+
}
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region lib/types/browser/tools.js
|
|
652
|
+
async function openTool(config, args) {
|
|
653
|
+
const page = await getPage(config);
|
|
654
|
+
await page.goto(String(args.url), {
|
|
655
|
+
waitUntil: args.waitUntil ?? "domcontentloaded",
|
|
656
|
+
timeout: args.timeoutMs ?? config.timeoutMs
|
|
657
|
+
});
|
|
658
|
+
return {
|
|
659
|
+
url: page.url(),
|
|
660
|
+
title: await page.title(),
|
|
661
|
+
status: "ok"
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
async function snapshotTool(config, args) {
|
|
665
|
+
const page = await getPage(config);
|
|
666
|
+
const maxText = args.maxTextChars ?? config.maxTextChars;
|
|
667
|
+
const maxLinks = args.maxLinks ?? config.maxLinks;
|
|
668
|
+
const data = await page.evaluate(([maxT, maxL]) => {
|
|
669
|
+
const text = document.body ? document.body.innerText : "";
|
|
670
|
+
const links = Array.from(document.querySelectorAll("a")).slice(0, maxL).map((a) => ({
|
|
671
|
+
text: (a.innerText || a.textContent || "").trim().slice(0, 200),
|
|
672
|
+
href: a.href
|
|
673
|
+
}));
|
|
674
|
+
const inputs = document.querySelectorAll("input, textarea, select").length;
|
|
675
|
+
return {
|
|
676
|
+
text: text.slice(0, maxT),
|
|
677
|
+
truncated: text.length > maxT,
|
|
678
|
+
links,
|
|
679
|
+
inputs
|
|
680
|
+
};
|
|
681
|
+
}, [maxText, maxLinks]);
|
|
682
|
+
return {
|
|
683
|
+
url: page.url(),
|
|
684
|
+
title: await page.title(),
|
|
685
|
+
...data
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
async function clickTool(config, args) {
|
|
689
|
+
const page = await getPage(config);
|
|
690
|
+
const selector = String(args.selector);
|
|
691
|
+
await page.click(selector, { timeout: args.timeoutMs ?? config.timeoutMs });
|
|
692
|
+
await page.waitForLoadState("domcontentloaded").catch(() => {});
|
|
693
|
+
return {
|
|
694
|
+
clicked: selector,
|
|
695
|
+
...await pageIdentity(page)
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
async function typeTool(config, args) {
|
|
699
|
+
const page = await getPage(config);
|
|
700
|
+
const selector = String(args.selector);
|
|
701
|
+
const text = String(args.text ?? "");
|
|
702
|
+
if (args.clear === true) await page.fill(selector, text, { timeout: args.timeoutMs ?? config.timeoutMs });
|
|
703
|
+
else {
|
|
704
|
+
await page.click(selector, { timeout: args.timeoutMs ?? config.timeoutMs });
|
|
705
|
+
await page.keyboard.type(text, { delay: args.delayMs ?? 0 });
|
|
706
|
+
}
|
|
707
|
+
if (args.submit === true) await page.keyboard.press("Enter");
|
|
708
|
+
return {
|
|
709
|
+
typed: true,
|
|
710
|
+
selector,
|
|
711
|
+
submit: args.submit === true,
|
|
712
|
+
...await pageIdentity(page)
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
async function pressTool(config, args) {
|
|
716
|
+
const page = await getPage(config);
|
|
717
|
+
const key = String(args.key);
|
|
718
|
+
if (args.selector) await page.press(String(args.selector), key, { timeout: args.timeoutMs ?? config.timeoutMs });
|
|
719
|
+
else await page.keyboard.press(key);
|
|
720
|
+
return {
|
|
721
|
+
pressed: key,
|
|
722
|
+
...await pageIdentity(page)
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
async function evalTool(config, args) {
|
|
726
|
+
const page = await getPage(config);
|
|
727
|
+
const script = String(args.script ?? "");
|
|
728
|
+
const wrapped = /[\n;]|^\s*return\b/.test(script) ? `(async () => {\n${script}\n})()` : `(${script})`;
|
|
729
|
+
const raw = await page.evaluate(wrapped);
|
|
730
|
+
let result;
|
|
731
|
+
try {
|
|
732
|
+
result = JSON.parse(JSON.stringify(raw ?? null));
|
|
733
|
+
} catch {
|
|
734
|
+
result = raw === void 0 ? null : String(raw);
|
|
735
|
+
}
|
|
736
|
+
return { result };
|
|
737
|
+
}
|
|
738
|
+
async function screenshotTool(config, args, exec) {
|
|
739
|
+
const page = await getPage(config);
|
|
740
|
+
const cwd = workspaceCwd(exec);
|
|
741
|
+
const screenshotDir = config.screenshotDir ?? ".dsh-browser/screenshots";
|
|
742
|
+
const dir = isAbsolute(screenshotDir) ? screenshotDir : resolve(cwd, screenshotDir);
|
|
743
|
+
mkdirSync(dir, { recursive: true });
|
|
744
|
+
const safeName = String(args.name ?? `shot-${Date.now()}`).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
745
|
+
const fileName = safeName.endsWith(".png") ? safeName : `${safeName}.png`;
|
|
746
|
+
const filePath = join(dir, fileName);
|
|
747
|
+
const buffer = await page.screenshot({ type: "png" });
|
|
748
|
+
writeFileSync(filePath, buffer);
|
|
749
|
+
noteScreenshotDir(dir);
|
|
750
|
+
cleanupScreenshots(dir, {
|
|
751
|
+
maxAgeDays: config.screenshotMaxAgeDays,
|
|
752
|
+
maxCount: config.screenshotMaxCount
|
|
753
|
+
});
|
|
754
|
+
const viewport = page.viewportSize() ?? {
|
|
755
|
+
width: 0,
|
|
756
|
+
height: 0
|
|
757
|
+
};
|
|
758
|
+
return {
|
|
759
|
+
path: filePath,
|
|
760
|
+
bytes: buffer.length,
|
|
761
|
+
width: viewport.width,
|
|
762
|
+
height: viewport.height
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
async function waitTool(config, args) {
|
|
766
|
+
const page = await getPage(config);
|
|
767
|
+
const ms = Math.max(0, Math.min(Number(args.ms ?? 1e3) || 0, 6e4));
|
|
768
|
+
await page.waitForTimeout(ms);
|
|
769
|
+
return {
|
|
770
|
+
waitedMs: ms,
|
|
771
|
+
...await pageIdentity(page)
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
async function backTool(config) {
|
|
775
|
+
const page = await getPage(config);
|
|
776
|
+
await page.goBack().catch(() => {});
|
|
777
|
+
return { ...await pageIdentity(page) };
|
|
778
|
+
}
|
|
779
|
+
async function reloadTool(config) {
|
|
780
|
+
const page = await getPage(config);
|
|
781
|
+
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
|
|
782
|
+
return { ...await pageIdentity(page) };
|
|
783
|
+
}
|
|
784
|
+
async function statusTool() {
|
|
785
|
+
const page = activePage();
|
|
786
|
+
const state = stateOf();
|
|
787
|
+
const profiles = [];
|
|
788
|
+
for (const [name, s] of profileStates) profiles.push({
|
|
789
|
+
name,
|
|
790
|
+
open: s.browser !== null || s.context !== null,
|
|
791
|
+
tabs: s.pages.size,
|
|
792
|
+
channel: s.channel ?? "",
|
|
793
|
+
persistent: s.persistent
|
|
794
|
+
});
|
|
795
|
+
if (page === null) return {
|
|
796
|
+
open: false,
|
|
797
|
+
profile: currentProfile,
|
|
798
|
+
channel: state.channel ?? "",
|
|
799
|
+
tabs: 0,
|
|
800
|
+
profiles
|
|
801
|
+
};
|
|
802
|
+
return {
|
|
803
|
+
open: true,
|
|
804
|
+
profile: currentProfile,
|
|
805
|
+
channel: state.channel ?? "",
|
|
806
|
+
tabs: state.pages.size,
|
|
807
|
+
profiles,
|
|
808
|
+
...await pageIdentity(page)
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
async function tabsTool(config, args) {
|
|
812
|
+
const action = args.action ?? "list";
|
|
813
|
+
const state = stateOf();
|
|
814
|
+
if (state.browser === null && state.context === null && action !== "list") await launchProfile(currentProfile, config);
|
|
815
|
+
if (state.browser === null && state.context === null) return {
|
|
816
|
+
action,
|
|
817
|
+
tabs: [],
|
|
818
|
+
activeId: -1
|
|
819
|
+
};
|
|
820
|
+
switch (action) {
|
|
821
|
+
case "list": return {
|
|
822
|
+
action,
|
|
823
|
+
tabs: await tabList(),
|
|
824
|
+
activeId: state.activeId ?? -1
|
|
825
|
+
};
|
|
826
|
+
case "new": {
|
|
827
|
+
const page = await newPage(config);
|
|
828
|
+
const id = state.counter++;
|
|
829
|
+
state.pages.set(id, page);
|
|
830
|
+
state.activeId = id;
|
|
831
|
+
if (args.url) await page.goto(String(args.url), {
|
|
832
|
+
waitUntil: args.waitUntil ?? "domcontentloaded",
|
|
833
|
+
timeout: args.timeoutMs ?? config.timeoutMs
|
|
834
|
+
});
|
|
835
|
+
return {
|
|
836
|
+
action,
|
|
837
|
+
tab: {
|
|
838
|
+
id,
|
|
839
|
+
url: page.url(),
|
|
840
|
+
title: await page.title()
|
|
841
|
+
},
|
|
842
|
+
tabs: await tabList(),
|
|
843
|
+
activeId: state.activeId
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
case "switch": {
|
|
847
|
+
const id = resolveTabId(args);
|
|
848
|
+
if (!state.pages.has(id)) throw new Error(`no tab with id ${id}`);
|
|
849
|
+
state.activeId = id;
|
|
850
|
+
return {
|
|
851
|
+
action,
|
|
852
|
+
activeId: state.activeId,
|
|
853
|
+
tabs: await tabList()
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
case "close": {
|
|
857
|
+
const id = resolveTabId(args);
|
|
858
|
+
const page = state.pages.get(id);
|
|
859
|
+
if (page === void 0) throw new Error(`no tab with id ${id}`);
|
|
860
|
+
await page.close().catch(() => {});
|
|
861
|
+
state.pages.delete(id);
|
|
862
|
+
if (state.activeId === id) {
|
|
863
|
+
const next = state.pages.keys().next().value;
|
|
864
|
+
state.activeId = next === void 0 ? null : next;
|
|
865
|
+
}
|
|
866
|
+
return {
|
|
867
|
+
action,
|
|
868
|
+
activeId: state.activeId ?? -1,
|
|
869
|
+
tabs: await tabList()
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
default: throw new Error(`unknown tabs action: ${action}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
function resolveTabId(args) {
|
|
876
|
+
const state = stateOf();
|
|
877
|
+
if (args.id !== void 0) return Number(args.id);
|
|
878
|
+
if (args.index !== void 0) {
|
|
879
|
+
const ids = [...state.pages.keys()];
|
|
880
|
+
const idx = Number(args.index);
|
|
881
|
+
if (idx < 0 || idx >= ids.length) throw new Error(`tab index ${idx} out of range`);
|
|
882
|
+
return ids[idx];
|
|
883
|
+
}
|
|
884
|
+
if (state.activeId !== null) return state.activeId;
|
|
885
|
+
throw new Error("no tab id/index given and no active tab");
|
|
886
|
+
}
|
|
887
|
+
async function downloadTool(config, args, exec) {
|
|
888
|
+
const recentDownloads = downloadsOf(currentProfile);
|
|
889
|
+
if (recentDownloads.length === 0) throw new Error("no recent downloads. Trigger a download in the page first (e.g. browser_click on a download link).");
|
|
890
|
+
const index = args.index !== void 0 ? Number(args.index) : recentDownloads.length - 1;
|
|
891
|
+
const entry = recentDownloads[index];
|
|
892
|
+
if (entry === void 0) throw new Error(`no download at index ${index}`);
|
|
893
|
+
const cwd = workspaceCwd(exec);
|
|
894
|
+
const downloadDir = config.downloadDir ?? ".dsh-browser/downloads";
|
|
895
|
+
const dir = isAbsolute(downloadDir) ? downloadDir : resolve(cwd, downloadDir);
|
|
896
|
+
mkdirSync(dir, { recursive: true });
|
|
897
|
+
const safe = String(entry.suggestedFilename || `download-${Date.now()}`).replace(/[\\/:*?"<>|]/g, "_");
|
|
898
|
+
const filePath = join(dir, safe);
|
|
899
|
+
if (!filePath.startsWith(resolve(dir))) throw new Error(`download path escapes directory: ${filePath}`);
|
|
900
|
+
await entry.download.saveAs(filePath);
|
|
901
|
+
return {
|
|
902
|
+
path: filePath,
|
|
903
|
+
filename: safe,
|
|
904
|
+
url: entry.download.url() ?? "",
|
|
905
|
+
bytes: statSync(filePath).size
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
async function uploadTool(config, args, exec) {
|
|
909
|
+
const page = await getPage(config);
|
|
910
|
+
const cwd = workspaceCwd(exec);
|
|
911
|
+
const filePath = isAbsolute(args.path) ? String(args.path) : resolve(cwd, String(args.path));
|
|
912
|
+
let stat;
|
|
913
|
+
try {
|
|
914
|
+
stat = statSync(filePath);
|
|
915
|
+
} catch {
|
|
916
|
+
throw new Error(`file not found: ${filePath}`);
|
|
917
|
+
}
|
|
918
|
+
if (!stat.isFile()) throw new Error(`not a file: ${filePath}`);
|
|
919
|
+
const resolvedPath = resolve(filePath);
|
|
920
|
+
await page.setInputFiles(String(args.selector), resolvedPath, { timeout: args.timeoutMs ?? config.timeoutMs });
|
|
921
|
+
return {
|
|
922
|
+
uploaded: filePath,
|
|
923
|
+
bytes: stat.size,
|
|
924
|
+
...await pageIdentity(page)
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
async function cookiesTool(config, args) {
|
|
928
|
+
const page = await getPage(config);
|
|
929
|
+
const context = page.context();
|
|
930
|
+
const action = args.action ?? "list";
|
|
931
|
+
if (action === "list") return { cookies: (await context.cookies(args.url ?? page.url())).map((c) => ({
|
|
932
|
+
name: c.name,
|
|
933
|
+
value: args.showValues === true ? c.value : "(hidden — set showValues=true to reveal)",
|
|
934
|
+
domain: c.domain,
|
|
935
|
+
path: c.path,
|
|
936
|
+
httpOnly: c.httpOnly,
|
|
937
|
+
secure: c.secure,
|
|
938
|
+
sameSite: c.sameSite,
|
|
939
|
+
expires: c.expires
|
|
940
|
+
})) };
|
|
941
|
+
if (action === "set") {
|
|
942
|
+
if (!args.name || args.value === void 0) throw new Error("set requires name and value");
|
|
943
|
+
await context.addCookies([{
|
|
944
|
+
name: String(args.name),
|
|
945
|
+
value: String(args.value),
|
|
946
|
+
url: args.url ?? page.url()
|
|
947
|
+
}]);
|
|
948
|
+
return {
|
|
949
|
+
set: true,
|
|
950
|
+
name: String(args.name),
|
|
951
|
+
url: args.url ?? page.url()
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
if (action === "clear") {
|
|
955
|
+
await context.clearCookies();
|
|
956
|
+
return { cleared: true };
|
|
957
|
+
}
|
|
958
|
+
throw new Error(`unknown cookies action: ${action}`);
|
|
959
|
+
}
|
|
960
|
+
async function formTool(config, args) {
|
|
961
|
+
const page = await getPage(config);
|
|
962
|
+
let fields = args.fields ?? [];
|
|
963
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
964
|
+
if (typeof args.from === "string" && savedForms.has(args.from)) fields = savedForms.get(args.from);
|
|
965
|
+
else throw new Error("fields must be a non-empty array (or pass from=<saved form name>)");
|
|
966
|
+
}
|
|
967
|
+
const filled = [];
|
|
968
|
+
for (const field of fields) {
|
|
969
|
+
const value = String(field.value ?? "");
|
|
970
|
+
if (field.selector) {
|
|
971
|
+
await page.fill(String(field.selector), value, { timeout: args.timeoutMs ?? config.timeoutMs });
|
|
972
|
+
filled.push({ selector: field.selector });
|
|
973
|
+
} else if (field.label) {
|
|
974
|
+
await page.getByLabel(String(field.label), { exact: true }).fill(value);
|
|
975
|
+
filled.push({ label: field.label });
|
|
976
|
+
} else throw new Error("each field needs a selector or a label");
|
|
977
|
+
}
|
|
978
|
+
setLastFormFields(fields.map((f) => ({ ...f })));
|
|
979
|
+
if (args.submit === true) await page.keyboard.press("Enter");
|
|
980
|
+
return {
|
|
981
|
+
filled,
|
|
982
|
+
submit: args.submit === true,
|
|
983
|
+
...await pageIdentity(page)
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
async function formSaveTool(args) {
|
|
987
|
+
let fields = args.fields;
|
|
988
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
989
|
+
if (lastFormFields.length === 0) throw new Error("no fields given and no previous browser_form to remember");
|
|
990
|
+
fields = lastFormFields;
|
|
991
|
+
}
|
|
992
|
+
const name = String(args.name ?? "");
|
|
993
|
+
if (!name) throw new Error("name is required");
|
|
994
|
+
savedForms.set(name, fields.map((f) => ({ ...f })));
|
|
995
|
+
return {
|
|
996
|
+
saved: name,
|
|
997
|
+
fields: fields.length
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
async function formsTool(args) {
|
|
1001
|
+
const action = args.action ?? "list";
|
|
1002
|
+
if (action === "list") return { forms: [...savedForms.entries()].map(([name, fields]) => ({
|
|
1003
|
+
name,
|
|
1004
|
+
fields: fields.length,
|
|
1005
|
+
preview: fields.slice(0, 3).map((f) => f.selector ?? f.label ?? "?").join(", ")
|
|
1006
|
+
})) };
|
|
1007
|
+
if (action === "delete") {
|
|
1008
|
+
if (!args.name) throw new Error("name is required");
|
|
1009
|
+
return {
|
|
1010
|
+
deleted: savedForms.delete(String(args.name)),
|
|
1011
|
+
name: String(args.name)
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
throw new Error(`unknown forms action: ${action}`);
|
|
1015
|
+
}
|
|
1016
|
+
async function profileTool(config, args) {
|
|
1017
|
+
const action = args.action ?? "list";
|
|
1018
|
+
const available = Object.keys(config.profiles ?? {});
|
|
1019
|
+
const describe = (name) => {
|
|
1020
|
+
const state = getProfileState(name);
|
|
1021
|
+
return {
|
|
1022
|
+
name,
|
|
1023
|
+
open: state.browser !== null || state.context !== null,
|
|
1024
|
+
tabs: state.pages.size,
|
|
1025
|
+
persistent: state.persistent,
|
|
1026
|
+
userDataDir: profileConfig(config, name).userDataDir ?? ""
|
|
1027
|
+
};
|
|
1028
|
+
};
|
|
1029
|
+
if (action === "list") return {
|
|
1030
|
+
current: currentProfile,
|
|
1031
|
+
profiles: available.map(describe)
|
|
1032
|
+
};
|
|
1033
|
+
if (action === "use") {
|
|
1034
|
+
const name = String(args.name ?? "");
|
|
1035
|
+
if (name !== "default" && !available.includes(name)) throw new Error(`unknown profile: ${name} (available: ${["default", ...available].join(", ")})`);
|
|
1036
|
+
setCurrentProfile(name);
|
|
1037
|
+
return {
|
|
1038
|
+
current: currentProfile,
|
|
1039
|
+
profiles: available.map(describe)
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
throw new Error(`unknown profile action: ${action}`);
|
|
1043
|
+
}
|
|
1044
|
+
async function elementsTool(config, args) {
|
|
1045
|
+
const page = await getPage(config);
|
|
1046
|
+
const max = Math.min(Math.max(Number(args.max ?? 60) || 60, 1), 200);
|
|
1047
|
+
const elements = await page.evaluate((maxN) => {
|
|
1048
|
+
const out = [];
|
|
1049
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1050
|
+
const nodes = Array.from(document.querySelectorAll("input, textarea, select, button, a[href]"));
|
|
1051
|
+
for (const el of nodes) {
|
|
1052
|
+
if (out.length >= maxN) break;
|
|
1053
|
+
const rect = el.getBoundingClientRect();
|
|
1054
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
1055
|
+
const info = {
|
|
1056
|
+
tag: el.tagName.toLowerCase(),
|
|
1057
|
+
type: el.type ?? "",
|
|
1058
|
+
name: el.name ?? "",
|
|
1059
|
+
id: el.id ?? "",
|
|
1060
|
+
placeholder: el.placeholder ?? "",
|
|
1061
|
+
ariaLabel: el.getAttribute("aria-label") ?? "",
|
|
1062
|
+
text: (el.innerText || el.textContent || "").trim().slice(0, 80),
|
|
1063
|
+
href: el.href ?? "",
|
|
1064
|
+
selector: el.id !== "" ? `#${CSS.escape(el.id)}` : el.name !== "" ? `${el.tagName.toLowerCase()}[name="${el.name}"]` : ""
|
|
1065
|
+
};
|
|
1066
|
+
const key = `${info.tag}|${info.name}|${info.id}|${info.placeholder}|${info.text}`;
|
|
1067
|
+
if (seen.has(key)) continue;
|
|
1068
|
+
seen.add(key);
|
|
1069
|
+
out.push(info);
|
|
1070
|
+
}
|
|
1071
|
+
return out;
|
|
1072
|
+
}, max);
|
|
1073
|
+
return {
|
|
1074
|
+
count: elements.length,
|
|
1075
|
+
elements
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
async function closeTool() {
|
|
1079
|
+
await closeBrowser();
|
|
1080
|
+
return { closed: true };
|
|
1081
|
+
}
|
|
1082
|
+
/** 单次检查:返回 { ok, detail }。不抛错,失败时 detail 给出原因。 */
|
|
1083
|
+
async function checkOnce(page, cond) {
|
|
1084
|
+
if (cond.selector !== void 0) {
|
|
1085
|
+
const locator = page.locator(String(cond.selector));
|
|
1086
|
+
const state = String(cond.state ?? "visible");
|
|
1087
|
+
try {
|
|
1088
|
+
await locator.waitFor({
|
|
1089
|
+
state,
|
|
1090
|
+
timeout: 120
|
|
1091
|
+
});
|
|
1092
|
+
return { ok: true };
|
|
1093
|
+
} catch {
|
|
1094
|
+
return {
|
|
1095
|
+
ok: false,
|
|
1096
|
+
detail: `元素 ${cond.selector} 未达到 ${state}`
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (cond.url !== void 0) {
|
|
1101
|
+
const url = page.url();
|
|
1102
|
+
try {
|
|
1103
|
+
return new RegExp(String(cond.url)).test(url) ? { ok: true } : {
|
|
1104
|
+
ok: false,
|
|
1105
|
+
detail: `URL 不匹配 ${cond.url}(当前 ${url})`
|
|
1106
|
+
};
|
|
1107
|
+
} catch {
|
|
1108
|
+
return {
|
|
1109
|
+
ok: false,
|
|
1110
|
+
detail: `非法正则: ${cond.url}`
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (cond.text !== void 0) {
|
|
1115
|
+
const needle = String(cond.text);
|
|
1116
|
+
try {
|
|
1117
|
+
return await page.evaluate((s) => (document.body ? document.body.innerText : "").includes(s), needle) ? { ok: true } : {
|
|
1118
|
+
ok: false,
|
|
1119
|
+
detail: `页面文本不含 "${needle}"`
|
|
1120
|
+
};
|
|
1121
|
+
} catch (e) {
|
|
1122
|
+
return {
|
|
1123
|
+
ok: false,
|
|
1124
|
+
detail: `文本检查失败: ${e instanceof Error ? e.message : String(e)}`
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (cond.count !== void 0 && typeof cond.count === "object") {
|
|
1129
|
+
const c = cond.count;
|
|
1130
|
+
try {
|
|
1131
|
+
const actual = await page.locator(String(c.selector)).count();
|
|
1132
|
+
const want = Number(c.value);
|
|
1133
|
+
const op = c.op ?? "eq";
|
|
1134
|
+
return (op === "eq" ? actual === want : op === "gt" ? actual > want : op === "gte" ? actual >= want : op === "lt" ? actual < want : op === "lte" ? actual <= want : false) ? { ok: true } : {
|
|
1135
|
+
ok: false,
|
|
1136
|
+
detail: `${c.selector} 数量 ${actual} 不满足 ${op} ${want}`
|
|
1137
|
+
};
|
|
1138
|
+
} catch (e) {
|
|
1139
|
+
return {
|
|
1140
|
+
ok: false,
|
|
1141
|
+
detail: `数量检查失败: ${e instanceof Error ? e.message : String(e)}`
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
if (cond.eval !== void 0) try {
|
|
1146
|
+
return await page.evaluate(String(cond.eval)) ? { ok: true } : {
|
|
1147
|
+
ok: false,
|
|
1148
|
+
detail: `eval 条件为假: ${cond.eval}`
|
|
1149
|
+
};
|
|
1150
|
+
} catch (e) {
|
|
1151
|
+
return {
|
|
1152
|
+
ok: false,
|
|
1153
|
+
detail: `eval 执行失败: ${e instanceof Error ? e.message : String(e)}`
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
throw new Error("条件无效: 需要 selector/url/text/count/eval 之一");
|
|
1157
|
+
}
|
|
1158
|
+
/** 轮询等待条件成立(intervalMs 步进),返回最后一次检查结果。 */
|
|
1159
|
+
async function pollCondition(page, cond, timeoutMs, intervalMs = 500) {
|
|
1160
|
+
const deadline = Date.now() + timeoutMs;
|
|
1161
|
+
let last = {
|
|
1162
|
+
ok: false,
|
|
1163
|
+
detail: "超时"
|
|
1164
|
+
};
|
|
1165
|
+
while (Date.now() < deadline) {
|
|
1166
|
+
try {
|
|
1167
|
+
last = await checkOnce(page, cond);
|
|
1168
|
+
if (last.ok) return last;
|
|
1169
|
+
} catch (e) {
|
|
1170
|
+
last = {
|
|
1171
|
+
ok: false,
|
|
1172
|
+
detail: e instanceof Error ? e.message : String(e)
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
await page.waitForTimeout(intervalMs);
|
|
1176
|
+
}
|
|
1177
|
+
return last;
|
|
1178
|
+
}
|
|
1179
|
+
/** 通用显式等待:等待条件成立,超时抛错。 */
|
|
1180
|
+
async function waitForTool(config, args) {
|
|
1181
|
+
const page = await getPage(config);
|
|
1182
|
+
const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 1e4) || 1e4, 1e3), 12e4);
|
|
1183
|
+
const intervalMs = Math.max(Number(args.intervalMs ?? 500) || 500, 100);
|
|
1184
|
+
const result = await pollCondition(page, args.condition, timeoutMs, intervalMs);
|
|
1185
|
+
if (!result.ok) throw new Error(`等待条件超时(${timeoutMs}ms): ${result.detail ?? "条件未满足"}`);
|
|
1186
|
+
return {
|
|
1187
|
+
met: true,
|
|
1188
|
+
...await pageIdentity(page)
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* 等待人工登录完成(配合 headless:false 使用):agent 打开登录页后调用本工具,
|
|
1193
|
+
* 提示用户在浏览器窗口中完成登录(输入/扫码/双因素)。基于通用条件引擎:
|
|
1194
|
+
* successSelector / successUrl 指定完成条件,两者都不给则以 URL 跳转视为完成。
|
|
1195
|
+
* 超时返回 loggedIn:false(不抛错)。
|
|
1196
|
+
*/
|
|
1197
|
+
async function waitForLoginTool(config, args) {
|
|
1198
|
+
const page = await getPage(config);
|
|
1199
|
+
const startUrl = page.url();
|
|
1200
|
+
let condition;
|
|
1201
|
+
if (args.successSelector) condition = {
|
|
1202
|
+
selector: String(args.successSelector),
|
|
1203
|
+
state: "visible"
|
|
1204
|
+
};
|
|
1205
|
+
else if (args.successUrl) condition = { url: String(args.successUrl) };
|
|
1206
|
+
else condition = { eval: `location.href !== ${JSON.stringify(startUrl)} && location.href !== "about:blank"` };
|
|
1207
|
+
const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 12e4) || 12e4, 5e3), 6e5);
|
|
1208
|
+
return {
|
|
1209
|
+
loggedIn: (await pollCondition(page, condition, timeoutMs, 2e3)).ok,
|
|
1210
|
+
...await pageIdentity(page)
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
/** 断言:条件在 timeoutMs 内成立则通过,否则失败并自动截图存证据。 */
|
|
1214
|
+
async function assertTool(config, args, exec) {
|
|
1215
|
+
const page = await getPage(config);
|
|
1216
|
+
const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 5e3) || 5e3, 100), 6e4);
|
|
1217
|
+
const intervalMs = Math.max(Number(args.intervalMs ?? 300) || 300, 50);
|
|
1218
|
+
const result = await pollCondition(page, args.condition, timeoutMs, intervalMs);
|
|
1219
|
+
if (result.ok) return {
|
|
1220
|
+
passed: true,
|
|
1221
|
+
...await pageIdentity(page)
|
|
1222
|
+
};
|
|
1223
|
+
const cwd = workspaceCwd(exec);
|
|
1224
|
+
const screenshotDir = config.screenshotDir ?? ".dsh-browser/screenshots";
|
|
1225
|
+
const dir = isAbsolute(screenshotDir) ? screenshotDir : resolve(cwd, screenshotDir);
|
|
1226
|
+
mkdirSync(dir, { recursive: true });
|
|
1227
|
+
const filePath = join(dir, `assert-fail-${Date.now()}.png`);
|
|
1228
|
+
try {
|
|
1229
|
+
const buffer = await page.screenshot({ type: "png" });
|
|
1230
|
+
writeFileSync(filePath, buffer);
|
|
1231
|
+
noteScreenshotDir(dir);
|
|
1232
|
+
cleanupScreenshots(dir, {
|
|
1233
|
+
maxAgeDays: config.screenshotMaxAgeDays,
|
|
1234
|
+
maxCount: config.screenshotMaxCount
|
|
1235
|
+
});
|
|
1236
|
+
} catch {}
|
|
1237
|
+
throw new Error(`断言失败: ${result.detail ?? "条件未满足"}; 失败截图: ${filePath}`);
|
|
1238
|
+
}
|
|
1239
|
+
function matchNetwork(entry, f) {
|
|
1240
|
+
if (f.urlSubstr && !entry.url.includes(String(f.urlSubstr))) return false;
|
|
1241
|
+
if (f.method && entry.method.toUpperCase() !== String(f.method).toUpperCase()) return false;
|
|
1242
|
+
if (f.status !== void 0 && Number(f.status) !== entry.status) return false;
|
|
1243
|
+
if (f.failedOnly === true && !entry.failed && (entry.status === void 0 || entry.status < 400)) return false;
|
|
1244
|
+
return true;
|
|
1245
|
+
}
|
|
1246
|
+
/** 网络记录查询:list / failed / wait / clear。 */
|
|
1247
|
+
async function networkTool(args) {
|
|
1248
|
+
const action = args.action ?? "list";
|
|
1249
|
+
const entries = networkEntries(currentProfile);
|
|
1250
|
+
if (action === "clear") {
|
|
1251
|
+
clearNetwork(currentProfile);
|
|
1252
|
+
return {
|
|
1253
|
+
cleared: true,
|
|
1254
|
+
entries: 0
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
if (action === "wait") {
|
|
1258
|
+
if (!args.url) throw new Error("wait 需要 url(正则字符串)");
|
|
1259
|
+
const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 1e4) || 1e4, 1e3), 6e4);
|
|
1260
|
+
const deadline = Date.now() + timeoutMs;
|
|
1261
|
+
while (Date.now() < deadline) {
|
|
1262
|
+
const hit = [...entries].reverse().find((e) => {
|
|
1263
|
+
if (!new RegExp(String(args.url)).test(e.url)) return false;
|
|
1264
|
+
if (args.status !== void 0 && Number(args.status) !== e.status) return false;
|
|
1265
|
+
return true;
|
|
1266
|
+
});
|
|
1267
|
+
if (hit !== void 0) return {
|
|
1268
|
+
matched: hit,
|
|
1269
|
+
waitMs: Date.now() + timeoutMs - deadline - (Date.now() + timeoutMs - deadline) + 0
|
|
1270
|
+
};
|
|
1271
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1272
|
+
}
|
|
1273
|
+
throw new Error(`等待接口超时(${timeoutMs}ms): ${args.url}`);
|
|
1274
|
+
}
|
|
1275
|
+
const filter = action === "failed" ? { failedOnly: true } : args;
|
|
1276
|
+
const list = [...entries].reverse().filter((e) => matchNetwork(e, filter)).slice(0, Math.min(Math.max(Number(args.limit ?? 50) || 50, 1), 200));
|
|
1277
|
+
if (action === "failed") return { failed: list };
|
|
1278
|
+
if (action === "list") return { entries: list };
|
|
1279
|
+
throw new Error(`unknown network action: ${action}`);
|
|
1280
|
+
}
|
|
1281
|
+
/** 录制管理:start / stop / save / list / delete。 */
|
|
1282
|
+
async function recordTool(args) {
|
|
1283
|
+
const action = args.action ?? "start";
|
|
1284
|
+
if (action === "start") {
|
|
1285
|
+
startRecording();
|
|
1286
|
+
return {
|
|
1287
|
+
recording: true,
|
|
1288
|
+
hint: "后续浏览器工具调用将记录;用 browser_record stop 结束,再 save <name> 保存"
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
if (action === "stop") {
|
|
1292
|
+
if (!isRecording()) throw new Error("当前没有录制");
|
|
1293
|
+
return {
|
|
1294
|
+
recording: false,
|
|
1295
|
+
steps: stopRecording().length
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
if (action === "save") {
|
|
1299
|
+
if (isRecording()) throw new Error("录制未结束:先 browser_record stop");
|
|
1300
|
+
const name = String(args.name ?? "");
|
|
1301
|
+
if (!name) throw new Error("save 需要 name");
|
|
1302
|
+
const steps = testLogOf().reverse().slice(-100).map((c) => ({
|
|
1303
|
+
tool: c.tool,
|
|
1304
|
+
args: c.args ?? {}
|
|
1305
|
+
}));
|
|
1306
|
+
savedRecordings.set(name, {
|
|
1307
|
+
steps,
|
|
1308
|
+
savedAt: Date.now()
|
|
1309
|
+
});
|
|
1310
|
+
const file = saveRecordingFile(name, steps);
|
|
1311
|
+
return {
|
|
1312
|
+
saved: name,
|
|
1313
|
+
steps: steps.length,
|
|
1314
|
+
file,
|
|
1315
|
+
hint: "已持久化到磁盘,重启 DSH 后仍可回放"
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
if (action === "list") return { recordings: listRecordings() };
|
|
1319
|
+
if (action === "delete") {
|
|
1320
|
+
if (!args.name) throw new Error("delete 需要 name");
|
|
1321
|
+
const name = String(args.name);
|
|
1322
|
+
return {
|
|
1323
|
+
deleted: deleteRecording(name),
|
|
1324
|
+
name
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
throw new Error(`unknown record action: ${action}`);
|
|
1328
|
+
}
|
|
1329
|
+
/** 回放执行器(步骤 → 工具调用),由 register.ts 注入实现 map。 */
|
|
1330
|
+
async function runReplay(steps, impls, failFast) {
|
|
1331
|
+
const results = [];
|
|
1332
|
+
let stoppedAt = null;
|
|
1333
|
+
let passed = 0;
|
|
1334
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1335
|
+
const step = steps[i];
|
|
1336
|
+
const impl = impls[step.tool];
|
|
1337
|
+
if (impl === void 0) {
|
|
1338
|
+
results.push({
|
|
1339
|
+
index: i,
|
|
1340
|
+
tool: step.tool,
|
|
1341
|
+
ok: false,
|
|
1342
|
+
error: `未知步骤工具: ${step.tool}`
|
|
1343
|
+
});
|
|
1344
|
+
if (failFast) {
|
|
1345
|
+
stoppedAt = i;
|
|
1346
|
+
break;
|
|
1347
|
+
}
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
try {
|
|
1351
|
+
await impl(step.args ?? {});
|
|
1352
|
+
passed++;
|
|
1353
|
+
results.push({
|
|
1354
|
+
index: i,
|
|
1355
|
+
tool: step.tool,
|
|
1356
|
+
ok: true
|
|
1357
|
+
});
|
|
1358
|
+
} catch (e) {
|
|
1359
|
+
results.push({
|
|
1360
|
+
index: i,
|
|
1361
|
+
tool: step.tool,
|
|
1362
|
+
ok: false,
|
|
1363
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1364
|
+
});
|
|
1365
|
+
if (failFast) {
|
|
1366
|
+
stoppedAt = i;
|
|
1367
|
+
break;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return {
|
|
1372
|
+
total: steps.length,
|
|
1373
|
+
passed,
|
|
1374
|
+
failed: results.length - passed,
|
|
1375
|
+
stoppedAt,
|
|
1376
|
+
results
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
/** 回放入口:name(已保存录制)或 steps(数组)二选一。 */
|
|
1380
|
+
async function replayTool(args, impls) {
|
|
1381
|
+
let steps = [];
|
|
1382
|
+
let source = "steps";
|
|
1383
|
+
if (typeof args.name === "string" && args.name) {
|
|
1384
|
+
const saved = savedRecordings.get(args.name);
|
|
1385
|
+
if (saved === void 0) throw new Error(`未找到录制: ${args.name}`);
|
|
1386
|
+
steps = saved.steps;
|
|
1387
|
+
source = args.name;
|
|
1388
|
+
} else if (Array.isArray(args.steps)) steps = args.steps;
|
|
1389
|
+
else throw new Error("replay 需要 name(已保存录制)或 steps(步骤数组)");
|
|
1390
|
+
if (steps.length === 0) throw new Error("步骤为空");
|
|
1391
|
+
const failFast = args.failFast !== false;
|
|
1392
|
+
const result = await runReplay(steps, impls, failFast);
|
|
1393
|
+
return {
|
|
1394
|
+
source,
|
|
1395
|
+
...result,
|
|
1396
|
+
summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${result.stoppedAt !== null ? `(停在步骤 ${result.stoppedAt})` : ""}` : ""}`
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
//#endregion
|
|
1400
|
+
//#region lib/types/browser/register.js
|
|
1401
|
+
function registerTools(ctx, config) {
|
|
1402
|
+
const tools = ctx.get("tools");
|
|
1403
|
+
if (tools === void 0) return;
|
|
1404
|
+
const timeout = (ms) => ms ?? config.timeoutMs;
|
|
1405
|
+
/** 参数净化:大字段(script/fields 等)截断,避免操作日志膨胀。 */
|
|
1406
|
+
function sanitizeArgs(args) {
|
|
1407
|
+
if (typeof args !== "object" || args === null) return args;
|
|
1408
|
+
const out = {};
|
|
1409
|
+
for (const [k, v] of Object.entries(args)) {
|
|
1410
|
+
const s = JSON.stringify(v);
|
|
1411
|
+
out[k] = s && s.length > 300 ? `${s.slice(0, 300)}…(截断)` : v;
|
|
1412
|
+
}
|
|
1413
|
+
return out;
|
|
1414
|
+
}
|
|
1415
|
+
/** 打点包装:每次工具调用记录操作轨迹(测试报告 / 录制数据源)。 */
|
|
1416
|
+
function register(def) {
|
|
1417
|
+
const original = def.execute;
|
|
1418
|
+
def.execute = async (args, exec) => {
|
|
1419
|
+
const t0 = Date.now();
|
|
1420
|
+
const sanitized = sanitizeArgs(args);
|
|
1421
|
+
try {
|
|
1422
|
+
const value = await original(args, exec);
|
|
1423
|
+
recordCall({
|
|
1424
|
+
tool: def.name,
|
|
1425
|
+
args: sanitized,
|
|
1426
|
+
ok: true,
|
|
1427
|
+
ms: Date.now() - t0,
|
|
1428
|
+
ts: Date.now()
|
|
1429
|
+
});
|
|
1430
|
+
return value;
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
recordCall({
|
|
1433
|
+
tool: def.name,
|
|
1434
|
+
args: sanitized,
|
|
1435
|
+
ok: false,
|
|
1436
|
+
ms: Date.now() - t0,
|
|
1437
|
+
ts: Date.now(),
|
|
1438
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1439
|
+
});
|
|
1440
|
+
throw error;
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
tools.register(def);
|
|
1444
|
+
}
|
|
1445
|
+
/** 回放步骤 → 工具实现映射(config 闭包)。 */
|
|
1446
|
+
const stepImpls = {
|
|
1447
|
+
browser_open: (a) => openTool(config, a),
|
|
1448
|
+
browser_snapshot: (a) => snapshotTool(config, a),
|
|
1449
|
+
browser_click: (a) => clickTool(config, a),
|
|
1450
|
+
browser_type: (a) => typeTool(config, a),
|
|
1451
|
+
browser_press: (a) => pressTool(config, a),
|
|
1452
|
+
browser_eval: (a) => evalTool(config, a),
|
|
1453
|
+
browser_screenshot: (a) => screenshotTool(config, a),
|
|
1454
|
+
browser_wait: (a) => waitTool(config, a),
|
|
1455
|
+
browser_back: () => backTool(config),
|
|
1456
|
+
browser_reload: () => reloadTool(config),
|
|
1457
|
+
browser_status: () => statusTool(),
|
|
1458
|
+
browser_close: () => closeTool(),
|
|
1459
|
+
browser_tabs: (a) => tabsTool(config, a),
|
|
1460
|
+
browser_download: (a) => downloadTool(config, a),
|
|
1461
|
+
browser_upload: (a) => uploadTool(config, a),
|
|
1462
|
+
browser_cookies: (a) => cookiesTool(config, a),
|
|
1463
|
+
browser_form: (a) => formTool(config, a),
|
|
1464
|
+
browser_form_save: (a) => formSaveTool(a),
|
|
1465
|
+
browser_forms: (a) => formsTool(a),
|
|
1466
|
+
browser_profile: (a) => profileTool(config, a),
|
|
1467
|
+
browser_elements: (a) => elementsTool(config, a),
|
|
1468
|
+
browser_wait_for_login: (a) => waitForLoginTool(config, a),
|
|
1469
|
+
browser_wait_for: (a) => waitForTool(config, a),
|
|
1470
|
+
browser_assert: (a) => assertTool(config, a, void 0),
|
|
1471
|
+
browser_network: (a) => networkTool(a),
|
|
1472
|
+
browser_record: (a) => recordTool(a),
|
|
1473
|
+
browser_replay: (a) => replayTool(a, stepImpls)
|
|
1474
|
+
};
|
|
1475
|
+
register(definePlainTool({
|
|
1476
|
+
name: "browser_open",
|
|
1477
|
+
description: "在共享浏览器中打开一个 URL。之后可用 browser_snapshot 读取页面内容,用 browser_click / browser_type / browser_eval 操作页面,用 browser_screenshot 截图。",
|
|
1478
|
+
parameters: {
|
|
1479
|
+
type: "object",
|
|
1480
|
+
properties: {
|
|
1481
|
+
url: {
|
|
1482
|
+
type: "string",
|
|
1483
|
+
description: "要打开的完整 URL(含协议)。"
|
|
1484
|
+
},
|
|
1485
|
+
waitUntil: {
|
|
1486
|
+
type: "string",
|
|
1487
|
+
enum: [
|
|
1488
|
+
"load",
|
|
1489
|
+
"domcontentloaded",
|
|
1490
|
+
"commit"
|
|
1491
|
+
],
|
|
1492
|
+
description: "等待策略,默认 domcontentloaded。"
|
|
1493
|
+
},
|
|
1494
|
+
timeoutMs: {
|
|
1495
|
+
type: "integer",
|
|
1496
|
+
description: "等待超时(毫秒)。"
|
|
1497
|
+
}
|
|
1498
|
+
},
|
|
1499
|
+
required: ["url"],
|
|
1500
|
+
additionalProperties: false
|
|
1501
|
+
},
|
|
1502
|
+
outputSchema: {
|
|
1503
|
+
type: "object",
|
|
1504
|
+
additionalProperties: false,
|
|
1505
|
+
properties: {
|
|
1506
|
+
url: { type: "string" },
|
|
1507
|
+
title: { type: "string" },
|
|
1508
|
+
status: { type: "string" }
|
|
1509
|
+
},
|
|
1510
|
+
required: [
|
|
1511
|
+
"url",
|
|
1512
|
+
"title",
|
|
1513
|
+
"status"
|
|
1514
|
+
]
|
|
1515
|
+
},
|
|
1516
|
+
render: (_args, value) => `Opened ${value.url}\nTitle: ${value.title}`,
|
|
1517
|
+
presentCall: (args) => genericCard("browser", String(args.url), String(args.url)),
|
|
1518
|
+
timeoutMs: timeout(),
|
|
1519
|
+
execute: (args) => openTool(config, args)
|
|
1520
|
+
}));
|
|
1521
|
+
register(definePlainTool({
|
|
1522
|
+
name: "browser_snapshot",
|
|
1523
|
+
description: "读取当前页面的可访问文本、链接清单与输入框数量(不截图)。maxTextChars 默认 20000。链接列表用于构造点击选择器。",
|
|
1524
|
+
parameters: {
|
|
1525
|
+
type: "object",
|
|
1526
|
+
properties: {
|
|
1527
|
+
maxTextChars: {
|
|
1528
|
+
type: "integer",
|
|
1529
|
+
description: "文本截断上限。"
|
|
1530
|
+
},
|
|
1531
|
+
maxLinks: {
|
|
1532
|
+
type: "integer",
|
|
1533
|
+
description: "链接数量上限。"
|
|
1534
|
+
}
|
|
1535
|
+
},
|
|
1536
|
+
additionalProperties: false
|
|
1537
|
+
},
|
|
1538
|
+
outputSchema: {
|
|
1539
|
+
type: "object",
|
|
1540
|
+
additionalProperties: false,
|
|
1541
|
+
properties: {
|
|
1542
|
+
url: { type: "string" },
|
|
1543
|
+
title: { type: "string" },
|
|
1544
|
+
text: { type: "string" },
|
|
1545
|
+
truncated: { type: "boolean" },
|
|
1546
|
+
links: {
|
|
1547
|
+
type: "array",
|
|
1548
|
+
items: { type: "object" }
|
|
1549
|
+
},
|
|
1550
|
+
inputs: { type: "integer" }
|
|
1551
|
+
},
|
|
1552
|
+
required: [
|
|
1553
|
+
"url",
|
|
1554
|
+
"title",
|
|
1555
|
+
"text",
|
|
1556
|
+
"truncated",
|
|
1557
|
+
"links",
|
|
1558
|
+
"inputs"
|
|
1559
|
+
]
|
|
1560
|
+
},
|
|
1561
|
+
render: (_args, value) => `URL: ${value.url}\nTitle: ${value.title}\nInputs: ${value.inputs}\n--- text ---\n${value.text}`,
|
|
1562
|
+
timeoutMs: timeout(),
|
|
1563
|
+
execute: (args) => snapshotTool(config, args)
|
|
1564
|
+
}));
|
|
1565
|
+
register(definePlainTool({
|
|
1566
|
+
name: "browser_click",
|
|
1567
|
+
description: "点击页面上匹配 CSS 选择器的元素(来自 browser_snapshot 的链接/表单分析)。",
|
|
1568
|
+
parameters: {
|
|
1569
|
+
type: "object",
|
|
1570
|
+
properties: {
|
|
1571
|
+
selector: {
|
|
1572
|
+
type: "string",
|
|
1573
|
+
description: "CSS 选择器。"
|
|
1574
|
+
},
|
|
1575
|
+
timeoutMs: { type: "integer" }
|
|
1576
|
+
},
|
|
1577
|
+
required: ["selector"],
|
|
1578
|
+
additionalProperties: false
|
|
1579
|
+
},
|
|
1580
|
+
outputSchema: {
|
|
1581
|
+
type: "object",
|
|
1582
|
+
additionalProperties: false,
|
|
1583
|
+
properties: {
|
|
1584
|
+
clicked: { type: "string" },
|
|
1585
|
+
url: { type: "string" },
|
|
1586
|
+
title: { type: "string" }
|
|
1587
|
+
},
|
|
1588
|
+
required: [
|
|
1589
|
+
"clicked",
|
|
1590
|
+
"url",
|
|
1591
|
+
"title"
|
|
1592
|
+
]
|
|
1593
|
+
},
|
|
1594
|
+
render: (_args, value) => `Clicked ${value.clicked}\nNow at: ${value.url}`,
|
|
1595
|
+
timeoutMs: timeout(),
|
|
1596
|
+
execute: (args) => clickTool(config, args)
|
|
1597
|
+
}));
|
|
1598
|
+
register(definePlainTool({
|
|
1599
|
+
name: "browser_type",
|
|
1600
|
+
description: "向 CSS 选择器指向的输入框输入文本。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车。",
|
|
1601
|
+
parameters: {
|
|
1602
|
+
type: "object",
|
|
1603
|
+
properties: {
|
|
1604
|
+
selector: { type: "string" },
|
|
1605
|
+
text: { type: "string" },
|
|
1606
|
+
clear: { type: "boolean" },
|
|
1607
|
+
submit: { type: "boolean" },
|
|
1608
|
+
delayMs: { type: "integer" },
|
|
1609
|
+
timeoutMs: { type: "integer" }
|
|
1610
|
+
},
|
|
1611
|
+
required: ["selector", "text"],
|
|
1612
|
+
additionalProperties: false
|
|
1613
|
+
},
|
|
1614
|
+
outputSchema: {
|
|
1615
|
+
type: "object",
|
|
1616
|
+
additionalProperties: false,
|
|
1617
|
+
properties: {
|
|
1618
|
+
typed: { type: "boolean" },
|
|
1619
|
+
selector: { type: "string" },
|
|
1620
|
+
submit: { type: "boolean" },
|
|
1621
|
+
url: { type: "string" },
|
|
1622
|
+
title: { type: "string" }
|
|
1623
|
+
},
|
|
1624
|
+
required: [
|
|
1625
|
+
"typed",
|
|
1626
|
+
"selector",
|
|
1627
|
+
"submit",
|
|
1628
|
+
"url",
|
|
1629
|
+
"title"
|
|
1630
|
+
]
|
|
1631
|
+
},
|
|
1632
|
+
render: (_args, value) => `Typed into ${value.selector}${value.submit ? " and submitted" : ""}\nNow at: ${value.url}`,
|
|
1633
|
+
timeoutMs: timeout(),
|
|
1634
|
+
execute: (args) => typeTool(config, args)
|
|
1635
|
+
}));
|
|
1636
|
+
register(definePlainTool({
|
|
1637
|
+
name: "browser_press",
|
|
1638
|
+
description: "按键:有 selector 时先聚焦该元素再按键(如 'Enter'、'Tab'、'Escape'、'Control+a'),否则在页面级按键。",
|
|
1639
|
+
parameters: {
|
|
1640
|
+
type: "object",
|
|
1641
|
+
properties: {
|
|
1642
|
+
key: {
|
|
1643
|
+
type: "string",
|
|
1644
|
+
description: "按键名(Playwright 键盘键名)。"
|
|
1645
|
+
},
|
|
1646
|
+
selector: { type: "string" },
|
|
1647
|
+
timeoutMs: { type: "integer" }
|
|
1648
|
+
},
|
|
1649
|
+
required: ["key"],
|
|
1650
|
+
additionalProperties: false
|
|
1651
|
+
},
|
|
1652
|
+
outputSchema: {
|
|
1653
|
+
type: "object",
|
|
1654
|
+
additionalProperties: false,
|
|
1655
|
+
properties: {
|
|
1656
|
+
pressed: { type: "string" },
|
|
1657
|
+
url: { type: "string" },
|
|
1658
|
+
title: { type: "string" }
|
|
1659
|
+
},
|
|
1660
|
+
required: [
|
|
1661
|
+
"pressed",
|
|
1662
|
+
"url",
|
|
1663
|
+
"title"
|
|
1664
|
+
]
|
|
1665
|
+
},
|
|
1666
|
+
render: (_args, value) => `Pressed ${value.pressed}`,
|
|
1667
|
+
timeoutMs: timeout(),
|
|
1668
|
+
execute: (args) => pressTool(config, args)
|
|
1669
|
+
}));
|
|
1670
|
+
register(definePlainTool({
|
|
1671
|
+
name: "browser_eval",
|
|
1672
|
+
description: "在页面上下文执行一段 JavaScript。表达式直接求值(如 'document.title');含换行/分号的语句会被包进 async IIFE,可用 return 返回。结果必须是可 JSON 序列化的值。",
|
|
1673
|
+
parameters: {
|
|
1674
|
+
type: "object",
|
|
1675
|
+
properties: { script: {
|
|
1676
|
+
type: "string",
|
|
1677
|
+
description: "要执行的 JavaScript。"
|
|
1678
|
+
} },
|
|
1679
|
+
required: ["script"],
|
|
1680
|
+
additionalProperties: false
|
|
1681
|
+
},
|
|
1682
|
+
outputSchema: {
|
|
1683
|
+
type: "object",
|
|
1684
|
+
additionalProperties: false,
|
|
1685
|
+
properties: { result: {} },
|
|
1686
|
+
required: ["result"]
|
|
1687
|
+
},
|
|
1688
|
+
render: (_args, value) => `Result: ${JSON.stringify(value.result)}`,
|
|
1689
|
+
timeoutMs: timeout(),
|
|
1690
|
+
execute: (args) => evalTool(config, args)
|
|
1691
|
+
}));
|
|
1692
|
+
register(definePlainTool({
|
|
1693
|
+
name: "browser_screenshot",
|
|
1694
|
+
description: "把当前页面截图保存为 PNG 文件(默认存到工作区 .dsh-browser/screenshots/),返回文件路径。纯文本模型看不到图,但用户可以在实时画面面板查看。",
|
|
1695
|
+
parameters: {
|
|
1696
|
+
type: "object",
|
|
1697
|
+
properties: { name: {
|
|
1698
|
+
type: "string",
|
|
1699
|
+
description: "文件名(不含扩展名也会自动补 .png)。"
|
|
1700
|
+
} },
|
|
1701
|
+
additionalProperties: false
|
|
1702
|
+
},
|
|
1703
|
+
outputSchema: {
|
|
1704
|
+
type: "object",
|
|
1705
|
+
additionalProperties: false,
|
|
1706
|
+
properties: {
|
|
1707
|
+
path: { type: "string" },
|
|
1708
|
+
bytes: { type: "integer" },
|
|
1709
|
+
width: { type: "integer" },
|
|
1710
|
+
height: { type: "integer" }
|
|
1711
|
+
},
|
|
1712
|
+
required: [
|
|
1713
|
+
"path",
|
|
1714
|
+
"bytes",
|
|
1715
|
+
"width",
|
|
1716
|
+
"height"
|
|
1717
|
+
]
|
|
1718
|
+
},
|
|
1719
|
+
render: (args, value) => `Saved ${value.bytes} bytes → ${value.path}`,
|
|
1720
|
+
timeoutMs: timeout(),
|
|
1721
|
+
execute: (args, exec) => screenshotTool(config, args, exec)
|
|
1722
|
+
}));
|
|
1723
|
+
register(definePlainTool({
|
|
1724
|
+
name: "browser_wait",
|
|
1725
|
+
description: "等待指定毫秒数(上限 60000),常用于等待页面渲染或请求完成。",
|
|
1726
|
+
parameters: {
|
|
1727
|
+
type: "object",
|
|
1728
|
+
properties: { ms: {
|
|
1729
|
+
type: "integer",
|
|
1730
|
+
description: "等待毫秒数,默认 1000。"
|
|
1731
|
+
} },
|
|
1732
|
+
additionalProperties: false
|
|
1733
|
+
},
|
|
1734
|
+
outputSchema: {
|
|
1735
|
+
type: "object",
|
|
1736
|
+
additionalProperties: false,
|
|
1737
|
+
properties: {
|
|
1738
|
+
waitedMs: { type: "integer" },
|
|
1739
|
+
url: { type: "string" },
|
|
1740
|
+
title: { type: "string" }
|
|
1741
|
+
},
|
|
1742
|
+
required: [
|
|
1743
|
+
"waitedMs",
|
|
1744
|
+
"url",
|
|
1745
|
+
"title"
|
|
1746
|
+
]
|
|
1747
|
+
},
|
|
1748
|
+
render: (_args, value) => `Waited ${value.waitedMs}ms`,
|
|
1749
|
+
timeoutMs: timeout(),
|
|
1750
|
+
execute: (args) => waitTool(config, args)
|
|
1751
|
+
}));
|
|
1752
|
+
register(definePlainTool({
|
|
1753
|
+
name: "browser_back",
|
|
1754
|
+
description: "返回上一页(如无历史则无操作)。",
|
|
1755
|
+
parameters: {
|
|
1756
|
+
type: "object",
|
|
1757
|
+
properties: {},
|
|
1758
|
+
additionalProperties: false
|
|
1759
|
+
},
|
|
1760
|
+
outputSchema: {
|
|
1761
|
+
type: "object",
|
|
1762
|
+
additionalProperties: false,
|
|
1763
|
+
properties: {
|
|
1764
|
+
url: { type: "string" },
|
|
1765
|
+
title: { type: "string" }
|
|
1766
|
+
},
|
|
1767
|
+
required: ["url", "title"]
|
|
1768
|
+
},
|
|
1769
|
+
render: (_args, value) => `Back to: ${value.url}`,
|
|
1770
|
+
timeoutMs: timeout(),
|
|
1771
|
+
execute: () => backTool(config)
|
|
1772
|
+
}));
|
|
1773
|
+
register(definePlainTool({
|
|
1774
|
+
name: "browser_reload",
|
|
1775
|
+
description: "重新加载当前页面。",
|
|
1776
|
+
parameters: {
|
|
1777
|
+
type: "object",
|
|
1778
|
+
properties: {},
|
|
1779
|
+
additionalProperties: false
|
|
1780
|
+
},
|
|
1781
|
+
outputSchema: {
|
|
1782
|
+
type: "object",
|
|
1783
|
+
additionalProperties: false,
|
|
1784
|
+
properties: {
|
|
1785
|
+
url: { type: "string" },
|
|
1786
|
+
title: { type: "string" }
|
|
1787
|
+
},
|
|
1788
|
+
required: ["url", "title"]
|
|
1789
|
+
},
|
|
1790
|
+
render: (_args, value) => `Reloaded: ${value.url}`,
|
|
1791
|
+
timeoutMs: timeout(),
|
|
1792
|
+
execute: () => reloadTool(config)
|
|
1793
|
+
}));
|
|
1794
|
+
register(definePlainTool({
|
|
1795
|
+
name: "browser_status",
|
|
1796
|
+
description: "查看浏览器会话是否打开、当前 URL 与标题。",
|
|
1797
|
+
parameters: {
|
|
1798
|
+
type: "object",
|
|
1799
|
+
properties: {},
|
|
1800
|
+
additionalProperties: false
|
|
1801
|
+
},
|
|
1802
|
+
outputSchema: {
|
|
1803
|
+
type: "object",
|
|
1804
|
+
additionalProperties: false,
|
|
1805
|
+
properties: {
|
|
1806
|
+
open: { type: "boolean" },
|
|
1807
|
+
profile: { type: "string" },
|
|
1808
|
+
channel: { type: "string" },
|
|
1809
|
+
tabs: { type: "integer" },
|
|
1810
|
+
profiles: {
|
|
1811
|
+
type: "array",
|
|
1812
|
+
items: { type: "object" }
|
|
1813
|
+
},
|
|
1814
|
+
url: { type: "string" },
|
|
1815
|
+
title: { type: "string" }
|
|
1816
|
+
},
|
|
1817
|
+
required: [
|
|
1818
|
+
"open",
|
|
1819
|
+
"profile",
|
|
1820
|
+
"channel",
|
|
1821
|
+
"tabs",
|
|
1822
|
+
"profiles"
|
|
1823
|
+
]
|
|
1824
|
+
},
|
|
1825
|
+
render: (_args, value) => value.open ? `Open (${value.channel}): ${value.url} — ${value.title}` : "Not open",
|
|
1826
|
+
timeoutMs: timeout(),
|
|
1827
|
+
execute: () => statusTool()
|
|
1828
|
+
}));
|
|
1829
|
+
register(definePlainTool({
|
|
1830
|
+
name: "browser_close",
|
|
1831
|
+
description: "关闭浏览器会话并释放资源;下次使用工具时会自动重新打开。",
|
|
1832
|
+
parameters: {
|
|
1833
|
+
type: "object",
|
|
1834
|
+
properties: {},
|
|
1835
|
+
additionalProperties: false
|
|
1836
|
+
},
|
|
1837
|
+
outputSchema: {
|
|
1838
|
+
type: "object",
|
|
1839
|
+
additionalProperties: false,
|
|
1840
|
+
properties: { closed: { type: "boolean" } },
|
|
1841
|
+
required: ["closed"]
|
|
1842
|
+
},
|
|
1843
|
+
render: () => "Browser closed",
|
|
1844
|
+
execute: () => closeTool()
|
|
1845
|
+
}));
|
|
1846
|
+
register(definePlainTool({
|
|
1847
|
+
name: "browser_tabs",
|
|
1848
|
+
description: "管理浏览器多标签页:list 列出所有标签,new 新建(可选带 url),switch 按 id 或 index 切换,close 关闭指定标签。id 来自 list 结果。",
|
|
1849
|
+
parameters: {
|
|
1850
|
+
type: "object",
|
|
1851
|
+
properties: {
|
|
1852
|
+
action: {
|
|
1853
|
+
type: "string",
|
|
1854
|
+
enum: [
|
|
1855
|
+
"list",
|
|
1856
|
+
"new",
|
|
1857
|
+
"switch",
|
|
1858
|
+
"close"
|
|
1859
|
+
]
|
|
1860
|
+
},
|
|
1861
|
+
url: {
|
|
1862
|
+
type: "string",
|
|
1863
|
+
description: "new 时打开的新标签 URL。"
|
|
1864
|
+
},
|
|
1865
|
+
id: {
|
|
1866
|
+
type: "integer",
|
|
1867
|
+
description: "目标标签 id(list 返回)。"
|
|
1868
|
+
},
|
|
1869
|
+
index: {
|
|
1870
|
+
type: "integer",
|
|
1871
|
+
description: "目标标签序号(0 起)。"
|
|
1872
|
+
},
|
|
1873
|
+
waitUntil: {
|
|
1874
|
+
type: "string",
|
|
1875
|
+
enum: [
|
|
1876
|
+
"load",
|
|
1877
|
+
"domcontentloaded",
|
|
1878
|
+
"commit"
|
|
1879
|
+
]
|
|
1880
|
+
},
|
|
1881
|
+
timeoutMs: { type: "integer" }
|
|
1882
|
+
},
|
|
1883
|
+
additionalProperties: false
|
|
1884
|
+
},
|
|
1885
|
+
outputSchema: {
|
|
1886
|
+
type: "object",
|
|
1887
|
+
additionalProperties: false,
|
|
1888
|
+
properties: {
|
|
1889
|
+
action: { type: "string" },
|
|
1890
|
+
tab: { type: "object" },
|
|
1891
|
+
tabs: {
|
|
1892
|
+
type: "array",
|
|
1893
|
+
items: { type: "object" }
|
|
1894
|
+
},
|
|
1895
|
+
activeId: { type: "integer" }
|
|
1896
|
+
},
|
|
1897
|
+
required: [
|
|
1898
|
+
"action",
|
|
1899
|
+
"tabs",
|
|
1900
|
+
"activeId"
|
|
1901
|
+
]
|
|
1902
|
+
},
|
|
1903
|
+
render: (args, value) => {
|
|
1904
|
+
const lines = value.tabs.map((t) => `${t.id === value.activeId ? "▶" : " "} #${t.id} ${t.url}${t.title ? ` — ${t.title}` : ""}`);
|
|
1905
|
+
return `tabs ${value.action}: ${lines.join("\n") || "(none)"}`;
|
|
1906
|
+
},
|
|
1907
|
+
timeoutMs: timeout(),
|
|
1908
|
+
execute: (args) => tabsTool(config, args)
|
|
1909
|
+
}));
|
|
1910
|
+
register(definePlainTool({
|
|
1911
|
+
name: "browser_download",
|
|
1912
|
+
description: "获取页面上最近触发的下载(如点击下载链接后),保存到工作区 .dsh-browser/downloads/ 并返回路径。index 可选,默认最近一次;浏览器会话关闭后下载记录丢失。",
|
|
1913
|
+
parameters: {
|
|
1914
|
+
type: "object",
|
|
1915
|
+
properties: { index: {
|
|
1916
|
+
type: "integer",
|
|
1917
|
+
description: "下载记录序号(0 起,默认最近)。"
|
|
1918
|
+
} },
|
|
1919
|
+
additionalProperties: false
|
|
1920
|
+
},
|
|
1921
|
+
outputSchema: {
|
|
1922
|
+
type: "object",
|
|
1923
|
+
additionalProperties: false,
|
|
1924
|
+
properties: {
|
|
1925
|
+
path: { type: "string" },
|
|
1926
|
+
filename: { type: "string" },
|
|
1927
|
+
url: { type: "string" },
|
|
1928
|
+
bytes: { type: "integer" }
|
|
1929
|
+
},
|
|
1930
|
+
required: [
|
|
1931
|
+
"path",
|
|
1932
|
+
"filename",
|
|
1933
|
+
"url",
|
|
1934
|
+
"bytes"
|
|
1935
|
+
]
|
|
1936
|
+
},
|
|
1937
|
+
render: (_args, value) => `Downloaded ${value.filename} (${value.bytes} bytes) → ${value.path}`,
|
|
1938
|
+
timeoutMs: timeout(),
|
|
1939
|
+
execute: (args, exec) => downloadTool(config, args, exec)
|
|
1940
|
+
}));
|
|
1941
|
+
register(definePlainTool({
|
|
1942
|
+
name: "browser_upload",
|
|
1943
|
+
description: "把本地文件上传到页面的文件输入框(selector 指向 input[type=file])。path 可以是绝对路径或相对工作区的路径。",
|
|
1944
|
+
parameters: {
|
|
1945
|
+
type: "object",
|
|
1946
|
+
properties: {
|
|
1947
|
+
selector: {
|
|
1948
|
+
type: "string",
|
|
1949
|
+
description: "文件输入框的 CSS 选择器。"
|
|
1950
|
+
},
|
|
1951
|
+
path: {
|
|
1952
|
+
type: "string",
|
|
1953
|
+
description: "要上传的文件路径。"
|
|
1954
|
+
},
|
|
1955
|
+
timeoutMs: { type: "integer" }
|
|
1956
|
+
},
|
|
1957
|
+
required: ["selector", "path"],
|
|
1958
|
+
additionalProperties: false
|
|
1959
|
+
},
|
|
1960
|
+
outputSchema: {
|
|
1961
|
+
type: "object",
|
|
1962
|
+
additionalProperties: false,
|
|
1963
|
+
properties: {
|
|
1964
|
+
uploaded: { type: "string" },
|
|
1965
|
+
bytes: { type: "integer" },
|
|
1966
|
+
url: { type: "string" },
|
|
1967
|
+
title: { type: "string" }
|
|
1968
|
+
},
|
|
1969
|
+
required: [
|
|
1970
|
+
"uploaded",
|
|
1971
|
+
"bytes",
|
|
1972
|
+
"url",
|
|
1973
|
+
"title"
|
|
1974
|
+
]
|
|
1975
|
+
},
|
|
1976
|
+
render: (args, value) => `Uploaded ${value.uploaded} (${value.bytes} bytes) to ${args.selector}`,
|
|
1977
|
+
timeoutMs: timeout(),
|
|
1978
|
+
execute: (args, exec) => uploadTool(config, args, exec)
|
|
1979
|
+
}));
|
|
1980
|
+
register(definePlainTool({
|
|
1981
|
+
name: "browser_cookies",
|
|
1982
|
+
description: "管理浏览器 Cookie:list 列出当前页面域名的 Cookie(默认隐藏值,showValues=true 显示),set 设置一个 Cookie(可指定 url,默认当前页面),clear 清空全部。用于处理登录态。",
|
|
1983
|
+
parameters: {
|
|
1984
|
+
type: "object",
|
|
1985
|
+
properties: {
|
|
1986
|
+
action: {
|
|
1987
|
+
type: "string",
|
|
1988
|
+
enum: [
|
|
1989
|
+
"list",
|
|
1990
|
+
"set",
|
|
1991
|
+
"clear"
|
|
1992
|
+
]
|
|
1993
|
+
},
|
|
1994
|
+
url: {
|
|
1995
|
+
type: "string",
|
|
1996
|
+
description: "list 的过滤域名或 set 的归属 URL。"
|
|
1997
|
+
},
|
|
1998
|
+
name: {
|
|
1999
|
+
type: "string",
|
|
2000
|
+
description: "set 时的 Cookie 名。"
|
|
2001
|
+
},
|
|
2002
|
+
value: {
|
|
2003
|
+
type: "string",
|
|
2004
|
+
description: "set 时的 Cookie 值。"
|
|
2005
|
+
},
|
|
2006
|
+
showValues: { type: "boolean" }
|
|
2007
|
+
},
|
|
2008
|
+
additionalProperties: false
|
|
2009
|
+
},
|
|
2010
|
+
outputSchema: {
|
|
2011
|
+
type: "object",
|
|
2012
|
+
additionalProperties: false,
|
|
2013
|
+
properties: {
|
|
2014
|
+
cookies: {
|
|
2015
|
+
type: "array",
|
|
2016
|
+
items: { type: "object" }
|
|
2017
|
+
},
|
|
2018
|
+
set: { type: "boolean" },
|
|
2019
|
+
cleared: { type: "boolean" },
|
|
2020
|
+
name: { type: "string" },
|
|
2021
|
+
url: { type: "string" }
|
|
2022
|
+
},
|
|
2023
|
+
required: []
|
|
2024
|
+
},
|
|
2025
|
+
render: (_args, value) => {
|
|
2026
|
+
if (value.cleared) return "All cookies cleared";
|
|
2027
|
+
if (value.set) return `Cookie ${value.name} set for ${value.url}`;
|
|
2028
|
+
return (value.cookies ?? []).map((c) => `${c.name}=${c.value} (${c.domain}${c.path}, httpOnly=${c.httpOnly})`).join("\n") || "(no cookies)";
|
|
2029
|
+
},
|
|
2030
|
+
timeoutMs: timeout(),
|
|
2031
|
+
execute: (args) => cookiesTool(config, args)
|
|
2032
|
+
}));
|
|
2033
|
+
register(definePlainTool({
|
|
2034
|
+
name: "browser_form",
|
|
2035
|
+
description: "批量填充表单:fields 数组,每项给 selector(CSS)或 label(可见文本)与 value;submit=true 时填完按回车提交。也可 from=<已保存表单名> 回放(browser_form_save 保存)。先 browser_elements 或 browser_snapshot 了解表单结构。",
|
|
2036
|
+
parameters: {
|
|
2037
|
+
type: "object",
|
|
2038
|
+
properties: {
|
|
2039
|
+
fields: {
|
|
2040
|
+
type: "array",
|
|
2041
|
+
items: {
|
|
2042
|
+
type: "object",
|
|
2043
|
+
properties: {
|
|
2044
|
+
selector: { type: "string" },
|
|
2045
|
+
label: { type: "string" },
|
|
2046
|
+
value: { type: "string" }
|
|
2047
|
+
}
|
|
2048
|
+
},
|
|
2049
|
+
description: "要填充的字段列表(from 回放时省略)。"
|
|
2050
|
+
},
|
|
2051
|
+
from: {
|
|
2052
|
+
type: "string",
|
|
2053
|
+
description: "回放已保存表单的名称。"
|
|
2054
|
+
},
|
|
2055
|
+
submit: { type: "boolean" },
|
|
2056
|
+
timeoutMs: { type: "integer" }
|
|
2057
|
+
},
|
|
2058
|
+
additionalProperties: false
|
|
2059
|
+
},
|
|
2060
|
+
outputSchema: {
|
|
2061
|
+
type: "object",
|
|
2062
|
+
additionalProperties: false,
|
|
2063
|
+
properties: {
|
|
2064
|
+
filled: {
|
|
2065
|
+
type: "array",
|
|
2066
|
+
items: { type: "object" }
|
|
2067
|
+
},
|
|
2068
|
+
submit: { type: "boolean" },
|
|
2069
|
+
url: { type: "string" },
|
|
2070
|
+
title: { type: "string" }
|
|
2071
|
+
},
|
|
2072
|
+
required: [
|
|
2073
|
+
"filled",
|
|
2074
|
+
"submit",
|
|
2075
|
+
"url",
|
|
2076
|
+
"title"
|
|
2077
|
+
]
|
|
2078
|
+
},
|
|
2079
|
+
render: (args, value) => `Filled ${value.filled.length} field(s)${value.submit ? " and submitted" : ""}\nNow at: ${value.url}`,
|
|
2080
|
+
timeoutMs: timeout(),
|
|
2081
|
+
execute: (args) => formTool(config, args)
|
|
2082
|
+
}));
|
|
2083
|
+
register(definePlainTool({
|
|
2084
|
+
name: "browser_form_save",
|
|
2085
|
+
description: "把最近一次 browser_form 填充的字段保存为命名表单(或直接传 fields),之后 browser_form 用 from=<name> 一键回放。",
|
|
2086
|
+
parameters: {
|
|
2087
|
+
type: "object",
|
|
2088
|
+
properties: {
|
|
2089
|
+
name: {
|
|
2090
|
+
type: "string",
|
|
2091
|
+
description: "表单名(回放时用)。"
|
|
2092
|
+
},
|
|
2093
|
+
fields: {
|
|
2094
|
+
type: "array",
|
|
2095
|
+
items: { type: "object" },
|
|
2096
|
+
description: "可选,不传则用最近一次填充。"
|
|
2097
|
+
}
|
|
2098
|
+
},
|
|
2099
|
+
required: ["name"],
|
|
2100
|
+
additionalProperties: false
|
|
2101
|
+
},
|
|
2102
|
+
outputSchema: {
|
|
2103
|
+
type: "object",
|
|
2104
|
+
additionalProperties: false,
|
|
2105
|
+
properties: {
|
|
2106
|
+
saved: { type: "string" },
|
|
2107
|
+
fields: { type: "integer" }
|
|
2108
|
+
},
|
|
2109
|
+
required: ["saved", "fields"]
|
|
2110
|
+
},
|
|
2111
|
+
render: (_args, value) => `Saved form "${value.saved}" with ${value.fields} field(s)`,
|
|
2112
|
+
timeoutMs: timeout(),
|
|
2113
|
+
execute: (args) => formSaveTool(args)
|
|
2114
|
+
}));
|
|
2115
|
+
register(definePlainTool({
|
|
2116
|
+
name: "browser_forms",
|
|
2117
|
+
description: "管理已保存的表单回放:list 列出,delete 删除指定表单。",
|
|
2118
|
+
parameters: {
|
|
2119
|
+
type: "object",
|
|
2120
|
+
properties: {
|
|
2121
|
+
action: {
|
|
2122
|
+
type: "string",
|
|
2123
|
+
enum: ["list", "delete"]
|
|
2124
|
+
},
|
|
2125
|
+
name: {
|
|
2126
|
+
type: "string",
|
|
2127
|
+
description: "delete 时的表单名。"
|
|
2128
|
+
}
|
|
2129
|
+
},
|
|
2130
|
+
additionalProperties: false
|
|
2131
|
+
},
|
|
2132
|
+
outputSchema: {
|
|
2133
|
+
type: "object",
|
|
2134
|
+
additionalProperties: false,
|
|
2135
|
+
properties: {
|
|
2136
|
+
forms: {
|
|
2137
|
+
type: "array",
|
|
2138
|
+
items: { type: "object" }
|
|
2139
|
+
},
|
|
2140
|
+
deleted: { type: "boolean" },
|
|
2141
|
+
name: { type: "string" }
|
|
2142
|
+
},
|
|
2143
|
+
required: []
|
|
2144
|
+
},
|
|
2145
|
+
render: (args, value) => {
|
|
2146
|
+
if (args.action === "delete") return value.deleted ? `Deleted "${value.name}"` : `No form "${value.name}"`;
|
|
2147
|
+
return (value.forms ?? []).map((f) => `"${f.name}" (${f.fields} fields): ${f.preview}`).join("\n") || "(no saved forms)";
|
|
2148
|
+
},
|
|
2149
|
+
timeoutMs: timeout(),
|
|
2150
|
+
execute: (args) => formsTool(args)
|
|
2151
|
+
}));
|
|
2152
|
+
register(definePlainTool({
|
|
2153
|
+
name: "browser_profile",
|
|
2154
|
+
description: "多浏览器配置文件:list 列出配置的 profiles(work/personal…)与当前会话状态,use <name> 切换当前 profile(后续浏览器工具作用于该 profile)。每个 profile 可配置独立 userDataDir(登录态隔离)。",
|
|
2155
|
+
parameters: {
|
|
2156
|
+
type: "object",
|
|
2157
|
+
properties: {
|
|
2158
|
+
action: {
|
|
2159
|
+
type: "string",
|
|
2160
|
+
enum: ["list", "use"]
|
|
2161
|
+
},
|
|
2162
|
+
name: {
|
|
2163
|
+
type: "string",
|
|
2164
|
+
description: "use 时的 profile 名(default 或配置的)。"
|
|
2165
|
+
}
|
|
2166
|
+
},
|
|
2167
|
+
additionalProperties: false
|
|
2168
|
+
},
|
|
2169
|
+
outputSchema: {
|
|
2170
|
+
type: "object",
|
|
2171
|
+
additionalProperties: false,
|
|
2172
|
+
properties: {
|
|
2173
|
+
current: { type: "string" },
|
|
2174
|
+
profiles: {
|
|
2175
|
+
type: "array",
|
|
2176
|
+
items: { type: "object" }
|
|
2177
|
+
}
|
|
2178
|
+
},
|
|
2179
|
+
required: ["current", "profiles"]
|
|
2180
|
+
},
|
|
2181
|
+
render: (args, value) => {
|
|
2182
|
+
if (args.action === "use") return `Switched to profile "${value.current}"`;
|
|
2183
|
+
return value.profiles.map((p) => `${p.name === value.current ? "▶" : " "} ${p.name}${p.open ? ` (open, ${p.tabs} tabs${p.persistent ? ", persistent" : ""})` : " (closed)"}${p.userDataDir ? ` → ${p.userDataDir}` : ""}`).join("\n") || "(no profiles configured)";
|
|
2184
|
+
},
|
|
2185
|
+
timeoutMs: timeout(),
|
|
2186
|
+
execute: (args) => profileTool(config, args)
|
|
2187
|
+
}));
|
|
2188
|
+
register(definePlainTool({
|
|
2189
|
+
name: "browser_wait_for_login",
|
|
2190
|
+
description: "等待人工登录完成。agent 在登录页调用本工具后,提示用户在浏览器窗口中完成登录(有头模式 headless:false 时用户可直接操作;配 userDataDir 登录态会持久化)。成功条件三选一:successSelector(登录后出现的元素,如 '#avatar')或 successUrl(登录后 URL 正则,如 'https://\\\\.app\\\\.example\\\\.com')都不给则 URL 发生变化即视为完成。轮询间隔 2 秒,超时返回 loggedIn:false。",
|
|
2191
|
+
parameters: {
|
|
2192
|
+
type: "object",
|
|
2193
|
+
properties: {
|
|
2194
|
+
timeoutMs: {
|
|
2195
|
+
type: "integer",
|
|
2196
|
+
description: "等待上限(毫秒,默认 120000,上限 600000)。"
|
|
2197
|
+
},
|
|
2198
|
+
successSelector: {
|
|
2199
|
+
type: "string",
|
|
2200
|
+
description: "登录成功后应出现的 CSS 选择器。"
|
|
2201
|
+
},
|
|
2202
|
+
successUrl: {
|
|
2203
|
+
type: "string",
|
|
2204
|
+
description: "登录成功后 URL 应匹配的正则。"
|
|
2205
|
+
}
|
|
2206
|
+
},
|
|
2207
|
+
additionalProperties: false
|
|
2208
|
+
},
|
|
2209
|
+
outputSchema: {
|
|
2210
|
+
type: "object",
|
|
2211
|
+
additionalProperties: false,
|
|
2212
|
+
properties: {
|
|
2213
|
+
loggedIn: { type: "boolean" },
|
|
2214
|
+
url: { type: "string" },
|
|
2215
|
+
title: { type: "string" }
|
|
2216
|
+
},
|
|
2217
|
+
required: [
|
|
2218
|
+
"loggedIn",
|
|
2219
|
+
"url",
|
|
2220
|
+
"title"
|
|
2221
|
+
]
|
|
2222
|
+
},
|
|
2223
|
+
render: (_args, value) => value.loggedIn ? `登录完成: ${value.url}` : `等待登录超时,仍在: ${value.url}`,
|
|
2224
|
+
timeoutMs: timeout(),
|
|
2225
|
+
execute: (args) => waitForLoginTool(config, args)
|
|
2226
|
+
}));
|
|
2227
|
+
register(definePlainTool({
|
|
2228
|
+
name: "browser_elements",
|
|
2229
|
+
description: "列出当前页面可交互元素(input/textarea/select/button/链接)的结构化清单:类型、name/id、placeholder、可见文本、可直接用于 browser_click/browser_type 的 CSS 选择器。比 browser_snapshot 更适合定位表单。",
|
|
2230
|
+
parameters: {
|
|
2231
|
+
type: "object",
|
|
2232
|
+
properties: { max: {
|
|
2233
|
+
type: "integer",
|
|
2234
|
+
description: "最多返回多少元素,默认 60。"
|
|
2235
|
+
} },
|
|
2236
|
+
additionalProperties: false
|
|
2237
|
+
},
|
|
2238
|
+
outputSchema: {
|
|
2239
|
+
type: "object",
|
|
2240
|
+
additionalProperties: false,
|
|
2241
|
+
properties: {
|
|
2242
|
+
count: { type: "integer" },
|
|
2243
|
+
elements: {
|
|
2244
|
+
type: "array",
|
|
2245
|
+
items: { type: "object" }
|
|
2246
|
+
}
|
|
2247
|
+
},
|
|
2248
|
+
required: ["count", "elements"]
|
|
2249
|
+
},
|
|
2250
|
+
render: (_args, value) => (value.elements ?? []).map((e) => `<${e.tag}${e.type ? ` type=${e.type}` : ""}>${e.name ? ` name=${e.name}` : ""}${e.id ? ` id=${e.id}` : ""}${e.placeholder ? ` ph="${e.placeholder}"` : ""}${e.text ? ` "${e.text.slice(0, 40)}"` : ""}${e.selector ? ` → ${e.selector}` : ""}`).join("\n") || "(no interactive elements)",
|
|
2251
|
+
timeoutMs: timeout(),
|
|
2252
|
+
execute: (args) => elementsTool(config, args)
|
|
2253
|
+
}));
|
|
2254
|
+
register(definePlainTool({
|
|
2255
|
+
name: "browser_wait_for",
|
|
2256
|
+
description: "通用显式等待(测试同步原语):轮询直到条件成立或超时抛错。condition 四类:1) {selector:'#btn', state:'visible|hidden|attached|detached'} 元素状态(默认 visible);2) {url:'正则字符串'} 当前 URL 匹配;3) {text:'子串'} 页面可见文本包含;4) {count:{selector,op:'eq|gt|gte|lt|lte',value}} 元素数量比较;5) {eval:'JS 表达式'} 求值 truthy。用于等页面渲染、等请求完成、等元素出现。",
|
|
2257
|
+
parameters: {
|
|
2258
|
+
type: "object",
|
|
2259
|
+
properties: {
|
|
2260
|
+
condition: {
|
|
2261
|
+
type: "object",
|
|
2262
|
+
description: "等待条件(见描述,五选一)。"
|
|
2263
|
+
},
|
|
2264
|
+
timeoutMs: {
|
|
2265
|
+
type: "integer",
|
|
2266
|
+
description: "超时毫秒,默认 10000,上限 120000。"
|
|
2267
|
+
},
|
|
2268
|
+
intervalMs: {
|
|
2269
|
+
type: "integer",
|
|
2270
|
+
description: "轮询间隔毫秒,默认 500。"
|
|
2271
|
+
}
|
|
2272
|
+
},
|
|
2273
|
+
required: ["condition"],
|
|
2274
|
+
additionalProperties: false
|
|
2275
|
+
},
|
|
2276
|
+
outputSchema: {
|
|
2277
|
+
type: "object",
|
|
2278
|
+
additionalProperties: false,
|
|
2279
|
+
properties: {
|
|
2280
|
+
met: { type: "boolean" },
|
|
2281
|
+
url: { type: "string" },
|
|
2282
|
+
title: { type: "string" }
|
|
2283
|
+
},
|
|
2284
|
+
required: [
|
|
2285
|
+
"met",
|
|
2286
|
+
"url",
|
|
2287
|
+
"title"
|
|
2288
|
+
]
|
|
2289
|
+
},
|
|
2290
|
+
render: (_args, value) => `等待条件满足: ${value.url}`,
|
|
2291
|
+
timeoutMs: timeout(),
|
|
2292
|
+
execute: (args) => waitForTool(config, args)
|
|
2293
|
+
}));
|
|
2294
|
+
register(definePlainTool({
|
|
2295
|
+
name: "browser_assert",
|
|
2296
|
+
description: "结构化断言(测试核心):condition 在 timeoutMs 内成立则通过(条件格式同 browser_wait_for)。失败时自动截图存证据目录(assert-fail-*.png)并抛错,错误消息含原因与截图路径。适合断言登录成功、关键元素出现、接口返回、文本正确。",
|
|
2297
|
+
parameters: {
|
|
2298
|
+
type: "object",
|
|
2299
|
+
properties: {
|
|
2300
|
+
condition: {
|
|
2301
|
+
type: "object",
|
|
2302
|
+
description: "断言条件(五选一,同 browser_wait_for)。"
|
|
2303
|
+
},
|
|
2304
|
+
timeoutMs: {
|
|
2305
|
+
type: "integer",
|
|
2306
|
+
description: "断言超时毫秒,默认 5000,上限 60000。"
|
|
2307
|
+
},
|
|
2308
|
+
intervalMs: {
|
|
2309
|
+
type: "integer",
|
|
2310
|
+
description: "轮询间隔毫秒,默认 300。"
|
|
2311
|
+
}
|
|
2312
|
+
},
|
|
2313
|
+
required: ["condition"],
|
|
2314
|
+
additionalProperties: false
|
|
2315
|
+
},
|
|
2316
|
+
outputSchema: {
|
|
2317
|
+
type: "object",
|
|
2318
|
+
additionalProperties: false,
|
|
2319
|
+
properties: {
|
|
2320
|
+
passed: { type: "boolean" },
|
|
2321
|
+
url: { type: "string" },
|
|
2322
|
+
title: { type: "string" }
|
|
2323
|
+
},
|
|
2324
|
+
required: [
|
|
2325
|
+
"passed",
|
|
2326
|
+
"url",
|
|
2327
|
+
"title"
|
|
2328
|
+
]
|
|
2329
|
+
},
|
|
2330
|
+
render: (_args, value) => `断言通过: ${value.url}`,
|
|
2331
|
+
timeoutMs: timeout(),
|
|
2332
|
+
execute: (args, exec) => assertTool(config, args, exec)
|
|
2333
|
+
}));
|
|
2334
|
+
register(definePlainTool({
|
|
2335
|
+
name: "browser_network",
|
|
2336
|
+
description: "网络记录查询(自动记录页面 XHR/fetch 接口请求,含请求/响应体截断存储,每 profile 上限 500 条)。action:list 列出(可按 urlSubstr/method/status 过滤,limit 默认 50);failed 只列失败/4xx5xx;wait 等待某个 url 正则匹配的接口出现(带 timeoutMs);clear 清空记录。适合断言接口出入参、排查 4xx/5xx。",
|
|
2337
|
+
parameters: {
|
|
2338
|
+
type: "object",
|
|
2339
|
+
properties: {
|
|
2340
|
+
action: {
|
|
2341
|
+
type: "string",
|
|
2342
|
+
enum: [
|
|
2343
|
+
"list",
|
|
2344
|
+
"failed",
|
|
2345
|
+
"wait",
|
|
2346
|
+
"clear"
|
|
2347
|
+
],
|
|
2348
|
+
description: "默认 list。"
|
|
2349
|
+
},
|
|
2350
|
+
urlSubstr: {
|
|
2351
|
+
type: "string",
|
|
2352
|
+
description: "URL 子串过滤(list/failed)。"
|
|
2353
|
+
},
|
|
2354
|
+
method: {
|
|
2355
|
+
type: "string",
|
|
2356
|
+
description: "HTTP 方法过滤(list/failed)。"
|
|
2357
|
+
},
|
|
2358
|
+
status: {
|
|
2359
|
+
type: "integer",
|
|
2360
|
+
description: "状态码过滤(list/failed)。"
|
|
2361
|
+
},
|
|
2362
|
+
limit: {
|
|
2363
|
+
type: "integer",
|
|
2364
|
+
description: "返回条数上限,默认 50。"
|
|
2365
|
+
},
|
|
2366
|
+
url: {
|
|
2367
|
+
type: "string",
|
|
2368
|
+
description: "wait 时的 URL 正则。"
|
|
2369
|
+
},
|
|
2370
|
+
timeoutMs: {
|
|
2371
|
+
type: "integer",
|
|
2372
|
+
description: "wait 超时毫秒,默认 10000。"
|
|
2373
|
+
}
|
|
2374
|
+
},
|
|
2375
|
+
additionalProperties: false
|
|
2376
|
+
},
|
|
2377
|
+
outputSchema: {
|
|
2378
|
+
type: "object",
|
|
2379
|
+
additionalProperties: false,
|
|
2380
|
+
properties: {
|
|
2381
|
+
entries: {
|
|
2382
|
+
type: "array",
|
|
2383
|
+
items: { type: "object" }
|
|
2384
|
+
},
|
|
2385
|
+
failed: {
|
|
2386
|
+
type: "array",
|
|
2387
|
+
items: { type: "object" }
|
|
2388
|
+
},
|
|
2389
|
+
matched: { type: "object" },
|
|
2390
|
+
cleared: { type: "boolean" }
|
|
2391
|
+
},
|
|
2392
|
+
required: []
|
|
2393
|
+
},
|
|
2394
|
+
render: (_args, value) => {
|
|
2395
|
+
const list = value.entries ?? value.failed ?? (value.matched ? [value.matched] : []);
|
|
2396
|
+
if (value.cleared) return "网络记录已清空";
|
|
2397
|
+
return list.map((e) => {
|
|
2398
|
+
const head = `${e.method} ${e.url} → ${e.failed ? "FAILED" : e.ok ? e.status : e.status + " !"}${e.durationMs !== void 0 ? ` ${e.durationMs}ms` : ""}`;
|
|
2399
|
+
const reqBody = e.postData ? `\n req: ${String(e.postData).slice(0, 160)}` : "";
|
|
2400
|
+
const resBody = e.body ? `\n res: ${String(e.body).slice(0, 240)}` : "";
|
|
2401
|
+
return head + reqBody + resBody;
|
|
2402
|
+
}).join("\n") || "(无记录)";
|
|
2403
|
+
},
|
|
2404
|
+
timeoutMs: timeout(),
|
|
2405
|
+
execute: (args) => networkTool(args)
|
|
2406
|
+
}));
|
|
2407
|
+
register(definePlainTool({
|
|
2408
|
+
name: "browser_record",
|
|
2409
|
+
description: "操作录制(回归测试数据源):start 开始录制(后续浏览器工具调用自动记录),stop 结束并返回步数,save <name> 保存最近 100 次调用为命名录制,list/delete 管理。配合 browser_replay 一键回放。",
|
|
2410
|
+
parameters: {
|
|
2411
|
+
type: "object",
|
|
2412
|
+
properties: {
|
|
2413
|
+
action: {
|
|
2414
|
+
type: "string",
|
|
2415
|
+
enum: [
|
|
2416
|
+
"start",
|
|
2417
|
+
"stop",
|
|
2418
|
+
"save",
|
|
2419
|
+
"list",
|
|
2420
|
+
"delete"
|
|
2421
|
+
],
|
|
2422
|
+
description: "默认 start。"
|
|
2423
|
+
},
|
|
2424
|
+
name: {
|
|
2425
|
+
type: "string",
|
|
2426
|
+
description: "save/delete 时的录制名。"
|
|
2427
|
+
}
|
|
2428
|
+
},
|
|
2429
|
+
additionalProperties: false
|
|
2430
|
+
},
|
|
2431
|
+
outputSchema: {
|
|
2432
|
+
type: "object",
|
|
2433
|
+
additionalProperties: false,
|
|
2434
|
+
properties: {
|
|
2435
|
+
recording: { type: "boolean" },
|
|
2436
|
+
steps: { type: "integer" },
|
|
2437
|
+
saved: { type: "string" },
|
|
2438
|
+
recordings: {
|
|
2439
|
+
type: "array",
|
|
2440
|
+
items: { type: "object" }
|
|
2441
|
+
},
|
|
2442
|
+
deleted: { type: "boolean" },
|
|
2443
|
+
name: { type: "string" },
|
|
2444
|
+
hint: { type: "string" }
|
|
2445
|
+
},
|
|
2446
|
+
required: []
|
|
2447
|
+
},
|
|
2448
|
+
render: (args, value) => {
|
|
2449
|
+
if (args.action === "start") return "开始录制(后续浏览器调用将记录)";
|
|
2450
|
+
if (args.action === "stop") return `录制结束,共 ${value.steps} 步(browser_record save <name> 保存)`;
|
|
2451
|
+
if (args.action === "save") return `已保存录制 "${value.saved}"(${value.steps} 步)`;
|
|
2452
|
+
if (args.action === "delete") return value.deleted ? `已删除 "${value.name}"` : `无此录制 "${value.name}"`;
|
|
2453
|
+
return (value.recordings ?? []).map((r) => `"${r.name}"(${r.steps} 步): ${r.preview}`).join("\n") || "(无录制)";
|
|
2454
|
+
},
|
|
2455
|
+
timeoutMs: timeout(),
|
|
2456
|
+
execute: (args) => recordTool(args)
|
|
2457
|
+
}));
|
|
2458
|
+
register(definePlainTool({
|
|
2459
|
+
name: "browser_replay",
|
|
2460
|
+
description: "回放录制或步骤序列(回归测试):name 指定已保存录制(browser_record save),或直接传 steps 数组(每步 {tool, args})。failFast 默认 true(遇错停止);返回每步结果与汇总。回放中的调用会记入操作轨迹。",
|
|
2461
|
+
parameters: {
|
|
2462
|
+
type: "object",
|
|
2463
|
+
properties: {
|
|
2464
|
+
name: {
|
|
2465
|
+
type: "string",
|
|
2466
|
+
description: "已保存录制名(与 steps 二选一)。"
|
|
2467
|
+
},
|
|
2468
|
+
steps: {
|
|
2469
|
+
type: "array",
|
|
2470
|
+
items: { type: "object" },
|
|
2471
|
+
description: "步骤数组 [{tool, args}]。"
|
|
2472
|
+
},
|
|
2473
|
+
failFast: {
|
|
2474
|
+
type: "boolean",
|
|
2475
|
+
description: "遇错即停,默认 true。"
|
|
2476
|
+
}
|
|
2477
|
+
},
|
|
2478
|
+
additionalProperties: false
|
|
2479
|
+
},
|
|
2480
|
+
outputSchema: {
|
|
2481
|
+
type: "object",
|
|
2482
|
+
additionalProperties: false,
|
|
2483
|
+
properties: {
|
|
2484
|
+
source: { type: "string" },
|
|
2485
|
+
total: { type: "integer" },
|
|
2486
|
+
passed: { type: "integer" },
|
|
2487
|
+
failed: { type: "integer" },
|
|
2488
|
+
stoppedAt: { type: "integer" },
|
|
2489
|
+
results: {
|
|
2490
|
+
type: "array",
|
|
2491
|
+
items: { type: "object" }
|
|
2492
|
+
},
|
|
2493
|
+
summary: { type: "string" }
|
|
2494
|
+
},
|
|
2495
|
+
required: [
|
|
2496
|
+
"source",
|
|
2497
|
+
"total",
|
|
2498
|
+
"passed",
|
|
2499
|
+
"failed",
|
|
2500
|
+
"results",
|
|
2501
|
+
"summary"
|
|
2502
|
+
]
|
|
2503
|
+
},
|
|
2504
|
+
render: (_args, value) => `${value.summary}\n${(value.results ?? []).map((r) => `${r.ok ? "✓" : "✗"} #${r.index} ${r.tool}${r.error ? ` — ${String(r.error).slice(0, 120)}` : ""}`).join("\n")}`,
|
|
2505
|
+
timeoutMs: timeout(),
|
|
2506
|
+
execute: (args) => replayTool(args, stepImpls)
|
|
2507
|
+
}));
|
|
2508
|
+
}
|
|
2509
|
+
//#endregion
|
|
2510
|
+
//#region lib/types/browser/ui.js
|
|
2511
|
+
function registerBrowserApi(ctx, config, base) {
|
|
2512
|
+
const webServer = ctx.get("webServer");
|
|
2513
|
+
if (webServer === void 0) return;
|
|
2514
|
+
const disposers = [];
|
|
2515
|
+
disposers.push(webServer.register({
|
|
2516
|
+
kind: "prefix",
|
|
2517
|
+
path: base,
|
|
2518
|
+
handler: async (req, res) => {
|
|
2519
|
+
const path = urlPath(req);
|
|
2520
|
+
if (path === `${base}/status`) {
|
|
2521
|
+
sendJson(res, 200, await statusTool());
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (path === `${base}/history`) {
|
|
2525
|
+
sendJson(res, 200, {
|
|
2526
|
+
profile: currentProfile,
|
|
2527
|
+
history: [...historyOf(currentProfile)].reverse()
|
|
2528
|
+
});
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
if (path === `${base}/log`) {
|
|
2532
|
+
sendJson(res, 200, {
|
|
2533
|
+
profile: currentProfile,
|
|
2534
|
+
calls: testLogOf().slice(0, 50)
|
|
2535
|
+
});
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
if (path === `${base}/recordings`) {
|
|
2539
|
+
if ((req.method ?? "GET") === "POST") {
|
|
2540
|
+
const body = await readJsonBody(req).catch(() => void 0);
|
|
2541
|
+
const name = typeof body?.name === "string" ? body.name : "";
|
|
2542
|
+
if (!name) {
|
|
2543
|
+
sendText(res, 400, "name required");
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
sendJson(res, 200, {
|
|
2547
|
+
deleted: deleteRecording(name),
|
|
2548
|
+
name
|
|
2549
|
+
});
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
sendJson(res, 200, { recordings: listRecordings() });
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
if (path === `${base}/recordings/detail`) {
|
|
2556
|
+
let name = "";
|
|
2557
|
+
try {
|
|
2558
|
+
name = new URL(req.url ?? "/", "http://x").searchParams.get("name") ?? "";
|
|
2559
|
+
} catch {}
|
|
2560
|
+
const detail = recordingDetail(name);
|
|
2561
|
+
if (detail === void 0) {
|
|
2562
|
+
sendText(res, 404, `no recording ${name}`);
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
sendJson(res, 200, detail);
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
if (path === `${base}/screenshot`) {
|
|
2569
|
+
const page = activePage();
|
|
2570
|
+
if (page === null || page.isClosed()) {
|
|
2571
|
+
sendText(res, 404, "browser not open");
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
try {
|
|
2575
|
+
const buffer = await page.screenshot({ type: "png" });
|
|
2576
|
+
res.writeHead(200, {
|
|
2577
|
+
"content-type": "image/png",
|
|
2578
|
+
"cache-control": "no-store",
|
|
2579
|
+
"content-length": buffer.length
|
|
2580
|
+
});
|
|
2581
|
+
res.end(buffer);
|
|
2582
|
+
} catch (error) {
|
|
2583
|
+
sendText(res, 500, `screenshot failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2584
|
+
}
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
sendText(res, 404, "not found");
|
|
2588
|
+
}
|
|
2589
|
+
}));
|
|
2590
|
+
ctx.effect(() => () => {
|
|
2591
|
+
for (const dispose of disposers) try {
|
|
2592
|
+
dispose();
|
|
2593
|
+
} catch {}
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
//#endregion
|
|
2597
|
+
//#region lib/types/embed.js
|
|
2598
|
+
/** 面板嵌入 + 实时画面 API 共用基路径。 */
|
|
2599
|
+
function embedJs(base) {
|
|
2600
|
+
return `(function () {
|
|
2601
|
+
"use strict";
|
|
2602
|
+
var B = ${JSON.stringify(base)};
|
|
2603
|
+
|
|
2604
|
+
// —— 挂载(等 body 就绪) ——
|
|
2605
|
+
var root = null, btn = null, panel = null, open = false, shot = null;
|
|
2606
|
+
var modal = null, mTitle = null, mShot = null, mHistory = null, mEmpty = null, modalOpen = false;
|
|
2607
|
+
var opsBox = null;
|
|
2608
|
+
|
|
2609
|
+
function css() {
|
|
2610
|
+
var s = document.createElement("style");
|
|
2611
|
+
s.textContent = [
|
|
2612
|
+
"#dsh-browser-fab{position:fixed;right:16px;bottom:16px;z-index:2147483000;width:40px;height:40px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-button-floating-fill);color:var(--dsw-alias-label-primary);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:18px;box-shadow:0 4px 16px var(--dsw-alias-bg-mask-2);transition:background .15s ease}",
|
|
2613
|
+
"#dsh-browser-fab:hover{background:var(--dsw-alias-button-floating-hover)}",
|
|
2614
|
+
"#dsh-browser-panel,#dsh-browser-panel *,.dsh-browser-modal-box,.dsh-browser-modal-box *{box-sizing:border-box}",
|
|
2615
|
+
"#dsh-browser-panel{position:fixed;right:16px;bottom:64px;z-index:2147483000;width:320px;max-height:70vh;overflow-y:auto;overflow-x:hidden;scrollbar-width:none;display:none;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-menu);box-shadow:0 8px 32px var(--dsw-alias-bg-mask-2);padding:12px}",
|
|
2616
|
+
"#dsh-browser-panel::-webkit-scrollbar{display:none}",
|
|
2617
|
+
"#dsh-browser-panel.open{display:flex}",
|
|
2618
|
+
".dsh-browser-row{display:flex;align-items:center;gap:8px;padding:6px 0}",
|
|
2619
|
+
".dsh-browser-dot{width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-label-dimmed);flex:none}",
|
|
2620
|
+
".dsh-browser-dot.ok{background:var(--dsw-alias-state-success-primary)}",
|
|
2621
|
+
".dsh-browser-dot.bad{background:var(--dsw-alias-state-error-primary)}",
|
|
2622
|
+
".dsh-browser-label{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
|
|
2623
|
+
".dsh-browser-value{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:150px}",
|
|
2624
|
+
".dsh-browser-shot{width:100%;display:none;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);cursor:pointer}",
|
|
2625
|
+
".dsh-browser-shot.on{display:block}",
|
|
2626
|
+
".dsh-browser-hint{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;display:none}",
|
|
2627
|
+
".dsh-browser-hint.on{display:block}",
|
|
2628
|
+
".dsh-browser-modal{position:fixed;inset:0;z-index:2147483001;display:none;align-items:center;justify-content:center;padding:24px;background:var(--dsw-alias-bg-mask-2)}",
|
|
2629
|
+
".dsh-browser-modal.open{display:flex}",
|
|
2630
|
+
".dsh-browser-modal-box{width:min(1200px,100%);max-height:90vh;display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-menu);padding:12px;box-shadow:0 16px 64px var(--dsw-alias-bg-mask-2)}",
|
|
2631
|
+
".dsh-browser-modal-head{display:flex;align-items:center;gap:8px}",
|
|
2632
|
+
".dsh-browser-modal-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}",
|
|
2633
|
+
".dsh-browser-modal-close{width:28px;height:28px;flex:none;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:14px;line-height:1}",
|
|
2634
|
+
".dsh-browser-modal-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
|
|
2635
|
+
".dsh-browser-modal-shot{width:100%;max-height:58vh;object-fit:contain;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}",
|
|
2636
|
+
".dsh-browser-modal-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;text-align:center;padding:24px 0}",
|
|
2637
|
+
".dsh-browser-history{display:none;flex-direction:column;gap:2px;max-height:24vh;overflow-y:auto;overflow-x:hidden;scrollbar-width:none;border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px}",
|
|
2638
|
+
".dsh-browser-history::-webkit-scrollbar{display:none}",
|
|
2639
|
+
".dsh-browser-history.on{display:flex}",
|
|
2640
|
+
".dsh-browser-history-title{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6;margin-bottom:4px}",
|
|
2641
|
+
".dsh-browser-history a{display:flex;gap:8px;align-items:center;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.6;text-decoration:none;border-radius:8px;padding:6px 8px;white-space:nowrap;overflow:hidden}",
|
|
2642
|
+
".dsh-browser-history a:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
|
|
2643
|
+
".dsh-browser-history .t{color:var(--dsw-alias-label-tertiary);flex:none;font-size:12px}",
|
|
2644
|
+
".dsh-browser-history .u{overflow:hidden;text-overflow:ellipsis}",
|
|
2645
|
+
".dsh-browser-ops{display:none;flex-direction:column;gap:2px;max-height:20vh;overflow-y:auto;overflow-x:hidden;scrollbar-width:none;border-top:1px solid var(--dsw-alias-border-l2);padding-top:6px;margin-top:2px}",
|
|
2646
|
+
".dsh-browser-ops::-webkit-scrollbar{display:none}",
|
|
2647
|
+
".dsh-browser-ops.on{display:flex}",
|
|
2648
|
+
".dsh-browser-ops-title{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6;margin-bottom:2px}",
|
|
2649
|
+
".dsh-browser-op{display:flex;gap:6px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:1px 4px}",
|
|
2650
|
+
".dsh-browser-op .ok{color:var(--dsw-alias-state-success-primary);flex:none}",
|
|
2651
|
+
".dsh-browser-op .fail{color:var(--dsw-alias-state-error-primary);flex:none}",
|
|
2652
|
+
".dsh-browser-op .t{color:var(--dsw-alias-label-tertiary);flex:none}",
|
|
2653
|
+
".dsh-browser-op .n{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}",
|
|
2654
|
+
].join("\\n");
|
|
2655
|
+
document.head.appendChild(s);
|
|
2656
|
+
}
|
|
2657
|
+
function el(tag, cls, text) {
|
|
2658
|
+
var n = document.createElement(tag);
|
|
2659
|
+
if (cls) n.className = cls;
|
|
2660
|
+
if (text !== undefined) n.textContent = text;
|
|
2661
|
+
return n;
|
|
2662
|
+
}
|
|
2663
|
+
function probe(url) {
|
|
2664
|
+
return fetch(url, { cache: "no-store" })
|
|
2665
|
+
.then(function (r) { return r.ok ? r : null; })
|
|
2666
|
+
.catch(function () { return null; });
|
|
2667
|
+
}
|
|
2668
|
+
function fmtTime(ts) {
|
|
2669
|
+
var d = new Date(ts);
|
|
2670
|
+
var hh = ("0" + d.getHours()).slice(-2), mm = ("0" + d.getMinutes()).slice(-2);
|
|
2671
|
+
return hh + ":" + mm;
|
|
2672
|
+
}
|
|
2673
|
+
// —— 大屏模态框:点面板缩略图打开,2 秒轮询实时画面 + 访问历史 ——
|
|
2674
|
+
function renderHistory(list) {
|
|
2675
|
+
mHistory.innerHTML = "";
|
|
2676
|
+
if (!list || list.length === 0) { mHistory.className = "dsh-browser-history"; return; }
|
|
2677
|
+
mHistory.className = "dsh-browser-history on";
|
|
2678
|
+
mHistory.appendChild(el("div", "dsh-browser-history-title", "访问历史(" + list.length + " 条,点击在新标签打开)"));
|
|
2679
|
+
for (var i = 0; i < list.length; i++) {
|
|
2680
|
+
var e = list[i];
|
|
2681
|
+
var a = document.createElement("a");
|
|
2682
|
+
a.href = e.url; a.target = "_blank"; a.rel = "noopener";
|
|
2683
|
+
if (e.title) a.title = e.title;
|
|
2684
|
+
a.appendChild(el("span", "t", fmtTime(e.ts)));
|
|
2685
|
+
a.appendChild(el("span", "u", (e.title || e.url).slice(0, 100)));
|
|
2686
|
+
mHistory.appendChild(a);
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
function renderOps(list) {
|
|
2690
|
+
if (!opsBox) return;
|
|
2691
|
+
opsBox.innerHTML = "";
|
|
2692
|
+
if (!list || list.length === 0) { opsBox.className = "dsh-browser-ops"; return; }
|
|
2693
|
+
opsBox.className = "dsh-browser-ops on";
|
|
2694
|
+
opsBox.appendChild(el("div", "dsh-browser-ops-title", "最近操作(" + Math.min(list.length, 8) + " 条)"));
|
|
2695
|
+
var slice = list.slice(0, 8);
|
|
2696
|
+
for (var i = 0; i < slice.length; i++) {
|
|
2697
|
+
var op = slice[i];
|
|
2698
|
+
var row = el("div", "dsh-browser-op");
|
|
2699
|
+
row.appendChild(el("span", op.ok ? "ok" : "fail", op.ok ? "✓" : "✗"));
|
|
2700
|
+
row.appendChild(el("span", "t", fmtTime(op.ts)));
|
|
2701
|
+
var desc = op.tool + (op.error ? ": " + op.error : "");
|
|
2702
|
+
var nameEl = el("span", "n", desc);
|
|
2703
|
+
nameEl.title = desc;
|
|
2704
|
+
row.appendChild(nameEl);
|
|
2705
|
+
opsBox.appendChild(row);
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
function modalRefresh() {
|
|
2709
|
+
if (!modalOpen) return;
|
|
2710
|
+
probe(B + "/status").then(function (r) {
|
|
2711
|
+
if (r === null) {
|
|
2712
|
+
mEmpty.style.display = "block"; mEmpty.textContent = "连接失败";
|
|
2713
|
+
mShot.style.display = "none"; mTitle.textContent = "浏览器实时画面";
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
return r.json().then(function (s) {
|
|
2717
|
+
if (!s.open) {
|
|
2718
|
+
mEmpty.style.display = "block"; mEmpty.textContent = "浏览器尚未打开。让 agent 调用 browser_open。";
|
|
2719
|
+
mShot.style.display = "none"; mTitle.textContent = "浏览器实时画面";
|
|
2720
|
+
} else {
|
|
2721
|
+
mEmpty.style.display = "none"; mShot.style.display = "block";
|
|
2722
|
+
mTitle.textContent = (s.url || "(空白页)") + " — " + (s.title || "");
|
|
2723
|
+
mShot.src = B + "/screenshot?v=" + Date.now();
|
|
2724
|
+
}
|
|
2725
|
+
});
|
|
2726
|
+
}).catch(function () {});
|
|
2727
|
+
probe(B + "/history").then(function (r) {
|
|
2728
|
+
if (r !== null) r.json().then(function (h) { renderHistory(h.history || []); }).catch(function () {});
|
|
2729
|
+
}).catch(function () {});
|
|
2730
|
+
setTimeout(modalRefresh, 2000);
|
|
2731
|
+
}
|
|
2732
|
+
function openModal() {
|
|
2733
|
+
if (modal === null) return;
|
|
2734
|
+
modalOpen = true;
|
|
2735
|
+
modal.className = "dsh-browser-modal open";
|
|
2736
|
+
modalRefresh();
|
|
2737
|
+
}
|
|
2738
|
+
function closeModal() {
|
|
2739
|
+
modalOpen = false;
|
|
2740
|
+
if (modal !== null) modal.className = "dsh-browser-modal";
|
|
2741
|
+
}
|
|
2742
|
+
function buildModal() {
|
|
2743
|
+
modal = el("div", "dsh-browser-modal");
|
|
2744
|
+
var box = el("div", "dsh-browser-modal-box");
|
|
2745
|
+
var head = el("div", "dsh-browser-modal-head");
|
|
2746
|
+
mTitle = el("span", "dsh-browser-modal-title", "浏览器实时画面");
|
|
2747
|
+
head.appendChild(mTitle);
|
|
2748
|
+
var close = el("button", "dsh-browser-modal-close", "✕");
|
|
2749
|
+
close.addEventListener("click", closeModal);
|
|
2750
|
+
head.appendChild(close);
|
|
2751
|
+
mEmpty = el("div", "dsh-browser-modal-empty", "浏览器尚未打开");
|
|
2752
|
+
mShot = el("img", "dsh-browser-modal-shot"); mShot.alt = "browser"; mShot.style.display = "none";
|
|
2753
|
+
mHistory = el("div", "dsh-browser-history");
|
|
2754
|
+
box.appendChild(head); box.appendChild(mEmpty); box.appendChild(mShot); box.appendChild(mHistory);
|
|
2755
|
+
modal.appendChild(box);
|
|
2756
|
+
modal.addEventListener("click", function (ev) { if (ev.target === modal) closeModal(); });
|
|
2757
|
+
document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") closeModal(); });
|
|
2758
|
+
root.appendChild(modal);
|
|
2759
|
+
}
|
|
2760
|
+
function mount() {
|
|
2761
|
+
if (root !== null || document.getElementById("dsh-browser-client-overlay")) return;
|
|
2762
|
+
css();
|
|
2763
|
+
btn = el("button", null); btn.id = "dsh-browser-fab"; btn.title = "dsh-browser";
|
|
2764
|
+
btn.textContent = "🌐";
|
|
2765
|
+
panel = el("div", null); panel.id = "dsh-browser-panel";
|
|
2766
|
+
function row(id) { var r = el("div", "dsh-browser-row"); var d = el("span", "dsh-browser-dot"); r.appendChild(d); var l = el("span", "dsh-browser-label", id); r.appendChild(l); var v = el("span", "dsh-browser-value", "—"); r.appendChild(v); return { row: r, dot: d, value: v }; }
|
|
2767
|
+
var rB = row("浏览器");
|
|
2768
|
+
shot = el("img", "dsh-browser-shot"); shot.alt = "browser";
|
|
2769
|
+
shot.addEventListener("click", openModal); // 点缩略图 → 大屏模态框
|
|
2770
|
+
var hint = el("div", "dsh-browser-hint", "点缩略图看实时画面与访问历史;agent 用 browser_* 工具操作浏览器。");
|
|
2771
|
+
opsBox = el("div", "dsh-browser-ops");
|
|
2772
|
+
panel.appendChild(rB.row);
|
|
2773
|
+
panel.appendChild(shot);
|
|
2774
|
+
panel.appendChild(opsBox);
|
|
2775
|
+
panel.appendChild(hint);
|
|
2776
|
+
btn.addEventListener("click", function () { open = !open; panel.className = open ? "open" : ""; panel.id = "dsh-browser-panel"; });
|
|
2777
|
+
root = document.createElement("div");
|
|
2778
|
+
root.appendChild(btn); root.appendChild(panel);
|
|
2779
|
+
document.body.appendChild(root);
|
|
2780
|
+
buildModal();
|
|
2781
|
+
function refresh() {
|
|
2782
|
+
probe(B + "/status").then(function (r) {
|
|
2783
|
+
if (r === null) { rB.dot.className = "dsh-browser-dot bad"; rB.value.textContent = "未启用"; return; }
|
|
2784
|
+
r.json().then(function (s) {
|
|
2785
|
+
rB.dot.className = "dsh-browser-dot " + (s.open ? "ok" : "bad");
|
|
2786
|
+
rB.value.textContent = s.open ? (s.tabs + " 标签 · " + (s.url || "").slice(0, 24)) : "未打开";
|
|
2787
|
+
if (open) {
|
|
2788
|
+
if (s.open) { shot.className = "dsh-browser-shot on"; shot.src = B + "/screenshot?v=" + Date.now(); hint.className = "dsh-browser-hint"; }
|
|
2789
|
+
else { shot.className = "dsh-browser-shot"; hint.className = "dsh-browser-hint on"; }
|
|
2790
|
+
}
|
|
2791
|
+
}).catch(function () {});
|
|
2792
|
+
}).catch(function () {});
|
|
2793
|
+
probe(B + "/log").then(function (r) {
|
|
2794
|
+
if (r !== null) r.json().then(function (d) { renderOps(d.calls || []); }).catch(function () {});
|
|
2795
|
+
}).catch(function () {});
|
|
2796
|
+
}
|
|
2797
|
+
refresh();
|
|
2798
|
+
setInterval(refresh, 5000);
|
|
2799
|
+
}
|
|
2800
|
+
if (document.body) mount();
|
|
2801
|
+
else document.addEventListener("DOMContentLoaded", mount);
|
|
2802
|
+
})();`;
|
|
2803
|
+
}
|
|
2804
|
+
/** 原生 UI 注入:exact 路由提供 embed.js + tapIndex 注入 script 标签。 */
|
|
2805
|
+
function registerEmbed(webServer, base) {
|
|
2806
|
+
const embedPath = `${base}/embed.js`;
|
|
2807
|
+
const script = embedJs(base);
|
|
2808
|
+
const disposeRoute = webServer.register({
|
|
2809
|
+
kind: "exact",
|
|
2810
|
+
path: embedPath,
|
|
2811
|
+
handler: (req, res) => {
|
|
2812
|
+
if ((req.method ?? "GET") !== "GET") {
|
|
2813
|
+
sendText(res, 405, "method not allowed");
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
res.writeHead(200, {
|
|
2817
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
2818
|
+
"cache-control": "no-store",
|
|
2819
|
+
"content-length": Buffer.byteLength(script)
|
|
2820
|
+
});
|
|
2821
|
+
res.end(script);
|
|
2822
|
+
}
|
|
2823
|
+
});
|
|
2824
|
+
const scriptTag = `<script src="${embedPath}" defer><\/script>`;
|
|
2825
|
+
const disposeTap = webServer.tapIndex((html) => {
|
|
2826
|
+
if (html.includes("dsh-browser")) return html;
|
|
2827
|
+
const idx = html.lastIndexOf("</head>");
|
|
2828
|
+
if (idx === -1) return html;
|
|
2829
|
+
return `${html.slice(0, idx)}${scriptTag}${html.slice(idx)}`;
|
|
2830
|
+
});
|
|
2831
|
+
return () => {
|
|
2832
|
+
try {
|
|
2833
|
+
disposeRoute();
|
|
2834
|
+
disposeTap();
|
|
2835
|
+
} catch {}
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2838
|
+
function applyEmbed(ctx, webServer, base) {
|
|
2839
|
+
const dispose = registerEmbed(webServer, base);
|
|
2840
|
+
ctx.effect(() => () => {
|
|
2841
|
+
try {
|
|
2842
|
+
dispose();
|
|
2843
|
+
} catch {}
|
|
2844
|
+
});
|
|
2845
|
+
if (typeof webServer.port === "number") ctx.logger?.info?.(`dsh-browser: native UI widget injected (${base}/embed.js)`);
|
|
2846
|
+
return dispose;
|
|
2847
|
+
}
|
|
2848
|
+
//#endregion
|
|
2849
|
+
//#region lib/types/index.js
|
|
2850
|
+
const name = "dsh-browser-view";
|
|
2851
|
+
const inject = [];
|
|
2852
|
+
const SCHEMA = {
|
|
2853
|
+
enabled: {
|
|
2854
|
+
type: "boolean",
|
|
2855
|
+
optional: true
|
|
2856
|
+
},
|
|
2857
|
+
channel: { type: "string" },
|
|
2858
|
+
executablePath: { type: "string" },
|
|
2859
|
+
headless: { type: "boolean" },
|
|
2860
|
+
userDataDir: { type: "string" },
|
|
2861
|
+
profiles: { type: "any" },
|
|
2862
|
+
screenshotDir: { type: "string" },
|
|
2863
|
+
downloadDir: { type: "string" },
|
|
2864
|
+
screenshotMaxAgeDays: {
|
|
2865
|
+
type: "number",
|
|
2866
|
+
min: 0
|
|
2867
|
+
},
|
|
2868
|
+
screenshotMaxCount: {
|
|
2869
|
+
type: "number",
|
|
2870
|
+
min: 0
|
|
2871
|
+
},
|
|
2872
|
+
basePath: { type: "string" },
|
|
2873
|
+
recordBodies: { type: "boolean" },
|
|
2874
|
+
maxTextChars: {
|
|
2875
|
+
type: "number",
|
|
2876
|
+
min: 1
|
|
2877
|
+
},
|
|
2878
|
+
maxLinks: {
|
|
2879
|
+
type: "number",
|
|
2880
|
+
min: 1
|
|
2881
|
+
},
|
|
2882
|
+
timeoutMs: {
|
|
2883
|
+
type: "number",
|
|
2884
|
+
min: 1
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2887
|
+
const DEFAULT_CONFIG = {
|
|
2888
|
+
channel: "auto",
|
|
2889
|
+
executablePath: "",
|
|
2890
|
+
headless: false,
|
|
2891
|
+
userDataDir: "",
|
|
2892
|
+
profiles: {},
|
|
2893
|
+
screenshotDir: ".dsh-browser/screenshots",
|
|
2894
|
+
downloadDir: ".dsh-browser/downloads",
|
|
2895
|
+
screenshotMaxAgeDays: 7,
|
|
2896
|
+
screenshotMaxCount: 200,
|
|
2897
|
+
basePath: "/browser",
|
|
2898
|
+
recordBodies: true,
|
|
2899
|
+
maxTextChars: 2e4,
|
|
2900
|
+
maxLinks: 50,
|
|
2901
|
+
timeoutMs: 3e4
|
|
2902
|
+
};
|
|
2903
|
+
function apply(ctx, rawConfig) {
|
|
2904
|
+
const resolved = resolveConfig("dsh-browser-view", SCHEMA, DEFAULT_CONFIG, rawConfig);
|
|
2905
|
+
if (resolved.enabled === false) return;
|
|
2906
|
+
const config = resolved;
|
|
2907
|
+
const base = (config.basePath ?? "/browser").replace(/\/+$/, "");
|
|
2908
|
+
const loadedRecordings = loadRecordings();
|
|
2909
|
+
if (loadedRecordings > 0) ctx.logger?.info?.(`dsh-browser-view: loaded ${loadedRecordings} saved recording(s)`);
|
|
2910
|
+
registerTools(ctx, config);
|
|
2911
|
+
const sectionDispose = ctx.get("systemPrompt")?.section?.({
|
|
2912
|
+
name: "tool:browser",
|
|
2913
|
+
order: 200,
|
|
2914
|
+
text: "浏览器工具操作一个共享浏览器会话,支持多标签页与多配置文件。打开页面后先用 browser_snapshot 读文本与链接、browser_elements 读表单结构,再决定 browser_click / browser_type / browser_form;标签管理用 browser_tabs;下载用 browser_download;登录态用 browser_cookies。做自动化测试时:用 browser_wait_for 显式等待页面稳定/元素出现,用 browser_assert 断言(失败自动截图),用 browser_network 检查接口调用与 4xx/5xx,跑完一轮可用 browser_record save 保存操作序列、browser_replay 回放做回归。遇到需要登录的网站:先 browser_open 打开登录页,告知用户请在浏览器窗口中完成登录,再调用 browser_wait_for_login 等待(可给 successSelector/successUrl);配置 userDataDir 时登录态会持久化。用户可通过界面右下角面板的实时画面查看浏览器。"
|
|
2915
|
+
});
|
|
2916
|
+
if (sectionDispose !== void 0) ctx.effect(() => sectionDispose);
|
|
2917
|
+
registerBrowserApi(ctx, config, base);
|
|
2918
|
+
const webServer = ctx.get("webServer");
|
|
2919
|
+
if (webServer !== void 0) {
|
|
2920
|
+
applyEmbed(ctx, webServer, base);
|
|
2921
|
+
const disposeConfig = webServer.register({
|
|
2922
|
+
kind: "exact",
|
|
2923
|
+
path: "/__dsh-browser-view__/config",
|
|
2924
|
+
handler: (_req, res) => {
|
|
2925
|
+
sendJson(res, 200, { basePath: base });
|
|
2926
|
+
}
|
|
2927
|
+
});
|
|
2928
|
+
ctx.effect(() => () => {
|
|
2929
|
+
try {
|
|
2930
|
+
disposeConfig();
|
|
2931
|
+
} catch {}
|
|
2932
|
+
});
|
|
2933
|
+
}
|
|
2934
|
+
const sweepTimer = setInterval(() => {
|
|
2935
|
+
try {
|
|
2936
|
+
sweepScreenshotDir({
|
|
2937
|
+
maxAgeDays: config.screenshotMaxAgeDays,
|
|
2938
|
+
maxCount: config.screenshotMaxCount
|
|
2939
|
+
});
|
|
2940
|
+
} catch {}
|
|
2941
|
+
}, 36e5);
|
|
2942
|
+
sweepTimer.unref?.();
|
|
2943
|
+
ctx.effect(() => () => {
|
|
2944
|
+
clearInterval(sweepTimer);
|
|
2945
|
+
closeBrowser().catch(() => {});
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
//#endregion
|
|
2949
|
+
export { apply, inject, name };
|