xshat-lite 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +211 -0
- package/bin/xshat.mjs +36 -0
- package/package.json +60 -0
- package/src/config/default.mjs +620 -0
- package/src/index.mjs +2865 -0
- package/src/modules/agent-executor.mjs +1184 -0
- package/src/modules/agent-helpers.mjs +132 -0
- package/src/modules/agent-loop.mjs +181 -0
- package/src/modules/agent.mjs +152 -0
- package/src/modules/api-chat.mjs +212 -0
- package/src/modules/browser.mjs +1384 -0
- package/src/modules/chat-helpers.mjs +275 -0
- package/src/modules/chat.mjs +589 -0
- package/src/modules/docs.mjs +88 -0
- package/src/modules/local-intent-router.mjs +280 -0
- package/src/modules/logger.mjs +111 -0
- package/src/modules/multi-agent.mjs +183 -0
- package/src/modules/navigation-recovery.mjs +35 -0
- package/src/modules/provider-debug.mjs +98 -0
- package/src/modules/provider-pool.mjs +66 -0
- package/src/modules/provider-runtime.mjs +26 -0
- package/src/modules/provider-strategies.mjs +102 -0
- package/src/modules/response-parser.mjs +511 -0
- package/src/modules/response-pipeline.mjs +211 -0
- package/src/modules/runtime-maintenance.mjs +195 -0
- package/src/modules/session-browser.mjs +180 -0
- package/src/modules/session.mjs +370 -0
- package/src/modules/settings-menu.mjs +367 -0
- package/src/modules/task-runner.mjs +511 -0
- package/src/modules/task-store.mjs +262 -0
- package/src/modules/ui.mjs +2204 -0
- package/src/utils/config.mjs +257 -0
- package/src/utils/key-input.mjs +86 -0
- package/src/utils/storage.mjs +55 -0
|
@@ -0,0 +1,1384 @@
|
|
|
1
|
+
import { launchPersistentContext } from 'cloakbrowser';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import config from '../utils/config.mjs';
|
|
4
|
+
|
|
5
|
+
function profileDirForChannel(userDataDir, channel) {
|
|
6
|
+
return resolve(userDataDir, channel);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function nowIso() {
|
|
10
|
+
return new Date().toISOString();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function clipText(text, maxChars = 400) {
|
|
14
|
+
const normalized = String(text ?? '')
|
|
15
|
+
.replace(/\s+/g, ' ')
|
|
16
|
+
.trim();
|
|
17
|
+
|
|
18
|
+
if (normalized.length <= maxChars) {
|
|
19
|
+
return normalized;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return `${normalized.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pushLimited(list, item, maxEntries = 30) {
|
|
26
|
+
list.push(item);
|
|
27
|
+
if (list.length > maxEntries) {
|
|
28
|
+
list.splice(0, list.length - maxEntries);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createObservationStore(maxEntries = 30) {
|
|
33
|
+
return {
|
|
34
|
+
maxEntries,
|
|
35
|
+
updatedAt: null,
|
|
36
|
+
requests: [],
|
|
37
|
+
responses: [],
|
|
38
|
+
console: [],
|
|
39
|
+
pageErrors: [],
|
|
40
|
+
websockets: []
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pickHeader(headers = {}, name = '') {
|
|
45
|
+
const lowerName = String(name).toLowerCase();
|
|
46
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
47
|
+
if (String(key).toLowerCase() === lowerName) {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isTextLikeContentType(contentType = '') {
|
|
55
|
+
const value = String(contentType).toLowerCase();
|
|
56
|
+
return (
|
|
57
|
+
value.includes('json') ||
|
|
58
|
+
value.includes('text/') ||
|
|
59
|
+
value.includes('javascript') ||
|
|
60
|
+
value.includes('xml') ||
|
|
61
|
+
value.includes('html')
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function localStorageEntriesFromState(state = {}) {
|
|
66
|
+
return (state.origins || [])
|
|
67
|
+
.filter((origin) => origin?.origin && Array.isArray(origin.localStorage))
|
|
68
|
+
.map((origin) => ({
|
|
69
|
+
origin: origin.origin,
|
|
70
|
+
localStorage: origin.localStorage
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function splitSelectorCandidates(selector) {
|
|
75
|
+
const source = String(selector ?? '').trim();
|
|
76
|
+
if (!source) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const parts = [];
|
|
81
|
+
let current = '';
|
|
82
|
+
let quote = '';
|
|
83
|
+
let depth = 0;
|
|
84
|
+
|
|
85
|
+
for (let index = 0; index < source.length; index++) {
|
|
86
|
+
const char = source[index];
|
|
87
|
+
const prev = source[index - 1];
|
|
88
|
+
|
|
89
|
+
if (quote) {
|
|
90
|
+
current += char;
|
|
91
|
+
if (char === quote && prev !== '\\') {
|
|
92
|
+
quote = '';
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (char === '"' || char === '\'') {
|
|
98
|
+
quote = char;
|
|
99
|
+
current += char;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (char === '(' || char === '[') {
|
|
104
|
+
depth++;
|
|
105
|
+
current += char;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if ((char === ')' || char === ']') && depth > 0) {
|
|
110
|
+
depth--;
|
|
111
|
+
current += char;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (char === ',' && depth === 0) {
|
|
116
|
+
if (current.trim()) {
|
|
117
|
+
parts.push(current.trim());
|
|
118
|
+
}
|
|
119
|
+
current = '';
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
current += char;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (current.trim()) {
|
|
127
|
+
parts.push(current.trim());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return parts;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const genericEditableFallbacks = [
|
|
134
|
+
{ selector: 'textarea:not([disabled]):not([readonly])', weight: 120 },
|
|
135
|
+
{ selector: 'textarea[placeholder]', weight: 140 },
|
|
136
|
+
{ selector: 'input[type="text"]:not([disabled]):not([readonly])', weight: 105 },
|
|
137
|
+
{ selector: 'input[type="search"]:not([disabled]):not([readonly])', weight: 100 },
|
|
138
|
+
{ selector: 'input:not([type]):not([disabled]):not([readonly])', weight: 95 },
|
|
139
|
+
{ selector: '[contenteditable="true"]', weight: 135 },
|
|
140
|
+
{ selector: '[contenteditable="plaintext-only"]', weight: 135 },
|
|
141
|
+
{ selector: '[role="textbox"]', weight: 90 },
|
|
142
|
+
{ selector: 'textarea', weight: 60 },
|
|
143
|
+
{ selector: 'input', weight: 30 }
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const chatLikeInputPattern = /message|prompt|chat|ask|question|input|search|write|tell|输入|消息|提问|发送|对话|问题|搜索|写点/i;
|
|
147
|
+
|
|
148
|
+
function isRecoverableActionError(error) {
|
|
149
|
+
const message = String(error?.message || error || '').toLowerCase();
|
|
150
|
+
return [
|
|
151
|
+
'element is not visible',
|
|
152
|
+
'element is outside of the viewport',
|
|
153
|
+
'element is not attached to the dom',
|
|
154
|
+
'element is detached from document',
|
|
155
|
+
'another element',
|
|
156
|
+
'intercepts pointer events',
|
|
157
|
+
'not stable',
|
|
158
|
+
'timeout',
|
|
159
|
+
'strict mode violation'
|
|
160
|
+
].some((pattern) => message.includes(pattern));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function pickBestLocator(page, selector, {
|
|
164
|
+
requireEditable = false
|
|
165
|
+
} = {}) {
|
|
166
|
+
const locator = page.locator(selector);
|
|
167
|
+
const count = await locator.count().catch(() => 0);
|
|
168
|
+
|
|
169
|
+
if (count <= 0) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let fallback = null;
|
|
174
|
+
|
|
175
|
+
for (let index = 0; index < count; index++) {
|
|
176
|
+
const candidate = locator.nth(index);
|
|
177
|
+
|
|
178
|
+
if (!fallback) {
|
|
179
|
+
fallback = candidate;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const visible = typeof candidate.isVisible === 'function'
|
|
184
|
+
? await candidate.isVisible()
|
|
185
|
+
: true;
|
|
186
|
+
|
|
187
|
+
if (!visible) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!requireEditable) {
|
|
192
|
+
return candidate;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const editable = await candidate.evaluate((node) => {
|
|
196
|
+
if (!(node instanceof HTMLElement)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
|
|
201
|
+
return !node.disabled && !node.readOnly;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return node.isContentEditable;
|
|
205
|
+
}).catch(() => false);
|
|
206
|
+
|
|
207
|
+
if (editable) {
|
|
208
|
+
return candidate;
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// keep trying other matches
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return fallback;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function scoreEditableCandidate(meta = {}, baseWeight = 0) {
|
|
219
|
+
let score = Number(baseWeight) || 0;
|
|
220
|
+
const tag = String(meta.tag || '').toLowerCase();
|
|
221
|
+
const type = String(meta.type || '').toLowerCase();
|
|
222
|
+
const combinedHint = [
|
|
223
|
+
meta.placeholder,
|
|
224
|
+
meta.ariaLabel,
|
|
225
|
+
meta.name,
|
|
226
|
+
meta.id,
|
|
227
|
+
...(Array.isArray(meta.classes) ? meta.classes : [])
|
|
228
|
+
].join(' ');
|
|
229
|
+
|
|
230
|
+
if (meta.visible) {
|
|
231
|
+
score += 220;
|
|
232
|
+
} else {
|
|
233
|
+
score -= 220;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (meta.editable) {
|
|
237
|
+
score += 180;
|
|
238
|
+
} else {
|
|
239
|
+
score -= 280;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (tag === 'textarea') {
|
|
243
|
+
score += 80;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (tag === 'input') {
|
|
247
|
+
if (!type || ['text', 'search', 'email', 'url', 'tel'].includes(type)) {
|
|
248
|
+
score += 60;
|
|
249
|
+
} else {
|
|
250
|
+
score -= 120;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (meta.contentEditable) {
|
|
255
|
+
score += 75;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (meta.role === 'textbox') {
|
|
259
|
+
score += 40;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (chatLikeInputPattern.test(combinedHint)) {
|
|
263
|
+
score += 110;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if ((meta.textLength || 0) > 0) {
|
|
267
|
+
score += Math.min(30, Math.floor((meta.textLength || 0) / 8));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const area = Number(meta.area) || 0;
|
|
271
|
+
if (area > 0) {
|
|
272
|
+
score += Math.min(45, Math.floor(area / 1200));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return score;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function inspectEditableCandidate(locator) {
|
|
279
|
+
return locator.evaluate((node) => {
|
|
280
|
+
if (!(node instanceof HTMLElement)) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const style = window.getComputedStyle(node);
|
|
285
|
+
const rect = node.getBoundingClientRect();
|
|
286
|
+
const tag = node.tagName.toLowerCase();
|
|
287
|
+
const input = node instanceof HTMLInputElement ? node : null;
|
|
288
|
+
const textarea = node instanceof HTMLTextAreaElement ? node : null;
|
|
289
|
+
const editable = Boolean(
|
|
290
|
+
(input && !input.disabled && !input.readOnly) ||
|
|
291
|
+
(textarea && !textarea.disabled && !textarea.readOnly) ||
|
|
292
|
+
node.isContentEditable
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
tag,
|
|
297
|
+
type: input?.type || '',
|
|
298
|
+
role: node.getAttribute('role') || '',
|
|
299
|
+
placeholder: input?.placeholder || textarea?.placeholder || node.getAttribute('placeholder') || '',
|
|
300
|
+
ariaLabel: node.getAttribute('aria-label') || '',
|
|
301
|
+
name: node.getAttribute('name') || '',
|
|
302
|
+
id: node.id || '',
|
|
303
|
+
classes: typeof node.className === 'string'
|
|
304
|
+
? node.className.split(/\s+/).filter(Boolean).slice(0, 8)
|
|
305
|
+
: [],
|
|
306
|
+
contentEditable: node.isContentEditable,
|
|
307
|
+
editable,
|
|
308
|
+
visible: (
|
|
309
|
+
rect.width > 0 &&
|
|
310
|
+
rect.height > 0 &&
|
|
311
|
+
style.visibility !== 'hidden' &&
|
|
312
|
+
style.display !== 'none'
|
|
313
|
+
),
|
|
314
|
+
textLength: String(node.textContent || '').trim().length,
|
|
315
|
+
area: Math.round(rect.width * rect.height)
|
|
316
|
+
};
|
|
317
|
+
}).catch(() => null);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function findEditableFallbackLocator(page) {
|
|
321
|
+
const best = {
|
|
322
|
+
locator: null,
|
|
323
|
+
score: Number.NEGATIVE_INFINITY
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
for (const entry of genericEditableFallbacks) {
|
|
327
|
+
const locator = page.locator(entry.selector);
|
|
328
|
+
const count = await locator.count().catch(() => 0);
|
|
329
|
+
|
|
330
|
+
for (let index = 0; index < count; index++) {
|
|
331
|
+
const candidate = locator.nth(index);
|
|
332
|
+
const meta = await inspectEditableCandidate(candidate);
|
|
333
|
+
if (!meta) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const score = scoreEditableCandidate(meta, entry.weight);
|
|
338
|
+
if (score > best.score) {
|
|
339
|
+
best.locator = candidate;
|
|
340
|
+
best.score = score;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return best.locator;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export class BrowserManager {
|
|
349
|
+
constructor({
|
|
350
|
+
launcher = launchPersistentContext,
|
|
351
|
+
browserConfig = config.browser
|
|
352
|
+
} = {}) {
|
|
353
|
+
this.launcher = launcher;
|
|
354
|
+
this.browserConfig = { ...browserConfig };
|
|
355
|
+
this.channels = new Map();
|
|
356
|
+
this.chatManager = null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
setChatManager(chatManager = null) {
|
|
360
|
+
this.chatManager = chatManager || null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
getChatManager() {
|
|
364
|
+
return this.chatManager;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
_getChannel(name) {
|
|
368
|
+
if (!this.channels.has(name)) {
|
|
369
|
+
this.channels.set(name, {
|
|
370
|
+
browser: null,
|
|
371
|
+
page: null,
|
|
372
|
+
config: { ...this.browserConfig },
|
|
373
|
+
pendingNavigation: null,
|
|
374
|
+
lastNavigationError: null,
|
|
375
|
+
observation: createObservationStore(
|
|
376
|
+
Number(this.browserConfig.observationLimit) || 30
|
|
377
|
+
)
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return this.channels.get(name);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
_markChannelClosed(name = 'chat') {
|
|
385
|
+
const channel = this._getChannel(name);
|
|
386
|
+
channel.browser = null;
|
|
387
|
+
channel.page = null;
|
|
388
|
+
channel.pendingNavigation = null;
|
|
389
|
+
channel.lastNavigationError = null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
_isClosedError(error) {
|
|
393
|
+
const message = String(error?.message || error || '').toLowerCase();
|
|
394
|
+
return (
|
|
395
|
+
message.includes('has been closed') ||
|
|
396
|
+
message.includes('target page, context or browser has been closed') ||
|
|
397
|
+
message.includes('browser has been closed') ||
|
|
398
|
+
message.includes('context has been closed') ||
|
|
399
|
+
message.includes('page has been closed')
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async probeChannel(name = 'chat') {
|
|
404
|
+
const channel = this._getChannel(name);
|
|
405
|
+
if (!channel.browser || !channel.page) {
|
|
406
|
+
this._markChannelClosed(name);
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
if (typeof channel.page.url === 'function') {
|
|
412
|
+
channel.page.url();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (typeof channel.browser.pages === 'function') {
|
|
416
|
+
await channel.browser.pages();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return true;
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (this._isClosedError(error)) {
|
|
422
|
+
this._markChannelClosed(name);
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async launchChannel(name = 'chat', overrides = {}) {
|
|
430
|
+
const channel = this._getChannel(name);
|
|
431
|
+
const launchOptions = {
|
|
432
|
+
headless: overrides.headless ?? channel.config.headless,
|
|
433
|
+
humanize: overrides.humanize ?? channel.config.humanize,
|
|
434
|
+
userDataDir:
|
|
435
|
+
overrides.userDataDir ??
|
|
436
|
+
profileDirForChannel(channel.config.userDataDir, name)
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
channel.config = {
|
|
440
|
+
...channel.config,
|
|
441
|
+
...overrides,
|
|
442
|
+
...launchOptions
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
channel.browser = await this.launcher({
|
|
446
|
+
headless: launchOptions.headless,
|
|
447
|
+
humanize: launchOptions.humanize,
|
|
448
|
+
userDataDir: launchOptions.userDataDir
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
channel.observation = createObservationStore(
|
|
452
|
+
Number(channel.config.observationLimit) ||
|
|
453
|
+
Number(this.browserConfig.observationLimit) ||
|
|
454
|
+
30
|
|
455
|
+
);
|
|
456
|
+
channel.page = await channel.browser.newPage();
|
|
457
|
+
this.annotatePage(channel.page, name);
|
|
458
|
+
this.attachChannelObservers(name);
|
|
459
|
+
|
|
460
|
+
if (this.browserConfig.shareAuthState && name !== 'chat' && this.isChannelReady('chat')) {
|
|
461
|
+
await this.syncStorageState('chat', name);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return channel.page;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async init(overrides = {}) {
|
|
468
|
+
return this.initChannel('chat', overrides);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async initChannel(name = 'chat', overrides = {}) {
|
|
472
|
+
const channel = this._getChannel(name);
|
|
473
|
+
if (await this.probeChannel(name)) {
|
|
474
|
+
return channel.page;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return this.launchChannel(name, overrides);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async goto(url) {
|
|
481
|
+
return this.gotoChannel('chat', url);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async gotoChannel(name = 'chat', url) {
|
|
485
|
+
return this.navigateChannel(name, url, { background: false });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async navigateChannel(name = 'chat', url, {
|
|
489
|
+
background = false,
|
|
490
|
+
waitUntil = 'domcontentloaded',
|
|
491
|
+
timeout
|
|
492
|
+
} = {}) {
|
|
493
|
+
const channel = this._getChannel(name);
|
|
494
|
+
if (!await this.probeChannel(name)) {
|
|
495
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
channel.lastNavigationError = null;
|
|
499
|
+
const navigation = channel.page.goto(url, {
|
|
500
|
+
waitUntil,
|
|
501
|
+
timeout: timeout ?? channel.config.gotoTimeout
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
if (!background) {
|
|
505
|
+
await navigation;
|
|
506
|
+
return channel.page;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
channel.pendingNavigation = navigation
|
|
510
|
+
.catch((error) => {
|
|
511
|
+
channel.lastNavigationError = error;
|
|
512
|
+
return null;
|
|
513
|
+
})
|
|
514
|
+
.finally(() => {
|
|
515
|
+
channel.pendingNavigation = null;
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return channel.page;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
getCurrentUrl() {
|
|
522
|
+
return this.getCurrentUrlFor('chat');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
getCurrentUrlFor(name = 'chat') {
|
|
526
|
+
const channel = this._getChannel(name);
|
|
527
|
+
return channel.page ? channel.page.url() : null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async relaunch({ url, ...overrides } = {}) {
|
|
531
|
+
return this.relaunchChannel('chat', { url, ...overrides });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async relaunchChannel(name = 'chat', { url, ...overrides } = {}) {
|
|
535
|
+
const targetUrl = url ?? this.getCurrentUrlFor(name);
|
|
536
|
+
await this.closeChannel(name);
|
|
537
|
+
await this.launchChannel(name, overrides);
|
|
538
|
+
|
|
539
|
+
if (targetUrl) {
|
|
540
|
+
await this.gotoChannel(name, targetUrl);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const page = this.getPage(name);
|
|
544
|
+
if (typeof page?.bringToFront === 'function') {
|
|
545
|
+
await page.bringToFront();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return page;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
annotatePage(page, name = 'chat') {
|
|
552
|
+
if (!page || typeof page !== 'object') {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
try {
|
|
557
|
+
page.__xshatChannelName = name;
|
|
558
|
+
page.__xshatBrowserManager = this;
|
|
559
|
+
} catch {
|
|
560
|
+
// ignore non-extensible page wrappers
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
attachChannelObservers(name = 'chat') {
|
|
565
|
+
const channel = this._getChannel(name);
|
|
566
|
+
const page = channel.page;
|
|
567
|
+
if (!page || typeof page.on !== 'function') {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const observation = channel.observation;
|
|
572
|
+
const maxEntries = observation.maxEntries;
|
|
573
|
+
const markUpdated = () => {
|
|
574
|
+
observation.updatedAt = nowIso();
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const pushEntry = (bucket, entry) => {
|
|
578
|
+
pushLimited(bucket, {
|
|
579
|
+
time: nowIso(),
|
|
580
|
+
...entry
|
|
581
|
+
}, maxEntries);
|
|
582
|
+
markUpdated();
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
page.on('request', (request) => {
|
|
586
|
+
try {
|
|
587
|
+
pushEntry(observation.requests, {
|
|
588
|
+
method: request?.method?.() || 'GET',
|
|
589
|
+
url: request?.url?.() || '',
|
|
590
|
+
resourceType: request?.resourceType?.() || '',
|
|
591
|
+
navigation: Boolean(request?.isNavigationRequest?.())
|
|
592
|
+
});
|
|
593
|
+
} catch {
|
|
594
|
+
// ignore observer failures
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
page.on('requestfailed', (request) => {
|
|
599
|
+
try {
|
|
600
|
+
pushEntry(observation.requests, {
|
|
601
|
+
method: request?.method?.() || 'GET',
|
|
602
|
+
url: request?.url?.() || '',
|
|
603
|
+
resourceType: request?.resourceType?.() || '',
|
|
604
|
+
navigation: Boolean(request?.isNavigationRequest?.()),
|
|
605
|
+
failed: true,
|
|
606
|
+
failureText: request?.failure?.()?.errorText || 'request failed'
|
|
607
|
+
});
|
|
608
|
+
} catch {
|
|
609
|
+
// ignore observer failures
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
page.on('response', (response) => {
|
|
614
|
+
const entry = {
|
|
615
|
+
time: nowIso(),
|
|
616
|
+
url: response?.url?.() || '',
|
|
617
|
+
status: response?.status?.() || 0,
|
|
618
|
+
ok: Boolean(response?.ok?.()),
|
|
619
|
+
method: response?.request?.()?.method?.() || 'GET',
|
|
620
|
+
resourceType: response?.request?.()?.resourceType?.() || '',
|
|
621
|
+
contentType: '',
|
|
622
|
+
bodyPreview: '',
|
|
623
|
+
bodyCaptured: false
|
|
624
|
+
};
|
|
625
|
+
pushLimited(observation.responses, entry, maxEntries);
|
|
626
|
+
markUpdated();
|
|
627
|
+
|
|
628
|
+
Promise.resolve()
|
|
629
|
+
.then(async () => {
|
|
630
|
+
const headers = await response.headers();
|
|
631
|
+
entry.contentType = pickHeader(headers, 'content-type');
|
|
632
|
+
|
|
633
|
+
if (!isTextLikeContentType(entry.contentType)) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const bodyText = await response.text();
|
|
638
|
+
entry.bodyPreview = clipText(bodyText, 500);
|
|
639
|
+
entry.bodyCaptured = true;
|
|
640
|
+
markUpdated();
|
|
641
|
+
})
|
|
642
|
+
.catch(() => {});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
page.on('console', async (message) => {
|
|
646
|
+
try {
|
|
647
|
+
const type = message?.type?.() || 'log';
|
|
648
|
+
const text = clipText(message?.text?.() || '', 300);
|
|
649
|
+
if (!text) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
pushEntry(observation.console, {
|
|
654
|
+
type,
|
|
655
|
+
text
|
|
656
|
+
});
|
|
657
|
+
} catch {
|
|
658
|
+
// ignore observer failures
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
page.on('pageerror', (error) => {
|
|
663
|
+
try {
|
|
664
|
+
pushEntry(observation.pageErrors, {
|
|
665
|
+
message: clipText(error?.message || String(error), 400)
|
|
666
|
+
});
|
|
667
|
+
} catch {
|
|
668
|
+
// ignore observer failures
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
page.on('websocket', (socket) => {
|
|
673
|
+
try {
|
|
674
|
+
pushEntry(observation.websockets, {
|
|
675
|
+
url: socket?.url?.() || ''
|
|
676
|
+
});
|
|
677
|
+
} catch {
|
|
678
|
+
// ignore observer failures
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async openAgentPage(url, overrides = {}) {
|
|
684
|
+
const agentWasReady = this.isChannelReady('agent');
|
|
685
|
+
const allowVisibleBrowser = this.browserConfig.allowVisibleBrowser !== false;
|
|
686
|
+
|
|
687
|
+
await this.initChannel('agent', {
|
|
688
|
+
headless: allowVisibleBrowser ? false : true,
|
|
689
|
+
...overrides
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
if (
|
|
693
|
+
agentWasReady &&
|
|
694
|
+
this.browserConfig.shareAuthState &&
|
|
695
|
+
this.isChannelReady('chat')
|
|
696
|
+
) {
|
|
697
|
+
await this.syncStorageState('chat', 'agent');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
await this.navigateChannel('agent', url, {
|
|
701
|
+
background: true,
|
|
702
|
+
waitUntil: overrides.waitUntil || 'domcontentloaded',
|
|
703
|
+
timeout: overrides.timeout
|
|
704
|
+
});
|
|
705
|
+
const page = this.getPage('agent');
|
|
706
|
+
if (typeof page?.bringToFront === 'function') {
|
|
707
|
+
await page.bringToFront();
|
|
708
|
+
}
|
|
709
|
+
return page;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async exportStorageState(name = 'chat') {
|
|
713
|
+
const channel = this._getChannel(name);
|
|
714
|
+
if (!await this.probeChannel(name)) {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (!channel.browser || typeof channel.browser.storageState !== 'function') {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return channel.browser.storageState();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async importStorageState(name = 'agent', state = null) {
|
|
726
|
+
const channel = this._getChannel(name);
|
|
727
|
+
if (!state) {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (!await this.probeChannel(name)) {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (!channel.browser) {
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (Array.isArray(state.cookies) && state.cookies.length > 0) {
|
|
740
|
+
if (typeof channel.browser.addCookies === 'function') {
|
|
741
|
+
try {
|
|
742
|
+
await channel.browser.addCookies(state.cookies);
|
|
743
|
+
} catch (error) {
|
|
744
|
+
if (this._isClosedError(error)) {
|
|
745
|
+
this._markChannelClosed(name);
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const originStates = localStorageEntriesFromState(state);
|
|
754
|
+
if (originStates.length === 0 || typeof channel.browser.newPage !== 'function') {
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
let tempPage;
|
|
759
|
+
try {
|
|
760
|
+
tempPage = await channel.browser.newPage();
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (this._isClosedError(error)) {
|
|
763
|
+
this._markChannelClosed(name);
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
for (const originState of originStates) {
|
|
770
|
+
if (!originState.localStorage.length) {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
try {
|
|
775
|
+
await tempPage.goto(originState.origin, {
|
|
776
|
+
waitUntil: 'domcontentloaded',
|
|
777
|
+
timeout: channel.config.gotoTimeout
|
|
778
|
+
});
|
|
779
|
+
await tempPage.evaluate((entries) => {
|
|
780
|
+
for (const item of entries) {
|
|
781
|
+
if (!item?.name) {
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
localStorage.setItem(item.name, String(item.value ?? ''));
|
|
785
|
+
}
|
|
786
|
+
}, originState.localStorage);
|
|
787
|
+
} catch {
|
|
788
|
+
// 某些站点不允许直接访问 origin 根路径,这里静默跳过,cookies 仍会保留。
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
} finally {
|
|
792
|
+
if (typeof tempPage.close === 'function') {
|
|
793
|
+
await tempPage.close().catch(() => {});
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async syncStorageState(from = 'chat', to = 'agent') {
|
|
801
|
+
if (from === to) {
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const sourceReady = await this.probeChannel(from).catch(() => false);
|
|
806
|
+
const targetReady = await this.probeChannel(to).catch(() => false);
|
|
807
|
+
if (!sourceReady || !targetReady) {
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const state = await this.exportStorageState(from);
|
|
812
|
+
if (!state) {
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return this.importStorageState(to, state);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async waitForChannelNavigation(name = 'agent') {
|
|
820
|
+
const channel = this._getChannel(name);
|
|
821
|
+
if (channel.pendingNavigation) {
|
|
822
|
+
await channel.pendingNavigation;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
error: channel.lastNavigationError
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
getChannelObservation(name = 'chat', { limit = 5 } = {}) {
|
|
831
|
+
const channel = this._getChannel(name);
|
|
832
|
+
const observation = channel.observation || createObservationStore();
|
|
833
|
+
const slice = (items = []) => items.slice(-Math.max(1, limit));
|
|
834
|
+
|
|
835
|
+
return {
|
|
836
|
+
updatedAt: observation.updatedAt,
|
|
837
|
+
counts: {
|
|
838
|
+
requests: observation.requests.length,
|
|
839
|
+
responses: observation.responses.length,
|
|
840
|
+
console: observation.console.length,
|
|
841
|
+
pageErrors: observation.pageErrors.length,
|
|
842
|
+
websockets: observation.websockets.length
|
|
843
|
+
},
|
|
844
|
+
requests: slice(observation.requests),
|
|
845
|
+
responses: slice(observation.responses),
|
|
846
|
+
console: slice(observation.console),
|
|
847
|
+
pageErrors: slice(observation.pageErrors),
|
|
848
|
+
websockets: slice(observation.websockets)
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async readChannelSnapshot(name = 'agent', {
|
|
853
|
+
maxChars = 4000,
|
|
854
|
+
includeHtml = false,
|
|
855
|
+
includeDomSummary = false,
|
|
856
|
+
includeObservation = true
|
|
857
|
+
} = {}) {
|
|
858
|
+
const page = this.getPage(name);
|
|
859
|
+
if (!page) {
|
|
860
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
await this.waitForChannelNavigation(name);
|
|
864
|
+
|
|
865
|
+
const title = typeof page.title === 'function' ? await page.title() : '';
|
|
866
|
+
const url = typeof page.url === 'function' ? page.url() : '';
|
|
867
|
+
const bodyText = await page.evaluate(() => document.body?.innerText || '');
|
|
868
|
+
const text = String(bodyText ?? '').replace(/\s+/g, ' ').trim().slice(0, maxChars);
|
|
869
|
+
const snapshot = {
|
|
870
|
+
title,
|
|
871
|
+
url,
|
|
872
|
+
text
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
if (includeObservation) {
|
|
876
|
+
snapshot.observation = this.getChannelObservation(name);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (includeHtml && typeof page.content === 'function') {
|
|
880
|
+
try {
|
|
881
|
+
snapshot.html = clipText(await page.content(), maxChars);
|
|
882
|
+
} catch {
|
|
883
|
+
snapshot.html = '';
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (includeDomSummary) {
|
|
888
|
+
snapshot.dom = await this.inspectDom(name, {
|
|
889
|
+
selector: 'body',
|
|
890
|
+
maxNodes: 12,
|
|
891
|
+
textLimit: 80
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return snapshot;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async clickSelector(name = 'agent', selector, options = {}) {
|
|
899
|
+
const page = this.getPage(name);
|
|
900
|
+
if (!page) {
|
|
901
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
await this.waitForChannelNavigation(name);
|
|
905
|
+
const selectors = splitSelectorCandidates(selector);
|
|
906
|
+
const timeout = options.timeout ?? 5000;
|
|
907
|
+
const maxAttempts = Math.max(
|
|
908
|
+
1,
|
|
909
|
+
Number(options.retryCount ?? this.browserConfig.actionRetryCount) || 1
|
|
910
|
+
);
|
|
911
|
+
const retryDelayMs =
|
|
912
|
+
Number(options.retryDelayMs ?? this.browserConfig.actionRetryDelayMs) || 350;
|
|
913
|
+
let lastError = null;
|
|
914
|
+
|
|
915
|
+
for (const candidate of selectors) {
|
|
916
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
917
|
+
const locator = await pickBestLocator(page, candidate);
|
|
918
|
+
|
|
919
|
+
if (!locator) {
|
|
920
|
+
lastError = new Error(`未找到可点击元素: ${candidate}`);
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
try {
|
|
925
|
+
await locator.waitFor({
|
|
926
|
+
state: options.state || 'attached',
|
|
927
|
+
timeout
|
|
928
|
+
});
|
|
929
|
+
await locator.scrollIntoViewIfNeeded().catch(() => {});
|
|
930
|
+
if (typeof page.waitForTimeout === 'function') {
|
|
931
|
+
await page.waitForTimeout(Math.min(250, retryDelayMs));
|
|
932
|
+
}
|
|
933
|
+
await locator.click({
|
|
934
|
+
timeout,
|
|
935
|
+
force: options.force ?? false
|
|
936
|
+
});
|
|
937
|
+
if (options.waitForNavigation || options.afterWaitForNavigation) {
|
|
938
|
+
await page.waitForLoadState(options.navigationState || 'domcontentloaded', {
|
|
939
|
+
timeout: options.navigationTimeout ?? timeout
|
|
940
|
+
}).catch(() => {});
|
|
941
|
+
} else if (typeof page.waitForTimeout === 'function') {
|
|
942
|
+
await page.waitForTimeout(Math.min(300, retryDelayMs));
|
|
943
|
+
}
|
|
944
|
+
return;
|
|
945
|
+
} catch (error) {
|
|
946
|
+
lastError = error;
|
|
947
|
+
|
|
948
|
+
if (!isRecoverableActionError(error) || attempt >= maxAttempts) {
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
await locator.scrollIntoViewIfNeeded().catch(() => {});
|
|
953
|
+
await page.waitForTimeout(retryDelayMs);
|
|
954
|
+
|
|
955
|
+
try {
|
|
956
|
+
await locator.click({
|
|
957
|
+
timeout,
|
|
958
|
+
force: true
|
|
959
|
+
});
|
|
960
|
+
return;
|
|
961
|
+
} catch (forcedError) {
|
|
962
|
+
lastError = forcedError;
|
|
963
|
+
|
|
964
|
+
if (!isRecoverableActionError(forcedError) || attempt >= maxAttempts) {
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
await page.waitForTimeout(retryDelayMs);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
throw lastError || new Error(`点击失败: ${selector}`);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async typeSelector(name = 'agent', selector, text, options = {}) {
|
|
978
|
+
const page = this.getPage(name);
|
|
979
|
+
if (!page) {
|
|
980
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
await this.waitForChannelNavigation(name);
|
|
984
|
+
const selectors = splitSelectorCandidates(selector);
|
|
985
|
+
const timeout = options.timeout ?? 5000;
|
|
986
|
+
let locator = null;
|
|
987
|
+
|
|
988
|
+
for (const candidate of selectors) {
|
|
989
|
+
locator = await pickBestLocator(page, candidate, { requireEditable: true });
|
|
990
|
+
if (locator) {
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (!locator) {
|
|
996
|
+
locator = await findEditableFallbackLocator(page);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (!locator) {
|
|
1000
|
+
throw new Error(`未找到可输入元素: ${selector}(已尝试通用输入框回退)`);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
await locator.waitFor({
|
|
1004
|
+
state: options.state || 'attached',
|
|
1005
|
+
timeout
|
|
1006
|
+
});
|
|
1007
|
+
await locator.scrollIntoViewIfNeeded().catch(() => {});
|
|
1008
|
+
await locator.click({
|
|
1009
|
+
timeout,
|
|
1010
|
+
force: options.force ?? true
|
|
1011
|
+
});
|
|
1012
|
+
if (typeof locator.focus === 'function') {
|
|
1013
|
+
await locator.focus().catch(() => {});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (options.clear !== false) {
|
|
1017
|
+
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
|
1018
|
+
await page.keyboard.press(`${modifier}+A`).catch(() => {});
|
|
1019
|
+
await page.keyboard.press('Backspace').catch(() => {});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const inputValue = String(text ?? '');
|
|
1023
|
+
|
|
1024
|
+
try {
|
|
1025
|
+
if (typeof locator.fill === 'function') {
|
|
1026
|
+
await locator.fill(inputValue);
|
|
1027
|
+
} else if (page.keyboard?.insertText) {
|
|
1028
|
+
await page.keyboard.insertText(inputValue);
|
|
1029
|
+
} else if (page.keyboard?.type) {
|
|
1030
|
+
await page.keyboard.type(inputValue);
|
|
1031
|
+
}
|
|
1032
|
+
} catch {
|
|
1033
|
+
await locator.evaluate((node, value) => {
|
|
1034
|
+
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
|
|
1035
|
+
node.value = value;
|
|
1036
|
+
node.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1037
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (node instanceof HTMLElement) {
|
|
1042
|
+
node.focus();
|
|
1043
|
+
node.textContent = value;
|
|
1044
|
+
node.dispatchEvent(new InputEvent('input', {
|
|
1045
|
+
bubbles: true,
|
|
1046
|
+
data: value,
|
|
1047
|
+
inputType: 'insertText'
|
|
1048
|
+
}));
|
|
1049
|
+
}
|
|
1050
|
+
}, inputValue);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (options.pressEnter || options.submit) {
|
|
1054
|
+
await page.keyboard.press(options.submitKey || 'Enter').catch(() => {});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (typeof page.waitForTimeout === 'function') {
|
|
1058
|
+
await page.waitForTimeout(150);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async waitForText(name = 'agent', text, options = {}) {
|
|
1063
|
+
const page = this.getPage(name);
|
|
1064
|
+
if (!page) {
|
|
1065
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
await this.waitForChannelNavigation(name);
|
|
1069
|
+
|
|
1070
|
+
const timeout = options.timeout ?? 10000;
|
|
1071
|
+
if (options.selector) {
|
|
1072
|
+
await page.locator(options.selector).filter({ hasText: text }).first().waitFor({
|
|
1073
|
+
timeout
|
|
1074
|
+
});
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
await page.waitForFunction(
|
|
1079
|
+
(expected) => document.body?.innerText?.includes(expected),
|
|
1080
|
+
text,
|
|
1081
|
+
{ timeout }
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async waitForSelector(name = 'agent', selector, options = {}) {
|
|
1086
|
+
const page = this.getPage(name);
|
|
1087
|
+
if (!page) {
|
|
1088
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
await this.waitForChannelNavigation(name);
|
|
1092
|
+
const selectors = splitSelectorCandidates(selector);
|
|
1093
|
+
let lastError = null;
|
|
1094
|
+
|
|
1095
|
+
for (const candidate of selectors) {
|
|
1096
|
+
try {
|
|
1097
|
+
await page.locator(candidate).first().waitFor({
|
|
1098
|
+
state: options.state || 'visible',
|
|
1099
|
+
timeout: options.timeout ?? 10000
|
|
1100
|
+
});
|
|
1101
|
+
return;
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
lastError = error;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
throw lastError || new Error(`未等到选择器出现: ${selector}`);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async waitForNavigation(name = 'agent', options = {}) {
|
|
1111
|
+
const page = this.getPage(name);
|
|
1112
|
+
if (!page) {
|
|
1113
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
await this.waitForChannelNavigation(name);
|
|
1117
|
+
await page.waitForLoadState(options.state || 'domcontentloaded', {
|
|
1118
|
+
timeout: options.timeout ?? 10000
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
async pressKey(name = 'agent', key, options = {}) {
|
|
1123
|
+
const page = this.getPage(name);
|
|
1124
|
+
if (!page) {
|
|
1125
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
await this.waitForChannelNavigation(name);
|
|
1129
|
+
|
|
1130
|
+
if (options.selector) {
|
|
1131
|
+
const locator = page.locator(options.selector).first();
|
|
1132
|
+
await locator.click({
|
|
1133
|
+
timeout: options.timeout ?? 5000,
|
|
1134
|
+
force: options.force ?? true
|
|
1135
|
+
});
|
|
1136
|
+
if (typeof locator.focus === 'function') {
|
|
1137
|
+
await locator.focus();
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const repeat = Math.max(1, Number(options.repeat) || 1);
|
|
1142
|
+
for (let index = 0; index < repeat; index++) {
|
|
1143
|
+
await page.keyboard.press(key);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async hoverSelector(name = 'agent', selector, options = {}) {
|
|
1148
|
+
const page = this.getPage(name);
|
|
1149
|
+
if (!page) {
|
|
1150
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
await this.waitForChannelNavigation(name);
|
|
1154
|
+
await page.locator(selector).first().hover({
|
|
1155
|
+
timeout: options.timeout ?? 5000,
|
|
1156
|
+
force: options.force ?? true
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async setCheckbox(name = 'agent', selector, checked = true, options = {}) {
|
|
1161
|
+
const page = this.getPage(name);
|
|
1162
|
+
if (!page) {
|
|
1163
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
await this.waitForChannelNavigation(name);
|
|
1167
|
+
const locator = page.locator(selector).first();
|
|
1168
|
+
const method = checked ? 'check' : 'uncheck';
|
|
1169
|
+
await locator[method]({
|
|
1170
|
+
timeout: options.timeout ?? 5000,
|
|
1171
|
+
force: options.force ?? true
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async selectOption(name = 'agent', selector, value, options = {}) {
|
|
1176
|
+
const page = this.getPage(name);
|
|
1177
|
+
if (!page) {
|
|
1178
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
await this.waitForChannelNavigation(name);
|
|
1182
|
+
const locator = page.locator(selector).first();
|
|
1183
|
+
const payload =
|
|
1184
|
+
value && typeof value === 'object'
|
|
1185
|
+
? value
|
|
1186
|
+
: { value: String(value ?? '') };
|
|
1187
|
+
|
|
1188
|
+
await locator.selectOption(payload, {
|
|
1189
|
+
timeout: options.timeout ?? 5000
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async scrollPage(name = 'agent', options = {}) {
|
|
1194
|
+
const page = this.getPage(name);
|
|
1195
|
+
if (!page) {
|
|
1196
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
await this.waitForChannelNavigation(name);
|
|
1200
|
+
|
|
1201
|
+
if (options.selector) {
|
|
1202
|
+
await page.locator(options.selector).first().scrollIntoViewIfNeeded();
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const x = Number(options.x ?? options.left ?? 0);
|
|
1207
|
+
const y = Number(options.y ?? options.top ?? 0);
|
|
1208
|
+
const behavior = options.behavior === 'smooth' ? 'smooth' : 'auto';
|
|
1209
|
+
const mode = options.mode === 'to' ? 'to' : 'by';
|
|
1210
|
+
|
|
1211
|
+
await page.evaluate(
|
|
1212
|
+
({ scrollX, scrollY, scrollBehavior, scrollMode }) => {
|
|
1213
|
+
if (scrollMode === 'to') {
|
|
1214
|
+
window.scrollTo({
|
|
1215
|
+
left: scrollX,
|
|
1216
|
+
top: scrollY,
|
|
1217
|
+
behavior: scrollBehavior
|
|
1218
|
+
});
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
window.scrollBy({
|
|
1223
|
+
left: scrollX,
|
|
1224
|
+
top: scrollY,
|
|
1225
|
+
behavior: scrollBehavior
|
|
1226
|
+
});
|
|
1227
|
+
},
|
|
1228
|
+
{
|
|
1229
|
+
scrollX: x,
|
|
1230
|
+
scrollY: y,
|
|
1231
|
+
scrollBehavior: behavior,
|
|
1232
|
+
scrollMode: mode
|
|
1233
|
+
}
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async extractSelectorText(name = 'agent', selector, options = {}) {
|
|
1238
|
+
const page = this.getPage(name);
|
|
1239
|
+
if (!page) {
|
|
1240
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
await this.waitForChannelNavigation(name);
|
|
1244
|
+
const selectors = splitSelectorCandidates(selector);
|
|
1245
|
+
const texts = [];
|
|
1246
|
+
|
|
1247
|
+
for (const candidate of selectors) {
|
|
1248
|
+
const locator = page.locator(candidate);
|
|
1249
|
+
const count = await locator.count().catch(() => 0);
|
|
1250
|
+
const limit = options.all ? count : Math.min(count, 1);
|
|
1251
|
+
|
|
1252
|
+
for (let index = 0; index < limit; index++) {
|
|
1253
|
+
const text = await locator.nth(index).textContent().catch(() => '');
|
|
1254
|
+
const normalized = String(text ?? '').trim();
|
|
1255
|
+
if (normalized) {
|
|
1256
|
+
texts.push(normalized);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (texts.length > 0 && !options.all) {
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
return options.all ? texts.filter(Boolean) : (texts[0] || '');
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async screenshotChannel(name = 'agent', options = {}) {
|
|
1269
|
+
const page = this.getPage(name);
|
|
1270
|
+
if (!page) {
|
|
1271
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
await this.waitForChannelNavigation(name);
|
|
1275
|
+
await page.screenshot({
|
|
1276
|
+
path: options.path,
|
|
1277
|
+
fullPage: options.fullPage !== false
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async inspectDom(name = 'agent', options = {}) {
|
|
1282
|
+
const page = this.getPage(name);
|
|
1283
|
+
if (!page) {
|
|
1284
|
+
throw new Error(`浏览器页面未初始化: ${name}`);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
await this.waitForChannelNavigation(name);
|
|
1288
|
+
const selector = options.selector || 'body';
|
|
1289
|
+
const maxNodes = Math.max(1, Number(options.maxNodes) || 20);
|
|
1290
|
+
const textLimit = Math.max(20, Number(options.textLimit) || 120);
|
|
1291
|
+
|
|
1292
|
+
return page.evaluate(
|
|
1293
|
+
({ rootSelector, maxItems, maxTextLength }) => {
|
|
1294
|
+
const root = document.querySelector(rootSelector);
|
|
1295
|
+
if (!root) {
|
|
1296
|
+
return [];
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const nodes = [root, ...Array.from(root.querySelectorAll('*'))].slice(0, maxItems);
|
|
1300
|
+
return nodes.map((node) => ({
|
|
1301
|
+
tag: node.tagName.toLowerCase(),
|
|
1302
|
+
id: node.id || '',
|
|
1303
|
+
classes: node.className
|
|
1304
|
+
? String(node.className)
|
|
1305
|
+
.split(/\s+/)
|
|
1306
|
+
.filter(Boolean)
|
|
1307
|
+
: [],
|
|
1308
|
+
text: (node.innerText || node.textContent || '')
|
|
1309
|
+
.replace(/\s+/g, ' ')
|
|
1310
|
+
.trim()
|
|
1311
|
+
.slice(0, maxTextLength),
|
|
1312
|
+
name: node.getAttribute('name') || '',
|
|
1313
|
+
role: node.getAttribute('role') || '',
|
|
1314
|
+
href: node.getAttribute('href') || '',
|
|
1315
|
+
type: node.getAttribute('type') || '',
|
|
1316
|
+
placeholder: node.getAttribute('placeholder') || '',
|
|
1317
|
+
ariaLabel: node.getAttribute('aria-label') || '',
|
|
1318
|
+
title: node.getAttribute('title') || '',
|
|
1319
|
+
value:
|
|
1320
|
+
node instanceof HTMLInputElement ||
|
|
1321
|
+
node instanceof HTMLTextAreaElement
|
|
1322
|
+
? (node.value || '').slice(0, maxTextLength)
|
|
1323
|
+
: '',
|
|
1324
|
+
contenteditable: node.getAttribute('contenteditable') || '',
|
|
1325
|
+
visible: (() => {
|
|
1326
|
+
const rect = node.getBoundingClientRect();
|
|
1327
|
+
const style = window.getComputedStyle(node);
|
|
1328
|
+
return (
|
|
1329
|
+
rect.width > 0 &&
|
|
1330
|
+
rect.height > 0 &&
|
|
1331
|
+
style.visibility !== 'hidden' &&
|
|
1332
|
+
style.display !== 'none'
|
|
1333
|
+
);
|
|
1334
|
+
})()
|
|
1335
|
+
}));
|
|
1336
|
+
},
|
|
1337
|
+
{
|
|
1338
|
+
rootSelector: selector,
|
|
1339
|
+
maxItems: maxNodes,
|
|
1340
|
+
maxTextLength: textLimit
|
|
1341
|
+
}
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
getChannelState(name = 'agent') {
|
|
1346
|
+
const channel = this._getChannel(name);
|
|
1347
|
+
return {
|
|
1348
|
+
ready: this.isChannelReady(name),
|
|
1349
|
+
url: channel.page ? channel.page.url() : null,
|
|
1350
|
+
hasPendingNavigation: Boolean(channel.pendingNavigation),
|
|
1351
|
+
lastNavigationError: channel.lastNavigationError?.message || null,
|
|
1352
|
+
observation: this.getChannelObservation(name, { limit: 3 })
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
async close() {
|
|
1357
|
+
const names = [...this.channels.keys()];
|
|
1358
|
+
await Promise.all(names.map((name) => this.closeChannel(name)));
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
async closeChannel(name = 'chat') {
|
|
1362
|
+
const channel = this._getChannel(name);
|
|
1363
|
+
if (channel.browser) {
|
|
1364
|
+
await channel.browser.close().catch(() => {});
|
|
1365
|
+
this._markChannelClosed(name);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
getPage(name = 'chat') {
|
|
1370
|
+
const channel = this._getChannel(name);
|
|
1371
|
+
return channel.page;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
isReady() {
|
|
1375
|
+
return this.isChannelReady('chat');
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
isChannelReady(name = 'chat') {
|
|
1379
|
+
const channel = this._getChannel(name);
|
|
1380
|
+
return channel.browser !== null && channel.page !== null;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
export default new BrowserManager();
|