wtagent 0.1.0 → 0.2.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.
@@ -1,279 +1,346 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { emitKeypressEvents } from "node:readline";
4
- import { launchAndConnectCdpChrome } from "./cdp-browser.js";
5
- import { discoverChromeExecutable } from "../platform/chrome-discovery.js";
6
- import { ensureDirectory } from "../platform/paths.js";
1
+ import { BaseWebAdapter, firstVisible, hasCompleteAgentEnvelope } from "./base-web-adapter.js";
7
2
  import { BrowserAdapterError } from "../shared/errors.js";
8
3
  import { isUsageLimitNotice } from "../shared/usage-limit.js";
9
4
  import {
10
5
  chooseModeOption,
11
6
  labelMatchesToken,
12
- runModeSelection,
13
7
  slugMatchesToken,
14
8
  normalizeToken,
15
9
  } from "./mode-selection.js";
16
10
 
17
- const CHATGPT_URL = "https://chatgpt.com/";
11
+ // Re-exported so existing importers (the runtime and browser-adapter tests)
12
+ // keep importing it from here even though it now lives on the provider-agnostic
13
+ // base module.
14
+ export { isConnectionLostError } from "./base-web-adapter.js";
18
15
 
19
- // Playwright error messages for a dead transport. The Chrome process itself is
20
- // usually still alive (e.g. the connection died while the Mac slept); these
21
- // errors mean "reconnect", not "the browser is gone".
22
- const CONNECTION_LOST_PATTERNS = [
23
- "target page, context or browser has been closed",
24
- "browser has been closed",
25
- "page has been closed",
26
- "connection closed",
27
- "connection is closed",
28
- ];
16
+ const CHATGPT_URL = "https://chatgpt.com/";
29
17
 
