wtagent 0.1.0 → 0.2.1
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 +53 -35
- package/package.json +2 -2
- package/src/browser/base-web-adapter.js +1178 -0
- package/src/browser/cdp-browser.js +62 -0
- package/src/browser/cdp-state.js +52 -4
- package/src/browser/chatgpt-web-adapter.js +301 -992
- package/src/browser/claude-web-adapter.js +205 -0
- package/src/browser/deepseek-web-adapter.js +337 -0
- package/src/browser/gemini-web-adapter.js +465 -0
- package/src/browser/glm-web-adapter.js +246 -0
- package/src/browser/kimi-web-adapter.js +285 -0
- package/src/browser/provider-registry.js +199 -0
- package/src/cli/main.js +284 -60
- package/src/cli/notice-store.js +125 -0
- package/src/cli/prompt-input.js +504 -170
- package/src/cli/render-events.js +14 -8
- package/src/cli/self-update.js +145 -0
- package/src/cli/startup-notices.js +161 -0
- package/src/platform/paths.js +7 -0
- package/src/platform/windows-diagnostics.js +37 -6
- package/src/protocol/markers.js +3 -2
- package/src/protocol/prompt-builder.js +9 -5
- package/src/protocol/xml-protocol.js +193 -11
- package/src/runtime/agent-runtime.js +131 -18
- package/src/session/agent-session.js +4 -1
- package/src/session/canonical-transcript.js +2 -2
- package/src/session/session-export.js +2 -2
- package/src/shared/fetch-json.js +24 -0
- package/src/shared/limits.js +11 -1
- package/src/shared/package-info.js +23 -0
- package/src/shared/usage-limit.js +2 -2
|
@@ -1,279 +1,346 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
const
|
|
32
|
-
return
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
49
|
-
if (!previous) return current;
|
|
50
|
-
if (current.startsWith(previous)) return current.slice(previous.length);
|
|
51
|
-
return "";
|
|
52
|
-
}
|
|
37
|
+
// ---- provider primitives ----------------------------------------------
|
|
53
38
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
)
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
await
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
441
|
-
|
|
442
|
-
return this.page.url();
|
|
443
|
-
}
|
|
509
|
+
async #findModelSwitcher(timeoutMs = 45_000) {
|
|
510
|
+
const deadline = Date.now() + timeoutMs;
|
|
444
511
|
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
}
|