wtagent 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -9
- package/package.json +9 -8
- package/src/artifacts/artifact-store.js +154 -0
- package/src/audio/music-artifact.js +66 -0
- package/src/audio/native-music-receiver.js +91 -0
- package/src/browser/base-web-adapter.js +2418 -201
- package/src/browser/cdp-browser.js +223 -25
- package/src/browser/cdp-state.js +8 -5
- package/src/browser/chatgpt-dom.js +179 -0
- package/src/browser/chatgpt-web-adapter.js +831 -69
- package/src/browser/claude-web-adapter.js +13 -0
- package/src/browser/fake-web-model-adapter.js +152 -3
- package/src/browser/gemini-web-adapter.js +71 -20
- package/src/browser/glm-web-adapter.js +2 -2
- package/src/browser/grok-web-adapter.js +35 -3
- package/src/browser/rendered-text.js +11 -0
- package/src/cli/i18n.js +50 -20
- package/src/cli/main.js +124 -40
- package/src/cli/prompt-input.js +68 -0
- package/src/image/browser-image-driver.js +238 -0
- package/src/image/browser-image-session-pool.js +96 -0
- package/src/image/image-generation-service.js +101 -0
- package/src/image/image-provider-driver.js +22 -0
- package/src/image/native-image-receiver.js +66 -0
- package/src/image/provider-registry.js +23 -0
- package/src/image/providers/chatgpt-image-driver.js +211 -0
- package/src/image/providers/gemini-image-driver.js +100 -0
- package/src/image/providers/grok-image-driver.js +193 -0
- package/src/platform/command-launcher.js +1 -1
- package/src/platform/paths.js +19 -0
- package/src/platform/windows-diagnostics.js +9 -16
- package/src/policy/path-guard.js +5 -1
- package/src/policy/policy-engine.js +10 -5
- package/src/protocol/envelope.js +74 -0
- package/src/protocol/markers.js +16 -0
- package/src/protocol/prompt-builder.js +12 -9
- package/src/protocol/xml-protocol.js +36 -18
- package/src/runtime/agent-runtime.js +1383 -238
- package/src/session/agent-session.js +1047 -121
- package/src/tools/default-tools.js +1 -1
- package/src/tools/image-tools.js +92 -0
- package/src/tools/registry.js +13 -1
- package/docs/technical-design.md +0 -866
|
@@ -22,6 +22,19 @@ export class ClaudeWebAdapter extends BaseWebAdapter {
|
|
|
22
22
|
return /^\/chat\//;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
freshConversationUrlPattern() {
|
|
26
|
+
return /^\/new\/?$/;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pendingAttachmentLocators() {
|
|
30
|
+
return [
|
|
31
|
+
this.page.locator(
|
|
32
|
+
'form [data-testid="file-thumbnail"], '
|
|
33
|
+
+ 'form button[aria-label*="remove file" i]',
|
|
34
|
+
),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
composerLocators() {
|
|
26
39
|
return [
|
|
27
40
|
this.page.locator('[data-testid="chat-input"]'),
|
|
@@ -7,17 +7,110 @@ export class FakeWebModelAdapter {
|
|
|
7
7
|
this.mode = null;
|
|
8
8
|
this.conversationUrl = "https://chatgpt.com/";
|
|
9
9
|
this.lastAssistantMessageId = null;
|
|
10
|
+
this.lastAssistantTurn = null;
|
|
10
11
|
this.responseNumber = 0;
|
|
11
12
|
this.startConversationCalls = [];
|
|
12
13
|
this.startConversationOptions = [];
|
|
14
|
+
this.startConversationOutcome = null;
|
|
13
15
|
this.sentAttachments = [];
|
|
16
|
+
this.sentOutboundIds = [];
|
|
17
|
+
this.targetId = "fake-target";
|
|
18
|
+
this.lastUserMessageId = null;
|
|
19
|
+
this.lastSendStatus = "not-submitted";
|
|
20
|
+
this.conversationIdentityListener = null;
|
|
21
|
+
this.pendingOutboundRecoverySupported = true;
|
|
22
|
+
this.recoveryTargetCalls = [];
|
|
23
|
+
this.reconciliationCalls = [];
|
|
24
|
+
this.reconciliationOutcome = null;
|
|
14
25
|
// Records window-state calls so tests can assert restore/minimize ordering.
|
|
15
26
|
this.windowStateCalls = [];
|
|
16
27
|
}
|
|
17
28
|
|
|
18
|
-
async launch(preferredUrl = null) {
|
|
29
|
+
async launch(preferredUrl = null, options = {}) {
|
|
19
30
|
this.launched = true;
|
|
20
31
|
this.lastLaunchUrl = preferredUrl;
|
|
32
|
+
this.lastLaunchOptions = options;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
supportsPendingOutboundRecovery() {
|
|
36
|
+
return this.pendingOutboundRecoverySupported;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async launchRecoveryTarget(targetId) {
|
|
40
|
+
this.launched = true;
|
|
41
|
+
this.recoveryTargetCalls.push(targetId);
|
|
42
|
+
if (!targetId || targetId !== this.targetId) {
|
|
43
|
+
const error = new Error("The fake recovery target is unavailable.");
|
|
44
|
+
error.code = "RECOVERY_TARGET_UNAVAILABLE";
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
conversationUrl: this.conversationUrl,
|
|
49
|
+
targetId,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async reconcilePendingOutbound(options = {}) {
|
|
54
|
+
this.reconciliationCalls.push(options);
|
|
55
|
+
const outcome = typeof this.reconciliationOutcome === "function"
|
|
56
|
+
? await this.reconciliationOutcome(options)
|
|
57
|
+
: this.reconciliationOutcome;
|
|
58
|
+
if (!outcome) {
|
|
59
|
+
const error = new Error("The fake pending outbound remains uncertain.");
|
|
60
|
+
error.code = "OUTBOUND_COMMIT_UNCERTAIN";
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
if (outcome.conversationUrl) {
|
|
64
|
+
this.conversationUrl = outcome.conversationUrl;
|
|
65
|
+
}
|
|
66
|
+
if (outcome.conversationTargetId) {
|
|
67
|
+
this.targetId = outcome.conversationTargetId;
|
|
68
|
+
}
|
|
69
|
+
this.lastUserMessageId = outcome.userMessageId ?? this.lastUserMessageId;
|
|
70
|
+
if (outcome.status === "complete") {
|
|
71
|
+
this.lastAssistantMessageId = outcome.assistantMessageId
|
|
72
|
+
?? this.lastAssistantMessageId;
|
|
73
|
+
this.lastAssistantTurn = outcome.assistantTurn ?? this.lastAssistantTurn;
|
|
74
|
+
}
|
|
75
|
+
return outcome;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
classifyConversationUrl(value) {
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(value);
|
|
81
|
+
if (url.protocol !== "https:" || url.origin !== "https://chatgpt.com") {
|
|
82
|
+
return "invalid";
|
|
83
|
+
}
|
|
84
|
+
if (/^\/c\/WEB:/.test(url.pathname)) {
|
|
85
|
+
return "provisional";
|
|
86
|
+
}
|
|
87
|
+
if (/^\/c\//.test(url.pathname)) {
|
|
88
|
+
return "restorable";
|
|
89
|
+
}
|
|
90
|
+
return url.pathname === "/" ? "fresh" : "unknown";
|
|
91
|
+
} catch {
|
|
92
|
+
return "invalid";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setConversationIdentityListener(listener) {
|
|
97
|
+
this.conversationIdentityListener = listener;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getConversationIdentity() {
|
|
101
|
+
return {
|
|
102
|
+
conversationUrl: this.conversationUrl,
|
|
103
|
+
targetId: this.targetId,
|
|
104
|
+
kind: this.classifyConversationUrl(this.conversationUrl),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
getLastSendStatus() {
|
|
109
|
+
return this.lastSendStatus;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async getLastUserMessageId() {
|
|
113
|
+
return this.lastUserMessageId;
|
|
21
114
|
}
|
|
22
115
|
|
|
23
116
|
async close() {
|
|
@@ -46,6 +139,19 @@ export class FakeWebModelAdapter {
|
|
|
46
139
|
} else {
|
|
47
140
|
this.conversationUrl = "https://chatgpt.com/";
|
|
48
141
|
}
|
|
142
|
+
const configuredOutcome = typeof this.startConversationOutcome === "function"
|
|
143
|
+
? await this.startConversationOutcome(conversationUrl, options)
|
|
144
|
+
: this.startConversationOutcome;
|
|
145
|
+
const isConversation = /^\/c\//.test(new URL(this.conversationUrl).pathname);
|
|
146
|
+
const outcome = configuredOutcome ?? {
|
|
147
|
+
status: isConversation ? "restored-existing" : "verified-fresh",
|
|
148
|
+
conversationUrl: this.conversationUrl,
|
|
149
|
+
targetId: this.targetId,
|
|
150
|
+
};
|
|
151
|
+
if (outcome.conversationUrl) {
|
|
152
|
+
this.conversationUrl = outcome.conversationUrl;
|
|
153
|
+
}
|
|
154
|
+
return outcome;
|
|
49
155
|
}
|
|
50
156
|
|
|
51
157
|
async selectMode(mode) {
|
|
@@ -63,13 +169,40 @@ export class FakeWebModelAdapter {
|
|
|
63
169
|
return this.conversationUrl;
|
|
64
170
|
}
|
|
65
171
|
|
|
66
|
-
async sendMessage(text, {
|
|
172
|
+
async sendMessage(text, {
|
|
173
|
+
files = [],
|
|
174
|
+
outboundId = null,
|
|
175
|
+
requireAttachments = false,
|
|
176
|
+
} = {}) {
|
|
177
|
+
const priorUserMessageId = this.lastUserMessageId;
|
|
178
|
+
const priorAssistantMessageId = this.lastAssistantMessageId;
|
|
179
|
+
this.lastSendStatus = "commit-unknown";
|
|
67
180
|
this.sentMessages.push(text);
|
|
68
181
|
this.sentAttachments.push(files);
|
|
182
|
+
this.sentOutboundIds.push(outboundId);
|
|
69
183
|
if (this.conversationUrl === "https://chatgpt.com/") {
|
|
70
184
|
this.conversationUrl = "https://chatgpt.com/c/fake";
|
|
71
185
|
}
|
|
72
|
-
|
|
186
|
+
this.lastSendStatus = "confirmed";
|
|
187
|
+
this.lastUserMessageId = `user-${this.sentMessages.length}`;
|
|
188
|
+
await this.conversationIdentityListener?.(this.getConversationIdentity());
|
|
189
|
+
return {
|
|
190
|
+
attachment: files.length ? { attached: files, failed: [] } : null,
|
|
191
|
+
userMessageId: this.lastUserMessageId,
|
|
192
|
+
userTurn: (this.sentMessages.length * 2) - 1,
|
|
193
|
+
conversationUrl: this.conversationUrl,
|
|
194
|
+
conversationTargetId: this.targetId,
|
|
195
|
+
assistantBaseline: {
|
|
196
|
+
ids: priorAssistantMessageId ? [priorAssistantMessageId] : [],
|
|
197
|
+
count: priorAssistantMessageId ? 1 : 0,
|
|
198
|
+
maxTurn: priorAssistantMessageId ? (this.responseNumber * 2) : null,
|
|
199
|
+
lastText: "",
|
|
200
|
+
},
|
|
201
|
+
preOutboundMarkerIds: [
|
|
202
|
+
priorUserMessageId,
|
|
203
|
+
priorAssistantMessageId,
|
|
204
|
+
].filter(Boolean),
|
|
205
|
+
};
|
|
73
206
|
}
|
|
74
207
|
|
|
75
208
|
async waitForTurnComplete({ onDelta } = {}) {
|
|
@@ -79,6 +212,7 @@ export class FakeWebModelAdapter {
|
|
|
79
212
|
const response = this.responses.shift();
|
|
80
213
|
this.responseNumber += 1;
|
|
81
214
|
this.lastAssistantMessageId = `assistant-${this.responseNumber}`;
|
|
215
|
+
this.lastAssistantTurn = this.responseNumber * 2;
|
|
82
216
|
if (response instanceof Error) {
|
|
83
217
|
throw response;
|
|
84
218
|
}
|
|
@@ -89,4 +223,19 @@ export class FakeWebModelAdapter {
|
|
|
89
223
|
async getLastAssistantMessageId() {
|
|
90
224
|
return this.lastAssistantMessageId;
|
|
91
225
|
}
|
|
226
|
+
|
|
227
|
+
async getLastAssistantTurn() {
|
|
228
|
+
return this.lastAssistantTurn;
|
|
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
|
+
}
|
|
92
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
|
|
|
@@ -25,6 +26,16 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
25
26
|
return /^\/app\/[^/]+/;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
pendingAttachmentLocators() {
|
|
30
|
+
return [
|
|
31
|
+
this.page.locator(
|
|
32
|
+
'[data-test-id="textarea-wrapper"] .attachment-container, '
|
|
33
|
+
+ '[data-test-id="textarea-wrapper"] '
|
|
34
|
+
+ 'button[aria-label*="remove" i]',
|
|
35
|
+
),
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
composerLocators() {
|
|
29
40
|
return [
|
|
30
41
|
this.page.locator(
|
|
@@ -114,24 +125,13 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
114
125
|
// Read Gemini's rendered answer body before falling back to the enclosing
|
|
115
126
|
// message-content. Attachment/source chips (for example a trailing "TXT")
|
|
116
127
|
// live beside `.markdown` and are UI chrome, not model output.
|
|
117
|
-
const markdown = message.locator("message-content .markdown")
|
|
128
|
+
const markdown = message.locator("message-content .markdown");
|
|
118
129
|
if (await markdown.count().catch(() => 0) > 0) {
|
|
119
|
-
|
|
120
|
-
const clone = element.cloneNode(true);
|
|
121
|
-
clone.querySelectorAll(
|
|
122
|
-
"sources-carousel-inline, source-inline-chip, source-footnote, "
|
|
123
|
-
+ ".hide-from-message-actions",
|
|
124
|
-
).forEach((item) => item.remove());
|
|
125
|
-
return clone.innerText;
|
|
126
|
-
}).catch(() => null);
|
|
127
|
-
if (cleaned != null) {
|
|
128
|
-
return cleaned;
|
|
129
|
-
}
|
|
130
|
-
return await markdown.innerText().catch(() => "");
|
|
130
|
+
return await markdown.evaluateAll(readRenderedBlocks);
|
|
131
131
|
}
|
|
132
|
-
const content = message.locator("message-content")
|
|
132
|
+
const content = message.locator("message-content");
|
|
133
133
|
if (await content.count().catch(() => 0) > 0) {
|
|
134
|
-
return await content.
|
|
134
|
+
return await content.evaluateAll(readRenderedBlocks);
|
|
135
135
|
}
|
|
136
136
|
return await message.innerText().catch(() => "");
|
|
137
137
|
}
|
|
@@ -153,7 +153,6 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
153
153
|
const completed = await firstVisible([
|
|
154
154
|
message.locator('[data-test-id="regenerate-button"]'),
|
|
155
155
|
message.getByRole("button", { name: /^(Redo|Regenerate|重做)$/i }),
|
|
156
|
-
message.getByRole("button", { name: /^(Copy|复制)$/i }),
|
|
157
156
|
]);
|
|
158
157
|
return !completed;
|
|
159
158
|
}
|
|
@@ -298,16 +297,66 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
298
297
|
}
|
|
299
298
|
}
|
|
300
299
|
|
|
301
|
-
async submitComposer(composer) {
|
|
300
|
+
async submitComposer(composer, options = {}) {
|
|
302
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
|
+
}
|
|
303
309
|
if (
|
|
304
310
|
sendButton
|
|
305
311
|
&& await sendButton.isEnabled().catch(() => false)
|
|
306
|
-
&& await this.#clickWithNativePointer(sendButton)
|
|
312
|
+
&& await this.#clickWithNativePointer(sendButton, options.assertActive)
|
|
307
313
|
) {
|
|
308
314
|
return;
|
|
309
315
|
}
|
|
310
|
-
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;
|
|
311
360
|
}
|
|
312
361
|
|
|
313
362
|
async dismissTransientOverlays() {
|
|
@@ -389,7 +438,8 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
389
438
|
return normalized;
|
|
390
439
|
}
|
|
391
440
|
|
|
392
|
-
async #clickWithNativePointer(locator) {
|
|
441
|
+
async #clickWithNativePointer(locator, assertActive = () => {}) {
|
|
442
|
+
assertActive();
|
|
393
443
|
await locator.scrollIntoViewIfNeeded().catch(() => null);
|
|
394
444
|
const box = await locator.boundingBox().catch(() => null);
|
|
395
445
|
if (!box || !this.page.mouse) {
|
|
@@ -399,6 +449,7 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
399
449
|
const y = box.y + box.height / 2;
|
|
400
450
|
await this.page.mouse.move(x, y, { steps: 8 });
|
|
401
451
|
await this.page.waitForTimeout(75);
|
|
452
|
+
assertActive();
|
|
402
453
|
await this.page.mouse.down();
|
|
403
454
|
await this.page.waitForTimeout(80);
|
|
404
455
|
await this.page.mouse.up();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseWebAdapter, firstVisible } from "./base-web-adapter.js";
|
|
2
2
|
import { isUsageLimitNotice } from "../shared/usage-limit.js";
|
|
3
|
+
import { readRenderedBlocks } from "./rendered-text.js";
|
|
3
4
|
|
|
4
5
|
export { isConnectionLostError } from "./base-web-adapter.js";
|
|
5
6
|
|
|
@@ -20,7 +21,11 @@ export class GrokWebAdapter extends BaseWebAdapter {
|
|
|
20
21
|
|
|
21
22
|
composerLocators() {
|
|
22
23
|
return [
|
|
23
|
-
|
|
24
|
+
// Grok replaces its initial textarea with a role=textbox editor during
|
|
25
|
+
// hydration. Keep both variants in one live locator so a composer found
|
|
26
|
+
// just before that replacement does not become an empty stale selector
|
|
27
|
+
// by the time sendMessage fills it.
|
|
28
|
+
this.page.locator('main textarea, main [role="textbox"]').first(),
|
|
24
29
|
this.page.locator("textarea[aria-label]").first(),
|
|
25
30
|
this.page.locator('[role="textbox"]').first(),
|
|
26
31
|
];
|
|
@@ -84,13 +89,40 @@ export class GrokWebAdapter extends BaseWebAdapter {
|
|
|
84
89
|
);
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
async userMessageText(message) {
|
|
93
|
+
const markdown = message.locator(".response-content-markdown");
|
|
94
|
+
if (await markdown.count().catch(() => 0) > 0) {
|
|
95
|
+
const exact = await markdown.evaluateAll(readRenderedBlocks);
|
|
96
|
+
if (exact.trim()) return exact;
|
|
97
|
+
}
|
|
98
|
+
return await super.userMessageText(message);
|
|
99
|
+
}
|
|
100
|
+
|
|
87
101
|
async messageIdentity(message) {
|
|
88
102
|
const testId = await message.getAttribute("data-testid").catch(() => null);
|
|
89
103
|
const aria = await message.getAttribute("aria-label").catch(() => null);
|
|
90
104
|
const scope = testId === "user-message" || aria === "You"
|
|
91
105
|
? this.userMessages()
|
|
92
106
|
: this.assistantMessages();
|
|
93
|
-
|
|
107
|
+
// Grok wraps every visible user/assistant bubble in a stable response UUID:
|
|
108
|
+
//
|
|
109
|
+
// <div id="response-..." data-scroll-anchor-root>
|
|
110
|
+
// <div data-testid="user-message|assistant-message">...</div>
|
|
111
|
+
// </div>
|
|
112
|
+
//
|
|
113
|
+
// Keep the count fallback for older DOM variants, but prefer the wrapper
|
|
114
|
+
// identity whenever it is present. Besides making normal multi-turn
|
|
115
|
+
// correlation stronger, this lets a fresh chat safely follow Grok's late
|
|
116
|
+
// root -> /c/... navigation after the user bubble has already committed.
|
|
117
|
+
const id = await message.evaluate((element) => (
|
|
118
|
+
element.closest('[data-scroll-anchor-root][id^="response-"]')?.id
|
|
119
|
+
?? element.closest('[id^="response-"]')?.id
|
|
120
|
+
?? null
|
|
121
|
+
)).catch(() => null);
|
|
122
|
+
return {
|
|
123
|
+
id: id || null,
|
|
124
|
+
turn: await scope.count().catch(() => 0),
|
|
125
|
+
};
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
isNewAssistantIdentity({ turn, text }) {
|
|
@@ -104,7 +136,7 @@ export class GrokWebAdapter extends BaseWebAdapter {
|
|
|
104
136
|
async assistantText(message) {
|
|
105
137
|
const response = message.locator(".response-content-markdown");
|
|
106
138
|
if (await response.count().catch(() => 0) > 0) {
|
|
107
|
-
return await response.
|
|
139
|
+
return await response.evaluateAll(readRenderedBlocks);
|
|
108
140
|
}
|
|
109
141
|
return await message.innerText().catch(() => "");
|
|
110
142
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Passed directly to Locator.evaluateAll: keep this function self-contained.
|
|
2
|
+
// Read connected nodes so innerText keeps layout whitespace and omits hidden
|
|
3
|
+
// descendants. Cloning first changes innerText into textContent-like output.
|
|
4
|
+
export function readRenderedBlocks(elements) {
|
|
5
|
+
const roots = elements.filter((element) => (
|
|
6
|
+
!elements.some((other) => other !== element && other.contains(element))
|
|
7
|
+
));
|
|
8
|
+
return roots.filter((element) => element.getClientRects().length > 0)
|
|
9
|
+
.map((element) => element.innerText)
|
|
10
|
+
.join("\n");
|
|
11
|
+
}
|