30
- export function isConnectionLostError(error) {
31
- const message = String(error?.message ?? "").toLowerCase();
32
- return CONNECTION_LOST_PATTERNS.some((pattern) => message.includes(pattern));
18
+ function parseConversationTurn(value) {
19
+ const match = String(value ?? "").match(/^conversation-turn-(\d+)$/);
20
+ return match ? Number.parseInt(match[1], 10) : null;
33
21
  }
34
22
 
35
- async function firstVisible(locators) {
36
- for (const locator of locators) {
37
- const count = await locator.count().catch(() => 0);
38
- for (let index = 0; index < count; index += 1) {
39
- const item = locator.nth(index);
40
- if (await item.isVisible().catch(() => false)) {
41
- return item;
42
- }
43
- }
23
+ // ChatGPT-specific implementation of the WebModelAdapter contract. Only the
24
+ // provider primitives (DOM locators, message identity, mode-switcher port,
25
+ // upload, overlay handling, usage-limit card) live here; all orchestration —
26
+ // launch/auth/send/reconnect and the turn-completion loop — is inherited from
27
+ // BaseWebAdapter.
28
+ export class ChatGPTWebAdapter extends BaseWebAdapter {
29
+ constructor(options = {}) {
30
+ super({
31
+ ...options,
32
+ baseUrl: options.baseUrl ?? CHATGPT_URL,
33
+ providerName: "ChatGPT",
34
+ });
44
35
  }
45
- return null;
46
- }
47
36
 
48
- function deltaFrom(previous, current) {
49
- if (!previous) return current;
50
- if (current.startsWith(previous)) return current.slice(previous.length);
51
- return "";
52
- }
37
+ // ---- provider primitives ----------------------------------------------
53
38
 
54
- function hasCompleteAgentEnvelope(text) {
55
- const trimmed = String(text ?? "").trim();
56
- const start = trimmed.indexOf("<agent_response");
57
- const endTag = "</agent_response>";
58
- const end = trimmed.lastIndexOf(endTag);
59
- // The envelope is "complete" as soon as both the opening and closing tags
60
- // are present. ChatGPT may append trailing text or render rich cards after
61
- // the XML, so we do not require the closing tag to be the last content.
62
- return start >= 0 && end >= start;
63
- }
39
+ conversationUrlPattern() {
40
+ return /^\/c\//;
41
+ }
64
42
 
65
- function sameConversationUrl(left, right) {
66
- try {
67
- const leftUrl = new URL(left);
68
- const rightUrl = new URL(right);
69
- return leftUrl.origin === rightUrl.origin
70
- && leftUrl.pathname === rightUrl.pathname;
71
- } catch {
72
- return false;
43
+ composerLocators() {
44
+ return [
45
+ this.page.locator("#prompt-textarea"),
46
+ this.page.locator('textarea[placeholder*="Message" i]'),
47
+ this.page.locator('textarea[placeholder*="消息"]'),
48
+ this.page.locator('div[contenteditable="true"][data-lexical-editor="true"]'),
49
+ this.page.locator('main div[contenteditable="true"]'),
50
+ ];
73
51
  }
74
- }
75
52
 
76
- function parseConversationTurn(value) {
77
- const match = String(value ?? "").match(/^conversation-turn-(\d+)$/);
78
- return match ? Number.parseInt(match[1], 10) : null;
79
- }
53
+ sendButtonLocators() {
54
+ return [
55
+ this.page.locator('[data-testid="send-button"]'),
56
+ this.page.getByRole("button", { name: /send|发送/i }),
57
+ ];
58
+ }
80
59
 
81
- export class ChatGPTWebAdapter {
82
- constructor({
83
- profileDir,
84
- chromePath,
85
- baseUrl = CHATGPT_URL,
86
- debug = false,
87
- minimized = false,
88
- stdinStream = process.stdin,
89
- cancelOnEsc = false,
90
- }) {
91
- this.profileDir = path.resolve(profileDir);
92
- this.chromePath = chromePath;
93
- this.baseUrl = baseUrl;
94
- this.debug = debug;
95
- this.minimized = minimized;
96
- this.stdinStream = stdinStream;
97
- this.cancelOnEsc = cancelOnEsc;
98
- this.escCancelRequested = false;
99
- this.context = null;
100
- this.cdpChrome = null;
101
- this.page = null;
102
- this.assistantIdsBeforeSend = new Set();
103
- this.assistantMaxTurnBeforeSend = null;
104
- this.sentUserTurn = null;
105
- this.lastAssistantMessageId = null;
106
- this.lastModeSelection = null;
60
+ stopButtonLocators() {
61
+ return [
62
+ this.page.locator('[data-testid="stop-button"]'),
63
+ this.page.getByRole("button", {
64
+ name: /stop generating|stop|停止生成|停止/i,
65
+ }),
66
+ ];
107
67
  }
108
68
 
109
- // `preferredUrl` lets a reused Chrome pick an existing tab that already
110
- // shows the conversation (instead of opening a new tab per run).
111
- async launch(preferredUrl = null) {
112
- if (this.context) {
113
- return;
114
- }
115
- await ensureDirectory(this.profileDir);
116
- const executablePath = discoverChromeExecutable(this.chromePath);
69
+ newConversationControls() {
70
+ return [
71
+ this.page.locator('[data-testid="create-new-chat-button"]'),
72
+ this.page.getByRole("button", { name: /^(new chat|新聊天|新对话)$/i }),
73
+ this.page.getByRole("link", { name: /^(new chat|新聊天|新对话)$/i }),
74
+ this.page.locator('a[href="/"]'),
75
+ ];
76
+ }
117
77
 
118
- this.cdpChrome = await launchAndConnectCdpChrome({
119
- executablePath,
120
- profileDir: this.profileDir,
121
- minimized: this.minimized,
122
- preferredUrl,
123
- });
124
- this.context = this.cdpChrome.context;
125
- this.page = this.cdpChrome.page;
126
- this.page.setDefaultTimeout(15_000);
127
- this.page.setDefaultNavigationTimeout(60_000);
128
- await this.page.goto(this.baseUrl, { waitUntil: "domcontentloaded" });
78
+ loginControlLocators() {
79
+ return [
80
+ this.page.getByRole("button", {
81
+ // ChatGPT ships many locales; cover the common ones for the login
82
+ // control. The /auth/ URL check below is the locale-independent
83
+ // primary signal — these patterns only improve guest-shell detection.
84
+ name: /^(log in|sign in|登录|ログイン|로그인|se connecter|anmelden|iniciar sesión|entrar|accedi)$/i,
85
+ }),
86
+ this.page.getByRole("link", {
87
+ name: /^(log in|sign in|登录|ログイン|로그인|se connecter|anmelden|iniciar sesión|entrar|accedi)$/i,
88
+ }),
89
+ ];
90
+ }
91
+
92
+ // Logged-out visitors are redirected to /auth/… — locale-independent.
93
+ authUrlPattern() {
94
+ return /^\/auth\//;
95
+ }
96
+
97
+ authTextPattern() {
98
+ return /log in to get answers|log in or sign up|sign up for free|get responses tailored to you|登录或注册|登录以|免费注册/i;
99
+ }
100
+
101
+ assistantMessages() {
102
+ // request-placeholder-* nodes are ChatGPT's in-flight/error stubs — the
103
+ // "正在思考" thinking slot and client error cards ("错误 d: 60758"-style).
104
+ // They carry the assistant role but are never real replies. Excluding them
105
+ // keeps a failed request from being read as an envelope-less reply and
106
+ // burned through the protocol-error retry loop; the turn instead sees "no
107
+ // reply" and recovers with the continuation nudge.
108
+ return this.page.locator(
109
+ '[data-message-author-role="assistant"]:not([id^="request-placeholder-"])',
110
+ );
111
+ }
112
+
113
+ userMessages() {
114
+ return this.page.locator('[data-message-author-role="user"]');
129
115
  }
130
116
 
131
- async close() {
132
- await this.cdpChrome?.close();
133
- this.cdpChrome = null;
134
- this.context = null;
135
- this.page = null;
117
+ conversationMessages() {
118
+ return this.page.locator(
119
+ '[data-message-author-role="user"], [data-message-author-role="assistant"]',
120
+ );
136
121
  }
137
122
 
138
- // Re-establishes the CDP connection to a still-alive Chrome after the
139
- // Playwright transport died mid-run (e.g. the Mac slept). launch() reuses
140
- // the saved CDP state, so Chrome is neither relaunched nor killed; an
141
- // existing tab on the preferred conversation is reused when available.
142
- async reconnect(preferredUrl = null) {
143
- await this.cdpChrome?.disconnect?.().catch(() => null);
144
- this.cdpChrome = null;
145
- this.context = null;
146
- this.page = null;
147
- await this.launch(preferredUrl);
123
+ async messageIdentity(message) {
124
+ const [id, turn] = await Promise.all([
125
+ message.getAttribute("data-message-id").catch(() => null),
126
+ this.#messageTurn(message),
127
+ ]);
128
+ return { id, turn };
148
129
  }
149
130
 
150
- // Bring the window forward (used before asking the user to log in or solve a
151
- // challenge). No-op when the window was never minimized.
152
- async restoreWindow() {
153
- if (this.minimized) {
154
- await this.cdpChrome?.restore?.();
131
+ async #messageTurn(message) {
132
+ if (typeof message?.evaluate !== "function") {
133
+ return null;
155
134
  }
135
+ const testId = await message.evaluate((element) => (
136
+ element.closest('[data-testid^="conversation-turn-"]')
137
+ ?.getAttribute("data-testid") ?? null
138
+ )).catch(() => null);
139
+ return parseConversationTurn(testId);
140
+ }
141
+
142
+ // The redesigned UI still exposes the stop button ([data-testid="stop-button"],
143
+ // aria-label 停止回答/Stop answering) — present while a generation is active
144
+ // (it stays visible through multi-minute pauses) and gone once the reply is
145
+ // finished. Verified live: every truncated-envelope acceptance happened with
146
+ // the button absent, and messages that later "grew" were ChatGPT REGENERATING
147
+ // the reply in place after the nudge, not paused generations resuming. So the
148
+ // signal is reliable and the short truncated-envelope grace applies.
149
+ hasReliableCompletionSignal() {
150
+ return true;
156
151
  }
157
152
 
158
- // Send the window back to minimized after the user is done. Only re-minimizes
159
- // when this run launched minimized in the first place.
160
- async minimizeWindow() {
161
- if (this.minimized) {
162
- await this.cdpChrome?.minimize?.();
153
+ // ChatGPT sometimes CONTINUES the previous message in place (same
154
+ // data-message-id) instead of starting a new one — e.g. when answering a
155
+ // format-retry nudge it appends the completion to the very message that was
156
+ // truncated. The id baseline alone would make that continuation invisible
157
+ // to the turn loop forever, so a baseline message whose text has GROWN
158
+ // since the send-time snapshot also counts as the new reply.
159
+ isNewAssistantIdentity({ id, turn, text }) {
160
+ if (super.isNewAssistantIdentity({ id, turn })) {
161
+ return true;
163
162
  }
163
+ const previous = String(this.lastAssistantTextBeforeSend ?? "");
164
+ return previous !== "" && String(text ?? "") !== previous;
164
165
  }
165
166
 
166
- async getAuthState() {
167
- this.#requirePage();
168
- if (await this.#findLoginControl()) {
169
- return "unauthenticated";
167
+ async assistantText(message) {
168
+ // Read the whole assistant turn first. A long XML response can contain
169
+ // Markdown fences inside CDATA; ChatGPT then splits the rendered response
170
+ // into many <pre><code> nodes and the first node contains the opening tag
171
+ // but not the closing tag. The parent innerText keeps the complete envelope
172
+ // and avoids one CDP round trip per nested code block on every poll.
173
+ const fullText = await message.innerText().catch(() => "");
174
+ if (fullText.includes("<agent_response")) {
175
+ return fullText;
170
176
  }
171
177
 
172
- const body = await this.page.locator("body").innerText().catch(() => "");
173
- if (
174
- /log in to get answers|log in or sign up|sign up for free|get responses tailored to you|登录或注册|登录以|免费注册/i
175
- .test(body)
176
- ) {
177
- return "unauthenticated";
178
+ // Rare fallback for alternate renderers where the parent text omits code
179
+ // contents: prefer a complete code-block envelope, but retain a partial
180
+ // one so streaming progress remains visible until its closing tag arrives.
181
+ const codeBlocks = message.locator("pre code");
182
+ const codeBlockCount = await codeBlocks.count();
183
+ let partialEnvelope = "";
184
+ for (let index = 0; index < codeBlockCount; index += 1) {
185
+ const code = await codeBlocks.nth(index).innerText().catch(() => "");
186
+ if (hasCompleteAgentEnvelope(code)) {
187
+ return code;
188
+ }
189
+ if (!partialEnvelope && code.includes("<agent_response")) {
190
+ partialEnvelope = code;
191
+ }
192
+ }
193
+ if (partialEnvelope) {
194
+ return partialEnvelope;
178
195
  }
179
196
 
180
- return await this.#findComposer() ? "authenticated" : "unknown";
197
+ const markdown = message.locator(".markdown");
198
+ if (await markdown.count()) {
199
+ return await markdown.last().innerText().catch(() => "");
200
+ }
201
+ return fullText;
181
202
  }
182
203
 
183
- async waitForManualLogin({ timeoutMs }) {
184
- this.#requirePage();
185
- const deadline = Date.now() + timeoutMs;
186
- let consecutiveAuthenticatedChecks = 0;
187
-
188
- while (Date.now() < deadline) {
189
- if (await this.getAuthState() === "authenticated") {
190
- consecutiveAuthenticatedChecks += 1;
191
- if (consecutiveAuthenticatedChecks >= 5) {
192
- return;
193
- }
194
- } else {
195
- consecutiveAuthenticatedChecks = 0;
196
- }
197
- await this.page.waitForTimeout(1_000);
204
+ // A plan/usage limit renders as an error card: error-tinted token classes
205
+ // (text-token-text-error / bg-token-surface-error) plus a regenerate button
206
+ // (data-testid="regenerate-thread-error-button"). Protocol replies are plain
207
+ // markdown and never contain those, so their presence confirms the matching
208
+ // text is a real notice rather than a reply that mentions "limit" in its
209
+ // content. Text stays the primary signal (a notice always says something
210
+ // recognizable in the UI language); the DOM features guard against false
211
+ // positives and future language additions.
212
+ // ChatGPT renders server-side generation failures as an error card —
213
+ // "Internal Server Error" / "Something went wrong" / 生成失败 — with a
214
+ // retry/regenerate button. A text match plus the retry affordance confirms
215
+ // it is a real failure card, never a deliberate answer.
216
+ async findGenerationErrorMarker(message) {
217
+ const text = await message.innerText().catch(() => "");
218
+ if (!/internal server error|something went wrong|生成失败|出错了|生成时发生错误/i.test(text)) {
219
+ return null;
198
220
  }
221
+ const control = await firstVisible([
222
+ message.getByRole("button", { name: /retry|重试|regenerate|重新生成|try again/i }),
223
+ message.locator('button[data-testid="regenerate-thread-error-button"]'),
224
+ ]);
225
+ return control ? text.trim().slice(0, 120) : null;
226
+ }
199
227
 
200
- throw new BrowserAdapterError(
201
- `Login was not detected within ${Math.round(timeoutMs / 60_000)} minutes.`,
202
- { code: "LOGIN_TIMEOUT" },
203
- );
228
+ async findUsageLimitMarker(message) {
229
+ const text = await message.innerText().catch(() => "");
230
+ if (!isUsageLimitNotice(text)) {
231
+ return null;
232
+ }
233
+ const control = await firstVisible([
234
+ message.locator('button[data-testid="regenerate-thread-error-button"]'),
235
+ message.locator('[class*="text-token-text-error"]'),
236
+ message.locator('[class*="bg-token-surface-error"]'),
237
+ message.getByRole("button", {
238
+ name: /retry|重试|try again|upgrade|升级/i,
239
+ }),
240
+ ]);
241
+ return control ? text.trim().slice(0, 120) : null;
204
242
  }
205
243
 
206
- async startConversation(
207
- conversationUrl = null,
208
- { expectedAssistantMessageId = null } = {},
209
- ) {
210
- this.#requirePage();
211
- let target = this.baseUrl;
212
- if (conversationUrl) {
213
- const parsed = new URL(conversationUrl);
214
- const base = new URL(this.baseUrl);
215
- if (parsed.protocol !== "https:" || parsed.hostname !== base.hostname) {
216
- throw new BrowserAdapterError(
217
- `Refusing to open a conversation outside ${base.hostname}.`,
218
- { code: "INVALID_CONVERSATION_URL" },
219
- );
220
- }
221
- target = parsed.href;
244
+ // Attaches local files to the composer via the hidden `#upload-files` input.
245
+ // Playwright's setInputFiles drives the <input type=file> directly, so no
246
+ // native OS file dialog is involved. Best-effort: returns which files were
247
+ // attached and any failures without throwing, so a broken selector or a
248
+ // rejected upload never aborts the turn.
249
+ async attachFiles(files) {
250
+ this.requirePage();
251
+ const paths = (files ?? [])
252
+ .map((file) => (typeof file === "string" ? file : file?.path))
253
+ .filter(Boolean);
254
+ if (paths.length === 0) {
255
+ return { attached: [], failed: [] };
222
256
  }
223
257
 
224
- const reuseCurrent = Boolean(
225
- conversationUrl
226
- && sameConversationUrl(this.page.url(), target),
227
- );
228
- if (!reuseCurrent) {
229
- await this.page.goto(target, { waitUntil: "domcontentloaded" });
258
+ const input = await firstVisible([
259
+ this.page.locator("#upload-files"),
260
+ ]) ?? this.page.locator('main input[type="file"]').first();
261
+
262
+ try {
263
+ await input.setInputFiles(paths, { timeout: 15_000 });
264
+ } catch (error) {
265
+ await this.writeDiagnostics("attach-files-failed");
266
+ return {
267
+ attached: [],
268
+ failed: paths.map((filePath) => ({ path: filePath, message: error.message })),
269
+ };
230
270
  }
231
- const composer = await this.#waitForComposer(30_000);
232
- if (!composer) {
233
- throw new BrowserAdapterError(
234
- "ChatGPT composer was not found after opening a new conversation.",
235
- { code: "COMPOSER_NOT_FOUND" },
236
- );
271
+
272
+ // Wait for the composer to register the upload(s) so we do not send before
273
+ // ChatGPT has ingested them. Uploaded files render as previews/chips; poll
274
+ // for any thumbnail/remove-file control, with a bounded timeout.
275
+ await this.page.locator(
276
+ '[data-testid$="-file-thumbnail"], [data-testid*="attachment"], '
277
+ + 'button[aria-label*="Remove" i], button[aria-label*="删除"]',
278
+ ).first().waitFor({ state: "visible", timeout: 20_000 }).catch(() => null);
279
+
280
+ return { attached: [...paths], failed: [] };
281
+ }
282
+
283
+ async dismissTransientOverlays() {
284
+ const beacon = this.page.locator("#modal-beacon");
285
+ if (!await beacon.isVisible().catch(() => false)) {
286
+ return;
237
287
  }
238
288
 
239
- if (conversationUrl) {
240
- await this.#waitForConversationHistory({
241
- expectedAssistantMessageId,
242
- expectedUrl: target,
243
- });
289
+ await this.page.keyboard.press("Escape").catch(() => null);
290
+ await this.page.waitForTimeout(250);
291
+ if (!await beacon.isVisible().catch(() => false)) {
244
292
  return;
245
293
  }
246
294
 
247
- if (!conversationUrl && !await this.#isFreshConversation()) {
248
- await this.#openNewConversation();
249
- const freshComposer = await this.#waitForComposer(30_000);
250
- if (!freshComposer || !await this.#isFreshConversation()) {
251
- await this.#writeDiagnostics("conversation-not-fresh");
252
- throw new BrowserAdapterError(
253
- "ChatGPT did not open a verified empty conversation. "
254
- + "Refusing to send a new session prompt into an existing chat.",
255
- { code: "CONVERSATION_NOT_FRESH" },
256
- );
257
- }
295
+ const dismiss = await firstVisible([
296
+ beacon.getByRole("button", {
297
+ name: /^(close|dismiss|not now|maybe later|skip|got it|done|关闭|取消|暂不|稍后|跳过|知道了|完成)$/i,
298
+ }),
299
+ beacon.locator(
300
+ 'button[aria-label*="close" i], button[aria-label*="dismiss" i], button[aria-label*="关闭"]',
301
+ ),
302
+ ]);
303
+ if (dismiss) {
304
+ await dismiss.click().catch(() => null);
305
+ await this.page.waitForTimeout(250);
258
306
  }
259
- }
260
307
 
261
- async selectMode(mode) {
262
- this.#requirePage();
263
- if (!mode) {
264
- return { status: "skipped", requested: mode, attempts: 0 };
308
+ if (await beacon.isVisible().catch(() => false)) {
309
+ await this.writeDiagnostics("blocking-modal");
310
+ throw new BrowserAdapterError(
311
+ "A ChatGPT modal is blocking the composer and could not be dismissed safely.",
312
+ { code: "BLOCKING_MODAL" },
313
+ );
265
314
  }
315
+ }
266
316
 
267
- const port = this.#modeSelectionPort();
268
- const result = await runModeSelection(port, mode);
269
- this.lastModeSelection = result;
270
- return result;
317
+ // ChatGPT's thread list is virtualized: only the visible window is mounted.
318
+ // Scrolls the thread container to the bottom so the latest replies mount.
319
+ // Best-effort — any failure is swallowed.
320
+ async scrollConversationToBottom() {
321
+ await this.page.evaluate?.(() => {
322
+ const message = document.querySelector("[data-message-author-role]");
323
+ if (!message) {
324
+ return;
325
+ }
326
+ let element = message.parentElement;
327
+ for (
328
+ let depth = 0;
329
+ element && depth < 12;
330
+ depth += 1, element = element.parentElement
331
+ ) {
332
+ if (element.scrollHeight > element.clientHeight + 50) {
333
+ element.scrollTop = element.scrollHeight;
334
+ return;
335
+ }
336
+ }
337
+ }).catch(() => null);
271
338
  }
272
339
 
273
340
  // DOM port for mode-selection.js. Stable attribute slugs are preferred;
274
341
  // exact visible labels are a fallback for current menus that omit those
275
342
  // attributes. Disabled state is still read from DOM state, never from text.
276
- #modeSelectionPort() {
343
+ modeSelectionPort() {
277
344
  const page = this.page;
278
345
  return {
279
346
  alreadyOnMode: async (requested) => {
@@ -286,7 +353,7 @@ export class ChatGPTWebAdapter {
286
353
  return true;
287
354
  }
288
355
 
289
- const messages = this.#assistantMessages();
356
+ const messages = this.assistantMessages();
290
357
  if (await messages.count() === 0) {
291
358
  return false;
292
359
  }
@@ -360,10 +427,12 @@ export class ChatGPTWebAdapter {
360
427
  await page.keyboard.press("Escape").catch(() => null);
361
428
  await page.waitForTimeout(150);
362
429
  },
363
- writeDiagnostics: async (label) => this.#writeDiagnostics(label),
430
+ writeDiagnostics: async (label) => this.writeDiagnostics(label),
364
431
  };
365
432
  }
366
433
 
434
+ // ---- ChatGPT-only mode-picker helpers (private) ------------------------
435
+
367
436
  #modeOptionLocators() {
368
437
  return this.page.locator(
369
438
  '[role="menu"]:visible [role="menuitemradio"]:visible, '
@@ -437,625 +506,24 @@ export class ChatGPTWebAdapter {
437
506
  return options;
438
507
  }
439
508
 
440
- async getConversationUrl() {
441
- this.#requirePage();
442
- return this.page.url();
443
- }
509
+ async #findModelSwitcher(timeoutMs = 45_000) {
510
+ const deadline = Date.now() + timeoutMs;
444
511
 
445
- async getLastAssistantMessageId() {
446
- return this.lastAssistantMessageId;
447
- }
512
+ while (Date.now() < deadline) {
513
+ const direct = await firstVisible([
514
+ this.page.locator('[data-testid="model-switcher-dropdown-button"]'),
515
+ this.page.locator(
516
+ 'main button.__composer-pill[aria-haspopup="menu"]',
517
+ ),
518
+ this.page.locator('button[aria-label*="model" i]'),
519
+ this.page.locator('button[aria-label*="模式"]'),
520
+ ]);
521
+ if (direct) {
522
+ return direct;
523
+ }
448
524
 
449
- async sendMessage(text, { files = [], maxBytes = null } = {}) {
450
- this.#requirePage();
451
- const messageBytes = Buffer.byteLength(String(text ?? ""), "utf8");
452
- if (maxBytes != null && messageBytes > maxBytes) {
453
- throw new BrowserAdapterError(
454
- `Outbound message is ${messageBytes} bytes; the limit is ${maxBytes} bytes.`,
455
- {
456
- code: "OUTBOUND_MESSAGE_TOO_LARGE",
457
- details: { messageBytes, maxBytes },
458
- },
459
- );
460
- }
461
- const urlBeforeSend = this.page.url();
462
- const composer = await this.#waitForComposer(30_000);
463
- if (!composer) {
464
- throw new BrowserAdapterError(
465
- "ChatGPT composer is unavailable.",
466
- { code: "COMPOSER_NOT_FOUND" },
467
- );
468
- }
469
-
470
- await this.#dismissTransientOverlays();
471
-
472
- // Attach any @file uploads before typing/sending. Upload is best-effort: a
473
- // failure is reported to the caller but does not block sending the text.
474
- let attachment = null;
475
- if (files.length > 0) {
476
- attachment = await this.attachFiles(files);
477
- }
478
-
479
- const assistantMessages = this.#assistantMessages();
480
- const assistantBaseline = await this.#captureMessageIdentities(
481
- assistantMessages,
482
- );
483
- const userBaseline = await this.#captureMessageIdentities(
484
- this.#userMessages(),
485
- );
486
- this.assistantIdsBeforeSend = assistantBaseline.ids;
487
- this.assistantMaxTurnBeforeSend = assistantBaseline.maxTurn;
488
- this.sentUserTurn = null;
489
-
490
- try {
491
- await composer.fill(text);
492
- } catch {
493
- await this.#dismissTransientOverlays();
494
- await composer.focus();
495
- await this.page.keyboard.press(
496
- process.platform === "darwin" ? "Meta+A" : "Control+A",
497
- );
498
- await this.page.keyboard.insertText(text);
499
- }
500
-
501
- const sendButton = await firstVisible([
502
- this.page.locator('[data-testid="send-button"]'),
503
- this.page.getByRole("button", { name: /send|发送/i }),
504
- ]);
505
-
506
- if (sendButton && await sendButton.isEnabled().catch(() => false)) {
507
- try {
508
- await sendButton.click();
509
- } catch {
510
- await this.#dismissTransientOverlays();
511
- await composer.press("Enter");
512
- }
513
- } else {
514
- await composer.press("Enter");
515
- }
516
-
517
- if (!/^\/c\//.test(new URL(urlBeforeSend).pathname)) {
518
- await this.page.waitForURL(
519
- (url) => /^\/c\//.test(url.pathname),
520
- { timeout: 5_000 },
521
- ).catch(() => null);
522
- }
523
-
524
- const sentMessage = await this.#waitForSentUserMessage(userBaseline);
525
- if (!sentMessage) {
526
- // ChatGPT never rendered the message: the send did not register (a
527
- // disabled send button, a missed Enter, or a transient UI state). Fail
528
- // loudly instead of pretending the message went out — otherwise the
529
- // runtime waits for a reply that ChatGPT never received.
530
- await this.#writeDiagnostics("send-not-detected");
531
- throw new BrowserAdapterError(
532
- "ChatGPT did not render the sent message; the send may have failed.",
533
- { code: "SEND_NOT_DETECTED" },
534
- );
535
- }
536
- return { attachment };
537
- }
538
-
539
- // Attaches local files to the composer via the hidden `#upload-files` input.
540
- // Playwright's setInputFiles drives the <input type=file> directly, so no
541
- // native OS file dialog is involved. Best-effort: returns which files were
542
- // attached and any failures without throwing, so a broken selector or a
543
- // rejected upload never aborts the turn.
544
- async attachFiles(files) {
545
- this.#requirePage();
546
- const paths = (files ?? [])
547
- .map((file) => (typeof file === "string" ? file : file?.path))
548
- .filter(Boolean);
549
- if (paths.length === 0) {
550
- return { attached: [], failed: [] };
551
- }
552
-
553
- const input = await firstVisible([
554
- this.page.locator("#upload-files"),
555
- ]) ?? this.page.locator('main input[type="file"]').first();
556
-
557
- try {
558
- await input.setInputFiles(paths, { timeout: 15_000 });
559
- } catch (error) {
560
- await this.#writeDiagnostics("attach-files-failed");
561
- return {
562
- attached: [],
563
- failed: paths.map((filePath) => ({ path: filePath, message: error.message })),
564
- };
565
- }
566
-
567
- // Wait for the composer to register the upload(s) so we do not send before
568
- // ChatGPT has ingested them. Uploaded files render as previews/chips; poll
569
- // for any thumbnail/remove-file control, with a bounded timeout.
570
- await this.page.locator(
571
- '[data-testid$="-file-thumbnail"], [data-testid*="attachment"], '
572
- + 'button[aria-label*="Remove" i], button[aria-label*="删除"]',
573
- ).first().waitFor({ state: "visible", timeout: 20_000 }).catch(() => null);
574
-
575
- return { attached: [...paths], failed: [] };
576
- }
577
-
578
- async waitForTurnComplete({
579
- timeoutMs,
580
- stableWindowMs,
581
- staleStopWindowMs = 15_000,
582
- emptyResponseWindowMs = 10_000,
583
- deadRequestGraceMs = 60_000,
584
- onDelta,
585
- }) {
586
- this.#requirePage();
587
- const deadline = Date.now() + timeoutMs;
588
- const startedAt = Date.now();
589
- let lastText = "";
590
- let stableSince = 0;
591
- let sawAssistant = false;
592
- let emptySince = 0;
593
- let emptyCandidate = null;
594
- let sawGenerationSignal = false;
595
- let lastGenerationSignalAt = 0;
596
- const detachEscCancel = this.#attachEscCancel();
597
- try {
598
- while (Date.now() < deadline) {
599
- await this.#throwIfBlockedPage();
600
-
601
- if (this.escCancelRequested) {
602
- // ESC = stop generating and hand control back to the user. Clicking
603
- // ChatGPT's stop button halts the in-flight reply; any partial text
604
- // stays in the conversation and counts as pre-existing for the next
605
- // send.
606
- await this.#clickStopButton();
607
- throw new BrowserAdapterError(
608
- "Turn cancelled by user.",
609
- { code: "TURN_CANCELLED" },
610
- );
611
- }
612
-
613
- const messages = this.#assistantMessages();
614
- const count = await messages.count();
615
- const lastMessage = count > 0 ? messages.last() : null;
616
- const candidateText = lastMessage
617
- ? await this.#assistantText(lastMessage)
618
- : "";
619
- const candidateId = lastMessage
620
- ? await lastMessage.getAttribute("data-message-id").catch(() => null)
621
- : null;
622
- const candidateTurn = lastMessage
623
- ? await this.#messageTurn(lastMessage)
624
- : null;
625
- let hasNewAssistant;
626
- if (this.sentUserTurn != null && candidateTurn != null) {
627
- // Strongest boundary: the answer must be a conversation turn after
628
- // the exact user message that sendMessage() observed in the DOM.
629
- hasNewAssistant = candidateTurn > this.sentUserTurn;
630
- } else if (candidateId) {
631
- // Stable ChatGPT message IDs are the next-best boundary. Never accept
632
- // an ID that existed before this send, even if its text or DOM position
633
- // changed during hydration.
634
- hasNewAssistant = !this.assistantIdsBeforeSend.has(candidateId);
635
- } else if (
636
- candidateTurn != null
637
- && this.assistantMaxTurnBeforeSend != null
638
- ) {
639
- hasNewAssistant = candidateTurn > this.assistantMaxTurnBeforeSend;
640
- } else {
641
- // Without a stable message ID or turn boundary we cannot prove this
642
- // reply belongs to the current send. Timing/count heuristics caused the
643
- // stale-answer bug, so fail closed and let the bounded wait time out.
644
- hasNewAssistant = false;
645
- }
646
-
647
- const stopVisible = await this.#isStopButtonVisible();
648
- if (hasNewAssistant || stopVisible) {
649
- // A reply node or a visible stop button proves ChatGPT started
650
- // generating; only a request with neither signal can be dead.
651
- sawGenerationSignal = true;
652
- lastGenerationSignalAt = Date.now();
653
- }
654
-
655
- if (hasNewAssistant) {
656
- sawAssistant = true;
657
- const text = candidateText;
658
- if (text !== lastText) {
659
- const delta = deltaFrom(lastText, text);
660
- lastText = text;
661
- stableSince = Date.now();
662
- if (delta) {
663
- await onDelta?.(delta);
664
- }
665
- }
666
-
667
- if (!text.trim() && !stopVisible) {
668
- // ChatGPT can create a real assistant turn and finish it without
669
- // rendering any content. Once that exact empty node remains stopped
670
- // for a short grace period, fail early instead of waiting for the
671
- // full model timeout. A different node restarts the grace period.
672
- const candidateIdentity = candidateId
673
- ?? (candidateTurn == null ? null : `turn:${candidateTurn}`);
674
- if (candidateIdentity !== emptyCandidate) {
675
- emptyCandidate = candidateIdentity;
676
- emptySince = Date.now();
677
- }
678
- if (
679
- emptySince > 0
680
- && Date.now() - emptySince >= emptyResponseWindowMs
681
- ) {
682
- this.lastAssistantMessageId = candidateId;
683
- throw new BrowserAdapterError(
684
- "ChatGPT completed an assistant turn without any content.",
685
- {
686
- code: "EMPTY_ASSISTANT_RESPONSE",
687
- details: {
688
- assistantMessageId: candidateId,
689
- assistantTurn: candidateTurn,
690
- },
691
- },
692
- );
693
- }
694
- } else {
695
- // Generation is still active, or text has begun rendering. Only an
696
- // empty and stopped reply should consume the empty-response window.
697
- emptyCandidate = null;
698
- emptySince = 0;
699
- }
700
- // If the reply looks like protocol XML, never accept it until BOTH the
701
- // opening and closing tags are present. During streaming the text can
702
- // briefly go quiet (or the stop button flip off) after "<agent_response"
703
- // is painted but before "</agent_response>" arrives; accepting there
704
- // hands the parser a truncated envelope. Non-protocol chatter (no
705
- // "<agent_response") is unaffected and still completes on the stable
706
- // window below.
707
- const looksLikeProtocol = lastText.includes("<agent_response");
708
- const envelopeReady = !looksLikeProtocol
709
- || hasCompleteAgentEnvelope(lastText);
710
- if (
711
- lastText.trim()
712
- && stableSince > 0
713
- && envelopeReady
714
- && (
715
- (
716
- !stopVisible
717
- && Date.now() - stableSince >= stableWindowMs
718
- )
719
- || (
720
- stopVisible
721
- && hasCompleteAgentEnvelope(lastText)
722
- && Date.now() - stableSince >= staleStopWindowMs
723
- )
724
- )
725
- ) {
726
- this.lastAssistantMessageId = candidateId;
727
- const limitMarker = await this.#findUsageLimitMarker(lastMessage);
728
- if (limitMarker) {
729
- throw new BrowserAdapterError(
730
- `ChatGPT reported a usage limit (${limitMarker}).`,
731
- { code: "USAGE_LIMIT_REACHED" },
732
- );
733
- }
734
- return lastText.trim();
735
- }
736
- }
737
-
738
- // Dead-request detection: the user message was sent but ChatGPT stopped
739
- // producing signals — either it never started (no node, no stop button)
740
- // or a started generation went quiet for several grace periods (stream
741
- // dropped, server-side abort, usage limit). A node or visible stop
742
- // button means generation is alive and resets the clock. Recover with a
743
- // continuation nudge instead of waiting out the full model timeout.
744
- const generationActive = hasNewAssistant || stopVisible;
745
- const quietSince = sawGenerationSignal
746
- ? lastGenerationSignalAt
747
- : startedAt;
748
- const quietGraceMs = sawGenerationSignal
749
- ? deadRequestGraceMs * 3
750
- : deadRequestGraceMs;
751
- if (
752
- !generationActive
753
- && this.sentUserTurn != null
754
- && Date.now() - quietSince >= quietGraceMs
755
- ) {
756
- await this.#writeDiagnostics("dead-request");
757
- throw new BrowserAdapterError(
758
- "ChatGPT stopped responding without completing a reply.",
759
- {
760
- code: "DEAD_ASSISTANT_REQUEST",
761
- details: { sentUserTurn: this.sentUserTurn },
762
- },
763
- );
764
- }
765
-
766
- await this.page.waitForTimeout(sawAssistant ? 250 : 500);
767
- }
768
-
769
- await this.#writeDiagnostics("turn-timeout");
770
- throw new BrowserAdapterError(
771
- `ChatGPT turn did not complete within ${Math.round(timeoutMs / 1000)} seconds.`,
772
- { code: "TURN_TIMEOUT" },
773
- );
774
- } finally {
775
- detachEscCancel?.();
776
- }
777
- }
778
-
779
- async #findComposer() {
780
- return await firstVisible([
781
- this.page.locator("#prompt-textarea"),
782
- this.page.locator('textarea[placeholder*="Message" i]'),
783
- this.page.locator('textarea[placeholder*="消息"]'),
784
- this.page.locator('div[contenteditable="true"][data-lexical-editor="true"]'),
785
- this.page.locator('main div[contenteditable="true"]'),
786
- ]);
787
- }
788
-
789
- async #isFreshConversation() {
790
- const current = new URL(this.page.url());
791
- const base = new URL(this.baseUrl);
792
- if (current.hostname !== base.hostname || /^\/c\//.test(current.pathname)) {
793
- return false;
794
- }
795
- return await this.#conversationMessages().count() === 0;
796
- }
797
-
798
- async #openNewConversation() {
799
- const control = await firstVisible([
800
- this.page.locator('[data-testid="create-new-chat-button"]'),
801
- this.page.getByRole("button", { name: /^(new chat|新聊天|新对话)$/i }),
802
- this.page.getByRole("link", { name: /^(new chat|新聊天|新对话)$/i }),
803
- this.page.locator('a[href="/"]'),
804
- ]);
805
- if (control) {
806
- await control.click().catch(() => null);
807
- await this.page.waitForTimeout(500);
808
- }
809
- if (!await this.#isFreshConversation()) {
810
- await this.page.goto(this.baseUrl, { waitUntil: "domcontentloaded" });
811
- }
812
- }
813
-
814
- #conversationMessages() {
815
- return this.page.locator(
816
- '[data-message-author-role="user"], [data-message-author-role="assistant"]',
817
- );
818
- }
819
-
820
- async #findLoginControl() {
821
- return await firstVisible([
822
- this.page.getByRole("button", {
823
- name: /^(log in|sign in|登录)$/i,
824
- }),
825
- this.page.getByRole("link", {
826
- name: /^(log in|sign in|登录)$/i,
827
- }),
828
- ]);
829
- }
830
-
831
- async #waitForComposer(timeoutMs) {
832
- const deadline = Date.now() + timeoutMs;
833
- while (Date.now() < deadline) {
834
- const composer = await this.#findComposer();
835
- if (composer) return composer;
836
- await this.page.waitForTimeout(500);
837
- }
838
- return null;
839
- }
840
-
841
- #assistantMessages() {
842
- return this.page.locator('[data-message-author-role="assistant"]');
843
- }
844
-
845
- #userMessages() {
846
- return this.page.locator('[data-message-author-role="user"]');
847
- }
848
-
849
- async #messageTurn(message) {
850
- if (typeof message?.evaluate !== "function") {
851
- return null;
852
- }
853
- const testId = await message.evaluate((element) => (
854
- element.closest('[data-testid^="conversation-turn-"]')
855
- ?.getAttribute("data-testid") ?? null
856
- )).catch(() => null);
857
- return parseConversationTurn(testId);
858
- }
859
-
860
- async #messageIdentity(message) {
861
- const [id, turn] = await Promise.all([
862
- message.getAttribute("data-message-id").catch(() => null),
863
- this.#messageTurn(message),
864
- ]);
865
- return { id, turn };
866
- }
867
-
868
- async #captureMessageIdentities(messages) {
869
- const count = await messages.count().catch(() => 0);
870
- const ids = new Set();
871
- let maxTurn = null;
872
- for (let index = 0; index < count; index += 1) {
873
- const identity = await this.#messageIdentity(messages.nth(index));
874
- if (identity.id) {
875
- ids.add(identity.id);
876
- }
877
- if (identity.turn != null) {
878
- maxTurn = maxTurn == null
879
- ? identity.turn
880
- : Math.max(maxTurn, identity.turn);
881
- }
882
- }
883
- return { count, ids, maxTurn };
884
- }
885
-
886
- // ChatGPT's thread list is virtualized: only the visible window is mounted.
887
- // Scrolls the thread container to the bottom so the latest replies mount.
888
- // Best-effort — any failure is swallowed.
889
- async #scrollConversationToBottom() {
890
- await this.page.evaluate?.(() => {
891
- const message = document.querySelector("[data-message-author-role]");
892
- if (!message) {
893
- return;
894
- }
895
- let element = message.parentElement;
896
- for (
897
- let depth = 0;
898
- element && depth < 12;
899
- depth += 1, element = element.parentElement
900
- ) {
901
- if (element.scrollHeight > element.clientHeight + 50) {
902
- element.scrollTop = element.scrollHeight;
903
- return;
904
- }
905
- }
906
- }).catch(() => null);
907
- }
908
-
909
- async #waitForSentUserMessage(baseline, attempts = 50) {
910
- for (let attempt = 0; attempt < attempts; attempt += 1) {
911
- const messages = this.#userMessages();
912
- const count = await messages.count().catch(() => 0);
913
- for (let index = count - 1; index >= 0; index -= 1) {
914
- const identity = await this.#messageIdentity(messages.nth(index));
915
- const newByTurn = identity.turn != null
916
- && (
917
- baseline.maxTurn == null
918
- || identity.turn > baseline.maxTurn
919
- );
920
- const newById = Boolean(
921
- identity.id
922
- && !baseline.ids.has(identity.id),
923
- );
924
- if (newByTurn || newById) {
925
- this.sentUserTurn = identity.turn;
926
- return identity;
927
- }
928
- }
929
- await this.page.waitForTimeout(100);
930
- }
931
- return null;
932
- }
933
-
934
- async #waitForConversationHistory({
935
- expectedAssistantMessageId = null,
936
- expectedUrl = null,
937
- attempts = 60,
938
- } = {}) {
939
- let previousSignature = null;
940
- let stableChecks = 0;
941
- let scrolledToBottom = 0;
942
-
943
- for (let attempt = 0; attempt < attempts; attempt += 1) {
944
- const assistant = await this.#captureMessageIdentities(
945
- this.#assistantMessages(),
946
- );
947
- if (
948
- expectedAssistantMessageId
949
- && assistant.ids.has(expectedAssistantMessageId)
950
- ) {
951
- return;
952
- }
953
-
954
- // The expected resume marker is the latest reply, near the bottom of a
955
- // virtualized thread: scroll down a few times to force the tail to mount.
956
- if (expectedAssistantMessageId && scrolledToBottom < 3) {
957
- await this.#scrollConversationToBottom();
958
- scrolledToBottom += 1;
959
- }
960
-
961
- const totalMessages = await this.#conversationMessages()
962
- .count()
963
- .catch(() => 0);
964
- const signature = [
965
- totalMessages,
966
- assistant.count,
967
- assistant.maxTurn ?? "",
968
- [...assistant.ids].join(","),
969
- ].join(":");
970
- if (totalMessages > 0 && signature === previousSignature) {
971
- stableChecks += 1;
972
- if (stableChecks >= 3) {
973
- // With an expected id we normally return as soon as it appears;
974
- // reaching stability instead means the id is not mounted or has been
975
- // deleted (ChatGPT removes transient error/limit cards, and the last
976
- // recorded reply can be one). Accept when the URL still proves this
977
- // is the expected conversation.
978
- if (
979
- !expectedAssistantMessageId
980
- || !expectedUrl
981
- || sameConversationUrl(this.page.url(), expectedUrl)
982
- ) {
983
- return;
984
- }
985
- }
986
- } else {
987
- stableChecks = 0;
988
- previousSignature = signature;
989
- }
990
-
991
- await this.page.waitForTimeout(250);
992
- }
993
-
994
- await this.#writeDiagnostics("conversation-history-mismatch");
995
- const detail = expectedAssistantMessageId
996
- ? ` Expected assistant message ${expectedAssistantMessageId} was not found.`
997
- : " Existing conversation history did not become stable.";
998
- throw new BrowserAdapterError(
999
- `ChatGPT conversation history could not be verified.${detail}`,
1000
- { code: "CONVERSATION_HISTORY_MISMATCH" },
1001
- );
1002
- }
1003
-
1004
- async #assistantText(message) {
1005
- // Read the whole assistant turn first. A long XML response can contain
1006
- // Markdown fences inside CDATA; ChatGPT then splits the rendered response
1007
- // into many <pre><code> nodes and the first node contains the opening tag
1008
- // but not the closing tag. The parent innerText keeps the complete envelope
1009
- // and avoids one CDP round trip per nested code block on every poll.
1010
- const fullText = await message.innerText().catch(() => "");
1011
- if (fullText.includes("<agent_response")) {
1012
- return fullText;
1013
- }
1014
-
1015
- // Rare fallback for alternate renderers where the parent text omits code
1016
- // contents: prefer a complete code-block envelope, but retain a partial
1017
- // one so streaming progress remains visible until its closing tag arrives.
1018
- const codeBlocks = message.locator("pre code");
1019
- const codeBlockCount = await codeBlocks.count();
1020
- let partialEnvelope = "";
1021
- for (let index = 0; index < codeBlockCount; index += 1) {
1022
- const code = await codeBlocks.nth(index).innerText().catch(() => "");
1023
- if (hasCompleteAgentEnvelope(code)) {
1024
- return code;
1025
- }
1026
- if (!partialEnvelope && code.includes("<agent_response")) {
1027
- partialEnvelope = code;
1028
- }
1029
- }
1030
- if (partialEnvelope) {
1031
- return partialEnvelope;
1032
- }
1033
-
1034
- const markdown = message.locator(".markdown");
1035
- if (await markdown.count()) {
1036
- return await markdown.last().innerText().catch(() => "");
1037
- }
1038
- return fullText;
1039
- }
1040
-
1041
- async #findModelSwitcher(timeoutMs = 45_000) {
1042
- const deadline = Date.now() + timeoutMs;
1043
-
1044
- while (Date.now() < deadline) {
1045
- const direct = await firstVisible([
1046
- this.page.locator('[data-testid="model-switcher-dropdown-button"]'),
1047
- this.page.locator(
1048
- 'main button.__composer-pill[aria-haspopup="menu"]',
1049
- ),
1050
- this.page.locator('button[aria-label*="model" i]'),
1051
- this.page.locator('button[aria-label*="模式"]'),
1052
- ]);
1053
- if (direct) {
1054
- return direct;
1055
- }
1056
-
1057
- const menuButtons = this.page.locator(
1058
- 'main button[aria-haspopup="menu"]:not([data-testid^="history-item-"])',
525
+ const menuButtons = this.page.locator(
526
+ 'main button[aria-haspopup="menu"]:not([data-testid^="history-item-"])',
1059
527
  );
1060
528
  const count = await menuButtons.count();
1061
529
  for (let index = 0; index < count; index += 1) {
@@ -1076,163 +544,4 @@ export class ChatGPTWebAdapter {
1076
544
 
1077
545
  return null;
1078
546
  }
1079
-
1080
- #stopButtonLocators() {
1081
- return [
1082
- this.page.locator('[data-testid="stop-button"]'),
1083
- this.page.getByRole("button", {
1084
- name: /stop generating|stop|停止生成|停止/i,
1085
- }),
1086
- ];
1087
- }
1088
-
1089
- async #isStopButtonVisible() {
1090
- return Boolean(await firstVisible(this.#stopButtonLocators()));
1091
- }
1092
-
1093
- // A plan/usage limit renders as an error card: error-tinted token classes
1094
- // (text-token-text-error / bg-token-surface-error) plus a regenerate button
1095
- // (data-testid="regenerate-thread-error-button"). Protocol replies are plain
1096
- // markdown and never contain those, so their presence confirms the matching
1097
- // text is a real notice rather than a reply that mentions "limit" in its
1098
- // content. Text stays the primary signal (a notice always says something
1099
- // recognizable in the UI language); the DOM features guard against false
1100
- // positives and future language additions.
1101
- async #findUsageLimitMarker(message) {
1102
- const text = await message.innerText().catch(() => "");
1103
- if (!isUsageLimitNotice(text)) {
1104
- return null;
1105
- }
1106
- const control = await firstVisible([
1107
- message.locator('button[data-testid="regenerate-thread-error-button"]'),
1108
- message.locator('[class*="text-token-text-error"]'),
1109
- message.locator('[class*="bg-token-surface-error"]'),
1110
- message.getByRole("button", {
1111
- name: /retry|重试|try again|upgrade|升级/i,
1112
- }),
1113
- ]);
1114
- return control ? text.trim().slice(0, 120) : null;
1115
- }
1116
-
1117
- async #clickStopButton() {
1118
- const stop = await firstVisible(this.#stopButtonLocators());
1119
- if (stop) {
1120
- await stop.click({ timeout: 3_000 }).catch(() => null);
1121
- }
1122
- }
1123
-
1124
- // While a turn is being processed, raw-mode stdin lets ESC cancel the wait
1125
- // (like ChatGPT). Raw mode swallows Ctrl+C, so forward it as a real SIGINT
1126
- // so the CLI's existing interrupt path still runs. The returned detach
1127
- // restores the previous terminal mode, keeping approval prompts (which also
1128
- // read stdin) working. Keys other than ESC/Ctrl+C are consumed and dropped.
1129
- #attachEscCancel() {
1130
- if (!this.cancelOnEsc || !this.stdinStream?.isTTY) {
1131
- return null;
1132
- }
1133
- const stream = this.stdinStream;
1134
- emitKeypressEvents(stream);
1135
- const previousRaw = stream.isRaw;
1136
- stream.setRawMode(true);
1137
- this.escCancelRequested = false;
1138
- const onKeypress = (_chunk, key) => {
1139
- if (key?.name === "escape") {
1140
- this.escCancelRequested = true;
1141
- } else if (key?.ctrl && key?.name === "c") {
1142
- process.kill(process.pid, "SIGINT");
1143
- }
1144
- };
1145
- stream.on("keypress", onKeypress);
1146
- return () => {
1147
- stream.removeListener("keypress", onKeypress);
1148
- stream.setRawMode(previousRaw);
1149
- };
1150
- }
1151
-
1152
- async #dismissTransientOverlays() {
1153
- const beacon = this.page.locator("#modal-beacon");
1154
- if (!await beacon.isVisible().catch(() => false)) {
1155
- return;
1156
- }
1157
-
1158
- await this.page.keyboard.press("Escape").catch(() => null);
1159
- await this.page.waitForTimeout(250);
1160
- if (!await beacon.isVisible().catch(() => false)) {
1161
- return;
1162
- }
1163
-
1164
- const dismiss = await firstVisible([
1165
- beacon.getByRole("button", {
1166
- name: /^(close|dismiss|not now|maybe later|skip|got it|done|关闭|取消|暂不|稍后|跳过|知道了|完成)$/i,
1167
- }),
1168
- beacon.locator(
1169
- 'button[aria-label*="close" i], button[aria-label*="dismiss" i], button[aria-label*="关闭"]',
1170
- ),
1171
- ]);
1172
- if (dismiss) {
1173
- await dismiss.click().catch(() => null);
1174
- await this.page.waitForTimeout(250);
1175
- }
1176
-
1177
- if (await beacon.isVisible().catch(() => false)) {
1178
- await this.#writeDiagnostics("blocking-modal");
1179
- throw new BrowserAdapterError(
1180
- "A ChatGPT modal is blocking the composer and could not be dismissed safely.",
1181
- { code: "BLOCKING_MODAL" },
1182
- );
1183
- }
1184
- }
1185
-
1186
- async #throwIfBlockedPage() {
1187
- const title = await this.page.title().catch(() => "");
1188
- const titleTokens = new Set(title.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
1189
- const challengeTitle =
1190
- (titleTokens.has("cloudflare") && titleTokens.has("attention")) ||
1191
- (titleTokens.has("verify") && titleTokens.has("human")) ||
1192
- (titleTokens.has("security") && titleTokens.has("check")) ||
1193
- (titleTokens.has("just") && titleTokens.has("moment"));
1194
-
1195
- const challengeElement = await firstVisible([
1196
- this.page.locator('iframe[src*="challenges.cloudflare.com"]'),
1197
- this.page.locator('input[name="cf-turnstile-response"]'),
1198
- this.page.locator('#challenge-stage'),
1199
- this.page.locator('form[action*="/cdn-cgi/challenge-platform/"]'),
1200
- this.page.locator('[data-testid="challenge-stage"]'),
1201
- ]);
1202
-
1203
- if (!challengeTitle && !challengeElement) {
1204
- return;
1205
- }
1206
-
1207
- // A CAPTCHA/challenge needs the user's eyes and hands — surface the window
1208
- // if it was minimized before reporting the block.
1209
- await this.restoreWindow();
1210
- throw new BrowserAdapterError("Browser access challenge detected.");
1211
- }
1212
-
1213
- async #writeDiagnostics(label) {
1214
- if (!this.debug || !this.page) {
1215
- return;
1216
- }
1217
- const directory = path.join(this.profileDir, "..", "diagnostics");
1218
- await ensureDirectory(directory);
1219
- const stamp = Date.now();
1220
- await Promise.all([
1221
- this.page.screenshot({
1222
- path: path.join(directory, `${stamp}-${label}.png`),
1223
- fullPage: true,
1224
- }).catch(() => null),
1225
- fs.writeFile(
1226
- path.join(directory, `${stamp}-${label}.html`),
1227
- await this.page.content(),
1228
- "utf8",
1229
- ).catch(() => null),
1230
- ]);
1231
- }
1232
-
1233
- #requirePage() {
1234
- if (!this.page) {
1235
- throw new BrowserAdapterError("Browser has not been launched.");
1236
- }
1237
- }
1238
547
  }