wtagent 0.3.0 → 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 +163 -9
- package/package.json +9 -8
- package/src/artifacts/artifact-store.js +154 -0
- package/src/audio/music-artifact.js +66 -0
- package/src/audio/native-music-receiver.js +91 -0
- package/src/browser/base-web-adapter.js +2418 -201
- package/src/browser/cdp-browser.js +223 -25
- package/src/browser/cdp-state.js +8 -5
- package/src/browser/chatgpt-dom.js +179 -0
- package/src/browser/chatgpt-web-adapter.js +831 -69
- package/src/browser/claude-web-adapter.js +13 -0
- package/src/browser/fake-web-model-adapter.js +152 -3
- package/src/browser/gemini-web-adapter.js +71 -20
- package/src/browser/glm-web-adapter.js +2 -2
- package/src/browser/grok-web-adapter.js +35 -3
- package/src/browser/rendered-text.js +11 -0
- package/src/cli/i18n.js +50 -20
- package/src/cli/main.js +124 -40
- package/src/cli/prompt-input.js +68 -0
- package/src/image/browser-image-driver.js +238 -0
- package/src/image/browser-image-session-pool.js +96 -0
- package/src/image/image-generation-service.js +101 -0
- package/src/image/image-provider-driver.js +22 -0
- package/src/image/native-image-receiver.js +66 -0
- package/src/image/provider-registry.js +23 -0
- package/src/image/providers/chatgpt-image-driver.js +211 -0
- package/src/image/providers/gemini-image-driver.js +100 -0
- package/src/image/providers/grok-image-driver.js +193 -0
- package/src/platform/command-launcher.js +1 -1
- package/src/platform/paths.js +19 -0
- package/src/platform/windows-diagnostics.js +9 -16
- package/src/policy/path-guard.js +5 -1
- package/src/policy/policy-engine.js +10 -5
- package/src/protocol/envelope.js +74 -0
- package/src/protocol/markers.js +16 -0
- package/src/protocol/prompt-builder.js +12 -9
- package/src/protocol/xml-protocol.js +36 -18
- package/src/runtime/agent-runtime.js +1383 -238
- package/src/session/agent-session.js +1047 -121
- package/src/tools/default-tools.js +1 -1
- package/src/tools/image-tools.js +92 -0
- package/src/tools/registry.js +13 -1
- package/docs/technical-design.md +0 -866
|
@@ -59,6 +59,12 @@ class CdpTimeoutError extends Error {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
function recoveryTargetUnavailable(message, cause = null) {
|
|
63
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
64
|
+
error.code = "RECOVERY_TARGET_UNAVAILABLE";
|
|
65
|
+
return error;
|
|
66
|
+
}
|
|
67
|
+
|
|
62
68
|
async function withTimeout(promise, timeoutMs, message) {
|
|
63
69
|
let timer;
|
|
64
70
|
try {
|
|
@@ -192,12 +198,49 @@ function sameOriginPath(left, right) {
|
|
|
192
198
|
}
|
|
193
199
|
}
|
|
194
200
|
|
|
201
|
+
async function getPageTargetId(context, page, { timeoutMs = 1_500 } = {}) {
|
|
202
|
+
if (!page || typeof context?.newCDPSession !== "function") {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
let session = null;
|
|
206
|
+
const probe = (async () => {
|
|
207
|
+
session = await context.newCDPSession(page).catch(() => null);
|
|
208
|
+
if (!session) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const { targetInfo } = await session.send("Target.getTargetInfo");
|
|
213
|
+
return targetInfo?.targetId ?? null;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
} finally {
|
|
217
|
+
await settleWithin(
|
|
218
|
+
Promise.resolve().then(() => session.detach()),
|
|
219
|
+
500,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
try {
|
|
224
|
+
return await withTimeout(
|
|
225
|
+
probe,
|
|
226
|
+
timeoutMs,
|
|
227
|
+
`Timed out reading the Chrome target identity after ${timeoutMs}ms.`,
|
|
228
|
+
);
|
|
229
|
+
} catch {
|
|
230
|
+
// A target that cannot prove its identity is never eligible for selection.
|
|
231
|
+
// The still-running probe owns eventual session cleanup through its finally.
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
195
236
|
export async function launchAndConnectCdpChrome({
|
|
196
237
|
executablePath,
|
|
197
238
|
profileDir,
|
|
198
239
|
url = "about:blank",
|
|
199
240
|
minimized = false,
|
|
200
241
|
preferredUrl = null,
|
|
242
|
+
preferredTargetId = null,
|
|
243
|
+
exactTargetOnly = false,
|
|
201
244
|
}, {
|
|
202
245
|
acquireProfileLock = acquireCdpProfileLock,
|
|
203
246
|
connectOverCDP = (endpoint) => chromium.connectOverCDP(endpoint),
|
|
@@ -214,7 +257,19 @@ export async function launchAndConnectCdpChrome({
|
|
|
214
257
|
waitForExit = waitForProcessExit,
|
|
215
258
|
waitForReady = waitForCdp,
|
|
216
259
|
connectTimeoutMs = 20_000,
|
|
260
|
+
transportCloseTimeoutMs = 1_500,
|
|
217
261
|
} = {}) {
|
|
262
|
+
if (
|
|
263
|
+
exactTargetOnly
|
|
264
|
+
&& (
|
|
265
|
+
typeof preferredTargetId !== "string"
|
|
266
|
+
|| preferredTargetId.trim().length === 0
|
|
267
|
+
)
|
|
268
|
+
) {
|
|
269
|
+
throw recoveryTargetUnavailable(
|
|
270
|
+
"Exact-target recovery requires a saved Chrome target ID.",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
218
273
|
const releaseProfileLock = await acquireProfileLock(profileDir);
|
|
219
274
|
let child = null;
|
|
220
275
|
let state = null;
|
|
@@ -227,9 +282,23 @@ export async function launchAndConnectCdpChrome({
|
|
|
227
282
|
}
|
|
228
283
|
|
|
229
284
|
try {
|
|
230
|
-
|
|
285
|
+
try {
|
|
286
|
+
state = await findReusable();
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (exactTargetOnly) {
|
|
289
|
+
throw recoveryTargetUnavailable(
|
|
290
|
+
"The saved Chrome instance could not be inspected for recovery.",
|
|
291
|
+
error,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
231
296
|
if (state) {
|
|
232
297
|
reused = true;
|
|
298
|
+
} else if (exactTargetOnly) {
|
|
299
|
+
throw recoveryTargetUnavailable(
|
|
300
|
+
"The saved Chrome instance is not available for exact-target recovery.",
|
|
301
|
+
);
|
|
233
302
|
} else {
|
|
234
303
|
await removeState(profileDir);
|
|
235
304
|
// A prior instance may have died leaving renderer children (and Chrome's
|
|
@@ -294,14 +363,28 @@ export async function launchAndConnectCdpChrome({
|
|
|
294
363
|
// without this guard Playwright hangs ~30s and leaves a dirty CDP state.
|
|
295
364
|
let context;
|
|
296
365
|
try {
|
|
297
|
-
if (reused) {
|
|
366
|
+
if (reused && !exactTargetOnly) {
|
|
298
367
|
await ensurePageTarget(state.endpoint);
|
|
299
368
|
}
|
|
300
|
-
|
|
301
|
-
connectOverCDP(state.endpoint)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
369
|
+
const connectPromise = Promise.resolve().then(() => (
|
|
370
|
+
connectOverCDP(state.endpoint)
|
|
371
|
+
));
|
|
372
|
+
try {
|
|
373
|
+
browser = await withTimeout(
|
|
374
|
+
connectPromise,
|
|
375
|
+
connectTimeoutMs,
|
|
376
|
+
`Timed out connecting to Chrome CDP at ${state.endpoint} after ${connectTimeoutMs}ms.`,
|
|
377
|
+
);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (error instanceof CdpTimeoutError) {
|
|
380
|
+
// connectOverCDP is not cancellable. If it completes after our timeout,
|
|
381
|
+
// disconnect that late transport instead of leaking ownership of Chrome.
|
|
382
|
+
void connectPromise.then((lateBrowser) => (
|
|
383
|
+
settleWithin(lateBrowser?.close?.(), transportCloseTimeoutMs)
|
|
384
|
+
)).catch(() => null);
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
305
388
|
// contexts() forces a real protocol round-trip, so it hangs too when the
|
|
306
389
|
// browser main thread is stuck — keep it inside the timeout budget.
|
|
307
390
|
const contexts = await withTimeout(
|
|
@@ -311,11 +394,25 @@ export async function launchAndConnectCdpChrome({
|
|
|
311
394
|
);
|
|
312
395
|
context = contexts[0];
|
|
313
396
|
} catch (error) {
|
|
397
|
+
if (exactTargetOnly) {
|
|
398
|
+
await settleWithin(
|
|
399
|
+
Promise.resolve().then(() => browser?.close()),
|
|
400
|
+
transportCloseTimeoutMs,
|
|
401
|
+
);
|
|
402
|
+
browser = null;
|
|
403
|
+
throw recoveryTargetUnavailable(
|
|
404
|
+
"The saved Chrome instance could not be attached for exact-target recovery.",
|
|
405
|
+
error,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
314
408
|
if (error instanceof CdpTimeoutError) {
|
|
315
409
|
// The verified-but-unusable instance we launched is a dead end. Kill it
|
|
316
410
|
// (only if we own it) and drop its CDP state so the next run starts
|
|
317
411
|
// clean instead of trying to reuse a hung endpoint.
|
|
318
|
-
await
|
|
412
|
+
await settleWithin(
|
|
413
|
+
Promise.resolve().then(() => browser?.close()),
|
|
414
|
+
transportCloseTimeoutMs,
|
|
415
|
+
);
|
|
319
416
|
if (!reused && child?.pid) {
|
|
320
417
|
await killTree(child.pid).catch(() => null);
|
|
321
418
|
}
|
|
@@ -328,25 +425,85 @@ export async function launchAndConnectCdpChrome({
|
|
|
328
425
|
throw error;
|
|
329
426
|
}
|
|
330
427
|
if (!context) {
|
|
331
|
-
throw
|
|
428
|
+
throw exactTargetOnly
|
|
429
|
+
? recoveryTargetUnavailable(
|
|
430
|
+
"The saved Chrome instance has no reusable browser context.",
|
|
431
|
+
)
|
|
432
|
+
: new Error("Chrome CDP connection did not expose a browser context.");
|
|
332
433
|
}
|
|
333
434
|
|
|
334
435
|
// A reused browser may still contain the previous conversation. Keep it
|
|
335
436
|
// intact and, when a preferred URL is given, reuse an existing tab already
|
|
336
437
|
// showing that conversation (origin + path) so resumed runs do not pile up
|
|
337
438
|
// tabs; otherwise create a fresh target for this CLI session.
|
|
439
|
+
const targetProbeDeadline = Date.now() + connectTimeoutMs;
|
|
440
|
+
const probeTargetId = async (candidate) => {
|
|
441
|
+
const remaining = targetProbeDeadline - Date.now();
|
|
442
|
+
if (remaining <= 0) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
return await getPageTargetId(context, candidate, {
|
|
446
|
+
timeoutMs: Math.min(1_500, remaining),
|
|
447
|
+
});
|
|
448
|
+
};
|
|
449
|
+
const pages = context.pages();
|
|
450
|
+
const existingPageUrls = pages.map((candidate) => (
|
|
451
|
+
candidate.url?.() ?? ""
|
|
452
|
+
));
|
|
338
453
|
let page;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
454
|
+
let preferredTabMatched = false;
|
|
455
|
+
let preferredTargetMatched = false;
|
|
456
|
+
let preferredTabAmbiguous = false;
|
|
457
|
+
let targetId = null;
|
|
458
|
+
if (exactTargetOnly) {
|
|
459
|
+
const matchingTargets = [];
|
|
460
|
+
for (const candidate of pages) {
|
|
461
|
+
const candidateTargetId = await probeTargetId(candidate);
|
|
462
|
+
if (candidateTargetId === preferredTargetId) {
|
|
463
|
+
matchingTargets.push({ page: candidate, targetId: candidateTargetId });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (matchingTargets.length !== 1) {
|
|
467
|
+
throw recoveryTargetUnavailable(
|
|
468
|
+
matchingTargets.length === 0
|
|
469
|
+
? "The exact saved Chrome target is no longer available."
|
|
470
|
+
: "The saved Chrome target identity is ambiguous.",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
page = matchingTargets[0].page;
|
|
474
|
+
targetId = matchingTargets[0].targetId;
|
|
475
|
+
preferredTargetMatched = true;
|
|
476
|
+
} else if (reused) {
|
|
477
|
+
if (preferredTargetId) {
|
|
478
|
+
for (const candidate of pages) {
|
|
479
|
+
const candidateTargetId = await probeTargetId(candidate);
|
|
480
|
+
if (candidateTargetId === preferredTargetId) {
|
|
481
|
+
page = candidate;
|
|
482
|
+
targetId = candidateTargetId;
|
|
483
|
+
preferredTargetMatched = true;
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (!page && preferredUrl) {
|
|
489
|
+
const matchingTabs = pages.filter((candidate) =>
|
|
342
490
|
sameOriginPath(candidate.url?.() ?? "", preferredUrl)
|
|
343
|
-
)
|
|
344
|
-
|
|
345
|
-
|
|
491
|
+
);
|
|
492
|
+
// Duplicate exact-URL tabs are ambiguous. Opening a fresh page is safer
|
|
493
|
+
// than attaching a pending result or follow-up to an arbitrary copy.
|
|
494
|
+
if (matchingTabs.length === 1) {
|
|
495
|
+
page = matchingTabs[0];
|
|
496
|
+
preferredTabMatched = true;
|
|
497
|
+
} else if (matchingTabs.length > 1) {
|
|
498
|
+
preferredTabAmbiguous = true;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
page ??= await context.newPage();
|
|
346
502
|
} else {
|
|
347
|
-
page =
|
|
503
|
+
page = pages[0] ?? await context.newPage();
|
|
348
504
|
}
|
|
349
|
-
|
|
505
|
+
targetId ??= await probeTargetId(page);
|
|
506
|
+
if (minimized && !exactTargetOnly) {
|
|
350
507
|
await setWindowState(context, page, "minimized");
|
|
351
508
|
}
|
|
352
509
|
|
|
@@ -358,35 +515,67 @@ export async function launchAndConnectCdpChrome({
|
|
|
358
515
|
page,
|
|
359
516
|
pid: state.pid,
|
|
360
517
|
reused,
|
|
518
|
+
targetId,
|
|
519
|
+
existingPageUrls,
|
|
520
|
+
exactTargetOnly,
|
|
521
|
+
preferredTabMatched,
|
|
522
|
+
preferredTargetMatched,
|
|
523
|
+
preferredTabAmbiguous,
|
|
361
524
|
// Minimize / restore the visible window on demand. The runtime restores
|
|
362
525
|
// the window when it needs the user (manual login, CAPTCHA) and
|
|
363
526
|
// re-minimizes afterward. Uses the live current page each time so it
|
|
364
527
|
// targets the window the user is actually looking at.
|
|
365
528
|
async minimize() {
|
|
366
|
-
return
|
|
529
|
+
return exactTargetOnly
|
|
530
|
+
? false
|
|
531
|
+
: await setWindowState(context, page, "minimized");
|
|
367
532
|
},
|
|
368
533
|
async restore() {
|
|
369
|
-
return
|
|
534
|
+
return exactTargetOnly
|
|
535
|
+
? false
|
|
536
|
+
: await setWindowState(context, page, "normal");
|
|
370
537
|
},
|
|
371
538
|
// Drops only the Playwright transport. Unlike close(), never asks Chrome
|
|
372
539
|
// to exit and never kills the process: used to recover from a dead CDP
|
|
373
540
|
// connection (e.g. after the Mac slept) while Chrome itself is alive.
|
|
374
541
|
// The profile lock stays held because the same CLI session will reconnect.
|
|
375
542
|
async disconnect() {
|
|
376
|
-
await settleWithin(
|
|
543
|
+
await settleWithin(
|
|
544
|
+
Promise.resolve().then(() => browser.close()),
|
|
545
|
+
transportCloseTimeoutMs,
|
|
546
|
+
);
|
|
377
547
|
},
|
|
378
548
|
// Leaves Chrome running (and its saved CDP state intact) but drops the
|
|
379
549
|
// Playwright transport and the profile lock so a later WTAgent process
|
|
380
550
|
// can reuse the window. Used after a failed run that wants the page
|
|
381
551
|
// left open for inspection without blocking the next launch.
|
|
382
552
|
async detach() {
|
|
383
|
-
|
|
384
|
-
|
|
553
|
+
try {
|
|
554
|
+
await settleWithin(
|
|
555
|
+
Promise.resolve().then(() => browser.close()),
|
|
556
|
+
transportCloseTimeoutMs,
|
|
557
|
+
);
|
|
558
|
+
} finally {
|
|
559
|
+
await releaseProfileLock();
|
|
560
|
+
}
|
|
385
561
|
},
|
|
386
562
|
async close() {
|
|
387
563
|
if (closePromise) {
|
|
388
564
|
return await closePromise;
|
|
389
565
|
}
|
|
566
|
+
if (exactTargetOnly) {
|
|
567
|
+
closePromise = (async () => {
|
|
568
|
+
try {
|
|
569
|
+
await settleWithin(
|
|
570
|
+
Promise.resolve().then(() => browser.close()),
|
|
571
|
+
transportCloseTimeoutMs,
|
|
572
|
+
);
|
|
573
|
+
} finally {
|
|
574
|
+
await releaseProfileLock();
|
|
575
|
+
}
|
|
576
|
+
})();
|
|
577
|
+
return await closePromise;
|
|
578
|
+
}
|
|
390
579
|
closePromise = (async () => {
|
|
391
580
|
let exited = false;
|
|
392
581
|
try {
|
|
@@ -396,10 +585,16 @@ export async function launchAndConnectCdpChrome({
|
|
|
396
585
|
.catch(() => null);
|
|
397
586
|
if (session) {
|
|
398
587
|
await settleWithin(session.send("Browser.close"), 1_500);
|
|
399
|
-
await settleWithin(
|
|
588
|
+
await settleWithin(
|
|
589
|
+
Promise.resolve().then(() => session.detach()),
|
|
590
|
+
500,
|
|
591
|
+
);
|
|
400
592
|
}
|
|
401
593
|
exited = await waitForExit(state.pid, 3_000);
|
|
402
|
-
await settleWithin(
|
|
594
|
+
await settleWithin(
|
|
595
|
+
Promise.resolve().then(() => browser.close()),
|
|
596
|
+
transportCloseTimeoutMs,
|
|
597
|
+
);
|
|
403
598
|
|
|
404
599
|
if (!exited) {
|
|
405
600
|
const childStillOwnsPid = child?.pid === state.pid
|
|
@@ -429,7 +624,10 @@ export async function launchAndConnectCdpChrome({
|
|
|
429
624
|
},
|
|
430
625
|
};
|
|
431
626
|
} catch (error) {
|
|
432
|
-
await
|
|
627
|
+
await settleWithin(
|
|
628
|
+
Promise.resolve().then(() => browser?.close()),
|
|
629
|
+
transportCloseTimeoutMs,
|
|
630
|
+
);
|
|
433
631
|
if (
|
|
434
632
|
child
|
|
435
633
|
&& child.exitCode == null
|
package/src/browser/cdp-state.js
CHANGED
|
@@ -545,7 +545,7 @@ export async function inspectCdpProfileState(profileDir, {
|
|
|
545
545
|
// live lock-holder pid from a recycled one: a process that started after the
|
|
546
546
|
// lock file was written cannot own it. Returns null when it cannot be
|
|
547
547
|
// determined (unknown process, platform tool unavailable).
|
|
548
|
-
async function processStartTimeMs(pid, { execFileImpl = execFileAsync } = {}) {
|
|
548
|
+
export async function processStartTimeMs(pid, { execFileImpl = execFileAsync } = {}) {
|
|
549
549
|
if (process.platform === "win32") {
|
|
550
550
|
const { stdout } = await execFileImpl(
|
|
551
551
|
"powershell.exe",
|
|
@@ -555,7 +555,7 @@ async function processStartTimeMs(pid, { execFileImpl = execFileAsync } = {}) {
|
|
|
555
555
|
"-Command",
|
|
556
556
|
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate`,
|
|
557
557
|
],
|
|
558
|
-
{ windowsHide: true },
|
|
558
|
+
{ windowsHide: true, timeout: 1_500 },
|
|
559
559
|
);
|
|
560
560
|
const parsed = Date.parse(String(stdout).trim());
|
|
561
561
|
return Number.isFinite(parsed) ? parsed : null;
|
|
@@ -563,10 +563,13 @@ async function processStartTimeMs(pid, { execFileImpl = execFileAsync } = {}) {
|
|
|
563
563
|
|
|
564
564
|
const { stdout } = await execFileImpl(
|
|
565
565
|
"ps",
|
|
566
|
-
["-p", String(pid), "-o", "
|
|
566
|
+
["-p", String(pid), "-o", "lstart="],
|
|
567
|
+
{
|
|
568
|
+
env: { ...process.env, LANG: "C", LC_ALL: "C" },
|
|
569
|
+
},
|
|
567
570
|
);
|
|
568
|
-
const
|
|
569
|
-
return Number.isFinite(
|
|
571
|
+
const parsed = Date.parse(String(stdout).trim());
|
|
572
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
570
573
|
}
|
|
571
574
|
|
|
572
575
|
export async function acquireCdpProfileLock(profileDir, {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// All supported ChatGPT DOM shapes live here. Text and localized headings must
|
|
2
|
+
// never supply a role, message identity, or ordering key.
|
|
3
|
+
const legacyUser = '[data-message-author-role="user"]';
|
|
4
|
+
const legacyAssistant = '[data-message-author-role="assistant"]:not([id^="request-placeholder-"])';
|
|
5
|
+
const modern = (role) => `[data-chatgpt-search-unit-key$=":${role}"]:not([id^="request-placeholder-"]):not(:has([data-message-author-role])):not([data-message-author-role] *)`;
|
|
6
|
+
export const CHATGPT_DOM = {
|
|
7
|
+
user: { legacy: legacyUser, modern: modern("user") },
|
|
8
|
+
assistant: { legacy: legacyAssistant, modern: modern("assistant") },
|
|
9
|
+
conversationLegacy: '[data-message-author-role="user"], [data-message-author-role="assistant"]',
|
|
10
|
+
composers: [
|
|
11
|
+
{ name: "prompt-textarea", selector: "#prompt-textarea" },
|
|
12
|
+
{ name: "markdown-composer", selector: '[data-composer-markdown][contenteditable="true"][role="textbox"]' },
|
|
13
|
+
{ name: "message-textarea-en", selector: 'textarea[placeholder*="Message" i]' },
|
|
14
|
+
{ name: "message-textarea-zh", selector: 'textarea[placeholder*="消息"]' },
|
|
15
|
+
{ name: "lexical-composer", selector: 'div[contenteditable="true"][data-lexical-editor="true"]' },
|
|
16
|
+
{ name: "main-contenteditable", selector: 'main div[contenteditable="true"]' },
|
|
17
|
+
],
|
|
18
|
+
send: '[data-testid="send-button"]',
|
|
19
|
+
stop: '[data-testid="stop-button"]',
|
|
20
|
+
};
|
|
21
|
+
CHATGPT_DOM.messages = [legacyUser, legacyAssistant, modern("user"), modern("assistant")].join(", ");
|
|
22
|
+
|
|
23
|
+
// This function is serialized into the page. Keep it self-contained, and pass
|
|
24
|
+
// the registry explicitly when requesting a capability sample.
|
|
25
|
+
export function readChatGPTDom(input) {
|
|
26
|
+
const options = input?.selector ? input : null;
|
|
27
|
+
const elements = typeof input === "string" || options
|
|
28
|
+
? [...document.querySelectorAll(options?.selector ?? input)]
|
|
29
|
+
: Array.isArray(input) ? input : [input];
|
|
30
|
+
const structure = { legacy: 0, modern: 0, unknown: 0, ambiguousIds: 0 };
|
|
31
|
+
const entries = elements.map((element, domIndex) => {
|
|
32
|
+
const legacyRole = element.getAttribute("data-message-author-role");
|
|
33
|
+
const key = element.getAttribute("data-chatgpt-search-unit-key") ?? "";
|
|
34
|
+
const modern = key.match(/^fallback-turn-(\d+):(\d+):(user|assistant)$/);
|
|
35
|
+
const role = legacyRole ?? modern?.[3] ?? null;
|
|
36
|
+
structure[legacyRole ? "legacy" : modern ? "modern" : "unknown"] += 1;
|
|
37
|
+
const wrapper = element.closest('[data-testid^="conversation-turn-"]');
|
|
38
|
+
const legacyTurn = wrapper?.getAttribute("data-testid")?.match(/^conversation-turn-(\d+)$/);
|
|
39
|
+
const messageIds = [...new Set((element.getAttribute("data-chatgpt-search-message-ids") ?? "").split(/\s+/).filter(Boolean))];
|
|
40
|
+
const legacyId = element.getAttribute("data-message-id");
|
|
41
|
+
if (legacyId == null && messageIds.length > 1) structure.ambiguousIds += 1;
|
|
42
|
+
const id = legacyId ?? (messageIds.length === 1 ? messageIds[0] : null);
|
|
43
|
+
// Thinking/tool blocks occupy positions too. Only the group and role define
|
|
44
|
+
// turn order; duplicate roles within a group are rejected by validation.
|
|
45
|
+
const turn = legacyTurn ? Number(legacyTurn[1])
|
|
46
|
+
: modern ? Number(modern[1]) * 2 + (role === "user" ? 1 : 2) : null;
|
|
47
|
+
let renderedText = element.innerText ?? element.textContent ?? "";
|
|
48
|
+
if (role === "assistant" && !renderedText.includes("<agent_response")) {
|
|
49
|
+
const codeTexts = [...element.querySelectorAll("pre code")].map((node) => node.innerText ?? node.textContent ?? "");
|
|
50
|
+
const envelope = codeTexts.find((text) => text.includes("<agent_response") && text.includes("</agent_response>"))
|
|
51
|
+
?? codeTexts.find((text) => text.includes("<agent_response"));
|
|
52
|
+
const markdown = [...element.querySelectorAll('.markdown, [data-markdown-text-style="assistant-message"]')].at(-1);
|
|
53
|
+
if (envelope) renderedText = envelope;
|
|
54
|
+
else if (markdown) renderedText = markdown.innerText ?? markdown.textContent ?? "";
|
|
55
|
+
}
|
|
56
|
+
return { domIndex, role, id, turn, renderedText };
|
|
57
|
+
});
|
|
58
|
+
const snapshot = { url: globalThis.location?.href ?? null, entries, structure };
|
|
59
|
+
if (options?.registry) {
|
|
60
|
+
const visible = (element) => element.getClientRects().length > 0
|
|
61
|
+
&& getComputedStyle(element).visibility !== "hidden";
|
|
62
|
+
const count = (selector) => [...document.querySelectorAll(selector)].filter(visible).length;
|
|
63
|
+
snapshot.capabilities = {
|
|
64
|
+
readyState: document.readyState,
|
|
65
|
+
composers: options.registry.composers.filter(({ selector }) => count(selector) > 0).map(({ name }) => name),
|
|
66
|
+
sendButton: count(options.registry.send) > 0,
|
|
67
|
+
stopButton: count(options.registry.stop) > 0,
|
|
68
|
+
placeholderCount: document.querySelectorAll('[id^="request-placeholder-"]').length,
|
|
69
|
+
// Unknown suffixes are evidence of a renderer change, not valid messages.
|
|
70
|
+
unknownSearchUnits: [...document.querySelectorAll('[data-chatgpt-search-unit-key]')]
|
|
71
|
+
.filter((element) => !/^fallback-turn-\d+:\d+:(user|assistant)$/.test(element.getAttribute("data-chatgpt-search-unit-key"))).length,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return snapshot;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Deliberately excludes message text, IDs, composer content, and raw attributes.
|
|
78
|
+
// This report may be collected even when screenshots/full HTML are disabled.
|
|
79
|
+
export function summarizeChatGPTDom(snapshot) {
|
|
80
|
+
if (!snapshot || !Array.isArray(snapshot.entries)) return { status: "unavailable" };
|
|
81
|
+
const entries = snapshot.entries;
|
|
82
|
+
const issues = [];
|
|
83
|
+
const countMissing = (test) => entries.filter(test).length;
|
|
84
|
+
const missing = {
|
|
85
|
+
role: countMissing((entry) => !["user", "assistant"].includes(entry.role)),
|
|
86
|
+
id: countMissing((entry) => typeof entry.id !== "string" || !entry.id.trim()),
|
|
87
|
+
turn: countMissing((entry) => !Number.isSafeInteger(entry.turn) || entry.turn < 0),
|
|
88
|
+
};
|
|
89
|
+
for (const [field, count] of Object.entries(missing)) if (count) issues.push(`missing-${field}`);
|
|
90
|
+
const ids = entries.map((entry) => entry.id).filter(Boolean);
|
|
91
|
+
if (new Set(ids).size !== ids.length) issues.push("duplicate-message-id");
|
|
92
|
+
if (entries.some((entry, index) => index > 0 && entry.turn <= entries[index - 1].turn)) issues.push("non-monotonic-turns");
|
|
93
|
+
if (snapshot.structure?.ambiguousIds) issues.push("ambiguous-message-ids");
|
|
94
|
+
if (snapshot.capabilities?.unknownSearchUnits) issues.push("unknown-search-unit-format");
|
|
95
|
+
if (snapshot.capabilities && !snapshot.capabilities.composers.length) issues.push("composer-not-found");
|
|
96
|
+
return {
|
|
97
|
+
status: issues.length ? "incomplete" : entries.length ? "recognized" : "empty",
|
|
98
|
+
structure: snapshot.structure,
|
|
99
|
+
capabilities: snapshot.capabilities,
|
|
100
|
+
counts: { total: entries.length, user: countMissing((entry) => entry.role === "user"), assistant: countMissing((entry) => entry.role === "assistant") },
|
|
101
|
+
missing,
|
|
102
|
+
issues,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseConversationTurn(value) {
|
|
107
|
+
const match = String(value ?? "").match(/^conversation-turn-(\d+)$/);
|
|
108
|
+
return match ? Number.parseInt(match[1], 10) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function readChatGPTMessageIdentity(message) {
|
|
112
|
+
if (await message.getAttribute("data-chatgpt-search-unit-key").catch(() => null)) {
|
|
113
|
+
const snapshot = await message.evaluate(readChatGPTDom).catch(() => null);
|
|
114
|
+
const entry = snapshot?.entries?.[0];
|
|
115
|
+
if (!entry || !["user", "assistant"].includes(entry.role)
|
|
116
|
+
|| !entry.id || !Number.isSafeInteger(entry.turn) || entry.turn < 0) {
|
|
117
|
+
return { id: null, turn: null };
|
|
118
|
+
}
|
|
119
|
+
return { id: entry.id, turn: entry.turn };
|
|
120
|
+
}
|
|
121
|
+
const [id, turn] = await Promise.all([
|
|
122
|
+
message.getAttribute("data-message-id").catch(() => null),
|
|
123
|
+
readLegacyTurn(message),
|
|
124
|
+
]);
|
|
125
|
+
return { id, turn };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readLegacyTurn(message) {
|
|
129
|
+
if (typeof message?.evaluate !== "function") {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const testId = await message.evaluate((element) => (
|
|
133
|
+
element.closest('[data-testid^="conversation-turn-"]')
|
|
134
|
+
?.getAttribute("data-testid") ?? null
|
|
135
|
+
)).catch(() => null);
|
|
136
|
+
return parseConversationTurn(testId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
export async function readChatGPTAssistantText(message, hasCompleteAgentEnvelope) {
|
|
141
|
+
if (await message.getAttribute("data-chatgpt-search-unit-key").catch(() => null)) {
|
|
142
|
+
const snapshot = await message.evaluate(readChatGPTDom).catch(() => null);
|
|
143
|
+
return snapshot?.entries?.[0]?.renderedText ?? "";
|
|
144
|
+
}
|
|
145
|
+
// Read the whole assistant turn first. A long XML response can contain
|
|
146
|
+
// Markdown fences inside CDATA; ChatGPT then splits the rendered response
|
|
147
|
+
// into many <pre><code> nodes and the first node contains the opening tag
|
|
148
|
+
// but not the closing tag. The parent innerText keeps the complete envelope
|
|
149
|
+
// and avoids one CDP round trip per nested code block on every poll.
|
|
150
|
+
const fullText = await message.innerText().catch(() => "");
|
|
151
|
+
if (fullText.includes("<agent_response")) {
|
|
152
|
+
return fullText;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Rare fallback for alternate renderers where the parent text omits code
|
|
156
|
+
// contents: prefer a complete code-block envelope, but retain a partial
|
|
157
|
+
// one so streaming progress remains visible until its closing tag arrives.
|
|
158
|
+
const codeBlocks = message.locator("pre code");
|
|
159
|
+
const codeBlockCount = await codeBlocks.count();
|
|
160
|
+
let partialEnvelope = "";
|
|
161
|
+
for (let index = 0; index < codeBlockCount; index += 1) {
|
|
162
|
+
const code = await codeBlocks.nth(index).innerText().catch(() => "");
|
|
163
|
+
if (hasCompleteAgentEnvelope(code)) {
|
|
164
|
+
return code;
|
|
165
|
+
}
|
|
166
|
+
if (!partialEnvelope && code.includes("<agent_response")) {
|
|
167
|
+
partialEnvelope = code;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (partialEnvelope) {
|
|
171
|
+
return partialEnvelope;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const markdown = message.locator(".markdown");
|
|
175
|
+
if (await markdown.count()) {
|
|
176
|
+
return await markdown.last().innerText().catch(() => "");
|
|
177
|
+
}
|
|
178
|
+
return fullText;
|
|
179
|
+
}
|