wtagent 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +83 -0
- package/docs/technical-design.md +838 -0
- package/package.json +55 -0
- package/src/browser/cdp-browser.js +170 -0
- package/src/browser/chatgpt-web-adapter.js +717 -0
- package/src/browser/fake-web-model-adapter.js +86 -0
- package/src/browser/mode-selection.js +179 -0
- package/src/browser/native-login.js +49 -0
- package/src/cli/at-files.js +93 -0
- package/src/cli/main.js +629 -0
- package/src/cli/render-events.js +367 -0
- package/src/platform/chrome-discovery.js +94 -0
- package/src/platform/paths.js +73 -0
- package/src/policy/path-guard.js +114 -0
- package/src/policy/policy-engine.js +277 -0
- package/src/protocol/markers.js +64 -0
- package/src/protocol/prompt-builder.js +152 -0
- package/src/protocol/xml-protocol.js +333 -0
- package/src/runtime/agent-runtime.js +636 -0
- package/src/session/agent-session.js +569 -0
- package/src/session/canonical-transcript.js +145 -0
- package/src/session/session-export.js +202 -0
- package/src/session/task-session.js +5 -0
- package/src/shared/errors.js +52 -0
- package/src/shared/limits.js +31 -0
- package/src/tools/default-tools.js +555 -0
- package/src/tools/process-manager.js +116 -0
- package/src/tools/process-utils.js +31 -0
- package/src/tools/registry.js +129 -0
- package/src/tools/safe-env.js +67 -0
- package/src/tools/terminal-exec.js +118 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { launchAndConnectCdpChrome } from "./cdp-browser.js";
|
|
4
|
+
import { discoverChromeExecutable } from "../platform/chrome-discovery.js";
|
|
5
|
+
import { ensureDirectory } from "../platform/paths.js";
|
|
6
|
+
import { BrowserAdapterError } from "../shared/errors.js";
|
|
7
|
+
import {
|
|
8
|
+
runModeSelection,
|
|
9
|
+
slugMatchesToken,
|
|
10
|
+
normalizeToken,
|
|
11
|
+
} from "./mode-selection.js";
|
|
12
|
+
|
|
13
|
+
const CHATGPT_URL = "https://chatgpt.com/";
|
|
14
|
+
|
|
15
|
+
async function firstVisible(locators) {
|
|
16
|
+
for (const locator of locators) {
|
|
17
|
+
const count = await locator.count().catch(() => 0);
|
|
18
|
+
for (let index = 0; index < count; index += 1) {
|
|
19
|
+
const item = locator.nth(index);
|
|
20
|
+
if (await item.isVisible().catch(() => false)) {
|
|
21
|
+
return item;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function deltaFrom(previous, current) {
|
|
29
|
+
if (!previous) return current;
|
|
30
|
+
if (current.startsWith(previous)) return current.slice(previous.length);
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasCompleteAgentEnvelope(text) {
|
|
35
|
+
const trimmed = String(text ?? "").trim();
|
|
36
|
+
const start = trimmed.indexOf("<agent_response");
|
|
37
|
+
const endTag = "</agent_response>";
|
|
38
|
+
const end = trimmed.lastIndexOf(endTag);
|
|
39
|
+
// The envelope is "complete" as soon as both the opening and closing tags
|
|
40
|
+
// are present. ChatGPT may append trailing text or render rich cards after
|
|
41
|
+
// the XML, so we do not require the closing tag to be the last content.
|
|
42
|
+
return start >= 0 && end >= start;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class ChatGPTWebAdapter {
|
|
46
|
+
constructor({
|
|
47
|
+
profileDir,
|
|
48
|
+
chromePath,
|
|
49
|
+
baseUrl = CHATGPT_URL,
|
|
50
|
+
debug = false,
|
|
51
|
+
minimized = false,
|
|
52
|
+
}) {
|
|
53
|
+
this.profileDir = path.resolve(profileDir);
|
|
54
|
+
this.chromePath = chromePath;
|
|
55
|
+
this.baseUrl = baseUrl;
|
|
56
|
+
this.debug = debug;
|
|
57
|
+
this.minimized = minimized;
|
|
58
|
+
this.context = null;
|
|
59
|
+
this.cdpChrome = null;
|
|
60
|
+
this.page = null;
|
|
61
|
+
this.assistantCountBeforeSend = 0;
|
|
62
|
+
this.assistantIdBeforeSend = null;
|
|
63
|
+
this.assistantTextBeforeSend = "";
|
|
64
|
+
this.lastAssistantMessageId = null;
|
|
65
|
+
this.lastModeSelection = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async launch() {
|
|
69
|
+
if (this.context) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await ensureDirectory(this.profileDir);
|
|
73
|
+
const executablePath = discoverChromeExecutable(this.chromePath);
|
|
74
|
+
|
|
75
|
+
this.cdpChrome = await launchAndConnectCdpChrome({
|
|
76
|
+
executablePath,
|
|
77
|
+
profileDir: this.profileDir,
|
|
78
|
+
minimized: this.minimized,
|
|
79
|
+
});
|
|
80
|
+
this.context = this.cdpChrome.context;
|
|
81
|
+
this.page = this.context.pages()[0] ?? await this.context.newPage();
|
|
82
|
+
this.page.setDefaultTimeout(15_000);
|
|
83
|
+
this.page.setDefaultNavigationTimeout(60_000);
|
|
84
|
+
await this.page.goto(this.baseUrl, { waitUntil: "domcontentloaded" });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async close() {
|
|
88
|
+
await this.cdpChrome?.close();
|
|
89
|
+
this.cdpChrome = null;
|
|
90
|
+
this.context = null;
|
|
91
|
+
this.page = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Bring the window forward (used before asking the user to log in or solve a
|
|
95
|
+
// challenge). No-op when the window was never minimized.
|
|
96
|
+
async restoreWindow() {
|
|
97
|
+
if (this.minimized) {
|
|
98
|
+
await this.cdpChrome?.restore?.();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Send the window back to minimized after the user is done. Only re-minimizes
|
|
103
|
+
// when this run launched minimized in the first place.
|
|
104
|
+
async minimizeWindow() {
|
|
105
|
+
if (this.minimized) {
|
|
106
|
+
await this.cdpChrome?.minimize?.();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async getAuthState() {
|
|
111
|
+
this.#requirePage();
|
|
112
|
+
if (await this.#findLoginControl()) {
|
|
113
|
+
return "unauthenticated";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const body = await this.page.locator("body").innerText().catch(() => "");
|
|
117
|
+
if (
|
|
118
|
+
/log in to get answers|log in or sign up|sign up for free|get responses tailored to you|登录或注册|登录以|免费注册/i
|
|
119
|
+
.test(body)
|
|
120
|
+
) {
|
|
121
|
+
return "unauthenticated";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return await this.#findComposer() ? "authenticated" : "unknown";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async waitForManualLogin({ timeoutMs }) {
|
|
128
|
+
this.#requirePage();
|
|
129
|
+
const deadline = Date.now() + timeoutMs;
|
|
130
|
+
let consecutiveAuthenticatedChecks = 0;
|
|
131
|
+
|
|
132
|
+
while (Date.now() < deadline) {
|
|
133
|
+
if (await this.getAuthState() === "authenticated") {
|
|
134
|
+
consecutiveAuthenticatedChecks += 1;
|
|
135
|
+
if (consecutiveAuthenticatedChecks >= 5) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
consecutiveAuthenticatedChecks = 0;
|
|
140
|
+
}
|
|
141
|
+
await this.page.waitForTimeout(1_000);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
throw new BrowserAdapterError(
|
|
145
|
+
`Login was not detected within ${Math.round(timeoutMs / 60_000)} minutes.`,
|
|
146
|
+
{ code: "LOGIN_TIMEOUT" },
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async startConversation(conversationUrl = null) {
|
|
151
|
+
this.#requirePage();
|
|
152
|
+
let target = this.baseUrl;
|
|
153
|
+
if (conversationUrl) {
|
|
154
|
+
const parsed = new URL(conversationUrl);
|
|
155
|
+
const base = new URL(this.baseUrl);
|
|
156
|
+
if (parsed.protocol !== "https:" || parsed.hostname !== base.hostname) {
|
|
157
|
+
throw new BrowserAdapterError(
|
|
158
|
+
`Refusing to open a conversation outside ${base.hostname}.`,
|
|
159
|
+
{ code: "INVALID_CONVERSATION_URL" },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
target = parsed.href;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await this.page.goto(target, { waitUntil: "domcontentloaded" });
|
|
166
|
+
const composer = await this.#waitForComposer(30_000);
|
|
167
|
+
if (!composer) {
|
|
168
|
+
throw new BrowserAdapterError(
|
|
169
|
+
"ChatGPT composer was not found after opening a new conversation.",
|
|
170
|
+
{ code: "COMPOSER_NOT_FOUND" },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!conversationUrl && !await this.#isFreshConversation()) {
|
|
175
|
+
await this.#openNewConversation();
|
|
176
|
+
const freshComposer = await this.#waitForComposer(30_000);
|
|
177
|
+
if (!freshComposer || !await this.#isFreshConversation()) {
|
|
178
|
+
await this.#writeDiagnostics("conversation-not-fresh");
|
|
179
|
+
throw new BrowserAdapterError(
|
|
180
|
+
"ChatGPT did not open a verified empty conversation. "
|
|
181
|
+
+ "Refusing to send a new session prompt into an existing chat.",
|
|
182
|
+
{ code: "CONVERSATION_NOT_FRESH" },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async selectMode(mode) {
|
|
189
|
+
this.#requirePage();
|
|
190
|
+
if (!mode) {
|
|
191
|
+
return { status: "skipped", requested: mode, attempts: 0 };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const port = this.#modeSelectionPort();
|
|
195
|
+
const result = await runModeSelection(port, mode);
|
|
196
|
+
this.lastModeSelection = result;
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// DOM port for the language-independent selector in mode-selection.js. Every
|
|
201
|
+
// option is identified by a stable attribute slug (data-testid / data-* / id)
|
|
202
|
+
// rather than its localized label, and its disabled state is read from ARIA.
|
|
203
|
+
#modeSelectionPort() {
|
|
204
|
+
const page = this.page;
|
|
205
|
+
return {
|
|
206
|
+
alreadyOnMode: async (requested) => {
|
|
207
|
+
const messages = this.#assistantMessages();
|
|
208
|
+
if (await messages.count() === 0) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
const slug = await messages.last()
|
|
212
|
+
.getAttribute("data-message-model-slug")
|
|
213
|
+
.catch(() => null);
|
|
214
|
+
return Boolean(slug) && slugMatchesToken(slug, normalizeToken(requested));
|
|
215
|
+
},
|
|
216
|
+
hasSwitcher: async () => Boolean(await this.#findModelSwitcher()),
|
|
217
|
+
openMenu: async () => {
|
|
218
|
+
const switcher = await this.#findModelSwitcher();
|
|
219
|
+
if (switcher) {
|
|
220
|
+
await switcher.click().catch(() => null);
|
|
221
|
+
// Wait for the Radix menu portal to attach before reading options.
|
|
222
|
+
await page.locator('[role="menu"] [role="menuitem"], [role="menu"] [role="menuitemradio"]')
|
|
223
|
+
.first().waitFor({ state: "visible", timeout: 5_000 })
|
|
224
|
+
.catch(() => null);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
readOptions: async () => this.#readModeOptions(),
|
|
228
|
+
clickOption: async (index) => {
|
|
229
|
+
const option = this.#modeOptionLocators().nth(index);
|
|
230
|
+
try {
|
|
231
|
+
await option.click({ timeout: 5_000 });
|
|
232
|
+
return true;
|
|
233
|
+
} catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
waitClosed: async () => {
|
|
238
|
+
const ok = await page.locator('[role="menu"]').first()
|
|
239
|
+
.waitFor({ state: "hidden", timeout: 5_000 })
|
|
240
|
+
.then(() => true)
|
|
241
|
+
.catch(() => false);
|
|
242
|
+
return ok;
|
|
243
|
+
},
|
|
244
|
+
closeMenu: async () => {
|
|
245
|
+
await page.keyboard.press("Escape").catch(() => null);
|
|
246
|
+
await page.waitForTimeout(150);
|
|
247
|
+
},
|
|
248
|
+
writeDiagnostics: async (label) => this.#writeDiagnostics(label),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#modeOptionLocators() {
|
|
253
|
+
return this.page.locator(
|
|
254
|
+
'[role="menu"] [role="menuitemradio"], [role="menu"] [role="menuitem"]',
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Enumerates the open menu's options into the language-independent shape
|
|
259
|
+
// { index, slug, label, disabled }. `slug` is the first stable attribute
|
|
260
|
+
// found; `disabled` is read from ARIA / data-state, never from text.
|
|
261
|
+
async #readModeOptions() {
|
|
262
|
+
const locator = this.#modeOptionLocators();
|
|
263
|
+
const count = await locator.count().catch(() => 0);
|
|
264
|
+
const options = [];
|
|
265
|
+
for (let index = 0; index < count; index += 1) {
|
|
266
|
+
const item = locator.nth(index);
|
|
267
|
+
const [testId, dataValue, dataTestValue, id, ariaDisabled, dataDisabled, dataState, label] =
|
|
268
|
+
await Promise.all([
|
|
269
|
+
item.getAttribute("data-testid").catch(() => null),
|
|
270
|
+
item.getAttribute("data-value").catch(() => null),
|
|
271
|
+
item.getAttribute("data-test-value").catch(() => null),
|
|
272
|
+
item.getAttribute("id").catch(() => null),
|
|
273
|
+
item.getAttribute("aria-disabled").catch(() => null),
|
|
274
|
+
item.getAttribute("data-disabled").catch(() => null),
|
|
275
|
+
item.getAttribute("data-state").catch(() => null),
|
|
276
|
+
item.innerText().catch(() => ""),
|
|
277
|
+
]);
|
|
278
|
+
const slug = [testId, dataValue, dataTestValue, id].find(Boolean) ?? "";
|
|
279
|
+
const disabled = ariaDisabled === "true"
|
|
280
|
+
|| dataDisabled === "true"
|
|
281
|
+
|| dataDisabled === ""
|
|
282
|
+
|| dataState === "disabled"
|
|
283
|
+
|| !await item.isEnabled().catch(() => true);
|
|
284
|
+
options.push({
|
|
285
|
+
index,
|
|
286
|
+
slug,
|
|
287
|
+
label: (label ?? "").trim(),
|
|
288
|
+
disabled,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return options;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async getConversationUrl() {
|
|
295
|
+
this.#requirePage();
|
|
296
|
+
return this.page.url();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async getLastAssistantMessageId() {
|
|
300
|
+
return this.lastAssistantMessageId;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async sendMessage(text, { files = [] } = {}) {
|
|
304
|
+
this.#requirePage();
|
|
305
|
+
const urlBeforeSend = this.page.url();
|
|
306
|
+
const composer = await this.#waitForComposer(30_000);
|
|
307
|
+
if (!composer) {
|
|
308
|
+
throw new BrowserAdapterError(
|
|
309
|
+
"ChatGPT composer is unavailable.",
|
|
310
|
+
{ code: "COMPOSER_NOT_FOUND" },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await this.#dismissTransientOverlays();
|
|
315
|
+
|
|
316
|
+
// Attach any @file uploads before typing/sending. Upload is best-effort: a
|
|
317
|
+
// failure is reported to the caller but does not block sending the text.
|
|
318
|
+
let attachment = null;
|
|
319
|
+
if (files.length > 0) {
|
|
320
|
+
attachment = await this.attachFiles(files);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const assistantMessages = this.#assistantMessages();
|
|
324
|
+
this.assistantCountBeforeSend = await assistantMessages.count();
|
|
325
|
+
if (this.assistantCountBeforeSend > 0) {
|
|
326
|
+
const lastAssistant = assistantMessages.last();
|
|
327
|
+
this.assistantIdBeforeSend = await lastAssistant
|
|
328
|
+
.getAttribute("data-message-id")
|
|
329
|
+
.catch(() => null);
|
|
330
|
+
this.assistantTextBeforeSend = await this.#assistantText(lastAssistant);
|
|
331
|
+
} else {
|
|
332
|
+
this.assistantIdBeforeSend = null;
|
|
333
|
+
this.assistantTextBeforeSend = "";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
await composer.fill(text);
|
|
338
|
+
} catch {
|
|
339
|
+
await this.#dismissTransientOverlays();
|
|
340
|
+
await composer.focus();
|
|
341
|
+
await this.page.keyboard.press(
|
|
342
|
+
process.platform === "darwin" ? "Meta+A" : "Control+A",
|
|
343
|
+
);
|
|
344
|
+
await this.page.keyboard.insertText(text);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const sendButton = await firstVisible([
|
|
348
|
+
this.page.locator('[data-testid="send-button"]'),
|
|
349
|
+
this.page.getByRole("button", { name: /send|发送/i }),
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
if (sendButton && await sendButton.isEnabled().catch(() => false)) {
|
|
353
|
+
try {
|
|
354
|
+
await sendButton.click();
|
|
355
|
+
} catch {
|
|
356
|
+
await this.#dismissTransientOverlays();
|
|
357
|
+
await composer.press("Enter");
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
await composer.press("Enter");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!/^\/c\//.test(new URL(urlBeforeSend).pathname)) {
|
|
364
|
+
await this.page.waitForURL(
|
|
365
|
+
(url) => /^\/c\//.test(url.pathname),
|
|
366
|
+
{ timeout: 5_000 },
|
|
367
|
+
).catch(() => null);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { attachment };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Attaches local files to the composer via the hidden `#upload-files` input.
|
|
374
|
+
// Playwright's setInputFiles drives the <input type=file> directly, so no
|
|
375
|
+
// native OS file dialog is involved. Best-effort: returns which files were
|
|
376
|
+
// attached and any failures without throwing, so a broken selector or a
|
|
377
|
+
// rejected upload never aborts the turn.
|
|
378
|
+
async attachFiles(files) {
|
|
379
|
+
this.#requirePage();
|
|
380
|
+
const paths = (files ?? [])
|
|
381
|
+
.map((file) => (typeof file === "string" ? file : file?.path))
|
|
382
|
+
.filter(Boolean);
|
|
383
|
+
if (paths.length === 0) {
|
|
384
|
+
return { attached: [], failed: [] };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const input = await firstVisible([
|
|
388
|
+
this.page.locator("#upload-files"),
|
|
389
|
+
]) ?? this.page.locator('main input[type="file"]').first();
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
await input.setInputFiles(paths, { timeout: 15_000 });
|
|
393
|
+
} catch (error) {
|
|
394
|
+
await this.#writeDiagnostics("attach-files-failed");
|
|
395
|
+
return {
|
|
396
|
+
attached: [],
|
|
397
|
+
failed: paths.map((filePath) => ({ path: filePath, message: error.message })),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Wait for the composer to register the upload(s) so we do not send before
|
|
402
|
+
// ChatGPT has ingested them. Uploaded files render as previews/chips; poll
|
|
403
|
+
// for any thumbnail/remove-file control, with a bounded timeout.
|
|
404
|
+
await this.page.locator(
|
|
405
|
+
'[data-testid$="-file-thumbnail"], [data-testid*="attachment"], '
|
|
406
|
+
+ 'button[aria-label*="Remove" i], button[aria-label*="删除"]',
|
|
407
|
+
).first().waitFor({ state: "visible", timeout: 20_000 }).catch(() => null);
|
|
408
|
+
|
|
409
|
+
return { attached: [...paths], failed: [] };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async waitForTurnComplete({
|
|
413
|
+
timeoutMs,
|
|
414
|
+
stableWindowMs,
|
|
415
|
+
staleStopWindowMs = 15_000,
|
|
416
|
+
onDelta,
|
|
417
|
+
}) {
|
|
418
|
+
this.#requirePage();
|
|
419
|
+
const deadline = Date.now() + timeoutMs;
|
|
420
|
+
let lastText = "";
|
|
421
|
+
let stableSince = 0;
|
|
422
|
+
let sawAssistant = false;
|
|
423
|
+
|
|
424
|
+
while (Date.now() < deadline) {
|
|
425
|
+
await this.#throwIfBlockedPage();
|
|
426
|
+
|
|
427
|
+
const messages = this.#assistantMessages();
|
|
428
|
+
const count = await messages.count();
|
|
429
|
+
const lastMessage = count > 0 ? messages.last() : null;
|
|
430
|
+
const candidateText = lastMessage
|
|
431
|
+
? await this.#assistantText(lastMessage)
|
|
432
|
+
: "";
|
|
433
|
+
const candidateId = lastMessage
|
|
434
|
+
? await lastMessage.getAttribute("data-message-id").catch(() => null)
|
|
435
|
+
: null;
|
|
436
|
+
const hasNewAssistant = count > this.assistantCountBeforeSend
|
|
437
|
+
|| (
|
|
438
|
+
candidateId
|
|
439
|
+
&& candidateId !== this.assistantIdBeforeSend
|
|
440
|
+
)
|
|
441
|
+
|| (
|
|
442
|
+
candidateText
|
|
443
|
+
&& candidateText !== this.assistantTextBeforeSend
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
if (hasNewAssistant) {
|
|
447
|
+
sawAssistant = true;
|
|
448
|
+
const text = candidateText;
|
|
449
|
+
if (text !== lastText) {
|
|
450
|
+
const delta = deltaFrom(lastText, text);
|
|
451
|
+
lastText = text;
|
|
452
|
+
stableSince = Date.now();
|
|
453
|
+
if (delta) {
|
|
454
|
+
await onDelta?.(delta);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const stopVisible = await this.#isStopButtonVisible();
|
|
459
|
+
if (
|
|
460
|
+
lastText.trim()
|
|
461
|
+
&& stableSince > 0
|
|
462
|
+
&& (
|
|
463
|
+
(
|
|
464
|
+
!stopVisible
|
|
465
|
+
&& Date.now() - stableSince >= stableWindowMs
|
|
466
|
+
)
|
|
467
|
+
|| (
|
|
468
|
+
stopVisible
|
|
469
|
+
&& hasCompleteAgentEnvelope(lastText)
|
|
470
|
+
&& Date.now() - stableSince >= staleStopWindowMs
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
) {
|
|
474
|
+
this.lastAssistantMessageId = candidateId;
|
|
475
|
+
return lastText.trim();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
await this.page.waitForTimeout(sawAssistant ? 250 : 500);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
await this.#writeDiagnostics("turn-timeout");
|
|
483
|
+
throw new BrowserAdapterError(
|
|
484
|
+
`ChatGPT turn did not complete within ${Math.round(timeoutMs / 1000)} seconds.`,
|
|
485
|
+
{ code: "TURN_TIMEOUT" },
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async #findComposer() {
|
|
490
|
+
return await firstVisible([
|
|
491
|
+
this.page.locator("#prompt-textarea"),
|
|
492
|
+
this.page.locator('textarea[placeholder*="Message" i]'),
|
|
493
|
+
this.page.locator('textarea[placeholder*="消息"]'),
|
|
494
|
+
this.page.locator('div[contenteditable="true"][data-lexical-editor="true"]'),
|
|
495
|
+
this.page.locator('main div[contenteditable="true"]'),
|
|
496
|
+
]);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async #isFreshConversation() {
|
|
500
|
+
const current = new URL(this.page.url());
|
|
501
|
+
const base = new URL(this.baseUrl);
|
|
502
|
+
if (current.hostname !== base.hostname || /^\/c\//.test(current.pathname)) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
return await this.#conversationMessages().count() === 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async #openNewConversation() {
|
|
509
|
+
const control = await firstVisible([
|
|
510
|
+
this.page.locator('[data-testid="create-new-chat-button"]'),
|
|
511
|
+
this.page.getByRole("button", { name: /^(new chat|新聊天|新对话)$/i }),
|
|
512
|
+
this.page.getByRole("link", { name: /^(new chat|新聊天|新对话)$/i }),
|
|
513
|
+
this.page.locator('a[href="/"]'),
|
|
514
|
+
]);
|
|
515
|
+
if (control) {
|
|
516
|
+
await control.click().catch(() => null);
|
|
517
|
+
await this.page.waitForTimeout(500);
|
|
518
|
+
}
|
|
519
|
+
if (!await this.#isFreshConversation()) {
|
|
520
|
+
await this.page.goto(this.baseUrl, { waitUntil: "domcontentloaded" });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
#conversationMessages() {
|
|
525
|
+
return this.page.locator(
|
|
526
|
+
'[data-message-author-role="user"], [data-message-author-role="assistant"]',
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async #findLoginControl() {
|
|
531
|
+
return await firstVisible([
|
|
532
|
+
this.page.getByRole("button", {
|
|
533
|
+
name: /^(log in|sign in|登录)$/i,
|
|
534
|
+
}),
|
|
535
|
+
this.page.getByRole("link", {
|
|
536
|
+
name: /^(log in|sign in|登录)$/i,
|
|
537
|
+
}),
|
|
538
|
+
]);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async #waitForComposer(timeoutMs) {
|
|
542
|
+
const deadline = Date.now() + timeoutMs;
|
|
543
|
+
while (Date.now() < deadline) {
|
|
544
|
+
const composer = await this.#findComposer();
|
|
545
|
+
if (composer) return composer;
|
|
546
|
+
await this.page.waitForTimeout(500);
|
|
547
|
+
}
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
#assistantMessages() {
|
|
552
|
+
return this.page.locator('[data-message-author-role="assistant"]');
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async #assistantText(message) {
|
|
556
|
+
// ChatGPT may render the model's XML inside one of several code blocks,
|
|
557
|
+
// alongside rich cards (weather, charts, etc.) or other markdown. Search
|
|
558
|
+
// every code block for the envelope first; if none contains it, fall back
|
|
559
|
+
// to the full message text so the protocol layer can extract the envelope
|
|
560
|
+
// from wherever it landed.
|
|
561
|
+
const codeBlocks = message.locator("pre code");
|
|
562
|
+
const codeBlockCount = await codeBlocks.count();
|
|
563
|
+
for (let index = 0; index < codeBlockCount; index += 1) {
|
|
564
|
+
const code = await codeBlocks.nth(index).innerText().catch(() => "");
|
|
565
|
+
if (code.includes("<agent_response")) {
|
|
566
|
+
return code;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const fullText = await message.innerText().catch(() => "");
|
|
571
|
+
if (fullText.includes("<agent_response")) {
|
|
572
|
+
return fullText;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const markdown = message.locator(".markdown");
|
|
576
|
+
if (await markdown.count()) {
|
|
577
|
+
return await markdown.last().innerText().catch(() => "");
|
|
578
|
+
}
|
|
579
|
+
return fullText;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async #findModelSwitcher(timeoutMs = 15_000) {
|
|
583
|
+
const deadline = Date.now() + timeoutMs;
|
|
584
|
+
|
|
585
|
+
while (Date.now() < deadline) {
|
|
586
|
+
const direct = await firstVisible([
|
|
587
|
+
this.page.locator('[data-testid="model-switcher-dropdown-button"]'),
|
|
588
|
+
this.page.locator(
|
|
589
|
+
'main button.__composer-pill[aria-haspopup="menu"]',
|
|
590
|
+
),
|
|
591
|
+
this.page.locator('button[aria-label*="model" i]'),
|
|
592
|
+
this.page.locator('button[aria-label*="模式"]'),
|
|
593
|
+
]);
|
|
594
|
+
if (direct) {
|
|
595
|
+
return direct;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const menuButtons = this.page.locator(
|
|
599
|
+
'main button[aria-haspopup="menu"]:not([data-testid^="history-item-"])',
|
|
600
|
+
);
|
|
601
|
+
const count = await menuButtons.count();
|
|
602
|
+
for (let index = 0; index < count; index += 1) {
|
|
603
|
+
const button = menuButtons.nth(index);
|
|
604
|
+
if (!await button.isVisible().catch(() => false)) continue;
|
|
605
|
+
const text = (await button.innerText().catch(() => "")).trim();
|
|
606
|
+
const label = await button.getAttribute("aria-label").catch(() => "");
|
|
607
|
+
if (
|
|
608
|
+
/^(chatgpt|gpt(?:-[\w.]+)?|auto|instant|thinking|pro)$/i.test(text)
|
|
609
|
+
|| /model|模型|模式/i.test(label ?? "")
|
|
610
|
+
) {
|
|
611
|
+
return button;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
await this.page.waitForTimeout(250);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async #isStopButtonVisible() {
|
|
622
|
+
const stop = await firstVisible([
|
|
623
|
+
this.page.locator('[data-testid="stop-button"]'),
|
|
624
|
+
this.page.getByRole("button", {
|
|
625
|
+
name: /stop generating|stop|停止生成|停止/i,
|
|
626
|
+
}),
|
|
627
|
+
]);
|
|
628
|
+
return Boolean(stop);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async #dismissTransientOverlays() {
|
|
632
|
+
const beacon = this.page.locator("#modal-beacon");
|
|
633
|
+
if (!await beacon.isVisible().catch(() => false)) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
await this.page.keyboard.press("Escape").catch(() => null);
|
|
638
|
+
await this.page.waitForTimeout(250);
|
|
639
|
+
if (!await beacon.isVisible().catch(() => false)) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const dismiss = await firstVisible([
|
|
644
|
+
beacon.getByRole("button", {
|
|
645
|
+
name: /^(close|dismiss|not now|maybe later|skip|got it|done|关闭|取消|暂不|稍后|跳过|知道了|完成)$/i,
|
|
646
|
+
}),
|
|
647
|
+
beacon.locator(
|
|
648
|
+
'button[aria-label*="close" i], button[aria-label*="dismiss" i], button[aria-label*="关闭"]',
|
|
649
|
+
),
|
|
650
|
+
]);
|
|
651
|
+
if (dismiss) {
|
|
652
|
+
await dismiss.click().catch(() => null);
|
|
653
|
+
await this.page.waitForTimeout(250);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (await beacon.isVisible().catch(() => false)) {
|
|
657
|
+
await this.#writeDiagnostics("blocking-modal");
|
|
658
|
+
throw new BrowserAdapterError(
|
|
659
|
+
"A ChatGPT modal is blocking the composer and could not be dismissed safely.",
|
|
660
|
+
{ code: "BLOCKING_MODAL" },
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async #throwIfBlockedPage() {
|
|
666
|
+
const title = await this.page.title().catch(() => "");
|
|
667
|
+
const titleTokens = new Set(title.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
668
|
+
const challengeTitle =
|
|
669
|
+
(titleTokens.has("cloudflare") && titleTokens.has("attention")) ||
|
|
670
|
+
(titleTokens.has("verify") && titleTokens.has("human")) ||
|
|
671
|
+
(titleTokens.has("security") && titleTokens.has("check")) ||
|
|
672
|
+
(titleTokens.has("just") && titleTokens.has("moment"));
|
|
673
|
+
|
|
674
|
+
const challengeElement = await firstVisible([
|
|
675
|
+
this.page.locator('iframe[src*="challenges.cloudflare.com"]'),
|
|
676
|
+
this.page.locator('input[name="cf-turnstile-response"]'),
|
|
677
|
+
this.page.locator('#challenge-stage'),
|
|
678
|
+
this.page.locator('form[action*="/cdn-cgi/challenge-platform/"]'),
|
|
679
|
+
this.page.locator('[data-testid="challenge-stage"]'),
|
|
680
|
+
]);
|
|
681
|
+
|
|
682
|
+
if (!challengeTitle && !challengeElement) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// A CAPTCHA/challenge needs the user's eyes and hands — surface the window
|
|
687
|
+
// if it was minimized before reporting the block.
|
|
688
|
+
await this.restoreWindow();
|
|
689
|
+
throw new BrowserAdapterError("Browser access challenge detected.");
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async #writeDiagnostics(label) {
|
|
693
|
+
if (!this.debug || !this.page) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const directory = path.join(this.profileDir, "..", "diagnostics");
|
|
697
|
+
await ensureDirectory(directory);
|
|
698
|
+
const stamp = Date.now();
|
|
699
|
+
await Promise.all([
|
|
700
|
+
this.page.screenshot({
|
|
701
|
+
path: path.join(directory, `${stamp}-${label}.png`),
|
|
702
|
+
fullPage: true,
|
|
703
|
+
}).catch(() => null),
|
|
704
|
+
fs.writeFile(
|
|
705
|
+
path.join(directory, `${stamp}-${label}.html`),
|
|
706
|
+
await this.page.content(),
|
|
707
|
+
"utf8",
|
|
708
|
+
).catch(() => null),
|
|
709
|
+
]);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
#requirePage() {
|
|
713
|
+
if (!this.page) {
|
|
714
|
+
throw new BrowserAdapterError("Browser has not been launched.");
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|