wtagent 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -7
- package/package.json +5 -4
- package/src/artifacts/artifact-store.js +154 -0
- package/src/audio/music-artifact.js +66 -0
- package/src/audio/native-music-receiver.js +91 -0
- package/src/browser/base-web-adapter.js +354 -50
- package/src/browser/chatgpt-dom.js +179 -0
- package/src/browser/chatgpt-web-adapter.js +89 -125
- package/src/browser/fake-web-model-adapter.js +16 -1
- package/src/browser/gemini-web-adapter.js +61 -20
- package/src/browser/grok-web-adapter.js +35 -3
- package/src/browser/rendered-text.js +11 -0
- package/src/cli/i18n.js +40 -10
- package/src/cli/main.js +32 -3
- package/src/cli/prompt-input.js +68 -0
- package/src/image/browser-image-driver.js +238 -0
- package/src/image/browser-image-session-pool.js +96 -0
- package/src/image/image-generation-service.js +101 -0
- package/src/image/image-provider-driver.js +22 -0
- package/src/image/native-image-receiver.js +66 -0
- package/src/image/provider-registry.js +23 -0
- package/src/image/providers/chatgpt-image-driver.js +211 -0
- package/src/image/providers/gemini-image-driver.js +100 -0
- package/src/image/providers/grok-image-driver.js +193 -0
- package/src/policy/policy-engine.js +10 -5
- package/src/protocol/envelope.js +74 -0
- package/src/protocol/markers.js +16 -0
- package/src/protocol/prompt-builder.js +12 -9
- package/src/protocol/xml-protocol.js +36 -18
- package/src/runtime/agent-runtime.js +345 -65
- package/src/session/agent-session.js +73 -2
- package/src/tools/image-tools.js +92 -0
- package/src/tools/registry.js +13 -1
- package/docs/technical-design.md +0 -873
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// All supported ChatGPT DOM shapes live here. Text and localized headings must
|
|
2
|
+
// never supply a role, message identity, or ordering key.
|
|
3
|
+
const legacyUser = '[data-message-author-role="user"]';
|
|
4
|
+
const legacyAssistant = '[data-message-author-role="assistant"]:not([id^="request-placeholder-"])';
|
|
5
|
+
const modern = (role) => `[data-chatgpt-search-unit-key$=":${role}"]:not([id^="request-placeholder-"]):not(:has([data-message-author-role])):not([data-message-author-role] *)`;
|
|
6
|
+
export const CHATGPT_DOM = {
|
|
7
|
+
user: { legacy: legacyUser, modern: modern("user") },
|
|
8
|
+
assistant: { legacy: legacyAssistant, modern: modern("assistant") },
|
|
9
|
+
conversationLegacy: '[data-message-author-role="user"], [data-message-author-role="assistant"]',
|
|
10
|
+
composers: [
|
|
11
|
+
{ name: "prompt-textarea", selector: "#prompt-textarea" },
|
|
12
|
+
{ name: "markdown-composer", selector: '[data-composer-markdown][contenteditable="true"][role="textbox"]' },
|
|
13
|
+
{ name: "message-textarea-en", selector: 'textarea[placeholder*="Message" i]' },
|
|
14
|
+
{ name: "message-textarea-zh", selector: 'textarea[placeholder*="消息"]' },
|
|
15
|
+
{ name: "lexical-composer", selector: 'div[contenteditable="true"][data-lexical-editor="true"]' },
|
|
16
|
+
{ name: "main-contenteditable", selector: 'main div[contenteditable="true"]' },
|
|
17
|
+
],
|
|
18
|
+
send: '[data-testid="send-button"]',
|
|
19
|
+
stop: '[data-testid="stop-button"]',
|
|
20
|
+
};
|
|
21
|
+
CHATGPT_DOM.messages = [legacyUser, legacyAssistant, modern("user"), modern("assistant")].join(", ");
|
|
22
|
+
|
|
23
|
+
// This function is serialized into the page. Keep it self-contained, and pass
|
|
24
|
+
// the registry explicitly when requesting a capability sample.
|
|
25
|
+
export function readChatGPTDom(input) {
|
|
26
|
+
const options = input?.selector ? input : null;
|
|
27
|
+
const elements = typeof input === "string" || options
|
|
28
|
+
? [...document.querySelectorAll(options?.selector ?? input)]
|
|
29
|
+
: Array.isArray(input) ? input : [input];
|
|
30
|
+
const structure = { legacy: 0, modern: 0, unknown: 0, ambiguousIds: 0 };
|
|
31
|
+
const entries = elements.map((element, domIndex) => {
|
|
32
|
+
const legacyRole = element.getAttribute("data-message-author-role");
|
|
33
|
+
const key = element.getAttribute("data-chatgpt-search-unit-key") ?? "";
|
|
34
|
+
const modern = key.match(/^fallback-turn-(\d+):(\d+):(user|assistant)$/);
|
|
35
|
+
const role = legacyRole ?? modern?.[3] ?? null;
|
|
36
|
+
structure[legacyRole ? "legacy" : modern ? "modern" : "unknown"] += 1;
|
|
37
|
+
const wrapper = element.closest('[data-testid^="conversation-turn-"]');
|
|
38
|
+
const legacyTurn = wrapper?.getAttribute("data-testid")?.match(/^conversation-turn-(\d+)$/);
|
|
39
|
+
const messageIds = [...new Set((element.getAttribute("data-chatgpt-search-message-ids") ?? "").split(/\s+/).filter(Boolean))];
|
|
40
|
+
const legacyId = element.getAttribute("data-message-id");
|
|
41
|
+
if (legacyId == null && messageIds.length > 1) structure.ambiguousIds += 1;
|
|
42
|
+
const id = legacyId ?? (messageIds.length === 1 ? messageIds[0] : null);
|
|
43
|
+
// Thinking/tool blocks occupy positions too. Only the group and role define
|
|
44
|
+
// turn order; duplicate roles within a group are rejected by validation.
|
|
45
|
+
const turn = legacyTurn ? Number(legacyTurn[1])
|
|
46
|
+
: modern ? Number(modern[1]) * 2 + (role === "user" ? 1 : 2) : null;
|
|
47
|
+
let renderedText = element.innerText ?? element.textContent ?? "";
|
|
48
|
+
if (role === "assistant" && !renderedText.includes("<agent_response")) {
|
|
49
|
+
const codeTexts = [...element.querySelectorAll("pre code")].map((node) => node.innerText ?? node.textContent ?? "");
|
|
50
|
+
const envelope = codeTexts.find((text) => text.includes("<agent_response") && text.includes("</agent_response>"))
|
|
51
|
+
?? codeTexts.find((text) => text.includes("<agent_response"));
|
|
52
|
+
const markdown = [...element.querySelectorAll('.markdown, [data-markdown-text-style="assistant-message"]')].at(-1);
|
|
53
|
+
if (envelope) renderedText = envelope;
|
|
54
|
+
else if (markdown) renderedText = markdown.innerText ?? markdown.textContent ?? "";
|
|
55
|
+
}
|
|
56
|
+
return { domIndex, role, id, turn, renderedText };
|
|
57
|
+
});
|
|
58
|
+
const snapshot = { url: globalThis.location?.href ?? null, entries, structure };
|
|
59
|
+
if (options?.registry) {
|
|
60
|
+
const visible = (element) => element.getClientRects().length > 0
|
|
61
|
+
&& getComputedStyle(element).visibility !== "hidden";
|
|
62
|
+
const count = (selector) => [...document.querySelectorAll(selector)].filter(visible).length;
|
|
63
|
+
snapshot.capabilities = {
|
|
64
|
+
readyState: document.readyState,
|
|
65
|
+
composers: options.registry.composers.filter(({ selector }) => count(selector) > 0).map(({ name }) => name),
|
|
66
|
+
sendButton: count(options.registry.send) > 0,
|
|
67
|
+
stopButton: count(options.registry.stop) > 0,
|
|
68
|
+
placeholderCount: document.querySelectorAll('[id^="request-placeholder-"]').length,
|
|
69
|
+
// Unknown suffixes are evidence of a renderer change, not valid messages.
|
|
70
|
+
unknownSearchUnits: [...document.querySelectorAll('[data-chatgpt-search-unit-key]')]
|
|
71
|
+
.filter((element) => !/^fallback-turn-\d+:\d+:(user|assistant)$/.test(element.getAttribute("data-chatgpt-search-unit-key"))).length,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return snapshot;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Deliberately excludes message text, IDs, composer content, and raw attributes.
|
|
78
|
+
// This report may be collected even when screenshots/full HTML are disabled.
|
|
79
|
+
export function summarizeChatGPTDom(snapshot) {
|
|
80
|
+
if (!snapshot || !Array.isArray(snapshot.entries)) return { status: "unavailable" };
|
|
81
|
+
const entries = snapshot.entries;
|
|
82
|
+
const issues = [];
|
|
83
|
+
const countMissing = (test) => entries.filter(test).length;
|
|
84
|
+
const missing = {
|
|
85
|
+
role: countMissing((entry) => !["user", "assistant"].includes(entry.role)),
|
|
86
|
+
id: countMissing((entry) => typeof entry.id !== "string" || !entry.id.trim()),
|
|
87
|
+
turn: countMissing((entry) => !Number.isSafeInteger(entry.turn) || entry.turn < 0),
|
|
88
|
+
};
|
|
89
|
+
for (const [field, count] of Object.entries(missing)) if (count) issues.push(`missing-${field}`);
|
|
90
|
+
const ids = entries.map((entry) => entry.id).filter(Boolean);
|
|
91
|
+
if (new Set(ids).size !== ids.length) issues.push("duplicate-message-id");
|
|
92
|
+
if (entries.some((entry, index) => index > 0 && entry.turn <= entries[index - 1].turn)) issues.push("non-monotonic-turns");
|
|
93
|
+
if (snapshot.structure?.ambiguousIds) issues.push("ambiguous-message-ids");
|
|
94
|
+
if (snapshot.capabilities?.unknownSearchUnits) issues.push("unknown-search-unit-format");
|
|
95
|
+
if (snapshot.capabilities && !snapshot.capabilities.composers.length) issues.push("composer-not-found");
|
|
96
|
+
return {
|
|
97
|
+
status: issues.length ? "incomplete" : entries.length ? "recognized" : "empty",
|
|
98
|
+
structure: snapshot.structure,
|
|
99
|
+
capabilities: snapshot.capabilities,
|
|
100
|
+
counts: { total: entries.length, user: countMissing((entry) => entry.role === "user"), assistant: countMissing((entry) => entry.role === "assistant") },
|
|
101
|
+
missing,
|
|
102
|
+
issues,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseConversationTurn(value) {
|
|
107
|
+
const match = String(value ?? "").match(/^conversation-turn-(\d+)$/);
|
|
108
|
+
return match ? Number.parseInt(match[1], 10) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function readChatGPTMessageIdentity(message) {
|
|
112
|
+
if (await message.getAttribute("data-chatgpt-search-unit-key").catch(() => null)) {
|
|
113
|
+
const snapshot = await message.evaluate(readChatGPTDom).catch(() => null);
|
|
114
|
+
const entry = snapshot?.entries?.[0];
|
|
115
|
+
if (!entry || !["user", "assistant"].includes(entry.role)
|
|
116
|
+
|| !entry.id || !Number.isSafeInteger(entry.turn) || entry.turn < 0) {
|
|
117
|
+
return { id: null, turn: null };
|
|
118
|
+
}
|
|
119
|
+
return { id: entry.id, turn: entry.turn };
|
|
120
|
+
}
|
|
121
|
+
const [id, turn] = await Promise.all([
|
|
122
|
+
message.getAttribute("data-message-id").catch(() => null),
|
|
123
|
+
readLegacyTurn(message),
|
|
124
|
+
]);
|
|
125
|
+
return { id, turn };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readLegacyTurn(message) {
|
|
129
|
+
if (typeof message?.evaluate !== "function") {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const testId = await message.evaluate((element) => (
|
|
133
|
+
element.closest('[data-testid^="conversation-turn-"]')
|
|
134
|
+
?.getAttribute("data-testid") ?? null
|
|
135
|
+
)).catch(() => null);
|
|
136
|
+
return parseConversationTurn(testId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
export async function readChatGPTAssistantText(message, hasCompleteAgentEnvelope) {
|
|
141
|
+
if (await message.getAttribute("data-chatgpt-search-unit-key").catch(() => null)) {
|
|
142
|
+
const snapshot = await message.evaluate(readChatGPTDom).catch(() => null);
|
|
143
|
+
return snapshot?.entries?.[0]?.renderedText ?? "";
|
|
144
|
+
}
|
|
145
|
+
// Read the whole assistant turn first. A long XML response can contain
|
|
146
|
+
// Markdown fences inside CDATA; ChatGPT then splits the rendered response
|
|
147
|
+
// into many <pre><code> nodes and the first node contains the opening tag
|
|
148
|
+
// but not the closing tag. The parent innerText keeps the complete envelope
|
|
149
|
+
// and avoids one CDP round trip per nested code block on every poll.
|
|
150
|
+
const fullText = await message.innerText().catch(() => "");
|
|
151
|
+
if (fullText.includes("<agent_response")) {
|
|
152
|
+
return fullText;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Rare fallback for alternate renderers where the parent text omits code
|
|
156
|
+
// contents: prefer a complete code-block envelope, but retain a partial
|
|
157
|
+
// one so streaming progress remains visible until its closing tag arrives.
|
|
158
|
+
const codeBlocks = message.locator("pre code");
|
|
159
|
+
const codeBlockCount = await codeBlocks.count();
|
|
160
|
+
let partialEnvelope = "";
|
|
161
|
+
for (let index = 0; index < codeBlockCount; index += 1) {
|
|
162
|
+
const code = await codeBlocks.nth(index).innerText().catch(() => "");
|
|
163
|
+
if (hasCompleteAgentEnvelope(code)) {
|
|
164
|
+
return code;
|
|
165
|
+
}
|
|
166
|
+
if (!partialEnvelope && code.includes("<agent_response")) {
|
|
167
|
+
partialEnvelope = code;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (partialEnvelope) {
|
|
171
|
+
return partialEnvelope;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const markdown = message.locator(".markdown");
|
|
175
|
+
if (await markdown.count()) {
|
|
176
|
+
return await markdown.last().innerText().catch(() => "");
|
|
177
|
+
}
|
|
178
|
+
return fullText;
|
|
179
|
+
}
|
|
@@ -14,12 +14,15 @@ import { isUsageLimitNotice } from "../shared/usage-limit.js";
|
|
|
14
14
|
// base module.
|
|
15
15
|
export { isConnectionLostError } from "./base-web-adapter.js";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
import {
|
|
18
|
+
CHATGPT_DOM,
|
|
19
|
+
readChatGPTDom,
|
|
20
|
+
summarizeChatGPTDom,
|
|
21
|
+
readChatGPTMessageIdentity,
|
|
22
|
+
readChatGPTAssistantText,
|
|
23
|
+
} from "./chatgpt-dom.js";
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
const match = String(value ?? "").match(/^conversation-turn-(\d+)$/);
|
|
21
|
-
return match ? Number.parseInt(match[1], 10) : null;
|
|
22
|
-
}
|
|
25
|
+
const CHATGPT_URL = "https://chatgpt.com/";
|
|
23
26
|
|
|
24
27
|
function normalizedConversationUrl(value) {
|
|
25
28
|
try {
|
|
@@ -101,11 +104,46 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
|
|
|
101
104
|
return kind;
|
|
102
105
|
}
|
|
103
106
|
const parsed = value instanceof URL ? value : new URL(value);
|
|
104
|
-
return /^\/c\/WEB
|
|
107
|
+
return /^\/c\/(?:WEB:|local-chatgpt(?::|%3[aA]))/.test(parsed.pathname)
|
|
105
108
|
? "provisional"
|
|
106
109
|
: "restorable";
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
async startConversation(...args) {
|
|
113
|
+
try {
|
|
114
|
+
const result = await super.startConversation(...args);
|
|
115
|
+
await this.collectStructuralDiagnostics();
|
|
116
|
+
return result;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const diagnostic = await this.writeDiagnostics("conversation-start-failed", { stage: "conversation-start" }).catch(() => null);
|
|
119
|
+
if (diagnostic?.filename) {
|
|
120
|
+
error.details = { ...error.details, diagnosticFile: diagnostic.filename, dom: diagnostic.report.dom };
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
hasStructuralDiagnostics() {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
recordConversationNavigation(url) {
|
|
131
|
+
if (url === this.lastDiagnosticUrl) return;
|
|
132
|
+
this.lastDiagnosticUrl = url;
|
|
133
|
+
this.diagnosticRoutes ??= [];
|
|
134
|
+
this.diagnosticRoutes.push({ kind: this.classifyConversationUrl(url), at: Date.now() });
|
|
135
|
+
this.diagnosticRoutes = this.diagnosticRoutes.slice(-12);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async structuralDiagnostics() {
|
|
139
|
+
const snapshot = await this.page.evaluate(readChatGPTDom, {
|
|
140
|
+
selector: CHATGPT_DOM.messages, registry: CHATGPT_DOM,
|
|
141
|
+
});
|
|
142
|
+
const report = summarizeChatGPTDom(snapshot);
|
|
143
|
+
this.lastDomReport = report;
|
|
144
|
+
return report;
|
|
145
|
+
}
|
|
146
|
+
|
|
109
147
|
pendingAttachmentLocators() {
|
|
110
148
|
return [
|
|
111
149
|
this.page.locator(
|
|
@@ -118,25 +156,19 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
|
|
|
118
156
|
}
|
|
119
157
|
|
|
120
158
|
composerLocators() {
|
|
121
|
-
return
|
|
122
|
-
this.page.locator("#prompt-textarea"),
|
|
123
|
-
this.page.locator('textarea[placeholder*="Message" i]'),
|
|
124
|
-
this.page.locator('textarea[placeholder*="消息"]'),
|
|
125
|
-
this.page.locator('div[contenteditable="true"][data-lexical-editor="true"]'),
|
|
126
|
-
this.page.locator('main div[contenteditable="true"]'),
|
|
127
|
-
];
|
|
159
|
+
return CHATGPT_DOM.composers.map(({ selector }) => this.page.locator(selector));
|
|
128
160
|
}
|
|
129
161
|
|
|
130
162
|
sendButtonLocators() {
|
|
131
163
|
return [
|
|
132
|
-
this.page.locator(
|
|
164
|
+
this.page.locator(CHATGPT_DOM.send),
|
|
133
165
|
this.page.getByRole("button", { name: /send|发送/i }),
|
|
134
166
|
];
|
|
135
167
|
}
|
|
136
168
|
|
|
137
169
|
stopButtonLocators() {
|
|
138
170
|
return [
|
|
139
|
-
this.page.locator(
|
|
171
|
+
this.page.locator(CHATGPT_DOM.stop),
|
|
140
172
|
this.page.getByRole("button", {
|
|
141
173
|
name: /stop generating|stop|停止生成|停止/i,
|
|
142
174
|
}),
|
|
@@ -182,19 +214,32 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
|
|
|
182
214
|
// keeps a failed request from being read as an envelope-less reply and
|
|
183
215
|
// burned through the protocol-error retry loop; the turn instead sees "no
|
|
184
216
|
// reply" and recovers with the continuation nudge.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
);
|
|
217
|
+
const legacy = this.page.locator(CHATGPT_DOM.assistant.legacy);
|
|
218
|
+
return legacy.or ? legacy.or(this.page.locator(CHATGPT_DOM.assistant.modern)) : legacy;
|
|
188
219
|
}
|
|
189
220
|
|
|
190
221
|
userMessages() {
|
|
191
|
-
|
|
222
|
+
const legacy = this.page.locator(CHATGPT_DOM.user.legacy);
|
|
223
|
+
return legacy.or ? legacy.or(this.page.locator(CHATGPT_DOM.user.modern)) : legacy;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async userMessageSnapshot() {
|
|
227
|
+
const messages = this.userMessages();
|
|
228
|
+
if (typeof messages.evaluateAll !== "function") {
|
|
229
|
+
return super.userMessageSnapshot();
|
|
230
|
+
}
|
|
231
|
+
// ChatGPT unmounts older turns as a long thread advances. count() followed
|
|
232
|
+
// by nth() can then wait for a vanished index until the send deadline, or
|
|
233
|
+
// combine one bubble's identity with another bubble's text.
|
|
234
|
+
return await messages.evaluateAll(readChatGPTDom).then((snapshot) => (
|
|
235
|
+
snapshot.entries.every((entry) => entry.role === "user" && entry.id && Number.isSafeInteger(entry.turn))
|
|
236
|
+
? snapshot.entries : null
|
|
237
|
+
)).catch(() => null);
|
|
192
238
|
}
|
|
193
239
|
|
|
194
240
|
conversationMessages() {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
);
|
|
241
|
+
const legacy = this.page.locator(CHATGPT_DOM.conversationLegacy);
|
|
242
|
+
return legacy.or ? legacy.or(this.page.locator(`${CHATGPT_DOM.user.modern}, ${CHATGPT_DOM.assistant.modern}`)) : legacy;
|
|
198
243
|
}
|
|
199
244
|
|
|
200
245
|
requiresOrderedConversationSnapshotForFreshSend() {
|
|
@@ -205,54 +250,9 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
|
|
|
205
250
|
if (typeof this.page?.evaluate !== "function") {
|
|
206
251
|
return null;
|
|
207
252
|
}
|
|
208
|
-
return await this.page.evaluate(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
'[data-message-author-role="user"], [data-message-author-role="assistant"]',
|
|
212
|
-
),
|
|
213
|
-
].filter((element) => {
|
|
214
|
-
const role = element.getAttribute("data-message-author-role");
|
|
215
|
-
return role === "user"
|
|
216
|
-
|| (role === "assistant" && !element.id.startsWith("request-placeholder-"));
|
|
217
|
-
}).map((element, domIndex) => {
|
|
218
|
-
const role = element.getAttribute("data-message-author-role");
|
|
219
|
-
const wrapper = element.closest('[data-testid^="conversation-turn-"]');
|
|
220
|
-
const turnMatch = wrapper?.getAttribute("data-testid")
|
|
221
|
-
?.match(/^conversation-turn-(\d+)$/);
|
|
222
|
-
let renderedText = element.innerText ?? element.textContent ?? "";
|
|
223
|
-
if (role === "assistant" && !renderedText.includes("<agent_response")) {
|
|
224
|
-
const codeTexts = [...element.querySelectorAll("pre code")]
|
|
225
|
-
.map((code) => code.innerText ?? code.textContent ?? "");
|
|
226
|
-
const completeEnvelope = codeTexts.find((text) => {
|
|
227
|
-
const start = text.trim().indexOf("<agent_response");
|
|
228
|
-
const end = text.trim().lastIndexOf("</agent_response>");
|
|
229
|
-
return start >= 0 && end >= start;
|
|
230
|
-
});
|
|
231
|
-
const partialEnvelope = codeTexts.find(
|
|
232
|
-
(text) => text.includes("<agent_response"),
|
|
233
|
-
);
|
|
234
|
-
if (completeEnvelope) {
|
|
235
|
-
renderedText = completeEnvelope;
|
|
236
|
-
} else if (partialEnvelope) {
|
|
237
|
-
renderedText = partialEnvelope;
|
|
238
|
-
} else {
|
|
239
|
-
const markdown = [...element.querySelectorAll(".markdown")];
|
|
240
|
-
if (markdown.length > 0) {
|
|
241
|
-
const last = markdown.at(-1);
|
|
242
|
-
renderedText = last.innerText ?? last.textContent ?? "";
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
return {
|
|
247
|
-
domIndex,
|
|
248
|
-
role,
|
|
249
|
-
id: element.getAttribute("data-message-id"),
|
|
250
|
-
turn: turnMatch ? Number.parseInt(turnMatch[1], 10) : null,
|
|
251
|
-
renderedText,
|
|
252
|
-
};
|
|
253
|
-
});
|
|
254
|
-
return { url: window.location.href, entries };
|
|
255
|
-
}).catch(() => null);
|
|
253
|
+
return await this.page.evaluate(readChatGPTDom,
|
|
254
|
+
CHATGPT_DOM.messages,
|
|
255
|
+
).catch(() => null);
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
async assertRecoveredTurnTopology({
|
|
@@ -523,7 +523,19 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
|
|
|
523
523
|
return found;
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
-
async reconcilePendingOutbound({
|
|
526
|
+
async reconcilePendingOutbound(options = {}) {
|
|
527
|
+
try {
|
|
528
|
+
return await this.#reconcilePendingOutbound(options);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
const diagnostic = await this.writeDiagnostics("pending-outbound-recovery-failed", { stage: "recovery" }).catch(() => null);
|
|
531
|
+
if (diagnostic?.filename) {
|
|
532
|
+
error.details = { ...error.details, diagnosticFile: diagnostic.filename, dom: diagnostic.report.dom };
|
|
533
|
+
}
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async #reconcilePendingOutbound({
|
|
527
539
|
pendingOutbound = null,
|
|
528
540
|
conversationTargetId = null,
|
|
529
541
|
lastUserMessageId = null,
|
|
@@ -718,7 +730,7 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
|
|
|
718
730
|
);
|
|
719
731
|
if (!generationError && !usageLimit) {
|
|
720
732
|
rawResponse = assistant.renderedText;
|
|
721
|
-
if (rawResponse.trim()) {
|
|
733
|
+
if (rawResponse.trim() || priorHandoff?.nativeImages?.length > 0) {
|
|
722
734
|
responseHash = createHash("sha256")
|
|
723
735
|
.update(rawResponse)
|
|
724
736
|
.digest("hex");
|
|
@@ -904,22 +916,7 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
|
|
|
904
916
|
}
|
|
905
917
|
|
|
906
918
|
async messageIdentity(message) {
|
|
907
|
-
|
|
908
|
-
message.getAttribute("data-message-id").catch(() => null),
|
|
909
|
-
this.#messageTurn(message),
|
|
910
|
-
]);
|
|
911
|
-
return { id, turn };
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
async #messageTurn(message) {
|
|
915
|
-
if (typeof message?.evaluate !== "function") {
|
|
916
|
-
return null;
|
|
917
|
-
}
|
|
918
|
-
const testId = await message.evaluate((element) => (
|
|
919
|
-
element.closest('[data-testid^="conversation-turn-"]')
|
|
920
|
-
?.getAttribute("data-testid") ?? null
|
|
921
|
-
)).catch(() => null);
|
|
922
|
-
return parseConversationTurn(testId);
|
|
919
|
+
return readChatGPTMessageIdentity(message);
|
|
923
920
|
}
|
|
924
921
|
|
|
925
922
|
// The redesigned UI still exposes the stop button ([data-testid="stop-button"],
|
|
@@ -957,40 +954,7 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
|
|
|
957
954
|
}
|
|
958
955
|
|
|
959
956
|
async assistantText(message) {
|
|
960
|
-
|
|
961
|
-
// Markdown fences inside CDATA; ChatGPT then splits the rendered response
|
|
962
|
-
// into many <pre><code> nodes and the first node contains the opening tag
|
|
963
|
-
// but not the closing tag. The parent innerText keeps the complete envelope
|
|
964
|
-
// and avoids one CDP round trip per nested code block on every poll.
|
|
965
|
-
const fullText = await message.innerText().catch(() => "");
|
|
966
|
-
if (fullText.includes("<agent_response")) {
|
|
967
|
-
return fullText;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
// Rare fallback for alternate renderers where the parent text omits code
|
|
971
|
-
// contents: prefer a complete code-block envelope, but retain a partial
|
|
972
|
-
// one so streaming progress remains visible until its closing tag arrives.
|
|
973
|
-
const codeBlocks = message.locator("pre code");
|
|
974
|
-
const codeBlockCount = await codeBlocks.count();
|
|
975
|
-
let partialEnvelope = "";
|
|
976
|
-
for (let index = 0; index < codeBlockCount; index += 1) {
|
|
977
|
-
const code = await codeBlocks.nth(index).innerText().catch(() => "");
|
|
978
|
-
if (hasCompleteAgentEnvelope(code)) {
|
|
979
|
-
return code;
|
|
980
|
-
}
|
|
981
|
-
if (!partialEnvelope && code.includes("<agent_response")) {
|
|
982
|
-
partialEnvelope = code;
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
if (partialEnvelope) {
|
|
986
|
-
return partialEnvelope;
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
const markdown = message.locator(".markdown");
|
|
990
|
-
if (await markdown.count()) {
|
|
991
|
-
return await markdown.last().innerText().catch(() => "");
|
|
992
|
-
}
|
|
993
|
-
return fullText;
|
|
957
|
+
return readChatGPTAssistantText(message, hasCompleteAgentEnvelope);
|
|
994
958
|
}
|
|
995
959
|
|
|
996
960
|
// A plan/usage limit renders as an error card: error-tinted token classes
|
|
@@ -1110,8 +1074,8 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
|
|
|
1110
1074
|
// Scrolls the thread container to the bottom so the latest replies mount.
|
|
1111
1075
|
// Best-effort — any failure is swallowed.
|
|
1112
1076
|
async scrollConversationToBottom() {
|
|
1113
|
-
await this.page.evaluate?.(() => {
|
|
1114
|
-
const message = document.querySelector(
|
|
1077
|
+
await this.page.evaluate?.((selector) => {
|
|
1078
|
+
const message = document.querySelector(selector);
|
|
1115
1079
|
if (!message) {
|
|
1116
1080
|
return;
|
|
1117
1081
|
}
|
|
@@ -1126,7 +1090,7 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
|
|
|
1126
1090
|
return;
|
|
1127
1091
|
}
|
|
1128
1092
|
}
|
|
1129
|
-
}).catch(() => null);
|
|
1093
|
+
}, CHATGPT_DOM.messages).catch(() => null);
|
|
1130
1094
|
}
|
|
1131
1095
|
|
|
1132
1096
|
}
|
|
@@ -169,7 +169,11 @@ export class FakeWebModelAdapter {
|
|
|
169
169
|
return this.conversationUrl;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
async sendMessage(text, {
|
|
172
|
+
async sendMessage(text, {
|
|
173
|
+
files = [],
|
|
174
|
+
outboundId = null,
|
|
175
|
+
requireAttachments = false,
|
|
176
|
+
} = {}) {
|
|
173
177
|
const priorUserMessageId = this.lastUserMessageId;
|
|
174
178
|
const priorAssistantMessageId = this.lastAssistantMessageId;
|
|
175
179
|
this.lastSendStatus = "commit-unknown";
|
|
@@ -223,4 +227,15 @@ export class FakeWebModelAdapter {
|
|
|
223
227
|
async getLastAssistantTurn() {
|
|
224
228
|
return this.lastAssistantTurn;
|
|
225
229
|
}
|
|
230
|
+
|
|
231
|
+
async captureAuxiliaryTurnCompletion() {
|
|
232
|
+
this.responseNumber += 1;
|
|
233
|
+
this.lastAssistantMessageId = `assistant-${this.responseNumber}`;
|
|
234
|
+
this.lastAssistantTurn = this.responseNumber * 2;
|
|
235
|
+
return {
|
|
236
|
+
assistantMessageId: this.lastAssistantMessageId,
|
|
237
|
+
assistantTurn: this.lastAssistantTurn,
|
|
238
|
+
conversationUrl: this.conversationUrl,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
226
241
|
}
|
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { BaseWebAdapter, firstVisible } from "./base-web-adapter.js";
|
|
3
3
|
import { BrowserAdapterError } from "../shared/errors.js";
|
|
4
4
|
import { isUsageLimitNotice } from "../shared/usage-limit.js";
|
|
5
|
+
import { readRenderedBlocks } from "./rendered-text.js";
|
|
5
6
|
|
|
6
7
|
export { isConnectionLostError } from "./base-web-adapter.js";
|
|
7
8
|
|
|
@@ -124,24 +125,13 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
124
125
|
// Read Gemini's rendered answer body before falling back to the enclosing
|
|
125
126
|
// message-content. Attachment/source chips (for example a trailing "TXT")
|
|
126
127
|
// live beside `.markdown` and are UI chrome, not model output.
|
|
127
|
-
const markdown = message.locator("message-content .markdown")
|
|
128
|
+
const markdown = message.locator("message-content .markdown");
|
|
128
129
|
if (await markdown.count().catch(() => 0) > 0) {
|
|
129
|
-
|
|
130
|
-
const clone = element.cloneNode(true);
|
|
131
|
-
clone.querySelectorAll(
|
|
132
|
-
"sources-carousel-inline, source-inline-chip, source-footnote, "
|
|
133
|
-
+ ".hide-from-message-actions",
|
|
134
|
-
).forEach((item) => item.remove());
|
|
135
|
-
return clone.innerText;
|
|
136
|
-
}).catch(() => null);
|
|
137
|
-
if (cleaned != null) {
|
|
138
|
-
return cleaned;
|
|
139
|
-
}
|
|
140
|
-
return await markdown.innerText().catch(() => "");
|
|
130
|
+
return await markdown.evaluateAll(readRenderedBlocks);
|
|
141
131
|
}
|
|
142
|
-
const content = message.locator("message-content")
|
|
132
|
+
const content = message.locator("message-content");
|
|
143
133
|
if (await content.count().catch(() => 0) > 0) {
|
|
144
|
-
return await content.
|
|
134
|
+
return await content.evaluateAll(readRenderedBlocks);
|
|
145
135
|
}
|
|
146
136
|
return await message.innerText().catch(() => "");
|
|
147
137
|
}
|
|
@@ -163,7 +153,6 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
163
153
|
const completed = await firstVisible([
|
|
164
154
|
message.locator('[data-test-id="regenerate-button"]'),
|
|
165
155
|
message.getByRole("button", { name: /^(Redo|Regenerate|重做)$/i }),
|
|
166
|
-
message.getByRole("button", { name: /^(Copy|复制)$/i }),
|
|
167
156
|
]);
|
|
168
157
|
return !completed;
|
|
169
158
|
}
|
|
@@ -308,16 +297,66 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
308
297
|
}
|
|
309
298
|
}
|
|
310
299
|
|
|
311
|
-
async submitComposer(composer) {
|
|
300
|
+
async submitComposer(composer, options = {}) {
|
|
312
301
|
const sendButton = await firstVisible(this.sendButtonLocators());
|
|
302
|
+
options.assertActive?.();
|
|
303
|
+
if (this.musicGeneration && sendButton && await sendButton.isEnabled()) {
|
|
304
|
+
// Gemini's current Music UI reliably activates through the focused
|
|
305
|
+
// button's keyboard handler. Submit once; never retry an unknown commit.
|
|
306
|
+
await sendButton.press("Space", { timeout: options.timeoutMs });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
313
309
|
if (
|
|
314
310
|
sendButton
|
|
315
311
|
&& await sendButton.isEnabled().catch(() => false)
|
|
316
|
-
&& await this.#clickWithNativePointer(sendButton)
|
|
312
|
+
&& await this.#clickWithNativePointer(sendButton, options.assertActive)
|
|
317
313
|
) {
|
|
318
314
|
return;
|
|
319
315
|
}
|
|
320
|
-
await super.submitComposer(composer);
|
|
316
|
+
await super.submitComposer(composer, options);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async prepareMusicGeneration() {
|
|
320
|
+
this.requirePage();
|
|
321
|
+
// Gemini may move Music from the regular chat tool menu to a dedicated
|
|
322
|
+
// creation screen. Its old tool chip then exists only in a hidden composer.
|
|
323
|
+
const prepareCreationPage = async () => {
|
|
324
|
+
if (typeof this.page.getByText !== "function") return false;
|
|
325
|
+
const heading = this.page.getByText("Create music", { exact: true });
|
|
326
|
+
if (!await heading.isVisible().catch(() => false)) return false;
|
|
327
|
+
const editor = this.page.locator('div.ql-editor[contenteditable="true"][role="textbox"]');
|
|
328
|
+
const template = this.page.getByRole("button", { name: "Background music", exact: true });
|
|
329
|
+
if (await template.isVisible().catch(() => false)) {
|
|
330
|
+
const placeholder = await editor.getAttribute("data-placeholder").catch(() => "");
|
|
331
|
+
if (!/background music/i.test(placeholder)) await template.click();
|
|
332
|
+
}
|
|
333
|
+
await editor.waitFor({ state: "visible", timeout: 15_000 });
|
|
334
|
+
this.musicGeneration = true;
|
|
335
|
+
return true;
|
|
336
|
+
};
|
|
337
|
+
if (await prepareCreationPage()) return;
|
|
338
|
+
try {
|
|
339
|
+
const selected = this.page.getByRole("button", {
|
|
340
|
+
name: /^(Deselect Music|取消选择音乐|取消選取音樂)$/i,
|
|
341
|
+
});
|
|
342
|
+
if (!await selected.isVisible().catch(() => false)) {
|
|
343
|
+
const tools = this.page.getByRole("button", { name: /^(Upload & tools|上传和工具|上傳和工具)$/i });
|
|
344
|
+
if (await tools.getAttribute("aria-expanded") !== "true") await tools.press("Space");
|
|
345
|
+
const music = this.page.locator('button[role="menuitemcheckbox"]:has([data-mat-icon-name="music"])');
|
|
346
|
+
await music.waitFor({ state: "visible", timeout: 10_000 });
|
|
347
|
+
if (await music.getAttribute("aria-disabled") === "true") {
|
|
348
|
+
throw new BrowserAdapterError("Gemini Music is unavailable for this account.", { code: "MUSIC_UNAVAILABLE", recoverable: false });
|
|
349
|
+
}
|
|
350
|
+
await music.press("Space");
|
|
351
|
+
await selected.waitFor({ state: "visible", timeout: 10_000 });
|
|
352
|
+
}
|
|
353
|
+
} catch (error) {
|
|
354
|
+
// Selecting Music in the old menu can navigate to the new screen before
|
|
355
|
+
// the old menu item appears. Reconcile the UI without sending twice.
|
|
356
|
+
if (await prepareCreationPage()) return;
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
this.musicGeneration = true;
|
|
321
360
|
}
|
|
322
361
|
|
|
323
362
|
async dismissTransientOverlays() {
|
|
@@ -399,7 +438,8 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
399
438
|
return normalized;
|
|
400
439
|
}
|
|
401
440
|
|
|
402
|
-
async #clickWithNativePointer(locator) {
|
|
441
|
+
async #clickWithNativePointer(locator, assertActive = () => {}) {
|
|
442
|
+
assertActive();
|
|
403
443
|
await locator.scrollIntoViewIfNeeded().catch(() => null);
|
|
404
444
|
const box = await locator.boundingBox().catch(() => null);
|
|
405
445
|
if (!box || !this.page.mouse) {
|
|
@@ -409,6 +449,7 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
409
449
|
const y = box.y + box.height / 2;
|
|
410
450
|
await this.page.mouse.move(x, y, { steps: 8 });
|
|
411
451
|
await this.page.waitForTimeout(75);
|
|
452
|
+
assertActive();
|
|
412
453
|
await this.page.mouse.down();
|
|
413
454
|
await this.page.waitForTimeout(80);
|
|
414
455
|
await this.page.mouse.up();
|